Compare commits
160 Commits
feature/em
...
hotfix/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f353b63f7 | ||
|
|
954a4988b5 | ||
|
|
4ac13268d9 | ||
|
|
84f364de74 | ||
|
|
39c955cdc4 | ||
|
|
e02ca54187 | ||
|
|
ac46ee004b | ||
|
|
17a6eb260d | ||
|
|
5ea80366be | ||
|
|
99aa0cb980 | ||
|
|
3de53a76d0 | ||
|
|
7fa9b6aff8 | ||
|
|
79e156bd24 | ||
|
|
12a6a8eb69 | ||
|
|
eb71477ec1 | ||
|
|
2ed54eced2 | ||
|
|
32fd2b0ab8 | ||
|
|
ded374de3c | ||
|
|
1cd11cbf67 | ||
|
|
26bf7ac377 | ||
|
|
ac1084d6fe | ||
|
|
1e2a579c4f | ||
|
|
37394786be | ||
|
|
0ce850decf | ||
|
|
02facc77c2 | ||
|
|
a5640375c3 | ||
|
|
fb28283f39 | ||
|
|
00903d7cb7 | ||
|
|
18de0fc97a | ||
|
|
4830d53f63 | ||
|
|
43625660bc | ||
|
|
985aec9c8a | ||
|
|
544c955cf4 | ||
|
|
fec27f1aeb | ||
|
|
c898c02b8b | ||
|
|
4ea1dbd1c2 | ||
|
|
b847f3745e | ||
|
|
00fcfe8a21 | ||
|
|
95bad62728 | ||
|
|
a7074d55e1 | ||
|
|
62d3dafe56 | ||
|
|
6ff88440ff | ||
|
|
9f07155517 | ||
|
|
2bdb752c21 | ||
|
|
bf81929587 | ||
|
|
73d4ecc7f5 | ||
|
|
5a1570468e | ||
|
|
b4c5b24294 | ||
|
|
af3a2dc61b | ||
|
|
52a8fe00e1 | ||
|
|
e23f3aff2f | ||
|
|
bd001e9547 | ||
|
|
1933983071 | ||
|
|
ae9de21a16 | ||
|
|
be2b7e56c5 | ||
|
|
d3516cdd60 | ||
|
|
8e0ce323a1 | ||
|
|
a284cb3eb6 | ||
|
|
1df59f527a | ||
|
|
d3f7a374ec | ||
|
|
2380d94d02 | ||
|
|
7c1fe3070f | ||
|
|
079e211a7c | ||
|
|
75c01d54e9 | ||
|
|
6158df0443 | ||
|
|
8e50a61811 | ||
|
|
b2353bfe23 | ||
|
|
7b1292448a | ||
|
|
8aa5e51d1c | ||
|
|
23e46f8085 | ||
|
|
4d3401eb84 | ||
|
|
0c9822b20f | ||
|
|
860bd093c0 | ||
|
|
41b970adc7 | ||
|
|
f5931b490c | ||
|
|
ae4ef621b9 | ||
|
|
7b0ee691e9 | ||
|
|
b82ab7d28b | ||
|
|
d602661e98 | ||
|
|
790fefd5cc | ||
|
|
011bb7433c | ||
|
|
3b57116c79 | ||
|
|
ce6667a54d | ||
|
|
2c3fa7680d | ||
|
|
79b4212efd | ||
|
|
1360468a48 | ||
|
|
58eb8bf636 | ||
|
|
9bddad6725 | ||
|
|
a5f4d99046 | ||
|
|
1925055d2a | ||
|
|
35db3a5f34 | ||
|
|
19668e7550 | ||
|
|
1f477d388e | ||
|
|
8b249a8b4a | ||
|
|
2982eb085b | ||
|
|
e341627aa1 | ||
|
|
0855fef884 | ||
|
|
9207b3ebbe | ||
|
|
1fae0c6426 | ||
|
|
51f96bdc04 | ||
|
|
72dd0bcebd | ||
|
|
eb9f737bbe | ||
|
|
d3ea27dff2 | ||
|
|
5e8c987edb | ||
|
|
257e819e75 | ||
|
|
aa95ad4e47 | ||
|
|
06e9402719 | ||
|
|
43883f5b36 | ||
|
|
06a004bc96 | ||
|
|
48060b2c18 | ||
|
|
0178fe6a75 | ||
|
|
7d4babc23a | ||
|
|
39708e31d8 | ||
|
|
439d5e4d90 | ||
|
|
12ad2254c9 | ||
|
|
8801ec59b0 | ||
|
|
8e232284a8 | ||
|
|
5c8b006ce6 | ||
|
|
0453d90aa7 | ||
|
|
751c452d6a | ||
|
|
863f0ba162 | ||
|
|
77fb5f9b30 | ||
|
|
9baf1e2a88 | ||
|
|
ee7fee23fd | ||
|
|
2ce56f59c5 | ||
|
|
dec859b486 | ||
|
|
f80d018de7 | ||
|
|
4c548fec6f | ||
|
|
b74e7f9e49 | ||
|
|
dbdd002906 | ||
|
|
b2a6b2a709 | ||
|
|
afb5e5293a | ||
|
|
34197a9551 | ||
|
|
b54edcbfcf | ||
|
|
adfaa0f088 | ||
|
|
b129e8a1f7 | ||
|
|
6bed3500e6 | ||
|
|
75fcd4842f | ||
|
|
a516a1cd3c | ||
|
|
8aa87e4464 | ||
|
|
9634985a3a | ||
|
|
2bf5acc257 | ||
|
|
34dfdb437b | ||
|
|
26d2718b23 | ||
|
|
1dd828bc12 | ||
|
|
4dbee9ed64 | ||
|
|
51979b23fa | ||
|
|
dc29cf58bd | ||
|
|
c478666fb9 | ||
|
|
b6045062cb | ||
|
|
2dff75e209 | ||
|
|
e1d4468667 | ||
|
|
a4b88abdaa | ||
|
|
58a1b6099f | ||
|
|
d3f17ce805 | ||
|
|
60bf5ac8c5 | ||
|
|
23e6cf1cff | ||
|
|
865ec44b33 | ||
|
|
01e9a4df0a | ||
|
|
9515901506 |
9
.blade-formatter.json
Normal file
9
.blade-formatter.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"indentSize": 4,
|
||||
"wrapAttributes": "auto",
|
||||
"wrapLineLength": 120,
|
||||
"endWithNewLine": true,
|
||||
"useTabs": false,
|
||||
"sortTailwindcssClasses": true,
|
||||
"sortHtmlAttributes": "none"
|
||||
}
|
||||
35
.claude/notes/number-input-spinners-removed.md
Normal file
35
.claude/notes/number-input-spinners-removed.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Number Input Spinners Removed
|
||||
|
||||
## Summary
|
||||
All number input spinner arrows (up/down buttons) have been globally removed from the application.
|
||||
|
||||
## Implementation
|
||||
CSS has been added to both main layout files to hide spinners:
|
||||
|
||||
1. **app.blade.php** (lines 17-31)
|
||||
2. **app-with-sidebar.blade.php** (lines 17-31)
|
||||
|
||||
## CSS Used
|
||||
```css
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
```
|
||||
|
||||
## User Preference
|
||||
User specifically requested:
|
||||
- Remove up/down arrows on number input boxes
|
||||
- Apply this globally across all pages
|
||||
- Remember this preference for future pages
|
||||
|
||||
## Date
|
||||
2025-11-05
|
||||
27
.claude/settings.local.json
Normal file
27
.claude/settings.local.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(test:*)",
|
||||
"Bash(docker exec:*)",
|
||||
"Bash(docker stats:*)",
|
||||
"Bash(docker logs:*)",
|
||||
"Bash(docker-compose down:*)",
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(php --version:*)",
|
||||
"Bash(docker-compose build:*)",
|
||||
"Bash(docker-compose restart:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(docker ps:*)",
|
||||
"Bash(php -l:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(docker update:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(php artisan:*)",
|
||||
"Bash(php check_blade.php:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
37
.env.example
37
.env.example
@@ -33,10 +33,30 @@ SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Laravel Reverb (WebSocket Server for Real-Time Broadcasting)
|
||||
# Generate credentials: php artisan reverb:install
|
||||
REVERB_APP_ID=182567
|
||||
REVERB_APP_KEY=ieplst7x2k8avnqcmmo6
|
||||
REVERB_APP_SECRET=ckhvaobktbozwpljzlrv
|
||||
# IMPORTANT: Use Docker service name when running in Sail
|
||||
# - Docker/Sail: REVERB_HOST=reverb
|
||||
# - Local dev: REVERB_HOST=localhost
|
||||
REVERB_HOST=reverb
|
||||
REVERB_PORT=8080
|
||||
REVERB_SCHEME=http
|
||||
|
||||
# Vite Environment Variables (Frontend WebSocket Connection)
|
||||
# IMPORTANT: Vite doesn't support ${} expansion - values must be LITERAL
|
||||
# These values are for the browser, so always use "localhost" (not "reverb")
|
||||
VITE_REVERB_APP_KEY=ieplst7x2k8avnqcmmo6
|
||||
VITE_REVERB_HOST=localhost
|
||||
VITE_REVERB_PORT=8080
|
||||
VITE_REVERB_SCHEME=http
|
||||
|
||||
CACHE_STORE=redis
|
||||
CACHE_PREFIX=
|
||||
|
||||
@@ -57,10 +77,25 @@ MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# AWS/MinIO S3 Storage Configuration
|
||||
# Local development: Use FILESYSTEM_DISK=public (default)
|
||||
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_ENDPOINT=
|
||||
AWS_URL=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
# Production MinIO Configuration (example):
|
||||
# FILESYSTEM_DISK=s3
|
||||
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
|
||||
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
# AWS_BUCKET=media
|
||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||
# AWS_URL=https://cdn.cannabrands.app/media
|
||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Pre-push hook - Runs tests before pushing
|
||||
# Pre-push hook - Runs tests before pushing (supports both Sail and K8s)
|
||||
# Can be skipped with: git push --no-verify
|
||||
#
|
||||
|
||||
@@ -8,11 +8,48 @@ echo "🧪 Running tests before push..."
|
||||
echo " (Use 'git push --no-verify' to skip)"
|
||||
echo ""
|
||||
|
||||
# Run tests
|
||||
./vendor/bin/sail artisan test --parallel
|
||||
# Detect which environment is running
|
||||
SAIL_RUNNING=false
|
||||
K8S_RUNNING=false
|
||||
|
||||
# Check exit code
|
||||
if [ $? -ne 0 ]; then
|
||||
# Check if Sail is running
|
||||
if docker ps --format '{{.Names}}' | grep -q "sail" 2>/dev/null; then
|
||||
SAIL_RUNNING=true
|
||||
echo "📦 Detected Sail environment"
|
||||
fi
|
||||
|
||||
# Check if k8s namespace exists for this worktree
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
K8S_NS=$(echo "$BRANCH" | sed 's/feature\//feat-/' | sed 's/bugfix\//fix-/' | sed 's/\//-/g')
|
||||
|
||||
if kubectl get namespace "$K8S_NS" >/dev/null 2>&1; then
|
||||
K8S_RUNNING=true
|
||||
echo "☸️ Detected K8s environment (namespace: $K8S_NS)"
|
||||
fi
|
||||
|
||||
# Run tests in appropriate environment
|
||||
if [ "$SAIL_RUNNING" = true ]; then
|
||||
./vendor/bin/sail artisan test --parallel
|
||||
TEST_EXIT_CODE=$?
|
||||
elif [ "$K8S_RUNNING" = true ]; then
|
||||
echo " Running tests in k8s pod..."
|
||||
kubectl -n "$K8S_NS" exec deploy/web -- php artisan test
|
||||
TEST_EXIT_CODE=$?
|
||||
else
|
||||
echo "⚠️ No environment running (Sail or K8s)"
|
||||
echo " Skipping tests - please run tests manually"
|
||||
echo ""
|
||||
read -p "Continue push anyway? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [ ! "$REPLY" = "y" ] && [ ! "$REPLY" = "Y" ]; then
|
||||
echo "Push aborted"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check test results
|
||||
if [ $TEST_EXIT_CODE -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Tests failed!"
|
||||
echo ""
|
||||
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
/storage/clockwork
|
||||
/vendor
|
||||
.DS_Store
|
||||
docker-compose.override.yml
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
@@ -37,3 +38,24 @@ yarn-error.log
|
||||
# Version files (generated at build time or locally)
|
||||
version.txt
|
||||
version.env
|
||||
# Local secrets backup (DO NOT COMMIT)
|
||||
*SECRETS_BACKUP*
|
||||
.cannabrands-secrets/
|
||||
reverb-keys*
|
||||
|
||||
# Core dumps and debug files
|
||||
core
|
||||
core.*
|
||||
*.core
|
||||
|
||||
# Random image files (screenshots, etc.)
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
!public/**/*.png
|
||||
!public/**/*.jpg
|
||||
!public/**/*.jpeg
|
||||
!resources/**/*.png
|
||||
!resources/**/*.jpg
|
||||
!resources/**/*.jpeg
|
||||
.claude/settings.local.json
|
||||
|
||||
20
.stylelintrc.json
Normal file
20
.stylelintrc.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "stylelint-config-standard",
|
||||
"plugins": [
|
||||
"stylelint-no-unsupported-browser-features"
|
||||
],
|
||||
"rules": {
|
||||
"no-descending-specificity": null,
|
||||
"selector-class-pattern": null,
|
||||
"custom-property-pattern": null,
|
||||
"declaration-block-no-duplicate-properties": true,
|
||||
"no-duplicate-selectors": true
|
||||
},
|
||||
"ignoreFiles": [
|
||||
"**/*.js",
|
||||
"**/*.php",
|
||||
"node_modules/**",
|
||||
"vendor/**",
|
||||
"public/**"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Woodpecker CI/CD Pipeline for Cannabrands CRM
|
||||
# Woodpecker CI/CD Pipeline for Cannabrands Hub
|
||||
# Documentation: https://woodpecker-ci.org/docs/intro
|
||||
#
|
||||
# 3-Environment Workflow:
|
||||
@@ -11,17 +11,8 @@ when:
|
||||
event: push
|
||||
- event: [pull_request, tag]
|
||||
|
||||
# PHP Syntax Check
|
||||
# Install dependencies first (needed for php-lint to resolve traits/classes)
|
||||
steps:
|
||||
php-lint:
|
||||
image: php:8.3-cli
|
||||
commands:
|
||||
- echo "Checking PHP syntax..."
|
||||
- find app -name "*.php" -exec php -l {} \;
|
||||
- find routes -name "*.php" -exec php -l {} \;
|
||||
- find database -name "*.php" -exec php -l {} \;
|
||||
- echo "PHP syntax check complete!"
|
||||
|
||||
# Restore Composer cache
|
||||
restore-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
@@ -89,6 +80,16 @@ steps:
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
|
||||
# PHP Syntax Check (runs after composer install so traits/classes are available)
|
||||
php-lint:
|
||||
image: php:8.3-cli
|
||||
commands:
|
||||
- echo "Checking PHP syntax..."
|
||||
- find app -name "*.php" -exec php -l {} \;
|
||||
- find routes -name "*.php" -exec php -l {} \;
|
||||
- find database -name "*.php" -exec php -l {} \;
|
||||
- echo "PHP syntax check complete!"
|
||||
|
||||
# Run Laravel Pint (Code Style)
|
||||
code-style:
|
||||
image: php:8.3-cli
|
||||
@@ -99,11 +100,12 @@ steps:
|
||||
|
||||
# Run PHPUnit Tests
|
||||
# Note: Uses array cache/session for speed and isolation (Laravel convention)
|
||||
# Only use real Redis/Queue services if testing cache/queue-specific behavior
|
||||
# Redis + Reverb services used for real-time broadcasting tests
|
||||
tests:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
environment:
|
||||
APP_ENV: testing
|
||||
BROADCAST_CONNECTION: reverb
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
@@ -113,10 +115,20 @@ steps:
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: testing
|
||||
DB_PASSWORD: testing
|
||||
REDIS_HOST: redis
|
||||
REVERB_APP_ID: test-app-id
|
||||
REVERB_APP_KEY: test-key
|
||||
REVERB_APP_SECRET: test-secret
|
||||
REVERB_HOST: localhost
|
||||
REVERB_PORT: 8080
|
||||
REVERB_SCHEME: http
|
||||
commands:
|
||||
- echo "Setting up Laravel environment..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Starting Reverb server in background..."
|
||||
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
|
||||
- sleep 2
|
||||
- echo "Running tests..."
|
||||
- php artisan test --parallel
|
||||
- echo "Tests complete!"
|
||||
@@ -137,8 +149,14 @@ steps:
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
|
||||
- ${CI_COMMIT_BRANCH} # Branch name (develop)
|
||||
build_args:
|
||||
- GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7}
|
||||
- APP_VERSION=dev-${CI_COMMIT_SHA:0:7}
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "dev"
|
||||
VITE_REVERB_APP_KEY: "6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU="
|
||||
VITE_REVERB_HOST: "dev.cannabrands.app"
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: "https"
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-dev
|
||||
platforms: linux/amd64
|
||||
when:
|
||||
branch: develop
|
||||
@@ -157,7 +175,7 @@ steps:
|
||||
- echo ""
|
||||
# Setup kubeconfig
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBECONFIG_CONTENT" | base64 -d > ~/.kube/config
|
||||
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
||||
- chmod 600 ~/.kube/config
|
||||
# Update deployment to use new SHA-tagged image (both app and init containers)
|
||||
- |
|
||||
@@ -197,8 +215,10 @@ steps:
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
|
||||
- ${CI_COMMIT_BRANCH} # Branch name (master)
|
||||
build_args:
|
||||
- GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7}
|
||||
- APP_VERSION=staging
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "staging"
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-staging
|
||||
platforms: linux/amd64
|
||||
when:
|
||||
branch: master
|
||||
@@ -219,8 +239,10 @@ steps:
|
||||
- ${CI_COMMIT_TAG} # CalVer tag (e.g., 2025.10.1)
|
||||
- latest # Latest stable release
|
||||
build_args:
|
||||
- GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7}
|
||||
- APP_VERSION=${CI_COMMIT_TAG}
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "${CI_COMMIT_TAG}"
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-prod
|
||||
platforms: linux/amd64
|
||||
when:
|
||||
event: tag
|
||||
@@ -306,7 +328,7 @@ steps:
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
fi
|
||||
|
||||
# Database service for tests
|
||||
# Services for tests
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -314,3 +336,8 @@ services:
|
||||
POSTGRES_USER: testing
|
||||
POSTGRES_PASSWORD: testing
|
||||
POSTGRES_DB: testing
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
commands:
|
||||
- redis-server --bind 0.0.0.0
|
||||
|
||||
@@ -1,578 +0,0 @@
|
||||
# Git Branching Strategy for Cannabrands
|
||||
|
||||
## Evolution of Your Workflow
|
||||
|
||||
Your branching strategy should evolve with your team size and customer base. This document outlines the transition path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Pre-Release (Current)
|
||||
|
||||
**Team Size:** 1-2 developers
|
||||
**Customers:** None yet
|
||||
**Goal:** Move fast, iterate quickly
|
||||
|
||||
### Current Workflow: Direct to Master
|
||||
|
||||
```
|
||||
Local changes → Commit to master → Push → CI tests → (Optional: Deploy to dev)
|
||||
```
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
# Make changes
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin master
|
||||
```
|
||||
|
||||
**When this works:**
|
||||
- ✅ Solo developer or tight 2-person team
|
||||
- ✅ No customers depending on stability
|
||||
- ✅ Fast iteration is priority #1
|
||||
- ✅ Quick fixes needed immediately
|
||||
|
||||
**When to stop:**
|
||||
- ❌ First paying customer signs up
|
||||
- ❌ Team grows to 3+ developers
|
||||
- ❌ Too many merge conflicts
|
||||
- ❌ Need code review before deployment
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Feature Branches (Transition)
|
||||
|
||||
**Team Size:** 2-5 developers
|
||||
**Customers:** First few customers OR approaching launch
|
||||
**Goal:** Add safety through code review
|
||||
|
||||
### GitHub Flow: Feature Branches + PRs
|
||||
|
||||
```
|
||||
master (stable, auto-deploys to dev)
|
||||
↑
|
||||
Pull Requests (code review required)
|
||||
↑
|
||||
feature branches (work in progress)
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
|
||||
```bash
|
||||
# 1. Create feature branch from master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git checkout -b feature/add-payment-terms
|
||||
|
||||
# 2. Make changes
|
||||
# ... edit files ...
|
||||
git add .
|
||||
git commit -m "feat: add payment term surcharge calculation"
|
||||
|
||||
# 3. Push feature branch
|
||||
git push origin feature/add-payment-terms
|
||||
|
||||
# 4. Create Pull Request in Gitea
|
||||
# - Go to Gitea UI
|
||||
# - Click "New Pull Request"
|
||||
# - Base: master, Compare: feature/add-payment-terms
|
||||
# - Add description and assign reviewer
|
||||
|
||||
# 5. After approval, merge to master
|
||||
# - Click "Merge" button in Gitea
|
||||
# - Delete feature branch
|
||||
|
||||
# 6. Pull updated master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git branch -d feature/add-payment-terms
|
||||
```
|
||||
|
||||
**Branch Naming Conventions:**
|
||||
```
|
||||
feature/short-description # New features
|
||||
fix/bug-description # Bug fixes
|
||||
refactor/what-changed # Code refactoring
|
||||
docs/what-documented # Documentation only
|
||||
test/what-tested # Test additions
|
||||
|
||||
Examples:
|
||||
feature/buyer-registration
|
||||
fix/invoice-calculation-error
|
||||
refactor/order-service
|
||||
docs/deployment-guide
|
||||
test/checkout-flow
|
||||
```
|
||||
|
||||
**Commit Message Format:**
|
||||
```
|
||||
type(scope): subject
|
||||
|
||||
Examples:
|
||||
feat(checkout): add payment term selection
|
||||
fix(invoice): correct tax calculation for multi-state orders
|
||||
refactor(orders): extract order validation logic
|
||||
docs(readme): update local setup instructions
|
||||
test(auth): add buyer login tests
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Code review before merging
|
||||
- ✅ Catch bugs early
|
||||
- ✅ Knowledge sharing across team
|
||||
- ✅ Cleaner git history
|
||||
- ✅ Can work on multiple features in parallel
|
||||
|
||||
**When to stop:**
|
||||
- ❌ Team grows beyond 10 people
|
||||
- ❌ Need to manage multiple versions
|
||||
- ❌ Need longer release cycles
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Environment Branches (Mature)
|
||||
|
||||
**Team Size:** 5+ developers
|
||||
**Customers:** Growing customer base
|
||||
**Goal:** Staged rollout with stability
|
||||
|
||||
### Two-Branch Model: Develop + Master
|
||||
|
||||
```
|
||||
master (production) ← deploys to app.cannabrands.com
|
||||
↑
|
||||
Pull Requests (from develop, tested on staging)
|
||||
↑
|
||||
develop (integration) ← deploys to staging.cannabrands.app
|
||||
↑
|
||||
Pull Requests (from feature branches)
|
||||
↑
|
||||
feature branches (work in progress)
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
|
||||
```bash
|
||||
# 1. Create feature branch from develop
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/bulk-order-import
|
||||
|
||||
# 2. Make changes and commit
|
||||
git add .
|
||||
git commit -m "feat(orders): add CSV bulk import"
|
||||
git push origin feature/bulk-order-import
|
||||
|
||||
# 3. Create PR: feature → develop
|
||||
# - Merging to develop auto-deploys to staging.cannabrands.app
|
||||
# - Team tests on staging
|
||||
|
||||
# 4. When staging is stable, create PR: develop → master
|
||||
# - Requires approval from team lead
|
||||
# - Merging to master deploys to production
|
||||
|
||||
# 5. Regular develop → master promotions
|
||||
# - Every 1-2 weeks
|
||||
# - Or after major features are tested
|
||||
```
|
||||
|
||||
**Branch Protection Rules:**
|
||||
|
||||
**Master Branch:**
|
||||
- ✅ Require pull request reviews (1+ approvals)
|
||||
- ✅ Require status checks to pass (CI tests)
|
||||
- ✅ Require branches to be up to date
|
||||
- ✅ Restrict who can push (admin only)
|
||||
|
||||
**Develop Branch:**
|
||||
- ✅ Require pull request reviews (1+ approval)
|
||||
- ✅ Require status checks to pass
|
||||
- ⬜ Allow force pushes (optional)
|
||||
|
||||
**Setup in Gitea:**
|
||||
```
|
||||
Settings → Repository → Branches → Add Branch Protection Rule
|
||||
- Branch name pattern: master
|
||||
- Enable protection
|
||||
- Require pull request reviews before merging
|
||||
- Dismiss stale pull request approvals when new commits are pushed
|
||||
- Require status checks to pass before merging
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Staging environment matches production
|
||||
- ✅ Catch integration issues before customers see them
|
||||
- ✅ Multiple features can be tested together
|
||||
- ✅ Rollback is easier (master = last known good)
|
||||
- ✅ QA team has stable environment to test
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Release Branches (Enterprise)
|
||||
|
||||
**Team Size:** 10+ developers
|
||||
**Customers:** Large customer base, SLA commitments
|
||||
**Goal:** Support multiple versions, scheduled releases
|
||||
|
||||
### Git Flow: Full Enterprise Model
|
||||
|
||||
```
|
||||
master (production) ← hotfixes, release branches
|
||||
↑
|
||||
release/v2.1.0 (release preparation)
|
||||
↑
|
||||
develop (integration) ← feature branches
|
||||
↑
|
||||
feature/* (work in progress)
|
||||
```
|
||||
|
||||
**When you need this:**
|
||||
- Multiple versions in production (e.g., self-hosted + SaaS)
|
||||
- Need to support old versions
|
||||
- Scheduled release cycles (monthly/quarterly)
|
||||
- Regulatory requirements for change control
|
||||
|
||||
**This is probably overkill for most cannabis startups.**
|
||||
|
||||
---
|
||||
|
||||
## Hotfix Workflow (All Phases)
|
||||
|
||||
**When:** Critical bug in production needs immediate fix
|
||||
|
||||
### Quick Hotfix Process
|
||||
|
||||
```bash
|
||||
# 1. Create hotfix branch from master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git checkout -b hotfix/invoice-calculation-fix
|
||||
|
||||
# 2. Make MINIMAL changes to fix the bug
|
||||
git add .
|
||||
git commit -m "fix(invoice): correct tax calculation for CA"
|
||||
|
||||
# 3. Test locally
|
||||
./vendor/bin/sail artisan test
|
||||
|
||||
# 4. Create PR to master (expedited review)
|
||||
# - Mark as "HOTFIX - URGENT"
|
||||
# - Get quick approval from team lead
|
||||
|
||||
# 5. After merging to master:
|
||||
# - Deploy to production immediately
|
||||
# - Backport fix to develop branch
|
||||
|
||||
git checkout develop
|
||||
git merge master
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
**Hotfix Rules:**
|
||||
- ⚠️ Only for critical production bugs
|
||||
- ⚠️ Must be small, focused changes
|
||||
- ⚠️ Expedited review process
|
||||
- ⚠️ Deploy ASAP after merge
|
||||
|
||||
---
|
||||
|
||||
## Recommended Transition Timeline
|
||||
|
||||
### Now → First Customer (Phase 0)
|
||||
|
||||
**Strategy:** Direct to master
|
||||
**Why:** Fast iteration, no customer impact
|
||||
|
||||
```bash
|
||||
# Your current workflow - keep doing this
|
||||
git commit -am "feat: add new feature"
|
||||
git push origin master
|
||||
```
|
||||
|
||||
### First Customer → 5 Customers (Phase 1)
|
||||
|
||||
**Trigger:** First paying customer OR 3+ developers
|
||||
**Strategy:** Feature branches with PRs
|
||||
**Timeline:** Implement within 2 weeks of first customer
|
||||
|
||||
**Action Items:**
|
||||
1. Create `CONTRIBUTING.md` with PR guidelines
|
||||
2. Enable branch protection on master
|
||||
3. Train team on PR workflow
|
||||
4. Set up code review rotation
|
||||
|
||||
### 5+ Customers → Growth Phase (Phase 2)
|
||||
|
||||
**Trigger:**
|
||||
- 5+ developers on team OR
|
||||
- 50+ active customers OR
|
||||
- Need for staging environment
|
||||
|
||||
**Strategy:** Develop + Master branches
|
||||
**Timeline:** Plan 1 month for transition
|
||||
|
||||
**Action Items:**
|
||||
1. Set up staging.cannabrands.app server
|
||||
2. Create develop branch
|
||||
3. Update CI/CD for both branches
|
||||
4. Document new workflow for team
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Best Practices
|
||||
|
||||
### Writing Good PRs
|
||||
|
||||
**Title Format:**
|
||||
```
|
||||
type(scope): brief description
|
||||
|
||||
Examples:
|
||||
feat(orders): add bulk order import
|
||||
fix(invoice): correct tax calculation
|
||||
refactor(auth): simplify login flow
|
||||
```
|
||||
|
||||
**Description Template:**
|
||||
```markdown
|
||||
## What Changed
|
||||
Brief description of what this PR does
|
||||
|
||||
## Why
|
||||
Explain the problem this solves or feature it adds
|
||||
|
||||
## How to Test
|
||||
1. Go to /orders/import
|
||||
2. Upload sample CSV
|
||||
3. Verify orders are created correctly
|
||||
|
||||
## Screenshots (if UI changes)
|
||||
[Attach screenshots]
|
||||
|
||||
## Checklist
|
||||
- [ ] Tests added/updated
|
||||
- [ ] Documentation updated
|
||||
- [ ] Tested locally
|
||||
- [ ] No merge conflicts
|
||||
```
|
||||
|
||||
### Reviewing PRs
|
||||
|
||||
**What to look for:**
|
||||
- ✅ Code solves the stated problem
|
||||
- ✅ Tests cover new functionality
|
||||
- ✅ Follows existing code style
|
||||
- ✅ No obvious bugs or security issues
|
||||
- ✅ Documentation is updated
|
||||
|
||||
**How to provide feedback:**
|
||||
```markdown
|
||||
## Blocking Issues (must fix before merge)
|
||||
- [ ] Line 45: This will cause a division by zero error
|
||||
|
||||
## Suggestions (nice to have)
|
||||
- Line 23: Consider extracting this to a helper method
|
||||
- Could add a comment explaining this logic
|
||||
|
||||
## Praise (always include!)
|
||||
- Great test coverage!
|
||||
- Clean implementation of the CSV parser
|
||||
```
|
||||
|
||||
### PR Etiquette
|
||||
|
||||
**Author:**
|
||||
- Keep PRs small (<400 lines changed)
|
||||
- Respond to feedback within 24 hours
|
||||
- Don't merge your own PRs (unless emergency)
|
||||
- Update PR if master changes
|
||||
|
||||
**Reviewer:**
|
||||
- Review within 24 hours
|
||||
- Be kind and constructive
|
||||
- Ask questions instead of making demands
|
||||
- Approve when ready (don't hold up progress)
|
||||
|
||||
---
|
||||
|
||||
## Merge Strategies
|
||||
|
||||
### Squash and Merge (Recommended for Feature Branches)
|
||||
|
||||
**What it does:** Combines all commits into one when merging
|
||||
|
||||
**Use when:**
|
||||
- Feature branch has messy commit history
|
||||
- Want clean master history
|
||||
- PRs are self-contained features
|
||||
|
||||
**Gitea Setting:** "Squash and merge" button in PR
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Before merge (feature branch):
|
||||
- fix typo
|
||||
- wip: add validation
|
||||
- add tests
|
||||
- fix tests
|
||||
- update docs
|
||||
|
||||
After merge (master):
|
||||
- feat(orders): add bulk import with CSV validation (#42)
|
||||
```
|
||||
|
||||
### Regular Merge (For Long-Running Branches)
|
||||
|
||||
**What it does:** Preserves all commits and creates merge commit
|
||||
|
||||
**Use when:**
|
||||
- Merging develop → master
|
||||
- Want to preserve detailed history
|
||||
- Multiple developers collaborated on branch
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Starting New Feature
|
||||
|
||||
```bash
|
||||
# Update master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
|
||||
# Create feature branch
|
||||
git checkout -b feature/product-variants
|
||||
|
||||
# Make changes
|
||||
# ... work work work ...
|
||||
|
||||
# Commit regularly (small commits)
|
||||
git add .
|
||||
git commit -m "feat(products): add variant model"
|
||||
git push origin feature/product-variants
|
||||
|
||||
# Create PR when ready
|
||||
```
|
||||
|
||||
### Updating Feature Branch with Latest Master
|
||||
|
||||
```bash
|
||||
# Your feature branch is behind master
|
||||
git checkout feature/product-variants
|
||||
|
||||
# Option 1: Merge master into feature (preserves commits)
|
||||
git merge origin/master
|
||||
git push origin feature/product-variants
|
||||
|
||||
# Option 2: Rebase on master (cleaner history)
|
||||
git rebase origin/master
|
||||
git push -f origin feature/product-variants # Force push needed after rebase
|
||||
```
|
||||
|
||||
**When to use each:**
|
||||
- **Merge:** Safer, preserves history, good for collaboration
|
||||
- **Rebase:** Cleaner history, good for solo feature branches
|
||||
|
||||
### Fixing Merge Conflicts
|
||||
|
||||
```bash
|
||||
# Pull latest master
|
||||
git checkout master
|
||||
git pull origin master
|
||||
|
||||
# Try to merge (conflict!)
|
||||
git checkout feature/product-variants
|
||||
git merge master
|
||||
|
||||
# Git shows conflicts
|
||||
# CONFLICT (content): Merge conflict in app/Models/Product.php
|
||||
|
||||
# Open conflicted file
|
||||
nano app/Models/Product.php
|
||||
|
||||
# Look for conflict markers:
|
||||
<<<<<<< HEAD
|
||||
// Your changes
|
||||
=======
|
||||
// Changes from master
|
||||
>>>>>>> master
|
||||
|
||||
# Resolve conflicts, remove markers
|
||||
# Save file
|
||||
|
||||
# Mark as resolved
|
||||
git add app/Models/Product.php
|
||||
git commit -m "fix: resolve merge conflicts with master"
|
||||
git push origin feature/product-variants
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Branch Cleanup
|
||||
|
||||
### Deleting Merged Feature Branches
|
||||
|
||||
```bash
|
||||
# After PR is merged, delete local branch
|
||||
git branch -d feature/product-variants
|
||||
|
||||
# Delete remote branch (Gitea can auto-delete)
|
||||
git push origin --delete feature/product-variants
|
||||
|
||||
# Remove stale remote references
|
||||
git remote prune origin
|
||||
|
||||
# See all branches
|
||||
git branch -a
|
||||
```
|
||||
|
||||
### Finding Old Branches
|
||||
|
||||
```bash
|
||||
# List branches by last commit date
|
||||
git for-each-ref --sort=-committerdate refs/heads/
|
||||
|
||||
# Delete branches older than 30 days
|
||||
git branch --merged master | grep -v "master" | xargs git branch -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary: Your Transition Path
|
||||
|
||||
### Today (Pre-Release)
|
||||
✅ **Direct to master** - current setup
|
||||
- Fast iteration
|
||||
- No PR overhead
|
||||
- Good for solo/pair development
|
||||
|
||||
### First Customer (In 1-3 Months)
|
||||
🔜 **Feature branches + PRs**
|
||||
- Add code review
|
||||
- Protect master branch
|
||||
- Enable CI on PRs
|
||||
|
||||
### Growing Team (In 6-12 Months)
|
||||
🔜 **Develop + Master branches**
|
||||
- Add staging environment
|
||||
- Staged rollouts
|
||||
- Better stability
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Phase | Team Size | Branches | Deploy To | When |
|
||||
|-------|-----------|----------|-----------|------|
|
||||
| 0: Pre-release | 1-2 | master | dev | Now |
|
||||
| 1: Feature branches | 2-5 | master + feature/* | dev | First customer |
|
||||
| 2: Environment branches | 5-10 | master + develop + feature/* | staging + prod | Growing team |
|
||||
| 3: Release branches | 10+ | master + develop + release/* + feature/* | Multiple envs | Enterprise |
|
||||
|
||||
**Recommended for Cannabrands:**
|
||||
- **Now:** Phase 0 (direct to master)
|
||||
- **Next:** Phase 1 (feature branches) - within 2 weeks of first customer
|
||||
- **Future:** Phase 2 (develop branch) - when team grows to 5+
|
||||
|
||||
**Key principle:** *"Choose the simplest workflow that meets your current needs. You can always add complexity later."*
|
||||
166
CLAUDE.md
166
CLAUDE.md
@@ -1,85 +1,123 @@
|
||||
# Claude Code Context
|
||||
|
||||
## Important Documentation to Review
|
||||
## 🚨 Critical Mistakes You Make
|
||||
|
||||
Before implementing any features, please review the following documentation:
|
||||
### 1. Business Isolation (MOST COMMON!)
|
||||
❌ **Wrong:** `Component::findOrFail($id)` then check business_id
|
||||
✅ **Right:** `Component::where('business_id', $business->id)->findOrFail($id)`
|
||||
**Why:** Prevents ID enumeration across tenants (see audit: BomController vulnerability)
|
||||
|
||||
### URL Structure and Architecture
|
||||
- **ALWAYS** review `docs/URL_STRUCTURE.md` before implementing any routing changes
|
||||
- The application uses a three-tier user system: buyers, sellers, and admins
|
||||
- URL prefixes: `/b/` (buyers), `/s/` (sellers), `/admin` (super admins)
|
||||
**Models needing business_id:** Component, Brand, Product, Driver, Vehicle, Contact, Invoice
|
||||
**Exception:** Orders span buyer + seller businesses - use `whereHas('items.product.brand')`
|
||||
|
||||
### Additional Documentation
|
||||
- `docs/DATABASE.md` - Database schema and conventions
|
||||
- `docs/DEVELOPMENT.md` - Development environment setup
|
||||
- `docs/KUBERNETES_DEPLOYMENT.md` - Kubernetes deployment guide
|
||||
- `docs/REGISTRY_CLEANUP.md` - Docker registry cleanup policy
|
||||
- `CONTRIBUTING.md` - Git workflow and PR process (root level)
|
||||
- `k8s/KUBECTL_COMMANDS.md` - kubectl operations reference
|
||||
### 2. Route Prefixes
|
||||
Check `docs/URL_STRUCTURE.md` BEFORE route changes.
|
||||
- `/b/*` → Buyers only
|
||||
- `/s/*` → Sellers only
|
||||
- `/admin` → Super admins only
|
||||
|
||||
### User Types and Account Structure
|
||||
- **Buyers** (Retailers/Dispensaries): Browse marketplace, instant approval
|
||||
- **Sellers** (Brands/Manufacturers): Manage products, require approval
|
||||
- **Admins**: Platform management
|
||||
### 3. Filament Usage Boundary
|
||||
**Filament = `/admin` ONLY** (super admin tools)
|
||||
**DO NOT** use Filament for `/b/` or `/s/` - use DaisyUI + Blade instead
|
||||
**Why:** Filament is admin panel framework, not customer-facing UI
|
||||
|
||||
### Development Guidelines
|
||||
1. Maintain Laravel Breeze compatibility for authentication
|
||||
2. Follow existing code conventions and patterns
|
||||
3. Always check existing components before creating new ones
|
||||
4. Use PostgreSQL-compatible migrations (no IF/ELSE logic)
|
||||
5. Test routes after implementation
|
||||
6. Create informative git commits with clear messages
|
||||
### 4. Multi-Tenancy Architecture
|
||||
**We do NOT use spatie/laravel-multitenancy** - manual business_id scoping
|
||||
**Why:** Two-sided marketplace needs cross-business queries (buyers browse all sellers' products)
|
||||
Orders link TWO businesses: buyer's business_id + seller's product→brand→business_id
|
||||
|
||||
## What NOT to Do
|
||||
### 5. Middleware Protection
|
||||
ALL routes need auth + user type middleware except public pages
|
||||
**Pattern:** `->middleware(['auth', 'verified', 'buyer'])` or `['seller', 'approved']`
|
||||
**Caught in audit:** Unprotected `/onboarding/*` routes - now fixed
|
||||
|
||||
- ❌ **NEVER** commit directly to `develop` or `master` branches
|
||||
- ❌ **NEVER** use raw SQL (use Eloquent/Query Builder)
|
||||
- ❌ **NEVER** skip authentication middleware on protected routes
|
||||
- ❌ **NEVER** commit `.env` files or secrets
|
||||
- ❌ **NEVER** create migrations with IF/ELSE logic (PostgreSQL incompatible)
|
||||
- ❌ **NEVER** bypass CI/CD checks
|
||||
- ❌ **NEVER** skip writing down() methods in migrations
|
||||
### 6. PostgreSQL Migrations
|
||||
❌ No IF/ELSE logic in migrations (not supported)
|
||||
✅ Use Laravel Schema builder or conditional PHP code
|
||||
|
||||
## Testing Requirements
|
||||
### 7. Styling - DaisyUI/Tailwind Only
|
||||
❌ **NEVER use inline `style=""` attributes** in Blade templates
|
||||
✅ **ALWAYS use DaisyUI/Tailwind utility classes**
|
||||
**Why:** Consistency, maintainability, theme switching, and better performance
|
||||
|
||||
- **ALWAYS** run tests before committing: `php artisan test --parallel`
|
||||
- **ALWAYS** check code style: `./vendor/bin/pint`
|
||||
- Tests must pass in CI/CD pipeline before merge
|
||||
**Correct patterns:**
|
||||
- Colors: Use `bg-primary`, `text-primary`, `bg-success`, etc. (defined in `resources/css/app.css`)
|
||||
- Spacing: Use `p-4`, `m-2`, `gap-3` (Tailwind utilities)
|
||||
- Layout: Use `flex`, `grid`, `items-center` (Tailwind utilities)
|
||||
- Custom colors: Add to `resources/css/app.css` theme variables, NOT inline
|
||||
|
||||
## Testing Credentials
|
||||
- Buyer: `dispensary@example.com` / `password`
|
||||
- Seller: `brand@example.com` / `password`
|
||||
- Admin: `admin@example.com` / `password`
|
||||
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
|
||||
|
||||
## Current Architecture Decisions
|
||||
- Dual registration flow with informative landing page at `/register`
|
||||
- Separate authentication controllers for buyers and sellers
|
||||
- Marketplace functionality under `/b/` prefix
|
||||
- Brand/seller CRM functionality under `/s/` prefix
|
||||
---
|
||||
|
||||
## Commands to Run After Changes
|
||||
- Clear caches: `php artisan cache:clear && php artisan config:clear && php artisan route:clear`
|
||||
- Run migrations: `php artisan migrate`
|
||||
- Seed test data: `php artisan db:seed --class=DevSeeder`
|
||||
## Tech Stack by Area
|
||||
|
||||
## CI/CD Pipeline
|
||||
| Area | Framework | Users | UI |
|
||||
|------|-----------|-------|-----|
|
||||
| `/admin` | Filament v3 | Super admins | Filament tables/forms |
|
||||
| `/b/` | Blade + DaisyUI | Buyers | Custom marketplace |
|
||||
| `/s/` | Blade + DaisyUI | Sellers | Custom CRM |
|
||||
|
||||
Woodpecker CI runs automatically on push to develop/master:
|
||||
1. PHP syntax check
|
||||
2. Code style check (Pint)
|
||||
3. PHPUnit tests
|
||||
4. Docker image build (only if all checks pass)
|
||||
---
|
||||
|
||||
**Do not merge Pull Requests if CI/CD fails.**
|
||||
## Business Types
|
||||
|
||||
## Server Requirements
|
||||
- `'buyer'` - Dispensary (browses marketplace, places orders)
|
||||
- `'seller'` - Brand (manages products, fulfills orders)
|
||||
- `'both'` - Vertically integrated
|
||||
|
||||
### PDF Generation (DomPDF)
|
||||
This application uses DomPDF (`barryvdh/laravel-dompdf`) for generating cannabis shipping manifests and invoices.
|
||||
Users have `user_type` matching their business type.
|
||||
|
||||
**No special server requirements needed** - DomPDF is pure PHP and works out of the box on all platforms (Linux, macOS, Windows, ARM64, x86_64).
|
||||
---
|
||||
|
||||
**Configuration:**
|
||||
- Package: `barryvdh/laravel-dompdf`
|
||||
- Already installed via Composer
|
||||
- No additional system dependencies required
|
||||
## Testing & Git
|
||||
|
||||
**Before commit:**
|
||||
```bash
|
||||
php artisan test --parallel # REQUIRED
|
||||
./vendor/bin/pint # REQUIRED
|
||||
```
|
||||
|
||||
**Credentials:** `{buyer,seller,admin}@example.com` / `password`
|
||||
|
||||
**Branches:** Never commit to `master`/`develop` directly - use feature branches
|
||||
|
||||
**CI/CD:** Woodpecker checks syntax → Pint → tests → Docker build
|
||||
|
||||
---
|
||||
|
||||
## Common Query Patterns
|
||||
|
||||
```php
|
||||
// Seller viewing their products
|
||||
Product::whereHas('brand', fn($q) => $q->where('business_id', $business->id))->get();
|
||||
|
||||
// Buyer viewing their orders
|
||||
Order::where('business_id', $business->id)->get();
|
||||
|
||||
// Seller viewing incoming orders
|
||||
Order::whereHas('items.product.brand', fn($q) => $q->where('business_id', $business->id))->get();
|
||||
|
||||
// Marketplace (cross-business - intentional!)
|
||||
Product::where('is_active', true)->get(); // No business_id filter!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## External Docs (Read When Needed)
|
||||
|
||||
- `docs/URL_STRUCTURE.md` - **READ BEFORE** routing changes
|
||||
- `docs/DATABASE.md` - **READ BEFORE** migrations
|
||||
- `docs/DEVELOPMENT.md` - Local setup
|
||||
- `CONTRIBUTING.md` - Detailed git workflow
|
||||
|
||||
---
|
||||
|
||||
## What You Often Forget
|
||||
|
||||
✅ Scope by business_id BEFORE finding by ID
|
||||
✅ Use Eloquent (never raw SQL)
|
||||
✅ Protect routes with middleware
|
||||
✅ DaisyUI for buyer/seller, Filament only for admin
|
||||
✅ NO inline styles - use Tailwind/DaisyUI classes only
|
||||
✅ Run tests before committing
|
||||
|
||||
@@ -1,891 +0,0 @@
|
||||
# Claude Code Collaboration Workflow Guide
|
||||
|
||||
**Project:** Cannabrands CRM → Filament Migration
|
||||
**Timeline:** 28 days
|
||||
**Developer:** Solo developer + Claude Code
|
||||
**Last Updated:** January 2025
|
||||
|
||||
---
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
This guide provides prompt templates, reference strategies, and workflows for effectively collaborating with Claude Code during the migration from the legacy Laravel 9 + VentureDrake CRM system to the new Laravel 12 + Filament 4 platform.
|
||||
|
||||
**Key Principle:** The old codebase (`/cannabrands_crm`) contains **business logic** that must be preserved, but **architectural patterns** that should NOT be replicated.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Prompt Templates
|
||||
|
||||
### Template 1: Implementing a New Feature
|
||||
|
||||
```
|
||||
I need to implement [FEATURE NAME] from the old system.
|
||||
|
||||
**Old System Reference:**
|
||||
- Location: [file path in /cannabrands_crm]
|
||||
- Key business rules: [specific rules to preserve]
|
||||
- Data involved: [models/tables]
|
||||
|
||||
**New System Requirements:**
|
||||
- Filament resource: [Yes/No]
|
||||
- Public-facing page: [Yes/No]
|
||||
- Special considerations: [any unique requirements]
|
||||
|
||||
Please:
|
||||
1. Read the old implementation to understand the business logic
|
||||
2. Implement using Filament 4 best practices
|
||||
3. Preserve all validation rules and business logic
|
||||
4. Do NOT copy the CRM-specific patterns
|
||||
|
||||
Reference documents:
|
||||
- FEATURE_IMPLEMENTATION_ROADMAP.md (Day X task)
|
||||
- FILAMENT_RESOURCES_SPEC.md (if applicable)
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```
|
||||
I need to implement the buyer application approval workflow.
|
||||
|
||||
**Old System Reference:**
|
||||
- Location: vendor/venturedrake/laravel-crm/src/Http/Controllers/CompaniesController.php (lines 520-790)
|
||||
- Key business rules:
|
||||
- Requires license document upload
|
||||
- Requires W9 tax form
|
||||
- Admin approval required before account activation
|
||||
- Email notification on approval/rejection
|
||||
- Data involved: companies table, documents, email_verifications
|
||||
|
||||
**New System Requirements:**
|
||||
- Filament resource: Yes (CompanyResource)
|
||||
- Public-facing page: Yes (buyer registration form)
|
||||
- Special considerations: Must integrate with existing email verification system
|
||||
|
||||
Please:
|
||||
1. Read the old implementation to understand document validation
|
||||
2. Implement using Filament 4 actions and notifications
|
||||
3. Preserve license/W9 validation logic
|
||||
4. Do NOT copy the CRM Organization model patterns
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Template 2: Understanding Business Logic
|
||||
|
||||
```
|
||||
I need to understand how [BUSINESS PROCESS] works in the old system.
|
||||
|
||||
**Process:** [name of workflow/calculation/rule]
|
||||
**Why:** [what you're trying to implement that needs this]
|
||||
|
||||
Please:
|
||||
1. Search the old codebase for relevant files
|
||||
2. Trace the workflow from start to finish
|
||||
3. Identify all business rules, validations, and side effects
|
||||
4. Explain the logic in plain English
|
||||
5. Recommend how to implement in new system
|
||||
|
||||
Do NOT implement yet - just analyze and explain.
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```
|
||||
I need to understand how orders convert to invoices in the old system.
|
||||
|
||||
**Process:** Order-to-Invoice conversion workflow
|
||||
**Why:** Need to implement this in new OrderResource and InvoiceResource
|
||||
|
||||
Please:
|
||||
1. Search the old codebase for order/invoice conversion logic
|
||||
2. Trace the workflow from order creation → invoice generation
|
||||
3. Identify all status transitions and triggers
|
||||
4. Explain when/why orders become invoices
|
||||
5. Recommend how to implement in Filament with separate Order/Invoice models
|
||||
|
||||
Do NOT implement yet - just analyze and explain.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Template 3: Creating a Migration Script
|
||||
|
||||
```
|
||||
I need to migrate [DATA TYPE] from old database to new database.
|
||||
|
||||
**Old Table(s):** [table names]
|
||||
**New Table(s):** [table names]
|
||||
**Record Count:** [approximate number]
|
||||
**Special Considerations:** [foreign keys, transformations, etc.]
|
||||
|
||||
Reference:
|
||||
- SCHEMA_TRANSFORMATION.md (Section: [section name])
|
||||
|
||||
Please:
|
||||
1. Create Laravel migration file
|
||||
2. Write data migration script (using DB facade or raw SQL)
|
||||
3. Include data transformation logic from SCHEMA_TRANSFORMATION.md
|
||||
4. Add verification queries to confirm successful migration
|
||||
5. Include rollback logic
|
||||
|
||||
Test with a small subset first (LIMIT 10).
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```
|
||||
I need to migrate products from old database to new database.
|
||||
|
||||
**Old Table(s):** crm_products, crm_brands, products
|
||||
**New Table(s):** products, brands
|
||||
**Record Count:** 883 products
|
||||
**Special Considerations:**
|
||||
- 552 products have NULL brand_id (assign to default "Cannabrands" brand)
|
||||
- Parent-child relationships for product varieties
|
||||
- BOM flags (isAssembly, isRaw, isSellable)
|
||||
|
||||
Reference:
|
||||
- SCHEMA_TRANSFORMATION.md (Section: Products Migration)
|
||||
|
||||
Please:
|
||||
1. Create Laravel migration file
|
||||
2. Write data migration script with brand assignment logic
|
||||
3. Handle NULL brand_id → default to Cannabrands
|
||||
4. Preserve parent_product_id relationships
|
||||
5. Include COUNT verification before/after
|
||||
|
||||
Test with LIMIT 10 first, then show me results before full migration.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Template 4: Debugging Behavior Mismatch
|
||||
|
||||
```
|
||||
The new implementation doesn't match old system behavior.
|
||||
|
||||
**Feature:** [what you implemented]
|
||||
**Expected Behavior:** [what should happen, based on old system]
|
||||
**Actual Behavior:** [what's happening in new system]
|
||||
**Old System Reference:** [file/line where expected behavior exists]
|
||||
|
||||
Please:
|
||||
1. Read the old implementation carefully
|
||||
2. Identify what business logic I missed
|
||||
3. Show me the discrepancy
|
||||
4. Fix the new implementation to match
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```
|
||||
The new implementation doesn't match old system behavior.
|
||||
|
||||
**Feature:** Order acceptance workflow
|
||||
**Expected Behavior:** When seller creates order, buyer must accept before it can be fulfilled. When buyer creates order, seller must accept.
|
||||
**Actual Behavior:** All orders can be immediately marked as accepted regardless of who created them.
|
||||
**Old System Reference:** app/Models/Invoice.php (acceptable() method, lines 45-52)
|
||||
|
||||
Please:
|
||||
1. Read the old Invoice model's acceptable() logic
|
||||
2. Identify the created_by and status checks I missed
|
||||
3. Show me what validation rules are missing
|
||||
4. Update OrderResource to match this business rule
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Reference Strategy
|
||||
|
||||
### ✅ WHEN to Read Old Codebase
|
||||
|
||||
**1. Business Rules & Validation**
|
||||
```php
|
||||
// OLD: vendor/venturedrake/laravel-crm/src/Http/Requests/StoreCompanyRequest.php
|
||||
'license_number' => 'required|string|max:50|unique:companies',
|
||||
'license_document' => 'required|file|mimes:pdf,jpg,png|max:5120',
|
||||
|
||||
// Extract these rules for new CompanyResource
|
||||
```
|
||||
|
||||
**2. Calculations & Formulas**
|
||||
```php
|
||||
// OLD: app/Models/Invoice.php
|
||||
public function calculateTotal() {
|
||||
return $this->lines->sum(function($line) {
|
||||
return $line->quantity * $line->unit_price * (1 - $line->discount/100);
|
||||
}) + $this->shipping_cost + $this->tax_amount;
|
||||
}
|
||||
|
||||
// Preserve exact calculation logic in new Order model
|
||||
```
|
||||
|
||||
**3. Status Workflows**
|
||||
```php
|
||||
// OLD: Check status transitions and conditions
|
||||
if ($order->status == 'new' && $order->created_by === 'seller') {
|
||||
// Buyer must accept
|
||||
} elseif ($order->status == 'accepted') {
|
||||
// Can be fulfilled
|
||||
}
|
||||
|
||||
// Replicate state machine logic in new system
|
||||
```
|
||||
|
||||
**4. Email Templates & Notifications**
|
||||
```php
|
||||
// OLD: resources/views/emails/order/accepted.blade.php
|
||||
// Copy branding, copy, and structure
|
||||
// Update to use new Filament notification patterns
|
||||
```
|
||||
|
||||
**5. Complex Queries**
|
||||
```php
|
||||
// OLD: Multi-table joins, aggregations, report logic
|
||||
$products = Product::with(['brand', 'parent'])
|
||||
->where('is_active', true)
|
||||
->whereHas('brand', fn($q) => $q->where('public', true))
|
||||
->get();
|
||||
|
||||
// Preserve query structure and business logic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ WHAT NOT to Copy
|
||||
|
||||
**1. VentureDrake Model Patterns**
|
||||
```php
|
||||
// DON'T COPY THIS:
|
||||
namespace VentureDrake\LaravelCrm\Models;
|
||||
use VentureDrake\LaravelCrm\Traits\BelongsToTeams;
|
||||
|
||||
// Instead: Use clean Laravel models
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
```
|
||||
|
||||
**2. CRM-Specific Features**
|
||||
```php
|
||||
// DON'T COPY: Leads, Deals, Pipelines, Campaigns
|
||||
// These are unused CRM bloat - implement only what's needed
|
||||
```
|
||||
|
||||
**3. Vendor Overrides & Hacks**
|
||||
```php
|
||||
// DON'T COPY: Direct vendor file modifications
|
||||
// Use proper Laravel extension patterns instead
|
||||
```
|
||||
|
||||
**4. Inefficient Queries**
|
||||
```php
|
||||
// DON'T COPY: N+1 queries or missing eager loading
|
||||
foreach ($orders as $order) {
|
||||
echo $order->company->name; // N+1 problem
|
||||
}
|
||||
|
||||
// Instead: Optimize with eager loading
|
||||
Order::with('company')->get();
|
||||
```
|
||||
|
||||
**5. Inline Business Logic in Controllers**
|
||||
```php
|
||||
// DON'T COPY: Fat controllers
|
||||
public function store(Request $request) {
|
||||
// 200 lines of business logic...
|
||||
}
|
||||
|
||||
// Instead: Use Services, Actions, or Model methods
|
||||
public function store(Request $request, OrderService $service) {
|
||||
$service->createOrder($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Development Workflow
|
||||
|
||||
### Git Strategy
|
||||
|
||||
**Branch Naming:**
|
||||
```bash
|
||||
feature/[feature-name] # New features (e.g., feature/shopping-cart)
|
||||
fix/[bug-description] # Bug fixes (e.g., fix/order-total-calculation)
|
||||
migrate/[data-type] # Data migrations (e.g., migrate/products)
|
||||
```
|
||||
|
||||
**Commit Message Pattern:**
|
||||
```
|
||||
[type]: [concise description]
|
||||
|
||||
[Optional detailed explanation]
|
||||
[Reference to old system if applicable]
|
||||
|
||||
Examples:
|
||||
✅ feat: implement buyer application approval workflow
|
||||
- Add CompanyResource approval/rejection actions
|
||||
- Preserve license validation from old CompaniesController.php:520
|
||||
- Add email notifications on status change
|
||||
|
||||
✅ fix: correct order total calculation to match legacy system
|
||||
- Include tax and shipping in total
|
||||
- Reference: app/Models/Invoice.php:calculateTotal()
|
||||
|
||||
✅ migrate: import 883 products from old database
|
||||
- Assign 552 unbranded products to default Cannabrands brand
|
||||
- Preserve parent_product_id relationships
|
||||
- Verified: All products migrated successfully
|
||||
```
|
||||
|
||||
**Daily Workflow:**
|
||||
```bash
|
||||
# Morning: Start day's feature
|
||||
git checkout -b feature/[todays-feature]
|
||||
|
||||
# During: Commit frequently
|
||||
git add .
|
||||
git commit -m "feat: [incremental progress]"
|
||||
|
||||
# End of Day: Merge if complete and tested
|
||||
git checkout feature/migration-implementation
|
||||
git merge feature/[todays-feature] --no-ff
|
||||
git branch -d feature/[todays-feature]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Testing After Each Feature
|
||||
|
||||
**1. Filament Resource Testing**
|
||||
```
|
||||
After implementing [Resource]Resource:
|
||||
|
||||
1. Visual Test:
|
||||
- Visit /admin/[resources] in browser
|
||||
- Create new record
|
||||
- Edit existing record
|
||||
- Test filters and search
|
||||
- Test custom actions
|
||||
|
||||
2. Validation Test:
|
||||
- Try submitting empty form (should fail)
|
||||
- Try invalid data (should show errors)
|
||||
- Try valid data (should succeed)
|
||||
|
||||
3. Relationship Test:
|
||||
- Test relation managers (if any)
|
||||
- Verify related records display
|
||||
- Test creating related records
|
||||
|
||||
4. Permission Test (if roles implemented):
|
||||
- Test as admin (should have full access)
|
||||
- Test as regular user (should have limited access)
|
||||
```
|
||||
|
||||
**2. Public Page Testing**
|
||||
```
|
||||
After implementing public-facing page:
|
||||
|
||||
1. Guest Test:
|
||||
- Access page without login
|
||||
- Submit form with valid data
|
||||
- Submit form with invalid data
|
||||
|
||||
2. Email Test:
|
||||
- Check Mailpit (localhost:8025)
|
||||
- Verify email content and styling
|
||||
- Test email links
|
||||
|
||||
3. Flow Test:
|
||||
- Complete full user journey (register → verify → login)
|
||||
- Check database records created
|
||||
- Verify relationships created
|
||||
```
|
||||
|
||||
**3. Migration Script Testing**
|
||||
```
|
||||
After creating migration script:
|
||||
|
||||
1. Test Run (Small Subset):
|
||||
php artisan migrate:fresh --seed
|
||||
php artisan migrate:legacy:products --limit=10
|
||||
|
||||
2. Verification Queries:
|
||||
SELECT COUNT(*) FROM products;
|
||||
SELECT * FROM products WHERE brand_id IS NULL; -- Should be 0
|
||||
SELECT * FROM products WHERE parent_product_id IS NOT NULL; -- Varieties
|
||||
|
||||
3. Rollback Test:
|
||||
php artisan migrate:rollback
|
||||
-- Verify data removed cleanly
|
||||
|
||||
4. Full Migration (After test passes):
|
||||
php artisan migrate:legacy:products
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Debugging Patterns
|
||||
|
||||
### Pattern 1: Behavior Doesn't Match Old System
|
||||
|
||||
**Symptom:** "This works, but it's not doing what the old system did"
|
||||
|
||||
**Process:**
|
||||
```
|
||||
1. Find old implementation:
|
||||
Prompt: "Search old codebase for [feature] logic in controllers, models, and services"
|
||||
|
||||
2. Trace execution:
|
||||
Prompt: "Read [old file] and explain step-by-step what happens when [action occurs]"
|
||||
|
||||
3. Compare implementations:
|
||||
Prompt: "Here's my new implementation [paste code]. Compare to old system and identify missing business logic"
|
||||
|
||||
4. Fix discrepancies:
|
||||
Prompt: "Update new implementation to include [missing rule] from old system"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
User: "Order totals in new system don't match old invoices"
|
||||
|
||||
Claude: Let me search for total calculation in old system
|
||||
[Reads app/Models/Invoice.php]
|
||||
|
||||
Claude: I found the issue. Old system includes:
|
||||
- Line items (quantity × price × discount)
|
||||
- Shipping cost
|
||||
- Tax amount
|
||||
|
||||
Your new Order model is missing shipping and tax. Here's the fix:
|
||||
[Provides corrected calculation method]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Data Migration Produces Incorrect Results
|
||||
|
||||
**Symptom:** "Migration ran, but data looks wrong"
|
||||
|
||||
**Process:**
|
||||
```
|
||||
1. Run diagnostic queries:
|
||||
SELECT COUNT(*), brand_id FROM products GROUP BY brand_id;
|
||||
SELECT * FROM products WHERE [expected_field] IS NULL LIMIT 10;
|
||||
|
||||
2. Compare to old system:
|
||||
-- Run same query on old database
|
||||
SELECT COUNT(*), brand_id FROM crm_products GROUP BY brand_id;
|
||||
|
||||
3. Identify transformation error:
|
||||
Prompt: "My migration produced [X results] but old system has [Y results].
|
||||
Here's my migration script [paste]. Find the bug."
|
||||
|
||||
4. Fix and re-run:
|
||||
php artisan migrate:rollback
|
||||
[Fix migration script]
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 3: Filament Action Not Working
|
||||
|
||||
**Symptom:** "Button appears but clicking does nothing / shows error"
|
||||
|
||||
**Debugging Steps:**
|
||||
```
|
||||
1. Check browser console for JS errors
|
||||
2. Check Laravel logs: tail -f storage/logs/laravel.log
|
||||
3. Verify action requirements:
|
||||
- Is user authorized?
|
||||
- Are required fields present?
|
||||
- Is model state correct?
|
||||
|
||||
Prompt: "My Filament action [action name] in [Resource] isn't working.
|
||||
Here's the code [paste]. Here's the error [paste].
|
||||
Reference old system at [file path] to see how it should work."
|
||||
```
|
||||
|
||||
**Common Fixes:**
|
||||
```php
|
||||
// Missing authorization
|
||||
Tables\Actions\Action::make('approve')
|
||||
->visible(fn ($record) => $record->status === 'pending') // Add this
|
||||
|
||||
// Missing requiresConfirmation
|
||||
Tables\Actions\Action::make('delete')
|
||||
->requiresConfirmation() // Add this for destructive actions
|
||||
|
||||
// Missing success notification
|
||||
->action(function ($record) {
|
||||
$record->approve();
|
||||
Notification::make()->success()->title('Approved')->send(); // Add this
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Example Interactions
|
||||
|
||||
### Example 1: Day 1 - Company Model Implementation
|
||||
|
||||
**User:**
|
||||
```
|
||||
Starting Day 1 tasks. I need to:
|
||||
1. Rename businesses table to companies
|
||||
2. Add buyer application fields
|
||||
3. Create CompanyResource
|
||||
|
||||
Reference:
|
||||
- FEATURE_IMPLEMENTATION_ROADMAP.md (Day 1)
|
||||
- FILAMENT_RESOURCES_SPEC.md (CompanyResource)
|
||||
- Old system: vendor/venturedrake/laravel-crm/src/Models/Organisation.php
|
||||
|
||||
Please implement step 1 (rename migration) first.
|
||||
```
|
||||
|
||||
**Claude:**
|
||||
```
|
||||
I'll create a migration to rename the businesses table to companies and add buyer application fields.
|
||||
|
||||
[Creates migration file]
|
||||
[Shows migration code]
|
||||
|
||||
This migration:
|
||||
1. Renames businesses → companies
|
||||
2. Adds license_number, license_document_path, w9_document_path
|
||||
3. Adds buyer application fields (status, approved_at, rejected_at, rejection_reason)
|
||||
4. Preserves all existing data
|
||||
|
||||
Run: php artisan migrate
|
||||
|
||||
After confirming this works, I'll proceed to step 2 (CompanyResource).
|
||||
```
|
||||
|
||||
**User:**
|
||||
```
|
||||
Migration ran successfully. Proceed with CompanyResource.
|
||||
```
|
||||
|
||||
**Claude:**
|
||||
```
|
||||
I'll create the CompanyResource with approval workflow.
|
||||
|
||||
First, let me read the old buyer application logic:
|
||||
[Reads vendor/venturedrake/.../CompaniesController.php lines 520-790]
|
||||
|
||||
I see the approval workflow requires:
|
||||
- License document validation
|
||||
- W9 document validation
|
||||
- Admin approval action
|
||||
- Email notification on approval/rejection
|
||||
|
||||
Creating CompanyResource with these features...
|
||||
[Creates resource file]
|
||||
[Shows code]
|
||||
|
||||
Test by:
|
||||
1. Visit /admin/companies
|
||||
2. Click on a pending company
|
||||
3. Click "Approve" action
|
||||
4. Check email in Mailpit (localhost:8025)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Understanding Order Workflow
|
||||
|
||||
**User:**
|
||||
```
|
||||
I need to understand how orders convert to invoices in the old system.
|
||||
|
||||
**Process:** Order-to-Invoice conversion workflow
|
||||
**Why:** Need to implement this in new OrderResource and InvoiceResource
|
||||
|
||||
Please analyze and explain - don't implement yet.
|
||||
```
|
||||
|
||||
**Claude:**
|
||||
```
|
||||
Let me trace the order-to-invoice workflow in the old system.
|
||||
|
||||
[Searches for 'invoice' AND 'order' in old codebase]
|
||||
[Reads app/Models/Invoice.php]
|
||||
[Reads vendor/venturedrake/.../InvoiceController.php]
|
||||
|
||||
Here's how it works:
|
||||
|
||||
**Data Structure:**
|
||||
- Old system uses SINGLE table: invoices
|
||||
- Column: type (values: 'order', 'invoice', 'quote', 'draft')
|
||||
- Column: status (values: 'new', 'accepted', 'create_manifest', 'invoiced', 'delivered')
|
||||
|
||||
**Workflow:**
|
||||
1. Order Created:
|
||||
- type = 'order'
|
||||
- status = 'new'
|
||||
- created_by = 'seller' OR 'buyer'
|
||||
|
||||
2. Acceptance:
|
||||
- If created_by = 'seller' → buyer must accept
|
||||
- If created_by = 'buyer' → seller must accept
|
||||
- Status changes: 'new' → 'accepted'
|
||||
|
||||
3. Fulfillment Prep:
|
||||
- Admin marks order for fulfillment
|
||||
- Status changes: 'accepted' → 'create_manifest'
|
||||
|
||||
4. **CONVERSION TRIGGER** (InvoiceController.php:434):
|
||||
- When status = 'create_manifest'
|
||||
- Type changes: 'order' → 'invoice'
|
||||
- Status changes: 'create_manifest' → 'invoiced'
|
||||
|
||||
5. Delivery:
|
||||
- Status changes: 'invoiced' → 'delivered'
|
||||
- Payment tracking begins
|
||||
|
||||
**Recommendation for New System:**
|
||||
- Use separate Order and Invoice models/tables
|
||||
- When Order.status = 'fulfilled', create Invoice record
|
||||
- Copy order data to invoice (preserve order_id foreign key)
|
||||
- Keep Order record for history (don't delete)
|
||||
|
||||
This gives you:
|
||||
- Cleaner separation of concerns
|
||||
- Better audit trail
|
||||
- Easier reporting
|
||||
|
||||
Would you like me to implement this workflow now?
|
||||
```
|
||||
|
||||
**User:**
|
||||
```
|
||||
Yes, implement this in OrderResource and InvoiceResource.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 3: Debugging Migration Discrepancy
|
||||
|
||||
**User:**
|
||||
```
|
||||
I ran the products migration but the counts are wrong.
|
||||
|
||||
Expected (from old system): 883 products
|
||||
Actual (new system): 1435 products
|
||||
|
||||
Here's my migration script: [pastes script]
|
||||
```
|
||||
|
||||
**Claude:**
|
||||
```
|
||||
Let me investigate. First, let me check if the old system has soft deletes:
|
||||
|
||||
[Reads old products table schema]
|
||||
|
||||
Found the issue! The old crm_products table has a deleted_at column.
|
||||
Your migration script is copying ALL records, including soft-deleted ones.
|
||||
|
||||
Old system:
|
||||
- Total records: 1435
|
||||
- Active (deleted_at IS NULL): 883
|
||||
- Soft-deleted: 552
|
||||
|
||||
Fix:
|
||||
[Shows updated migration script with WHERE deleted_at IS NULL]
|
||||
|
||||
Run this to fix:
|
||||
php artisan migrate:rollback
|
||||
php artisan migrate
|
||||
php artisan migrate:legacy:products
|
||||
|
||||
You should now see exactly 883 products.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Daily Checklist
|
||||
|
||||
**Every Morning:**
|
||||
```
|
||||
[ ] Review FEATURE_IMPLEMENTATION_ROADMAP.md for today's tasks
|
||||
[ ] Create feature branch: git checkout -b feature/[todays-feature]
|
||||
[ ] Identify which old files contain business logic for today's feature
|
||||
```
|
||||
|
||||
**During Implementation:**
|
||||
```
|
||||
[ ] Read old implementation first (understand before coding)
|
||||
[ ] Extract business rules (validations, calculations, workflows)
|
||||
[ ] Implement in Filament/Laravel best practices
|
||||
[ ] Do NOT copy CRM-specific patterns
|
||||
[ ] Commit incrementally (every 1-2 hours)
|
||||
```
|
||||
|
||||
**Before Marking Task Complete:**
|
||||
```
|
||||
[ ] Visual test in browser (if applicable)
|
||||
[ ] Validation test (try to break it)
|
||||
[ ] Check Laravel logs for errors
|
||||
[ ] Run relevant test suite (if tests exist)
|
||||
[ ] Commit final changes with descriptive message
|
||||
```
|
||||
|
||||
**End of Day:**
|
||||
```
|
||||
[ ] Merge feature branch if complete and tested
|
||||
[ ] Update progress in FEATURE_IMPLEMENTATION_ROADMAP.md (add checkmarks)
|
||||
[ ] Note any blockers or questions for tomorrow
|
||||
[ ] Push to remote: git push origin feature/migration-implementation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 When to Ask for Clarification
|
||||
|
||||
**ASK when:**
|
||||
- Old system has conflicting business rules in different files
|
||||
- Data migration produces unexpected results
|
||||
- You're unsure which old implementation is the "source of truth"
|
||||
- Performance implications of copying old approach (e.g., N+1 queries)
|
||||
- Security concerns with old implementation
|
||||
|
||||
**DON'T ASK when:**
|
||||
- You can find answer in old codebase (read it first)
|
||||
- It's covered in migration documents (SCHEMA_TRANSFORMATION.md, etc.)
|
||||
- It's a standard Laravel/Filament pattern (use best practices)
|
||||
- Old implementation is clearly wrong (use correct approach in new system)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Common Pitfalls & Solutions
|
||||
|
||||
### Pitfall 1: Copying CRM Model Structure
|
||||
```
|
||||
❌ WRONG:
|
||||
namespace App\Models;
|
||||
use VentureDrake\LaravelCrm\Traits\BelongsToTeams;
|
||||
class Company extends Model {
|
||||
use BelongsToTeams; // Don't copy CRM traits
|
||||
}
|
||||
|
||||
✅ RIGHT:
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class Company extends Model {
|
||||
// Clean Laravel model
|
||||
}
|
||||
```
|
||||
|
||||
### Pitfall 2: Assuming Table Names Match
|
||||
```
|
||||
❌ WRONG:
|
||||
Assuming old "organisations" = new "companies" directly
|
||||
|
||||
✅ RIGHT:
|
||||
Check SCHEMA_TRANSFORMATION.md for exact table mappings
|
||||
Old: companies (CRM Contact companies) + organisations (CRM Orgs)
|
||||
New: companies (unified, with type field)
|
||||
```
|
||||
|
||||
### Pitfall 3: Missing Business Rules
|
||||
```
|
||||
❌ WRONG:
|
||||
Creating generic CRUD without checking old validations
|
||||
|
||||
✅ RIGHT:
|
||||
Read old Request classes for validation rules:
|
||||
- StoreCompanyRequest.php
|
||||
- UpdateProductRequest.php
|
||||
Extract rules and apply to Filament resource
|
||||
```
|
||||
|
||||
### Pitfall 4: Over-Engineering Too Soon
|
||||
```
|
||||
❌ WRONG:
|
||||
Implementing features that don't exist in old system
|
||||
"Should I add multi-currency support?"
|
||||
|
||||
✅ RIGHT:
|
||||
Implement only what old system has (feature parity first)
|
||||
Note ideas for post-launch improvements
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Quick Reference
|
||||
|
||||
**Old Codebase Locations:**
|
||||
```
|
||||
Business Logic: app/Models/*.php
|
||||
Controllers: app/Http/Controllers/**/*.php
|
||||
Validations: app/Http/Requests/*.php
|
||||
CRM Controllers: vendor/venturedrake/.../Controllers/*.php
|
||||
CRM Models: vendor/venturedrake/.../Models/*.php
|
||||
Email Templates: resources/views/emails/**/*.blade.php
|
||||
Routes: routes/*.php
|
||||
```
|
||||
|
||||
**New Codebase Locations:**
|
||||
```
|
||||
Models: app/Models/*.php
|
||||
Filament Resources: app/Filament/Resources/*.php
|
||||
Services: app/Services/*.php
|
||||
Migrations: database/migrations/*.php
|
||||
Seeders: database/seeders/*.php
|
||||
Public Pages: app/Http/Controllers/**/*.php
|
||||
Views: resources/views/**/*.blade.php
|
||||
```
|
||||
|
||||
**Key Documents:**
|
||||
```
|
||||
Migration Strategy: MIGRATION_MASTER_PLAN.md
|
||||
Data Mappings: SCHEMA_TRANSFORMATION.md
|
||||
Day-by-Day Tasks: FEATURE_IMPLEMENTATION_ROADMAP.md
|
||||
Filament Code: FILAMENT_RESOURCES_SPEC.md
|
||||
This Guide: CLAUDE_COLLABORATION_WORKFLOW.md
|
||||
```
|
||||
|
||||
**Useful Commands:**
|
||||
```bash
|
||||
# Development
|
||||
php artisan serve
|
||||
php artisan migrate
|
||||
php artisan migrate:fresh --seed
|
||||
|
||||
# Testing
|
||||
php artisan tinker
|
||||
php artisan route:list
|
||||
tail -f storage/logs/laravel.log
|
||||
|
||||
# Email Testing
|
||||
docker-compose up mailpit -d
|
||||
# Visit: localhost:8025
|
||||
|
||||
# Database
|
||||
php artisan db:seed
|
||||
php artisan migrate:rollback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Indicators
|
||||
|
||||
**You're on the right track when:**
|
||||
- New feature behavior matches old system exactly
|
||||
- Migration scripts produce expected record counts
|
||||
- Filament resources are cleaner than old CRM code
|
||||
- Business rules are preserved, but implementation is modern
|
||||
- You can explain why new approach is better than old
|
||||
|
||||
**Red flags:**
|
||||
- Copying vendor file structures
|
||||
- Implementing unused CRM features
|
||||
- Can't explain business rule origin
|
||||
- Migration counts don't match old system
|
||||
- New feature does something old system didn't
|
||||
|
||||
---
|
||||
|
||||
**End of Guide**
|
||||
|
||||
*This document evolves as you progress through the migration. Update it with new patterns, pitfalls, and solutions as you discover them.*
|
||||
258
CONTRIBUTING.md
258
CONTRIBUTING.md
@@ -68,21 +68,59 @@ Our workflow provides audit trails regulators love:
|
||||
|
||||
---
|
||||
|
||||
## Branch Protection & Pull Request Workflow
|
||||
|
||||
**IMPORTANT:** The `develop` and `master` branches are **protected** - you cannot push directly to them.
|
||||
|
||||
### Standard Workflow:
|
||||
|
||||
```bash
|
||||
# 1. Create a feature branch
|
||||
git checkout -b feature/my-feature-name
|
||||
|
||||
# 2. Make changes and commit
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
|
||||
# 3. Push to your feature branch
|
||||
git push origin feature/my-feature-name
|
||||
|
||||
# 4. Create Pull Request on Gitea
|
||||
# - Navigate to https://code.cannabrands.app
|
||||
# - Create PR to merge your branch into develop
|
||||
# - CI will run automatically
|
||||
# - Request review from team
|
||||
|
||||
# 5. After approval and passing CI
|
||||
# - Merge PR via Gitea interface
|
||||
# - Delete feature branch
|
||||
```
|
||||
|
||||
### Branch Naming Conventions:
|
||||
|
||||
- `feature/` - New features (e.g., `feature/bulk-import`)
|
||||
- `fix/` - Bug fixes (e.g., `fix/tax-calculation`)
|
||||
- `chore/` - Maintenance tasks (e.g., `chore/upgrade-php`)
|
||||
- `docs/` - Documentation changes (e.g., `docs/update-readme`)
|
||||
|
||||
---
|
||||
|
||||
## Real-World Team Scenarios
|
||||
|
||||
### Scenario 1: Normal Feature Development
|
||||
**Developer Jon adds bulk import feature**
|
||||
|
||||
```bash
|
||||
$ git checkout -b feature/bulk-import # Create feature branch
|
||||
$ vim app/Orders.php # Make changes
|
||||
$ git add .
|
||||
$ git commit -m "feat(orders): add bulk import"
|
||||
🎨 Pre-commit: Pint formats code (1s) ✅
|
||||
|
||||
$ git push origin master
|
||||
$ git push origin feature/bulk-import
|
||||
🧪 Pre-push: Tests run (30s) ✅
|
||||
✅ All tests passed! Pushing...
|
||||
🚀 CI: Full verification (5min) ✅
|
||||
🚀 Create PR → merge to develop → CI verifies (5min) ✅
|
||||
```
|
||||
|
||||
**Time cost: 31 seconds** (vs 5+ minutes if tests failed in CI)
|
||||
@@ -122,14 +160,15 @@ $ git push --no-verify # Skip tests intentionally
|
||||
**Developer Emma fixes production bug**
|
||||
|
||||
```bash
|
||||
$ vim app/Invoice.php # Critical bug fix
|
||||
$ git checkout -b fix/tax-calculation # Create hotfix branch
|
||||
$ vim app/Invoice.php # Critical bug fix
|
||||
$ git commit -m "fix(invoices): correct tax calculation"
|
||||
🎨 Pre-commit: Formats ✅
|
||||
|
||||
$ git push origin master
|
||||
$ git push origin fix/tax-calculation
|
||||
🧪 Pre-push: Tests run (30s) ✅
|
||||
🚀 CI: Passes (5min) ✅
|
||||
📦 Deploy: Safe to release ✅
|
||||
🚀 Create PR → fast-track review → merge to develop ✅
|
||||
📦 CI: Passes (5min) → Safe to release ✅
|
||||
```
|
||||
|
||||
**Safety: Tests caught regression** before it reached production
|
||||
@@ -138,7 +177,8 @@ $ git push origin master
|
||||
**Developer Alex updates dependencies**
|
||||
|
||||
```bash
|
||||
$ vim Dockerfile # Update PHP version
|
||||
$ git checkout -b chore/php-8.3-upgrade # Create branch
|
||||
$ vim Dockerfile # Update PHP version
|
||||
|
||||
# Test locally FIRST (best practice)
|
||||
$ docker build -t cannabrands:test .
|
||||
@@ -146,8 +186,8 @@ $ docker build -t cannabrands:test .
|
||||
|
||||
# Then push
|
||||
$ git commit -m "chore: upgrade PHP to 8.3"
|
||||
$ git push origin master
|
||||
🚀 CI: Rebuilds (8min) ✅
|
||||
$ git push origin chore/php-8.3-upgrade
|
||||
🚀 Create PR → CI rebuilds (8min) ✅
|
||||
```
|
||||
|
||||
**Time saved: 5 minutes** by catching Docker issues locally
|
||||
@@ -170,20 +210,27 @@ Layer 3: CI (REQUIRED) → Final verification (~5 minutes)
|
||||
|
||||
**For most changes:**
|
||||
```bash
|
||||
# 1. Make your changes
|
||||
# 1. Create feature branch
|
||||
git checkout -b feature/my-feature
|
||||
|
||||
# 2. Make your changes
|
||||
vim app/SomeFile.php
|
||||
|
||||
# 2. Commit (formatting happens automatically)
|
||||
# 3. Commit (formatting happens automatically)
|
||||
git add .
|
||||
git commit -m "feat(scope): description"
|
||||
→ Pre-commit runs Laravel Pint ✅
|
||||
→ Code formatted automatically ✅
|
||||
|
||||
# 3. Push (tests run automatically)
|
||||
git push origin master
|
||||
# 4. Push (tests run automatically)
|
||||
git push origin feature/my-feature
|
||||
→ Pre-push runs tests (30 seconds) ✅
|
||||
→ If tests pass, push continues ✅
|
||||
|
||||
# 5. Create Pull Request
|
||||
→ Open PR on Gitea to merge into develop
|
||||
→ CI verifies everything (5 minutes) ✅
|
||||
→ After review, merge PR
|
||||
```
|
||||
|
||||
**For quick documentation changes:**
|
||||
@@ -192,6 +239,163 @@ git push origin master
|
||||
git push --no-verify
|
||||
```
|
||||
|
||||
### Keeping Your Feature Branch Up-to-Date
|
||||
|
||||
**Best practice for teams:** Sync your feature branch with `develop` regularly to avoid large merge conflicts.
|
||||
|
||||
#### Daily Start-of-Work Routine
|
||||
|
||||
```bash
|
||||
# 1. Get latest changes from develop
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
|
||||
# 2. Update your feature branch
|
||||
git checkout feature/my-feature
|
||||
git merge develop
|
||||
|
||||
# 3. If there are conflicts (see below), resolve them
|
||||
# 4. Continue working
|
||||
```
|
||||
|
||||
**How often?**
|
||||
- Minimum: Once per day (start of work)
|
||||
- Better: Multiple times per day if develop is active
|
||||
- Always: Before creating your Pull Request
|
||||
|
||||
#### Merge vs Rebase: Which to Use?
|
||||
|
||||
**For teams of 5+ developers, use `merge` (not `rebase`):**
|
||||
|
||||
```bash
|
||||
git checkout feature/my-feature
|
||||
git merge develop
|
||||
```
|
||||
|
||||
**Why merge over rebase?**
|
||||
- ✅ Safer: Preserves your commit history
|
||||
- ✅ Collaborative: Works when multiple people work on the same feature branch
|
||||
- ✅ Transparent: Shows when you integrated upstream changes
|
||||
- ✅ No force-push: Once you've pushed to origin, merge won't require `--force`
|
||||
|
||||
**When to use rebase:**
|
||||
- ⚠️ Only if you haven't pushed yet
|
||||
- ⚠️ Only if you're the sole developer on the branch
|
||||
- ⚠️ You want a cleaner, linear history
|
||||
|
||||
```bash
|
||||
# Only do this if you haven't pushed yet!
|
||||
git checkout feature/my-feature
|
||||
git rebase develop
|
||||
```
|
||||
|
||||
**Never rebase after pushing** - it rewrites history and breaks collaboration.
|
||||
|
||||
#### Handling Merge Conflicts
|
||||
|
||||
When you run `git merge develop` and see conflicts:
|
||||
|
||||
```bash
|
||||
$ git merge develop
|
||||
Auto-merging app/Http/Controllers/OrderController.php
|
||||
CONFLICT (content): Merge conflict in app/Http/Controllers/OrderController.php
|
||||
Automatic merge failed; fix conflicts and then commit the result.
|
||||
```
|
||||
|
||||
**Step-by-step resolution:**
|
||||
|
||||
1. **See which files have conflicts:**
|
||||
```bash
|
||||
git status
|
||||
# Look for "both modified:" files
|
||||
```
|
||||
|
||||
2. **Open conflicted files** - look for conflict markers:
|
||||
```php
|
||||
<<<<<<< HEAD
|
||||
// Your code
|
||||
=======
|
||||
// Code from develop
|
||||
>>>>>>> develop
|
||||
```
|
||||
|
||||
3. **Resolve conflicts** - edit the file to keep what you need:
|
||||
```php
|
||||
// Choose your code, their code, or combine both
|
||||
// Remove the <<<, ===, >>> markers
|
||||
```
|
||||
|
||||
4. **Mark as resolved:**
|
||||
```bash
|
||||
git add app/Http/Controllers/OrderController.php
|
||||
```
|
||||
|
||||
5. **Complete the merge:**
|
||||
```bash
|
||||
git commit -m "merge: resolve conflicts with develop"
|
||||
```
|
||||
|
||||
6. **Run tests to ensure nothing broke:**
|
||||
```bash
|
||||
./vendor/bin/sail artisan test
|
||||
```
|
||||
|
||||
7. **Push the merge commit:**
|
||||
```bash
|
||||
git push origin feature/my-feature
|
||||
```
|
||||
|
||||
#### When Conflicts Are Too Complex
|
||||
|
||||
If conflicts are extensive or you're unsure:
|
||||
|
||||
1. **Abort the merge:**
|
||||
```bash
|
||||
git merge --abort
|
||||
```
|
||||
|
||||
2. **Ask for help** in #engineering Slack:
|
||||
- "I'm merging develop into feature/X and have conflicts in OrderController"
|
||||
- Someone might have context on the upstream changes
|
||||
|
||||
3. **Pair program the resolution** - screen share with the person who made the conflicting changes
|
||||
|
||||
4. **Alternative: Start fresh** (last resort):
|
||||
```bash
|
||||
# Create new branch from latest develop
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/my-feature-v2
|
||||
|
||||
# Cherry-pick your commits
|
||||
git cherry-pick <commit-hash>
|
||||
```
|
||||
|
||||
#### Example: Multi-Day Feature Work
|
||||
|
||||
```bash
|
||||
# Monday morning
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout feature/payment-integration
|
||||
git merge develop # Get latest changes
|
||||
# Work all day, make commits
|
||||
|
||||
# Tuesday morning
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout feature/payment-integration
|
||||
git merge develop # Sync again (someone added auth changes)
|
||||
# Continue working
|
||||
|
||||
# Wednesday
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout feature/payment-integration
|
||||
git merge develop # Final sync before PR
|
||||
git push origin feature/payment-integration
|
||||
# Create Pull Request
|
||||
```
|
||||
|
||||
**Result:** Small, manageable syncs instead of one huge conflict on PR day.
|
||||
|
||||
### When to Test Locally
|
||||
|
||||
**Always run tests before pushing if you:**
|
||||
@@ -276,7 +480,7 @@ vim app/SomeFile.php
|
||||
# Push fix
|
||||
git add .
|
||||
git commit -m "fix: resolve test failure"
|
||||
git push origin master
|
||||
git push origin feature/my-feature # Push to your feature branch
|
||||
```
|
||||
|
||||
---
|
||||
@@ -304,9 +508,9 @@ git commit --no-verify # Skip formatting (fix in next commit)
|
||||
|
||||
**❌ Skipping because tests fail** → Fix the tests instead
|
||||
**❌ Skipping to avoid formatting** → Let Pint format it
|
||||
**❌ Skipping on master before deploy** → CI will block you anyway
|
||||
**❌ Skipping to merge PR to develop/master** → CI will block you anyway
|
||||
|
||||
**Remember:** CI can't be bypassed, so issues will be caught before production.
|
||||
**Remember:** CI can't be bypassed, and develop/master are protected branches requiring PRs and passing CI.
|
||||
|
||||
---
|
||||
|
||||
@@ -321,7 +525,7 @@ git config core.hooksPath .githooks
|
||||
|
||||
### How it works:
|
||||
```bash
|
||||
$ git push origin master
|
||||
$ git push origin feature/my-feature
|
||||
|
||||
🧪 Running tests before push...
|
||||
(Use 'git push --no-verify' to skip)
|
||||
@@ -347,8 +551,8 @@ docker build -t cannabrands:test .
|
||||
# If successful, test run it
|
||||
docker run --rm cannabrands:test php -v
|
||||
|
||||
# Then push
|
||||
git push origin master
|
||||
# Then push to feature branch
|
||||
git push origin feature/my-feature
|
||||
```
|
||||
|
||||
**Why?** Docker builds take 5-10 minutes in CI vs 2-3 minutes locally.
|
||||
@@ -383,17 +587,23 @@ If you're responsible for creating releases, see:
|
||||
# 1. Determine version (CalVer: YYYY.MM.MICRO)
|
||||
git tag -l "2025.11.*" | sort -V | tail -1 # Check latest
|
||||
|
||||
# 2. Create release tag
|
||||
# 2. Ensure you're on master and up-to-date
|
||||
git checkout master
|
||||
git pull origin master
|
||||
|
||||
# 3. Create release tag on master
|
||||
git tag -a 2025.11.1 -m "Release notes here"
|
||||
git push origin 2025.11.1
|
||||
|
||||
# 3. CI builds production image automatically
|
||||
# 4. CI builds production image automatically
|
||||
|
||||
# 4. Generate changelog
|
||||
# 5. Generate changelog (create PR for this)
|
||||
git checkout -b chore/changelog-2025.11.1
|
||||
npm run changelog
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs: update changelog for 2025.11.1"
|
||||
git push origin master
|
||||
git push origin chore/changelog-2025.11.1
|
||||
# Create PR to merge into master
|
||||
```
|
||||
|
||||
---
|
||||
@@ -455,7 +665,7 @@ Trust the process, and the process will catch your mistakes before they reach pr
|
||||
|
||||
Consider adding:
|
||||
- Code review requirement for certain files
|
||||
- Protected branches (master requires PR)
|
||||
- ✅ Protected branches already in place (develop/master require PRs)
|
||||
- Mandatory tests on pre-push (harder to skip)
|
||||
|
||||
### If You Reach 50+ developers
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -7,6 +7,18 @@ FROM node:22-alpine AS node-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Accept Vite environment variables as build arguments
|
||||
ARG VITE_REVERB_APP_KEY
|
||||
ARG VITE_REVERB_HOST
|
||||
ARG VITE_REVERB_PORT=443
|
||||
ARG VITE_REVERB_SCHEME=https
|
||||
|
||||
# Export as environment variables for Vite build
|
||||
ENV VITE_REVERB_APP_KEY=${VITE_REVERB_APP_KEY}
|
||||
ENV VITE_REVERB_HOST=${VITE_REVERB_HOST}
|
||||
ENV VITE_REVERB_PORT=${VITE_REVERB_PORT}
|
||||
ENV VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME}
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -18,7 +30,7 @@ COPY resources ./resources
|
||||
COPY vite.config.js tailwind.config.js ./
|
||||
COPY public ./public
|
||||
|
||||
# Build frontend assets
|
||||
# Build frontend assets (Vite will inline VITE_* env vars)
|
||||
RUN npm run build
|
||||
|
||||
# ==================== Stage 2: Composer Builder ====================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,716 +0,0 @@
|
||||
# 🚀 Cannabrands CRM → New Platform Migration Master Plan
|
||||
|
||||
**Project**: Migration from Laravel 9 + VentureDrake CRM to Laravel 12 + Filament 4
|
||||
**Timeline**: 28 days (End of Month Launch)
|
||||
**Scope**: Full data migration with feature parity for core commerce features
|
||||
**Risk Level**: Medium (greenfield rebuild, but parallel operation possible)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Executive Summary
|
||||
|
||||
### Current State
|
||||
- **Old System**: Laravel 9, VentureDrake CRM, MySQL, ~1000 SKUs, 20 users, 5 brands
|
||||
- **Problem**: Vendor file modifications (100+ commits), blocked Laravel upgrades, maintenance nightmare
|
||||
- **Data**: 1+ year operational history, ~1000 products, 20 active users, ongoing orders
|
||||
|
||||
### Target State
|
||||
- **New System**: Laravel 12, Filament 4, PostgreSQL, clean architecture
|
||||
- **Architecture**: License → Company → Brands → Stores (LeafLink model)
|
||||
- **Launch Date**: End of month (~28 days)
|
||||
- **Cannabrands Brands**: Doobz, Thunderbud, High Expectations, Hash Factory, Twisties
|
||||
|
||||
### Migration Strategy
|
||||
**Two-track parallel development:**
|
||||
1. **New Platform Development**: Build features in new codebase (weeks 1-3)
|
||||
2. **Data Migration**: Import existing data from old system (week 4)
|
||||
3. **Parallel Operation**: Old system stays live until cutover
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Business Goals
|
||||
|
||||
### Primary Objectives (Launch Blockers)
|
||||
1. ✅ **Product Catalog**: 1000 SKUs with strains, lab results, varieties
|
||||
2. ✅ **Shopping Cart + Checkout**: Buyer portal commerce flow
|
||||
3. ✅ **Order Management**: Order lifecycle, statuses, fulfillment
|
||||
4. ✅ **Invoice Generation**: Invoice creation + payment tracking
|
||||
5. ✅ **Buyer Application**: Company signup with compliance approval
|
||||
6. ✅ **Company/Location Management**: Multi-location buyers
|
||||
7. ✅ **Component/BOM System**: Build SKUs from components
|
||||
|
||||
### Post-Launch Features (Deferred)
|
||||
- ⏸️ Raffles system
|
||||
- ⏸️ Sample requests
|
||||
- ⏸️ Feedback system
|
||||
|
||||
### Success Criteria
|
||||
- All 1000 SKUs migrated and searchable
|
||||
- All 20 users can log in with existing credentials
|
||||
- Historical orders visible and accessible
|
||||
- New orders can be placed and fulfilled
|
||||
- Invoices generate correctly
|
||||
- Zero data loss
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Transformation
|
||||
|
||||
### Old System Architecture
|
||||
```
|
||||
User (Laravel Auth)
|
||||
└─> CrmContact (company_contacts)
|
||||
└─> CrmCompany (companies) - BUYER
|
||||
|
||||
Cannabrands (hardcoded seller)
|
||||
└─> CrmProduct (products)
|
||||
└─> CrmInvoice (type='order')
|
||||
```
|
||||
|
||||
### New System Architecture
|
||||
```
|
||||
Company (License Holder)
|
||||
├─> type: 'seller' (Cannabrands)
|
||||
│ └─> Brands (Stores)
|
||||
│ ├─> Doobz
|
||||
│ ├─> Thunderbud
|
||||
│ ├─> High Expectations
|
||||
│ ├─> Hash Factory
|
||||
│ └─> Twisties
|
||||
│ └─> Products (SKUs)
|
||||
│ └─> Components (BOM)
|
||||
│
|
||||
└─> type: 'buyer' (Dispensaries)
|
||||
├─> Locations (delivery addresses)
|
||||
└─> Contacts (users)
|
||||
└─> Orders → Invoices
|
||||
```
|
||||
|
||||
### Key Changes
|
||||
1. **Rename**: `businesses` table → `companies` table (better terminology)
|
||||
2. **Multi-brand**: Cannabrands operates 5 brands (stores) under one license
|
||||
3. **Separation**: Products belong to Brands, not Companies
|
||||
4. **Dual-purpose removed**: Split `crm_invoices` (type='order'|'invoice') into separate tables
|
||||
5. **Clean schema**: No VentureDrake CRM bloat (70% unused tables eliminated)
|
||||
|
||||
---
|
||||
|
||||
## 📅 4-Week Implementation Timeline
|
||||
|
||||
### Week 1: Foundation & Core Models (Days 1-7)
|
||||
**Goal**: Database schema + authentication ready
|
||||
|
||||
#### Days 1-2: Schema Refactoring
|
||||
- [ ] Rename `businesses` → `companies` (migration + models)
|
||||
- [ ] Create `brands` table and Brand model
|
||||
- [ ] Create `products` table (clean, no CRM dependency)
|
||||
- [ ] Create `components` table (BOM system)
|
||||
- [ ] Create `orders` table (separate from invoices)
|
||||
- [ ] Create `order_items` table (line items)
|
||||
- [ ] Update all relationships and foreign keys
|
||||
|
||||
#### Days 3-4: Authentication & Users
|
||||
- [ ] User migration strategy (preserve passwords)
|
||||
- [ ] Spatie Permission roles: admin, company-owner, company-user, buyer
|
||||
- [ ] Contact model (user relationships)
|
||||
- [ ] Location model (delivery addresses)
|
||||
- [ ] License model (compliance tracking)
|
||||
|
||||
#### Days 5-7: Filament Admin Panel Setup
|
||||
- [ ] CompanyResource (CRUD for license holders)
|
||||
- [ ] BrandResource (store management for sellers)
|
||||
- [ ] UserResource (user management)
|
||||
- [ ] Dashboard widgets (overview stats)
|
||||
- [ ] Navigation structure
|
||||
|
||||
**Milestone 1 Deliverable**: Can create companies, brands, users in Filament
|
||||
|
||||
---
|
||||
|
||||
### Week 2: Product Catalog & BOM (Days 8-14)
|
||||
**Goal**: 1000 SKUs ready to migrate
|
||||
|
||||
#### Days 8-10: Product System
|
||||
- [ ] ProductResource in Filament
|
||||
- Form: name, SKU, description, price, strain, lab
|
||||
- Table: searchable, filterable by brand
|
||||
- RelationManager: ProductImages, ProductPrices
|
||||
- [ ] StrainResource (cannabis strains)
|
||||
- [ ] LabResource (lab test results - THC/CBD)
|
||||
- [ ] ProductCategoryResource (taxonomy)
|
||||
- [ ] Product varieties system (parent-child products)
|
||||
|
||||
#### Days 11-12: Component/BOM System
|
||||
- [ ] ComponentResource (raw materials)
|
||||
- [ ] Product → Components relationship (junction table)
|
||||
- [ ] BOM calculator (cost calculation)
|
||||
- [ ] Inventory tracking (if needed)
|
||||
|
||||
#### Days 13-14: Product Import Preparation
|
||||
- [ ] Extract old product data structure
|
||||
- [ ] Create product mapping script (old SKU → new product)
|
||||
- [ ] Create component extraction script
|
||||
- [ ] Build product seeder from old database
|
||||
- [ ] Test import with 50 sample products
|
||||
|
||||
**Milestone 2 Deliverable**: Can create/manage products with components
|
||||
|
||||
---
|
||||
|
||||
### Week 3: Commerce & Orders (Days 15-21)
|
||||
**Goal**: Full buyer purchasing flow working
|
||||
|
||||
#### Days 15-16: Shopping Cart (Buyer Portal)
|
||||
- [ ] Cart model + session management
|
||||
- [ ] Add to cart functionality
|
||||
- [ ] Cart display page (`/b/cart`)
|
||||
- [ ] Update quantities, remove items
|
||||
- [ ] Cart persistence (logged-in users)
|
||||
|
||||
#### Days 17-18: Checkout Flow
|
||||
- [ ] Checkout page (`/b/checkout`)
|
||||
- [ ] Select delivery location
|
||||
- [ ] Payment terms selection (COD, Net 15/30/60/90)
|
||||
- [ ] Order preview and confirmation
|
||||
- [ ] Order creation from cart
|
||||
|
||||
#### Days 19-20: Order Management
|
||||
- [ ] OrderResource in Filament
|
||||
- Table: order number, buyer, total, status, date
|
||||
- Form: view order details, line items
|
||||
- Actions: Accept, Fulfill, Deliver, Cancel
|
||||
- [ ] Order status workflow (new → accepted → fulfilled → delivered)
|
||||
- [ ] Email notifications (order placed, status changes)
|
||||
- [ ] Buyer order history page (`/b/orders`)
|
||||
|
||||
#### Day 21: Invoice System
|
||||
- [ ] InvoiceResource in Filament
|
||||
- [ ] Generate invoice from order
|
||||
- [ ] Invoice PDF generation
|
||||
- [ ] Payment tracking (paid/unpaid status)
|
||||
- [ ] Invoice email delivery
|
||||
|
||||
**Milestone 3 Deliverable**: Can place order, fulfill, generate invoice
|
||||
|
||||
---
|
||||
|
||||
### Week 4: Data Migration & Launch (Days 22-28)
|
||||
**Goal**: Old data migrated, system live
|
||||
|
||||
#### Days 22-23: Data Migration - Phase 1 (Companies & Users)
|
||||
- [ ] Export old database schema
|
||||
- [ ] Create Cannabrands as Company #1 (type='seller')
|
||||
- [ ] Create 5 brands linked to Cannabrands
|
||||
- Brand 1: Doobz
|
||||
- Brand 2: Thunderbud
|
||||
- Brand 3: High Expectations
|
||||
- Brand 4: Hash Factory
|
||||
- Brand 5: Twisties
|
||||
- [ ] Migrate buyer companies (old `companies` → new `companies` type='buyer')
|
||||
- [ ] Migrate users (preserve password hashes)
|
||||
- [ ] Migrate contacts → users relationship
|
||||
- [ ] Migrate locations (delivery addresses)
|
||||
|
||||
#### Days 24-25: Data Migration - Phase 2 (Products & Catalog)
|
||||
- [ ] Migrate strains table (direct copy)
|
||||
- [ ] Migrate labs table (direct copy)
|
||||
- [ ] Migrate components (BOM data)
|
||||
- [ ] Migrate products (1000 SKUs)
|
||||
- Map old `crm_products` → new `products`
|
||||
- Assign products to appropriate brand (need brand mapping logic)
|
||||
- Migrate product images
|
||||
- Migrate product prices
|
||||
- Migrate product varieties
|
||||
- [ ] Verify product data integrity (spot checks)
|
||||
|
||||
#### Day 26: Data Migration - Phase 3 (Orders & Invoices)
|
||||
- [ ] Migrate historical orders (all time)
|
||||
- Old `crm_invoices` (type='order') → new `orders`
|
||||
- Old `crm_invoice_lines` → new `order_items`
|
||||
- Preserve order statuses and dates
|
||||
- [ ] Migrate invoices
|
||||
- Old `crm_invoices` (type='invoice') → new `invoices`
|
||||
- Link to corresponding orders
|
||||
- [ ] Migrate invoice payments
|
||||
- [ ] Verify order totals match
|
||||
|
||||
#### Day 27: Testing & Bug Fixes
|
||||
- [ ] End-to-end testing
|
||||
- Register new buyer account
|
||||
- Browse products by brand
|
||||
- Add to cart, checkout
|
||||
- Place order
|
||||
- Admin: accept, fulfill order
|
||||
- Generate invoice
|
||||
- Record payment
|
||||
- [ ] User acceptance testing (UAT) with Cannabrands team
|
||||
- [ ] Performance testing (1000 products, 20 concurrent users)
|
||||
- [ ] Fix critical bugs
|
||||
- [ ] Data integrity verification
|
||||
|
||||
#### Day 28: Launch & Cutover
|
||||
- [ ] Final data sync (if parallel operation)
|
||||
- [ ] DNS/domain cutover
|
||||
- [ ] SSL certificate setup
|
||||
- [ ] Monitor error logs
|
||||
- [ ] User training documentation
|
||||
- [ ] Announce launch to users
|
||||
- [ ] Post-launch support monitoring
|
||||
|
||||
**Milestone 4 Deliverable**: New system live, old system retired
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Data Migration Detailed Plan
|
||||
|
||||
### Migration Tools
|
||||
- **Laravel Commands**: Custom Artisan commands for each data type
|
||||
- **Direct SQL**: For bulk operations (faster)
|
||||
- **Seeders**: For reference data (strains, categories)
|
||||
- **Validation**: Hash comparisons to verify data integrity
|
||||
|
||||
### Migration Order (Critical Dependencies)
|
||||
```
|
||||
1. Companies (license holders) - no dependencies
|
||||
2. Brands (stores) - depends on companies
|
||||
3. Users - depends on companies
|
||||
4. Contacts - depends on users + companies
|
||||
5. Locations - depends on companies
|
||||
6. Strains - no dependencies
|
||||
7. Labs - no dependencies
|
||||
8. Components - no dependencies
|
||||
9. Products - depends on brands, strains, labs
|
||||
10. Product Components - depends on products, components
|
||||
11. Orders - depends on companies (buyer), users, products
|
||||
12. Order Items - depends on orders, products
|
||||
13. Invoices - depends on orders
|
||||
14. Invoice Payments - depends on invoices
|
||||
```
|
||||
|
||||
### Data Transformation Scripts
|
||||
|
||||
**Location**: `/Users/jon/projects/cannabrands/cannabrands_new/database/migrations/data/`
|
||||
|
||||
**Scripts to Create**:
|
||||
1. `migrate_companies.php` - Companies + Cannabrands setup
|
||||
2. `migrate_brands.php` - 5 Cannabrands brands
|
||||
3. `migrate_users.php` - User accounts (preserve passwords)
|
||||
4. `migrate_products.php` - 1000 SKUs with relationships
|
||||
5. `migrate_orders.php` - Historical orders + invoices
|
||||
6. `verify_migration.php` - Data integrity checks
|
||||
|
||||
### Password Preservation
|
||||
```php
|
||||
// Old system uses Laravel Hash (bcrypt)
|
||||
// New system uses Laravel Hash (bcrypt)
|
||||
// Direct copy of password hashes works
|
||||
|
||||
User::create([
|
||||
'email' => $oldUser->email,
|
||||
'password' => $oldUser->password, // Direct copy - NO rehashing
|
||||
'email_verified_at' => $oldUser->email_verified_at,
|
||||
]);
|
||||
```
|
||||
|
||||
### Rollback Strategy
|
||||
- Keep old system running in parallel for 2 weeks post-launch
|
||||
- Database snapshots before each migration phase
|
||||
- Export old database to SQL dump (backup)
|
||||
- Document rollback commands for each migration script
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Filament Resources Specification
|
||||
|
||||
### 1. CompanyResource
|
||||
**Purpose**: Manage license holders (buyers + sellers)
|
||||
|
||||
**Table Columns**:
|
||||
- Name (searchable)
|
||||
- Type (badge: buyer/seller/both)
|
||||
- License # (searchable)
|
||||
- Status (badge: active/pending/suspended)
|
||||
- Created date
|
||||
|
||||
**Form Fields**:
|
||||
- Business Information: name, DBA, legal name, type
|
||||
- License: number, expiry, document upload
|
||||
- Contact: email, phone, address
|
||||
- Compliance: W9, insurance, cannabis license
|
||||
|
||||
**Relations**:
|
||||
- Brands (HasMany) - for sellers
|
||||
- Locations (HasMany)
|
||||
- Contacts (HasMany)
|
||||
- Users (BelongsToMany via pivot)
|
||||
|
||||
**Actions**:
|
||||
- Approve Company
|
||||
- Suspend Company
|
||||
- Download Compliance Docs
|
||||
|
||||
---
|
||||
|
||||
### 2. BrandResource
|
||||
**Purpose**: Manage stores (product catalogs under a company)
|
||||
|
||||
**Table Columns**:
|
||||
- Logo (image)
|
||||
- Name (searchable)
|
||||
- Company (relationship)
|
||||
- Product count
|
||||
- Status (active/inactive)
|
||||
|
||||
**Form Fields**:
|
||||
- Brand Identity: name, slug, logo, description
|
||||
- Company (BelongsTo selector)
|
||||
- Social Media: Instagram, website
|
||||
- Settings: active status
|
||||
|
||||
**Relations**:
|
||||
- Products (HasMany)
|
||||
- Company (BelongsTo)
|
||||
|
||||
**Actions**:
|
||||
- View Storefront
|
||||
- Clone Brand
|
||||
- Activate/Deactivate
|
||||
|
||||
---
|
||||
|
||||
### 3. ProductResource
|
||||
**Purpose**: Manage SKUs (products sold on platform)
|
||||
|
||||
**Table Columns**:
|
||||
- Image (thumbnail)
|
||||
- SKU (searchable)
|
||||
- Name (searchable)
|
||||
- Brand (relationship)
|
||||
- Strain (relationship)
|
||||
- Price (money format)
|
||||
- Stock status
|
||||
|
||||
**Form Fields**:
|
||||
- Product Info: name, SKU, description
|
||||
- Brand (BelongsTo selector)
|
||||
- Pricing: base price, quantity breaks
|
||||
- Cannabis: Strain (BelongsTo), Lab results (BelongsTo)
|
||||
- Media: Images (multiple upload)
|
||||
- Inventory: track stock, quantity
|
||||
|
||||
**Relations**:
|
||||
- Brand (BelongsTo)
|
||||
- Strain (BelongsTo)
|
||||
- Lab (BelongsTo)
|
||||
- Components (BelongsToMany) - BOM
|
||||
- ProductImages (HasMany)
|
||||
- ProductPrices (HasMany)
|
||||
- Varieties (HasMany) - parent/child products
|
||||
|
||||
**Actions**:
|
||||
- Clone Product
|
||||
- Generate Barcode
|
||||
- Export to CSV
|
||||
- Activate/Deactivate
|
||||
|
||||
---
|
||||
|
||||
### 4. OrderResource
|
||||
**Purpose**: Manage buyer purchase orders
|
||||
|
||||
**Table Columns**:
|
||||
- Order # (searchable)
|
||||
- Buyer Company (relationship)
|
||||
- Brand (relationship)
|
||||
- Total (money format)
|
||||
- Status (badge with colors)
|
||||
- Order date (sortable)
|
||||
|
||||
**Form Fields**:
|
||||
- Order Info: number, date, buyer, location
|
||||
- Line Items: Repeater (product, quantity, price)
|
||||
- Totals: subtotal, tax, total
|
||||
- Status: workflow selector
|
||||
- Notes: internal notes
|
||||
|
||||
**Relations**:
|
||||
- Company (BelongsTo) - buyer
|
||||
- User (BelongsTo) - who placed it
|
||||
- Location (BelongsTo) - delivery address
|
||||
- OrderItems (HasMany)
|
||||
- Invoice (HasOne)
|
||||
|
||||
**Actions**:
|
||||
- Accept Order
|
||||
- Mark as Fulfilled
|
||||
- Mark as Delivered
|
||||
- Cancel Order
|
||||
- Generate Invoice
|
||||
- Email Customer
|
||||
|
||||
**Status Workflow**:
|
||||
```
|
||||
new → accepted → fulfilled → delivered
|
||||
↓ ↓ ↓
|
||||
cancelled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. ComponentResource (BOM System)
|
||||
**Purpose**: Manage raw materials used to build products
|
||||
|
||||
**Table Columns**:
|
||||
- Name (searchable)
|
||||
- Type (badge: flower/extract/packaging)
|
||||
- Unit cost (money)
|
||||
- Unit (oz/g/each)
|
||||
- Stock
|
||||
|
||||
**Form Fields**:
|
||||
- Component Info: name, description, type
|
||||
- Pricing: cost per unit, unit of measure
|
||||
- Inventory: current stock, reorder point
|
||||
- Supplier: supplier info (optional)
|
||||
|
||||
**Relations**:
|
||||
- Products (BelongsToMany via product_components)
|
||||
|
||||
**Actions**:
|
||||
- View Products Using This Component
|
||||
- Update Cost
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Compliance
|
||||
|
||||
### Data Protection
|
||||
- **Password hashes**: Direct copy (bcrypt compatible)
|
||||
- **Sensitive documents**: Migrate file paths, verify file existence
|
||||
- **License data**: Encrypted at rest in new system
|
||||
- **Payment info**: PCI compliance (if storing cards)
|
||||
|
||||
### Access Control
|
||||
**Roles** (Spatie Permission):
|
||||
- `admin` - Platform administrators (you/your team)
|
||||
- `company-owner` - Company account owner (full company access)
|
||||
- `company-manager` - Can manage orders, products (limited)
|
||||
- `company-user` - Can place orders, view history (buyer role)
|
||||
|
||||
**Permissions**:
|
||||
- `companies.view`, `companies.create`, `companies.edit`, `companies.delete`
|
||||
- `brands.manage` - Create/edit brands (seller only)
|
||||
- `products.manage` - Manage product catalog (seller only)
|
||||
- `orders.place` - Place orders (buyer only)
|
||||
- `orders.manage` - Accept/fulfill orders (seller only)
|
||||
- `invoices.view`, `invoices.generate`
|
||||
|
||||
---
|
||||
|
||||
## 📈 Performance Optimization
|
||||
|
||||
### Database Indexes (Critical for 1000 SKUs)
|
||||
```sql
|
||||
-- Products table
|
||||
CREATE INDEX idx_products_brand_id ON products(brand_id);
|
||||
CREATE INDEX idx_products_sku ON products(sku);
|
||||
CREATE INDEX idx_products_strain_id ON products(strain_id);
|
||||
|
||||
-- Orders table
|
||||
CREATE INDEX idx_orders_company_id ON orders(company_id);
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
CREATE INDEX idx_orders_created_at ON orders(created_at);
|
||||
|
||||
-- Companies table
|
||||
CREATE INDEX idx_companies_type ON companies(type);
|
||||
CREATE INDEX idx_companies_license ON companies(license_number);
|
||||
```
|
||||
|
||||
### Caching Strategy
|
||||
- **Product catalog**: Cache for 1 hour
|
||||
- **Brand data**: Cache for 24 hours
|
||||
- **Shopping cart**: Session-based (no DB queries)
|
||||
- **Order history**: Cache per user (invalidate on new order)
|
||||
|
||||
### Eager Loading (Prevent N+1 Queries)
|
||||
```php
|
||||
// Products with relationships
|
||||
Product::with(['brand', 'strain', 'lab', 'images', 'prices'])->get();
|
||||
|
||||
// Orders with line items
|
||||
Order::with(['items.product', 'company', 'location'])->get();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### Unit Tests (Pest)
|
||||
- [ ] Product model relationships
|
||||
- [ ] Order total calculations
|
||||
- [ ] BOM cost calculations
|
||||
- [ ] User authentication
|
||||
- [ ] Permission checks
|
||||
|
||||
### Feature Tests
|
||||
- [ ] User registration flow
|
||||
- [ ] Company approval workflow
|
||||
- [ ] Product CRUD operations
|
||||
- [ ] Shopping cart functionality
|
||||
- [ ] Order placement end-to-end
|
||||
- [ ] Invoice generation
|
||||
|
||||
### Migration Verification Tests
|
||||
- [ ] User count matches (20 users)
|
||||
- [ ] Product count matches (~1000 SKUs)
|
||||
- [ ] Order totals match historical data
|
||||
- [ ] Company count matches
|
||||
- [ ] All file paths resolve (images, documents)
|
||||
|
||||
### Load Testing
|
||||
- [ ] 1000 products loaded in catalog page (<2s)
|
||||
- [ ] 20 concurrent users placing orders
|
||||
- [ ] Search performance with 1000 SKUs (<500ms)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Risk Mitigation
|
||||
|
||||
### High-Risk Items
|
||||
|
||||
**1. Password Migration**
|
||||
- **Risk**: Users can't log in if hashes incompatible
|
||||
- **Mitigation**: Test migration with 3 sample users first
|
||||
- **Rollback**: Keep old system for password resets
|
||||
|
||||
**2. Product-to-Brand Mapping**
|
||||
- **Risk**: Old system doesn't track which brand owns which product
|
||||
- **Mitigation**: Manual mapping CSV: product_id → brand_name
|
||||
- **Fallback**: Assign all to "Cannabrands General" brand initially
|
||||
|
||||
**3. Order Data Integrity**
|
||||
- **Risk**: Order totals don't match after migration
|
||||
- **Mitigation**: Checksum verification on order totals
|
||||
- **Testing**: Compare 10 random orders old vs new
|
||||
|
||||
**4. File Path Migration**
|
||||
- **Risk**: Product images, compliance docs not found
|
||||
- **Mitigation**: Copy entire storage directory
|
||||
- **Verification**: Script to check all file paths resolve
|
||||
|
||||
**5. Email Notifications**
|
||||
- **Risk**: Spamming users during migration testing
|
||||
- **Mitigation**: Use Mailpit, disable SMTP until launch
|
||||
- **Testing**: Check email queue, don't send during migration
|
||||
|
||||
### Medium-Risk Items
|
||||
- Component data incomplete (some products missing BOM)
|
||||
- Variety relationships complex (parent-child products)
|
||||
- Historical data too large (performance issues)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Launch Checklist
|
||||
|
||||
### Pre-Launch (Day 27)
|
||||
- [ ] All 1000 products migrated and visible
|
||||
- [ ] All 20 users can log in successfully
|
||||
- [ ] Test order placement end-to-end (3 different buyers)
|
||||
- [ ] Filament admin panel accessible
|
||||
- [ ] Email notifications working (test mode)
|
||||
- [ ] SSL certificate installed
|
||||
- [ ] Database backups automated
|
||||
- [ ] Error monitoring setup (Sentry/Bugsnag)
|
||||
|
||||
### Launch Day (Day 28)
|
||||
- [ ] Final data sync from old database
|
||||
- [ ] DNS cutover to new system
|
||||
- [ ] Announce to users via email
|
||||
- [ ] Monitor error logs (first 2 hours)
|
||||
- [ ] Support team on standby
|
||||
- [ ] Old system in read-only mode (fallback)
|
||||
|
||||
### Post-Launch (Week 5)
|
||||
- [ ] Daily monitoring for 1 week
|
||||
- [ ] User feedback collection
|
||||
- [ ] Bug fix hot patches
|
||||
- [ ] Performance tuning based on real usage
|
||||
- [ ] Retire old system after 2 weeks stable operation
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development Tools & Resources
|
||||
|
||||
### Required Packages (Already Installed)
|
||||
- ✅ Laravel 12
|
||||
- ✅ Filament 4
|
||||
- ✅ Spatie Permission v6
|
||||
- ✅ Laravel Breeze (auth scaffolding)
|
||||
- ✅ Pest (testing)
|
||||
|
||||
### Additional Packages Needed
|
||||
```bash
|
||||
composer require barryvdh/laravel-dompdf # Invoice PDFs
|
||||
composer require intervention/image # Image processing
|
||||
composer require maatwebsite/excel # CSV imports/exports
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
```bash
|
||||
# New project
|
||||
cd /Users/jon/projects/cannabrands/cannabrands_new
|
||||
|
||||
# Old project (reference only)
|
||||
cd /Users/jon/projects/cannabrands/cannabrands_crm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Communication
|
||||
|
||||
### Daily Standup (Recommended)
|
||||
- What was completed yesterday
|
||||
- What's planned for today
|
||||
- Any blockers
|
||||
|
||||
### Claude Code Collaboration
|
||||
- Reference old codebase: `/Users/jon/projects/cannabrands/cannabrands_crm`
|
||||
- Active development: `/Users/jon/projects/cannabrands/cannabrands_new`
|
||||
- Use CLAUDE_COLLABORATION_WORKFLOW.md for prompt templates
|
||||
|
||||
### Decision Log
|
||||
| Date | Decision | Rationale |
|
||||
|------|----------|-----------|
|
||||
| Today | Use "companies" not "businesses" | Better industry terminology |
|
||||
| Today | 5 brands under Cannabrands | Matches business structure |
|
||||
| Today | Separate orders/invoices tables | Cleaner than dual-purpose |
|
||||
| Today | PostgreSQL for new system | Better JSON support, scalability |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Metrics
|
||||
|
||||
### Technical Metrics
|
||||
- Zero data loss (100% of records migrated)
|
||||
- <2s page load for product catalog
|
||||
- <500ms search response time
|
||||
- 99.9% uptime in first month
|
||||
|
||||
### Business Metrics
|
||||
- All 5 Cannabrands brands have active storefronts
|
||||
- 20/20 users successfully migrated and active
|
||||
- First order placed within 48 hours of launch
|
||||
- Invoice generation working for all brands
|
||||
|
||||
### User Satisfaction
|
||||
- User training completed (1 hour session)
|
||||
- <5 support tickets in first week
|
||||
- Positive feedback from Cannabrands team
|
||||
- Buyers can easily find products
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: December 2024
|
||||
**Owner**: Cannabrands Development Team
|
||||
**Status**: Ready for Execution
|
||||
**Next Step**: Begin Week 1 - Schema Refactoring
|
||||
144
Makefile
144
Makefile
@@ -1,8 +1,45 @@
|
||||
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
|
||||
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-dev k-down k-logs k-shell k-artisan k-composer k-vite k-status prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# ==================== K8s Variables ====================
|
||||
# K3d cluster must be created with dual volume mounts:
|
||||
# k3d cluster create dev \
|
||||
# --api-port 6443 \
|
||||
# --port "80:80@loadbalancer" \
|
||||
# --port "443:443@loadbalancer" \
|
||||
# --volume /Users/jon/projects/cannabrands/cannabrands_new/.worktrees:/worktrees \
|
||||
# --volume /Users/jon/projects/cannabrands/cannabrands_new:/project-root \
|
||||
# --volume k3d-dev-images:/k3d/images
|
||||
|
||||
# Detect if we're in a worktree or project root
|
||||
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
|
||||
IS_WORKTREE := $(shell echo "$(GIT_DIR)" | grep -q ".worktrees" && echo "true" || echo "false")
|
||||
|
||||
# Set paths based on location
|
||||
ifeq ($(IS_WORKTREE),true)
|
||||
# In a worktree - use worktree-specific path
|
||||
WORKTREE_NAME := $(shell basename $(CURDIR))
|
||||
K8S_VOLUME_PATH := /worktrees/$(WORKTREE_NAME)
|
||||
else
|
||||
# In project root - use root path
|
||||
WORKTREE_NAME := root
|
||||
K8S_VOLUME_PATH := /project-root
|
||||
endif
|
||||
|
||||
# Generate namespace from branch name (feat-branch-name)
|
||||
CURRENT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
|
||||
K8S_NS := $(shell echo "$(CURRENT_BRANCH)" | sed 's/feature\//feat-/' | sed 's/bugfix\//fix-/' | sed 's/\//-/g')
|
||||
# Generate sanitized branch name for database
|
||||
SANITIZED_BRANCH := $(shell echo "$(CURRENT_BRANCH)" | sed 's/[^a-zA-Z0-9]/_/g')
|
||||
# Generate host from branch
|
||||
K8S_HOST := $(shell echo "$(CURRENT_BRANCH)" | sed 's/feature\///' | sed 's/bugfix\///' | sed 's/\//-/g').cannabrands.test
|
||||
# Read database credentials from .env
|
||||
DB_USERNAME := $(shell grep '^DB_USERNAME=' .env 2>/dev/null | cut -d '=' -f2)
|
||||
DB_PASSWORD := $(shell grep '^DB_PASSWORD=' .env 2>/dev/null | cut -d '=' -f2)
|
||||
DB_DATABASE := $(shell grep '^DB_DATABASE=' .env 2>/dev/null | cut -d '=' -f2)
|
||||
|
||||
# ==================== Local Development (Sail) ====================
|
||||
dev: ## Start local development environment with Sail
|
||||
./vendor/bin/sail up -d
|
||||
@@ -31,6 +68,94 @@ dev-composer: ## Run composer command (usage: make dev-composer CMD="install")
|
||||
dev-vite: ## Start Vite dev server (run after 'make dev')
|
||||
./vendor/bin/sail npm run dev
|
||||
|
||||
# ==================== K8s Local Development ====================
|
||||
k-dev: ## Start k8s local environment (like Sail, but with namespace isolation)
|
||||
@echo "🚀 Starting k8s environment"
|
||||
@echo " Location: $(if $(filter true,$(IS_WORKTREE)),Worktree ($(WORKTREE_NAME)),Project Root)"
|
||||
@echo " Namespace: $(K8S_NS)"
|
||||
@echo " Branch: $(CURRENT_BRANCH)"
|
||||
@echo " URL: http://$(K8S_HOST)"
|
||||
@echo ""
|
||||
@# Create namespace
|
||||
@kubectl create ns $(K8S_NS) --dry-run=client -o yaml | kubectl apply -f -
|
||||
@# Create secrets from .env
|
||||
@kubectl -n $(K8S_NS) delete secret app-env --ignore-not-found
|
||||
@kubectl -n $(K8S_NS) create secret generic app-env --from-env-file=.env
|
||||
@# Create PostgreSQL auth secret (using credentials from .env)
|
||||
@kubectl -n $(K8S_NS) create secret generic pg-auth --dry-run=client -o yaml \
|
||||
--from-literal=POSTGRES_DB=$(DB_DATABASE) \
|
||||
--from-literal=POSTGRES_USER=$(DB_USERNAME) \
|
||||
--from-literal=POSTGRES_PASSWORD=$(DB_PASSWORD) | kubectl apply -f -
|
||||
@# Deploy PostgreSQL
|
||||
@export NS=$(K8S_NS) PG_DB=$(DB_DATABASE) PG_USER=$(DB_USERNAME) PG_PASS=$(DB_PASSWORD) && \
|
||||
envsubst < k8s/local/postgres.yaml | kubectl apply -f -
|
||||
@# Deploy Redis
|
||||
@export NS=$(K8S_NS) && \
|
||||
envsubst < k8s/local/redis.yaml | kubectl apply -f -
|
||||
@# Deploy Reverb (WebSocket server)
|
||||
@export NS=$(K8S_NS) K8S_VOLUME_PATH=$(K8S_VOLUME_PATH) K8S_HOST=$(K8S_HOST) && \
|
||||
envsubst < k8s/local/reverb.yaml | kubectl apply -f -
|
||||
@# Wait for DB
|
||||
@echo "⏳ Waiting for PostgreSQL..."
|
||||
@kubectl -n $(K8S_NS) wait --for=condition=ready pod -l app=postgres --timeout=60s
|
||||
@# Deploy app (with code volume mounted)
|
||||
@export NS=$(K8S_NS) K8S_VOLUME_PATH=$(K8S_VOLUME_PATH) K8S_HOST=$(K8S_HOST) && \
|
||||
envsubst < k8s/local/deployment.yaml | kubectl apply -f -
|
||||
@# Create service + ingress
|
||||
@export NS=$(K8S_NS) K8S_HOST=$(K8S_HOST) && \
|
||||
envsubst < k8s/local/service.yaml | kubectl apply -f - && \
|
||||
envsubst < k8s/local/ingress.yaml | kubectl apply -f -
|
||||
@echo ""
|
||||
@echo "✅ Ready! Visit: http://$(K8S_HOST)"
|
||||
@echo ""
|
||||
@echo "💡 Your code is volume-mounted - changes are instant!"
|
||||
@echo " Edit files → refresh browser → see changes"
|
||||
@echo ""
|
||||
@echo "📝 Useful commands:"
|
||||
@echo " make k-logs # View app logs"
|
||||
@echo " make k-shell # Open shell in pod"
|
||||
@echo " make k-vite # Start Vite dev server"
|
||||
@echo ""
|
||||
@echo "🔌 WebSocket (Reverb) available at: ws://reverb.$(K8S_HOST):8080"
|
||||
|
||||
k-down: ## Stop k8s environment
|
||||
@echo "🗑 Removing namespace: $(K8S_NS)"
|
||||
@kubectl delete ns $(K8S_NS) --ignore-not-found
|
||||
@echo "✅ Cleaned up"
|
||||
|
||||
k-logs: ## View app logs
|
||||
@kubectl -n $(K8S_NS) logs -f deploy/web --all-containers=true
|
||||
|
||||
k-shell: ## Shell into app container
|
||||
@kubectl -n $(K8S_NS) exec -it deploy/web -- /bin/bash
|
||||
|
||||
k-artisan: ## Run artisan command (usage: make k-artisan CMD="migrate")
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan $(CMD)
|
||||
|
||||
k-composer: ## Run composer (usage: make k-composer CMD="install")
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- composer $(CMD)
|
||||
|
||||
k-vite: ## Run Vite dev server in k8s pod
|
||||
@echo "🎨 Starting Vite dev server in pod..."
|
||||
@echo " Access at: http://vite.$(K8S_HOST)"
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- npm run dev
|
||||
|
||||
k-test: ## Run tests in k8s pod
|
||||
@echo "🧪 Running tests in k8s pod..."
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan test
|
||||
|
||||
k-seed: ## Run database seeders in k8s (usage: make k-seed SEEDER=DevSeeder)
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan db:seed --class=$(SEEDER)
|
||||
|
||||
k-migrate-fresh: ## Fresh database with seeding in k8s pod
|
||||
@echo "🔄 Running fresh migration with seeding..."
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan migrate:fresh --seed
|
||||
|
||||
k-status: ## Show k8s environment status
|
||||
@echo "📊 Status for namespace: $(K8S_NS)"
|
||||
@echo ""
|
||||
@kubectl -n $(K8S_NS) get pods,svc,ingress
|
||||
|
||||
# ==================== Production ====================
|
||||
prod-build: ## Build production Docker image
|
||||
docker build -t cannabrands/app:latest -f Dockerfile .
|
||||
@@ -132,10 +257,27 @@ install: ## Initial project setup
|
||||
mailpit: ## Open Mailpit web UI
|
||||
@open http://localhost:8025 || xdg-open http://localhost:8025 || echo "Open http://localhost:8025 in your browser"
|
||||
|
||||
new-worktree: ## Create new worktree (usage: make new-worktree BRANCH=feature/my-feature or make new-worktree BRANCH=feature/my-feature NEW=true)
|
||||
@if [ -z "$(BRANCH)" ]; then \
|
||||
echo "❌ Error: BRANCH parameter required"; \
|
||||
echo ""; \
|
||||
echo "Usage:"; \
|
||||
echo " make new-worktree BRANCH=feature/my-feature # Checkout existing branch"; \
|
||||
echo " make new-worktree BRANCH=feature/my-feature NEW=true # Create new branch"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ "$(NEW)" = "true" ]; then \
|
||||
./scripts/new-worktree.sh -b $(BRANCH); \
|
||||
else \
|
||||
./scripts/new-worktree.sh $(BRANCH); \
|
||||
fi
|
||||
|
||||
help: ## Show this help message
|
||||
@echo "\n📦 CannaBrands Docker Commands\n"
|
||||
@echo "Local Development (Sail):"
|
||||
@grep -E '^dev.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}'
|
||||
@echo "\nK8s Local Development:"
|
||||
@grep -E '^k-.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[35m%-25s\033[0m %s\n", $$1, $$2}'
|
||||
@echo "\nProduction Testing (Local):"
|
||||
@grep -E '^prod-test.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[33m%-25s\033[0m %s\n", $$1, $$2}'
|
||||
@echo "\nProduction (K8s/Deployment):"
|
||||
|
||||
237
NOTIFICATIONS.md
237
NOTIFICATIONS.md
@@ -1,237 +0,0 @@
|
||||
# Notification & Email Policy
|
||||
|
||||
**Last Updated**: January 2025
|
||||
**Status**: Initial Implementation (Day 15)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the notification and email strategy for the Cannabrands B2B platform. The system uses both email notifications (via Laravel Mail) and in-app notifications for real-time updates.
|
||||
|
||||
---
|
||||
|
||||
## Email Notifications
|
||||
|
||||
### Order Workflow Emails
|
||||
|
||||
| Event | Recipient | Template | Priority | Notes |
|
||||
|-------|-----------|----------|----------|-------|
|
||||
| **New Order Placed** | Seller (Admin) | `emails.orders.new-order` | High | Includes order summary, buyer info, items list |
|
||||
| **Order Accepted** | Buyer | `emails.orders.order-accepted` | Medium | Confirms acceptance, shows estimated timeline |
|
||||
| **Order Ready for Delivery** | Buyer | `emails.orders.ready-for-delivery` | High | Notifies order is packed and ready to ship |
|
||||
| **Order Delivered** | Buyer | `emails.orders.order-delivered` | Medium | Delivery confirmation with thank you message |
|
||||
| **Order Cancelled** | Buyer | `emails.orders.order-cancelled` | High | Cancellation notice with reason if provided |
|
||||
|
||||
### Invoice Workflow Emails
|
||||
|
||||
| Event | Recipient | Template | Priority | Notes |
|
||||
|-------|-----------|----------|----------|-------|
|
||||
| **Invoice Generated** | Buyer | `emails.invoices.invoice-ready` | High | Invoice ready for buyer approval after seller review |
|
||||
| **Invoice Overdue (3 days)** | Buyer | `emails.invoices.payment-reminder-3day` | High | First reminder |
|
||||
| **Invoice Overdue (7 days)** | Buyer | `emails.invoices.payment-reminder-7day` | High | Second reminder |
|
||||
| **Invoice Overdue (14 days)** | Buyer + Seller | `emails.invoices.payment-reminder-14day` | Critical | Final reminder, copied to seller |
|
||||
| **Payment Received** | Buyer | `emails.invoices.payment-received` | Low | Payment confirmation (future feature) |
|
||||
|
||||
### Picking Workflow Emails
|
||||
|
||||
| Event | Recipient | Template | Priority | Notes |
|
||||
|-------|-----------|----------|----------|-------|
|
||||
| **Picking Complete (100%)** | Seller | `emails.orders.picking-complete` | High | Alerts seller to review and generate invoice |
|
||||
|
||||
---
|
||||
|
||||
## In-App Notifications
|
||||
|
||||
### Notification Types
|
||||
|
||||
**For Buyers:**
|
||||
- 🛒 New order confirmation
|
||||
- ✅ Order accepted by seller
|
||||
- 📦 Order ready for delivery
|
||||
- 🚚 Order delivered
|
||||
- 📄 Invoice ready for approval
|
||||
- ⚠️ Payment due soon (3 days before due date)
|
||||
- 🔴 Payment overdue
|
||||
|
||||
**For Sellers (Admin Panel):**
|
||||
- 🛍️ New order received
|
||||
- 📋 Picking complete - ready for review
|
||||
- 💳 Payment received (future)
|
||||
|
||||
### Notification Bell Behavior
|
||||
|
||||
- **Unread Count Badge**: Shows count of unread notifications
|
||||
- **Auto-refresh**: Checks for new notifications every 30 seconds
|
||||
- **Mark as Read**: Clicking notification marks it as read
|
||||
- **Persistence**: Notifications stored in database, not deleted after reading
|
||||
- **Archive**: Users can manually dismiss notifications (future feature)
|
||||
|
||||
---
|
||||
|
||||
## Email Template Design
|
||||
|
||||
All emails follow the branded template pattern established in `emails.registration.verification`:
|
||||
|
||||
**Brand Elements:**
|
||||
- Logo: `https://cannabrands.app/assets/images/canna_white.png`
|
||||
- Primary Color: `#014847` (teal gradient)
|
||||
- Font: DM Sans
|
||||
- Button Style: Teal gradient background, white text
|
||||
- Footer: Cannabrands © 2025, support contact
|
||||
|
||||
**Template Structure:**
|
||||
```blade
|
||||
@component('mail::message')
|
||||
# [Email Subject Line]
|
||||
|
||||
[Email body content - plain language, buyer/seller focused]
|
||||
|
||||
@component('mail::button', ['url' => $actionUrl, 'color' => 'primary'])
|
||||
[Call to Action Button]
|
||||
@endcomponent
|
||||
|
||||
[Additional information or next steps]
|
||||
|
||||
Thanks,<br>
|
||||
The Cannabrands Team
|
||||
@endcomponent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notification Preferences (Future)
|
||||
|
||||
Allow users to control notification settings:
|
||||
|
||||
### Email Preferences
|
||||
- [ ] Order updates (placed, accepted, delivered)
|
||||
- [ ] Invoice notifications (generated, due soon, overdue)
|
||||
- [ ] Marketing emails (new products, promotions)
|
||||
- [ ] System announcements
|
||||
|
||||
### In-App Preferences
|
||||
- [ ] Real-time notifications
|
||||
- [ ] Desktop push notifications (future)
|
||||
- [ ] Sound alerts
|
||||
|
||||
### Frequency Settings
|
||||
- [ ] Immediate (default)
|
||||
- [ ] Daily digest
|
||||
- [ ] Weekly summary
|
||||
- [ ] Disabled (except critical)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Current Status (Day 15)
|
||||
|
||||
**Implemented:**
|
||||
- Email infrastructure using Laravel Mail
|
||||
- Notification database table and model
|
||||
- In-app notification dropdown (frontend exists, needs backend)
|
||||
- Brand-consistent email templates
|
||||
|
||||
**Not Yet Implemented:**
|
||||
- Automated overdue payment reminders (requires scheduled task)
|
||||
- Email preference management
|
||||
- Notification archiving
|
||||
- Push notifications
|
||||
- SMS notifications (future consideration)
|
||||
|
||||
### Technical Details
|
||||
|
||||
**Email Service**: Mailpit (local), SMTP (production)
|
||||
|
||||
**Notification Storage**:
|
||||
- Table: `notifications`
|
||||
- Model: `App\Models\Notification`
|
||||
- Polymorphic relationship to User
|
||||
|
||||
**Queue System**:
|
||||
- Development: sync driver
|
||||
- Production: database/redis queue recommended for email sending
|
||||
|
||||
**Rate Limiting**:
|
||||
- Payment reminders: Max 1 per invoice per day
|
||||
- Marketing emails: Opt-in only, max 2 per week
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Email Testing
|
||||
- [ ] New order email arrives at seller email
|
||||
- [ ] Order acceptance email arrives at buyer email
|
||||
- [ ] Invoice ready email includes correct invoice number and amount
|
||||
- [ ] Overdue reminders send at correct intervals
|
||||
- [ ] All emails render correctly in Gmail, Outlook, Apple Mail
|
||||
- [ ] All CTA buttons link to correct pages
|
||||
- [ ] Unsubscribe links work (future)
|
||||
|
||||
### In-App Notification Testing
|
||||
- [ ] Notification bell shows unread count
|
||||
- [ ] Clicking notification marks it as read
|
||||
- [ ] Notification links to correct resource (order/invoice)
|
||||
- [ ] Notifications auto-refresh without page reload
|
||||
- [ ] Mark all as read functionality works
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2 (Post-MVP)
|
||||
1. **Digest Emails**: Daily/weekly summary of activity
|
||||
2. **Smart Notifications**: AI-powered suggestions based on buying patterns
|
||||
3. **SMS Notifications**: Critical alerts via Twilio
|
||||
4. **Webhook Support**: Allow third-party integrations
|
||||
5. **Notification Templates**: Customizable by company
|
||||
|
||||
### Phase 3 (Advanced)
|
||||
1. **Multi-channel**: Email + SMS + Push + Slack
|
||||
2. **Notification Analytics**: Track open rates, click-through
|
||||
3. **A/B Testing**: Test email subject lines and content
|
||||
4. **Scheduled Sends**: Time-zone aware delivery
|
||||
5. **Rich Notifications**: Images, action buttons in notifications
|
||||
|
||||
---
|
||||
|
||||
## Refinement Areas
|
||||
|
||||
**To be reviewed and potentially changed:**
|
||||
|
||||
1. **Overdue Reminder Intervals**: Currently 3/7/14 days - should it be 1/3/7 or 5/10/15?
|
||||
2. **Seller Notifications**: Should sellers get daily digest of new orders or immediate alerts?
|
||||
3. **Picking Alerts**: Should lab crew get notifications when orders are accepted?
|
||||
4. **Invoice Approval**: Should seller be notified when buyer approves invoice?
|
||||
5. **Marketing vs Transactional**: Clear separation needed for CAN-SPAM compliance
|
||||
6. **Notification Retention**: How long to keep old notifications? Auto-archive after 30 days?
|
||||
7. **Critical vs Non-Critical**: Which emails should bypass "unsubscribe" (transactional only)
|
||||
8. **CC Recipients**: Should account managers be CC'd on order emails?
|
||||
|
||||
---
|
||||
|
||||
## Compliance Notes
|
||||
|
||||
**CAN-SPAM Act Requirements:**
|
||||
- ✅ Clear "From" name (Cannabrands)
|
||||
- ✅ Accurate subject lines
|
||||
- ✅ Physical address in footer
|
||||
- ✅ Unsubscribe mechanism (for marketing only)
|
||||
- ⚠️ Transactional emails exempt from unsubscribe requirement
|
||||
|
||||
**GDPR Considerations** (if applicable):
|
||||
- User consent for marketing emails
|
||||
- Right to export notification history
|
||||
- Right to delete notification data
|
||||
- Data retention policies
|
||||
|
||||
---
|
||||
|
||||
## Contact for Policy Changes
|
||||
|
||||
**Product Owner**: [To be filled]
|
||||
**Technical Lead**: [To be filled]
|
||||
**Last Review Date**: January 2025
|
||||
**Next Review Date**: March 2025
|
||||
258
PRODUCT2_INSTRUCTIONS.md
Normal file
258
PRODUCT2_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# PRODUCT2 MIGRATION INSTRUCTIONS
|
||||
|
||||
## Context
|
||||
We are migrating the OLD seller product page from `../cannabrands-hub-old` to create a new "Product2" page in the current project at `/hub`. This page will be a comprehensive, modernized version of the old seller product edit page.
|
||||
|
||||
## Critical Rules
|
||||
1. **SELLER SIDE ONLY** - Work only with `/s/` routes (seller area)
|
||||
2. **STAY IN BRANCH** - `feature/product-page-migrate` (verify before making changes)
|
||||
3. **ROLLBACK READY** - All database migrations must be fully reversible
|
||||
4. **DO NOT TOUCH BOM** - Leave existing BOM functionality completely as-is (we'll discuss later)
|
||||
5. **SINGLE PAGE LAYOUT** - No tabs, use card-based layout with Nexus components
|
||||
6. **FOLLOW OLD LAYOUT** - Modernize the old product page structure, don't reinvent
|
||||
|
||||
## Old Project Analysis Complete
|
||||
- Old project location: `../cannabrands-hub-old`
|
||||
- Old used Laravel CRM for product management
|
||||
- Comprehensive field analysis done (see below)
|
||||
- Old layout analyzed from vendor views
|
||||
|
||||
## Complete Missing Fields (from migrations analysis)
|
||||
|
||||
### From `products` table:
|
||||
```sql
|
||||
-- Metadata
|
||||
product_line (text, nullable)
|
||||
product_link (text, nullable) -- External URL
|
||||
creatives (text, nullable) -- Marketing assets
|
||||
barcode (string, nullable)
|
||||
brand_display_order (integer, nullable)
|
||||
|
||||
-- Configuration
|
||||
has_varieties (boolean, default: false)
|
||||
license_id (unsignedBigInteger, nullable)
|
||||
sell_multiples (boolean, default: false)
|
||||
fractional_quantities (boolean, default: false)
|
||||
allow_sample (boolean, default: false)
|
||||
isFPR (boolean, default: false)
|
||||
isSellable (boolean, default: false)
|
||||
|
||||
-- Case/Box Packaging
|
||||
isCase (boolean, default: false)
|
||||
cased_qty (integer, default: 0)
|
||||
isBox (boolean, default: false)
|
||||
boxed_qty (integer, default: 0)
|
||||
|
||||
-- Dates
|
||||
launch_date (date, nullable)
|
||||
|
||||
-- Inventory Management
|
||||
inventory_manage_pct (integer, nullable) -- 0-100%
|
||||
min_order_qty (integer, nullable)
|
||||
max_order_qty (integer, nullable)
|
||||
low_stock_threshold (integer, nullable)
|
||||
low_stock_alert_enabled (boolean, default: false)
|
||||
|
||||
-- Strain
|
||||
strain_value (decimal 8,2, nullable)
|
||||
|
||||
-- Arizona Compliance
|
||||
arz_total_weight (decimal 10,3, nullable)
|
||||
arz_usable_mmj (decimal 10,3, nullable)
|
||||
|
||||
-- Descriptions
|
||||
long_description (text, nullable)
|
||||
ingredients (text, nullable)
|
||||
effects (text, nullable)
|
||||
dosage_guidelines (text, nullable)
|
||||
|
||||
-- Visibility
|
||||
show_inventory_to_buyers (boolean, default: false)
|
||||
|
||||
-- Threshold Automation
|
||||
decreasing_qty_threshold (integer, nullable)
|
||||
decreasing_qty_action (string, nullable)
|
||||
increasing_qty_threshold (integer, nullable)
|
||||
increasing_qty_action (string, nullable)
|
||||
|
||||
-- Packaging Reference
|
||||
packaging_id (foreignId, nullable)
|
||||
|
||||
-- Enhanced Status
|
||||
status (enum: available, archived, sample, backorder, internal, unavailable)
|
||||
```
|
||||
|
||||
### Need to create:
|
||||
- `product_packaging` table (id, name, description, is_active, timestamps)
|
||||
|
||||
## Product2 Page Layout (Single Page, No Tabs)
|
||||
|
||||
### Structure:
|
||||
```
|
||||
HEADER (Product name, SKU, status badges, action buttons)
|
||||
|
||||
LEFT SIDEBAR (1/3 width):
|
||||
- Product Images (main + gallery + upload)
|
||||
- Quick Stats Card (cost, wholesale, MSRP, margin)
|
||||
- Audit Info Card (created, modified, by user)
|
||||
|
||||
MAIN CONTENT (2/3 width):
|
||||
Card 1: Basic Information
|
||||
Card 2: Pricing & Units
|
||||
Card 3: Inventory Management
|
||||
Card 4: Cannabis Information
|
||||
Card 5: Product Details & Content
|
||||
Card 6: Advanced Settings
|
||||
Card 7: Compliance & Tracking
|
||||
|
||||
FULL WIDTH (bottom):
|
||||
Card 8: Product Varieties (if has_varieties = true)
|
||||
Card 9: Lab Test Results (link to separate management)
|
||||
Collapsible: Audit History
|
||||
```
|
||||
|
||||
### Cards Detail:
|
||||
|
||||
**Card 1: Basic Information**
|
||||
- Brand (dropdown) *
|
||||
- Product Line (text)
|
||||
- SKU (text) *
|
||||
- Barcode (text)
|
||||
- Product Name (text) *
|
||||
- Type (dropdown) *
|
||||
- Category (text)
|
||||
- Description (textarea)
|
||||
- Active toggle
|
||||
- Featured toggle
|
||||
|
||||
**Card 2: Pricing & Units**
|
||||
- Cost Price, Wholesale, MSRP, Margin (auto-calc)
|
||||
- Price Unit dropdown
|
||||
- Net Weight + Weight Unit
|
||||
- Units Per Case
|
||||
- Checkboxes: Sell in Multiples, Fractional Quantities, Sell as Case, Sell as Box
|
||||
|
||||
**Card 3: Inventory Management**
|
||||
- On Hand, Allocated, Available, Reorder Point (display)
|
||||
- Min/Max Order Qty
|
||||
- Low Stock Threshold + Alert checkbox
|
||||
- Show Inventory to Buyers checkbox
|
||||
- Inventory Management slider (0-100%)
|
||||
- Threshold Automation (decrease/increase triggers)
|
||||
|
||||
**Card 4: Cannabis Information**
|
||||
- THC%, CBD%, THC mg, CBD mg
|
||||
- Strain dropdown (with classification)
|
||||
- Strain Value
|
||||
- Product Packaging dropdown
|
||||
- Ingredients, Effects, Dosing Guidelines (text areas)
|
||||
- Arizona Compliance (Total Weight, Usable MMJ)
|
||||
|
||||
**Card 5: Product Details & Content**
|
||||
- Short Description
|
||||
- Long Description (rich text editor)
|
||||
- Product Link (external URL)
|
||||
- Creatives/Assets
|
||||
|
||||
**Card 6: Advanced Settings**
|
||||
- Enable Sample Requests checkbox
|
||||
- Sellable Product checkbox
|
||||
- Finished Product Ready checkbox
|
||||
- Status dropdown
|
||||
- Display Order (within brand)
|
||||
|
||||
**Card 7: Compliance & Tracking**
|
||||
- Metrc ID
|
||||
- License dropdown
|
||||
- Launch Date, Harvest Date, Package Date, Test Date
|
||||
|
||||
**Card 8: Product Varieties** (conditional)
|
||||
- Table showing child products with name, SKU, prices, stock
|
||||
- Add Variety button
|
||||
|
||||
**Card 9: Lab Test Results**
|
||||
- Summary of latest lab test
|
||||
- Link to full lab management (don't build lab CRUD yet)
|
||||
|
||||
## Tasks to Complete
|
||||
|
||||
### 1. Database Migration (with rollback)
|
||||
- Create migration: `add_product2_fields_to_products_table.php`
|
||||
- Add ALL missing fields listed above
|
||||
- Proper indexes
|
||||
- Full `down()` method for rollback
|
||||
- Create `product_packaging` table migration
|
||||
|
||||
### 2. Routes
|
||||
- File: `routes/seller.php`
|
||||
- Add under existing products routes:
|
||||
- `/{product}/edit2` → Product2 edit page
|
||||
- Keep existing routes intact
|
||||
|
||||
### 3. Controller
|
||||
- Create: `app/Http/Controllers/Seller/Product2Controller.php`
|
||||
- Methods: edit(), update()
|
||||
- Full validation for all new fields
|
||||
- Business isolation checks (CRITICAL - see CLAUDE.md)
|
||||
- Image upload handling
|
||||
|
||||
### 4. Model Updates
|
||||
- Update `app/Models/Product.php` fillable array
|
||||
- Add new relationships if needed (packaging)
|
||||
- Add accessors/mutators as needed
|
||||
|
||||
### 5. Views
|
||||
- Create: `resources/views/seller/products/edit2.blade.php`
|
||||
- Use Nexus card components
|
||||
- Single page layout (no tabs)
|
||||
- Alpine.js for interactivity
|
||||
- Follow structure outlined above
|
||||
- Use existing DaisyUI + Nexus patterns
|
||||
|
||||
### 6. Nexus Components Available
|
||||
From `nexus-html@3.1.0/resources/views/`:
|
||||
- Cards: `card`, `card-body`, `card-title`
|
||||
- Forms: `input`, `select`, `textarea`, `checkbox`, `toggle`, `label`, `fieldset`
|
||||
- Layouts: Grid system with responsive columns
|
||||
- File upload: FilePond integration
|
||||
- Date picker: Flatpickr
|
||||
- Icons: Iconify (lucide set)
|
||||
|
||||
## Key Files from Old Project
|
||||
- Controller: `vendor/venturedrake/laravel-crm/src/Http/Controllers/ProductController.php`
|
||||
- Edit View: `vendor/venturedrake/laravel-crm/resources/views/products/edit.blade.php`
|
||||
- Fields Form: `vendor/venturedrake/laravel-crm/resources/views/products/partials/fields.blade.php` (1400+ lines!)
|
||||
|
||||
## Current Project Files
|
||||
- Routes: `routes/seller.php`
|
||||
- Controller: `app/Http/Controllers/Seller/ProductController.php`
|
||||
- Model: `app/Models/Product.php`
|
||||
- Current Edit: `resources/views/seller/products/edit.blade.php`
|
||||
- Migration: `database/migrations/2025_10_07_172951_create_products_table.php`
|
||||
|
||||
## Important Notes from CLAUDE.md
|
||||
1. **Business Isolation**: ALWAYS scope by business_id BEFORE finding by ID
|
||||
- `Product::whereHas('brand', fn($q) => $q->where('business_id', $business->id))->findOrFail($id)`
|
||||
2. **Route Protection**: Use middleware `['auth', 'verified', 'seller', 'approved']`
|
||||
3. **No Filament**: Use DaisyUI + Blade for seller area
|
||||
4. **Run tests before commit**: `php artisan test --parallel && ./vendor/bin/pint`
|
||||
|
||||
## Git Branch
|
||||
- Current: `feature/product-page-migrate`
|
||||
- DO NOT commit to develop directly
|
||||
|
||||
## Next Steps
|
||||
1. Verify branch: `git branch` (should show feature/product-page-migrate)
|
||||
2. Create migrations with full rollback capability
|
||||
3. Update Product model
|
||||
4. Create Product2Controller
|
||||
5. Create edit2.blade.php view
|
||||
6. Test thoroughly
|
||||
7. Run Pint + tests
|
||||
8. Commit with clear message
|
||||
|
||||
## Questions to Clarify Before Building
|
||||
- Collapsible cards to reduce clutter? (yes/no)
|
||||
- Should quantity_on_hand be editable in UI? (currently hidden)
|
||||
- Which fields are absolutely required vs nice-to-have?
|
||||
- SQL dump ready for real data analysis?
|
||||
24
README.md
24
README.md
@@ -429,15 +429,37 @@ No setup required - just works!
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### **Getting Started**
|
||||
- **[Development Guide](docs/DEVELOPMENT.md)**: Complete developer setup and workflow
|
||||
- ✅ **Standard/Hybrid Flow** (PHP on host + Docker services)
|
||||
- ✅ **Local Kubernetes Development** (k3d, minikube, kind, Docker Desktop K8s)
|
||||
- Covers all local development approaches
|
||||
- **[Docker & Sail Guide](DOCKER.md)**: Laravel Sail development environment
|
||||
- ✅ **Laravel Sail Flow** (All services in Docker)
|
||||
- Quick start for containerized development
|
||||
|
||||
### **Deployment & DevOps**
|
||||
- **[Deployment Workflow](docs/DEPLOYMENT_WORKFLOW.md)**: Branching strategy, CI/CD pipeline, and deployment guide
|
||||
- **[Kubernetes Deployment](docs/KUBERNETES_DEPLOYMENT.md)**: Complete Kubernetes deployment guide (for DevOps)
|
||||
- **[Kubernetes Deployment](docs/KUBERNETES_DEPLOYMENT.md)**: Production Kubernetes deployment guide (for DevOps/SRE)
|
||||
- Production/Staging/Dev K8s clusters
|
||||
- Not for local development (see DEVELOPMENT.md for local K8s)
|
||||
|
||||
### **Application Reference**
|
||||
- **[Setup Guide](docs/SETUP.md)**: Detailed installation and configuration
|
||||
- **[API Reference](docs/API.md)**: Complete API endpoint documentation
|
||||
- **[Database Schema](docs/DATABASE.md)**: Database structure and relationships
|
||||
- **[Notifications](docs/NOTIFICATIONS.md)**: Notification system guide
|
||||
- **[App Overview](docs/APP_OVERVIEW.md)**: Project roadmap and architecture
|
||||
|
||||
### **Development Flow Options Summary**
|
||||
|
||||
| Flow | Document | Best For |
|
||||
|------|----------|----------|
|
||||
| **Local PHP + Docker services** | [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Daily development (fastest) |
|
||||
| **Laravel Sail (all Docker)** | [DOCKER.md](DOCKER.md) | Environment consistency |
|
||||
| **Local Kubernetes (k3d/minikube)** | [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Testing K8s deployments |
|
||||
| **Production Kubernetes** | [KUBERNETES_DEPLOYMENT.md](docs/KUBERNETES_DEPLOYMENT.md) | Production/staging clusters |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,368 +0,0 @@
|
||||
# Invoice Approval & Modification System - Testing Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Generate Test Data
|
||||
|
||||
Run this command to create a complete test order with invoice:
|
||||
|
||||
```bash
|
||||
php artisan test:invoice-approval
|
||||
```
|
||||
|
||||
Or specify a specific buyer:
|
||||
```bash
|
||||
php artisan test:invoice-approval --buyer-email=your-buyer@example.com
|
||||
```
|
||||
|
||||
This will:
|
||||
- ✅ Create a test order with 5 random products
|
||||
- ✅ Progress it through the workflow (accepted → in_progress → ready_for_invoice → invoiced)
|
||||
- ✅ Generate an invoice with `approval_status = 'pending_buyer_approval'`
|
||||
- ✅ Display test URLs and credentials
|
||||
|
||||
---
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### Scenario 1: Approve Invoice Without Changes
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Approve Invoice"** button
|
||||
4. Confirm the action
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Invoice `approval_status` → `'buyer_approved'`
|
||||
- ✅ Invoice `approved_at` timestamp set
|
||||
- ✅ Invoice `approved_by` = current user ID
|
||||
- ✅ Order `status` → `'manifest_created'`
|
||||
- ✅ Order `manifest_created_at` timestamp set
|
||||
- ✅ Success message displayed
|
||||
- ✅ Page reloads with success alert
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
-- Check invoice approval
|
||||
SELECT id, invoice_number, approval_status, approved_at, approved_by
|
||||
FROM invoices
|
||||
WHERE id = {invoice_id};
|
||||
|
||||
-- Check order status progression
|
||||
SELECT id, order_number, status, manifest_created_at
|
||||
FROM orders
|
||||
WHERE id = {order_id};
|
||||
|
||||
-- Should be no changes recorded (direct approval)
|
||||
SELECT COUNT(*) FROM order_changes WHERE order_id = {order_id};
|
||||
-- Expected: 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2: Reject Invoice
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Reject Invoice"** button
|
||||
4. Modal opens
|
||||
5. Enter rejection reason: "Prices too high"
|
||||
6. Click **"Confirm Rejection"**
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Invoice `approval_status` → `'rejected'`
|
||||
- ✅ Invoice `rejected_at` timestamp set
|
||||
- ✅ Invoice `rejection_reason` = "Prices too high"
|
||||
- ✅ Order `status` → `'rejected'`
|
||||
- ✅ Order `rejected_at` timestamp set
|
||||
- ✅ Order `rejected_reason` = "Prices too high"
|
||||
- ✅ Redirect to invoices index with success message
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
SELECT id, invoice_number, approval_status, rejected_at, rejection_reason
|
||||
FROM invoices
|
||||
WHERE id = {invoice_id};
|
||||
|
||||
SELECT id, order_number, status, rejected_at, rejected_reason
|
||||
FROM orders
|
||||
WHERE id = {order_id};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3: Modify Invoice - Reduce Quantity (Auto-Approved)
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Modify Invoice"** button
|
||||
4. Edit mode activates
|
||||
5. Find a line item with quantity 10
|
||||
6. Change quantity to **9** (10% reduction - should auto-approve)
|
||||
7. Click **"Save Changes"**
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Invoice `approval_status` → `'buyer_modified'`
|
||||
- ✅ `order_changes` record created:
|
||||
- `change_type` = 'quantity_edit'
|
||||
- `old_value` = 10
|
||||
- `new_value` = 9
|
||||
- `status` = **'auto_approved'** (because <10% reduction)
|
||||
- `negotiation_round` = 1
|
||||
- `user_type` = 'buyer'
|
||||
- ✅ Success message: "Changes saved successfully..."
|
||||
- ✅ Page reloads
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
-- Check the change was recorded
|
||||
SELECT * FROM order_changes
|
||||
WHERE order_id = {order_id}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- Verify auto-approval
|
||||
SELECT change_type, old_value, new_value, status
|
||||
FROM order_changes
|
||||
WHERE order_id = {order_id} AND status = 'auto_approved';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 4: Modify Invoice - Reduce Quantity (>10%, Needs Review)
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Modify Invoice"**
|
||||
4. Change a quantity from **10 to 5** (50% reduction)
|
||||
5. Click **"Save Changes"**
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Invoice `approval_status` → `'buyer_modified'`
|
||||
- ✅ `order_changes` record created with `status` = **'pending'** (not auto-approved)
|
||||
- ✅ Seller will need to review this change
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
SELECT change_type, old_value, new_value, status
|
||||
FROM order_changes
|
||||
WHERE order_id = {order_id} AND status = 'pending';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 5: Delete Line Item
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Modify Invoice"**
|
||||
4. Click **"Remove"** button on a line item
|
||||
5. Item shows as deleted (opacity-50, crossed out)
|
||||
6. Click **"Save Changes"**
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Invoice `approval_status` → `'buyer_modified'`
|
||||
- ✅ `order_changes` record created:
|
||||
- `change_type` = 'item_delete'
|
||||
- `old_value` = {original_quantity}
|
||||
- `new_value` = 0
|
||||
- `status` = 'pending'
|
||||
- `order_item_id` = {deleted_item_id}
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
SELECT * FROM order_changes
|
||||
WHERE order_id = {order_id} AND change_type = 'item_delete';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 6: Try to Increase Quantity (Should Fail)
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Navigate to `/b/invoices/{invoice_id}`
|
||||
3. Click **"Modify Invoice"**
|
||||
4. Try to change quantity from **10 to 15**
|
||||
5. Input field should prevent this OR show validation error
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Validation prevents increase
|
||||
- ✅ Alert: "You can only reduce quantities, not increase them."
|
||||
- ✅ Quantity resets to original value
|
||||
- ✅ No change saved
|
||||
|
||||
---
|
||||
|
||||
### Scenario 7: Multiple Changes in One Modification
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Click **"Modify Invoice"**
|
||||
3. Reduce item 1 quantity from 10 → 8
|
||||
4. Delete item 2 entirely
|
||||
5. Reduce item 3 quantity from 12 → 10
|
||||
6. Click **"Save Changes"**
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ 3 separate `order_changes` records created
|
||||
- ✅ Each tracked independently
|
||||
- ✅ All have same `negotiation_round` (1)
|
||||
- ✅ All have same `created_at` (batch)
|
||||
|
||||
**Database Verification:**
|
||||
```sql
|
||||
SELECT id, change_type, order_item_id, old_value, new_value, status, negotiation_round
|
||||
FROM order_changes
|
||||
WHERE order_id = {order_id}
|
||||
ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 8: View Change History
|
||||
|
||||
**Steps:**
|
||||
1. After making changes (Scenario 7)
|
||||
2. Scroll down to **"Change History"** section
|
||||
3. Review the table
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ Shows recent changes (up to 5)
|
||||
- ✅ Displays: Round, Type, Product, Old Value, New Value, Changed By, Status
|
||||
- ✅ Status badges: success (approved/auto-approved), warning (pending), error (rejected)
|
||||
- ✅ "View All Changes" button visible
|
||||
|
||||
---
|
||||
|
||||
### Scenario 9: Cancel Edit Mode
|
||||
|
||||
**Steps:**
|
||||
1. Login as buyer
|
||||
2. Click **"Modify Invoice"**
|
||||
3. Make some changes (reduce qty, delete item)
|
||||
4. Click **"Cancel"** button
|
||||
|
||||
**Expected Results:**
|
||||
- ✅ All changes discarded
|
||||
- ✅ Quantities reset to original values
|
||||
- ✅ Deleted items restored
|
||||
- ✅ Edit mode exits
|
||||
- ✅ No changes saved to database
|
||||
|
||||
---
|
||||
|
||||
### Scenario 10: Negotiation Round Tracking
|
||||
|
||||
**This requires seller response - will test once seller UI is complete**
|
||||
|
||||
1. Buyer modifies invoice (Round 1)
|
||||
2. Seller counter-modifies (Round 2)
|
||||
3. Buyer modifies again (Round 3)
|
||||
4. Check `current_negotiation_round` field increments
|
||||
|
||||
**Expected:**
|
||||
- ✅ Max 3 rounds allowed
|
||||
- ✅ `invoice.current_negotiation_round` increments
|
||||
|
||||
---
|
||||
|
||||
## Browser Console Testing
|
||||
|
||||
Open browser console while testing to see:
|
||||
|
||||
```javascript
|
||||
// Alpine.js state inspection
|
||||
Alpine.store('invoice')
|
||||
|
||||
// Check reactive data
|
||||
$data.items
|
||||
$data.editMode
|
||||
$data.hasChanges
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Network Tab Verification
|
||||
|
||||
Monitor the following API calls:
|
||||
|
||||
### Approve Invoice
|
||||
```
|
||||
POST /b/invoices/{invoice}/approve
|
||||
Response: { success: true, message: "Invoice approved successfully." }
|
||||
```
|
||||
|
||||
### Reject Invoice
|
||||
```
|
||||
POST /b/invoices/{invoice}/reject
|
||||
Body: { reason: "..." }
|
||||
Redirect: /b/invoices
|
||||
```
|
||||
|
||||
### Modify Invoice
|
||||
```
|
||||
POST /b/invoices/{invoice}/modify
|
||||
Body: {
|
||||
items: [
|
||||
{ id: 1, quantity: 8, deleted: false },
|
||||
{ id: 2, quantity: 0, deleted: true }
|
||||
]
|
||||
}
|
||||
Response: { success: true, message: "Changes saved..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases to Test
|
||||
|
||||
### ❌ Unauthorized Access
|
||||
- Try accessing another company's invoice
|
||||
- Expected: 403 Forbidden
|
||||
|
||||
### ❌ Already Approved Invoice
|
||||
- Try modifying an invoice with `approval_status = 'buyer_approved'`
|
||||
- Expected: Buttons hidden, "This invoice cannot be modified" message
|
||||
|
||||
### ❌ Already Rejected Invoice
|
||||
- Try approving a rejected invoice
|
||||
- Expected: Buttons hidden, rejection reason displayed
|
||||
|
||||
### ❌ Empty Changes
|
||||
- Click "Modify Invoice"
|
||||
- Don't change anything
|
||||
- Click "Save Changes"
|
||||
- Expected: "No changes detected" error
|
||||
|
||||
---
|
||||
|
||||
## Post-Testing Cleanup
|
||||
|
||||
Remove test data:
|
||||
```sql
|
||||
-- Find test orders
|
||||
SELECT * FROM orders WHERE notes LIKE '%Test order for invoice approval%';
|
||||
|
||||
-- Delete test data (cascades to order_items, invoices, order_changes)
|
||||
DELETE FROM orders WHERE notes LIKE '%Test order for invoice approval%';
|
||||
```
|
||||
|
||||
Or keep for repeated testing!
|
||||
|
||||
---
|
||||
|
||||
## Next Steps: Seller Testing (Once UI Complete)
|
||||
|
||||
1. Seller receives notification of buyer modifications
|
||||
2. Seller reviews changes at `/seller/invoices/{invoice}`
|
||||
3. Seller can:
|
||||
- Approve buyer's changes (applies them, moves to amendment_in_progress)
|
||||
- Counter-modify (make own changes, increments negotiation round)
|
||||
- Reject outright
|
||||
|
||||
This will be tested once seller review UI is implemented.
|
||||
@@ -1,174 +0,0 @@
|
||||
# Testing Production Docker Image Locally
|
||||
|
||||
This guide helps you test the production Docker image locally before pushing to CI/CD.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build and start the production image locally
|
||||
docker-compose -f docker-compose.prod-test.yml up --build
|
||||
|
||||
# Access the app at: http://localhost:8080
|
||||
# PostgreSQL accessible at: localhost:5433
|
||||
```
|
||||
|
||||
## Why Test Locally?
|
||||
|
||||
- ✅ **Faster feedback** - 2-5 min vs 10-15 min through CI/CD
|
||||
- ✅ **Save CI resources** - Don't waste build minutes
|
||||
- ✅ **Easier debugging** - Direct container access
|
||||
- ✅ **Catch issues early** - Before they hit version control
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Build and Run
|
||||
```bash
|
||||
# Start everything (builds if needed)
|
||||
docker-compose -f docker-compose.prod-test.yml up
|
||||
|
||||
# Build with no cache (clean build)
|
||||
docker-compose -f docker-compose.prod-test.yml build --no-cache
|
||||
|
||||
# Run in background
|
||||
docker-compose -f docker-compose.prod-test.yml up -d
|
||||
|
||||
# View logs
|
||||
docker-compose -f docker-compose.prod-test.yml logs -f app
|
||||
```
|
||||
|
||||
### Debug Inside Container
|
||||
```bash
|
||||
# Execute shell in running container
|
||||
docker-compose -f docker-compose.prod-test.yml exec app /bin/sh
|
||||
|
||||
# Check supervisor status
|
||||
docker-compose -f docker-compose.prod-test.yml exec app supervisorctl status
|
||||
|
||||
# View nginx logs
|
||||
docker-compose -f docker-compose.prod-test.yml exec app cat /var/log/nginx/error.log
|
||||
|
||||
# View Laravel logs
|
||||
docker-compose -f docker-compose.prod-test.yml exec app tail -f /var/www/html/storage/logs/laravel.log
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
```bash
|
||||
# Stop everything
|
||||
docker-compose -f docker-compose.prod-test.yml down
|
||||
|
||||
# Remove volumes too (fresh database)
|
||||
docker-compose -f docker-compose.prod-test.yml down -v
|
||||
```
|
||||
|
||||
## Testing Workflow
|
||||
|
||||
### Before Pushing to CI/CD:
|
||||
|
||||
1. **Make your changes** to Dockerfile, configs, etc.
|
||||
|
||||
2. **Test locally:**
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod-test.yml up --build
|
||||
```
|
||||
|
||||
3. **Verify it works:**
|
||||
- App accessible at http://localhost:8080
|
||||
- No errors in logs: `docker-compose -f docker-compose.prod-test.yml logs app`
|
||||
- All services running: `docker-compose -f docker-compose.prod-test.yml exec app supervisorctl status`
|
||||
|
||||
4. **If it works, push to develop:**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "fix: your change"
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
5. **If it doesn't work, debug:**
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.prod-test.yml logs app
|
||||
|
||||
# Exec into container
|
||||
docker-compose -f docker-compose.prod-test.yml exec app /bin/sh
|
||||
|
||||
# Make fixes and rebuild
|
||||
docker-compose -f docker-compose.prod-test.yml up --build
|
||||
```
|
||||
|
||||
## Differences from Production
|
||||
|
||||
This local test environment differs from production K8s in:
|
||||
|
||||
- Uses local PostgreSQL (not persistent volume)
|
||||
- Runs on localhost:8080 (not ingress with TLS)
|
||||
- Uses test APP_KEY (not secret from K8s)
|
||||
- Single replica (not multiple pods)
|
||||
- No load balancer or ingress
|
||||
|
||||
But it **does test**:
|
||||
- ✅ Dockerfile builds correctly
|
||||
- ✅ All directories exist and have correct permissions
|
||||
- ✅ Supervisor starts all services (nginx, php-fpm, workers, scheduler)
|
||||
- ✅ Laravel boots and connects to database
|
||||
- ✅ Migrations run successfully
|
||||
- ✅ Application responds to HTTP requests
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port conflicts
|
||||
If 8080 or 5433 are already in use, edit `docker-compose.prod-test.yml` and change the ports.
|
||||
|
||||
### Build errors
|
||||
```bash
|
||||
# Clean build from scratch
|
||||
docker-compose -f docker-compose.prod-test.yml build --no-cache --pull
|
||||
```
|
||||
|
||||
### Container crashes immediately
|
||||
```bash
|
||||
# Check logs for error
|
||||
docker-compose -f docker-compose.prod-test.yml logs app
|
||||
|
||||
# Try running without detach to see output
|
||||
docker-compose -f docker-compose.prod-test.yml up
|
||||
```
|
||||
|
||||
### Need fresh database
|
||||
```bash
|
||||
# Remove volumes and recreate
|
||||
docker-compose -f docker-compose.prod-test.yml down -v
|
||||
docker-compose -f docker-compose.prod-test.yml up
|
||||
```
|
||||
|
||||
## Integration with Development Workflow
|
||||
|
||||
### Using alongside Laravel Sail
|
||||
|
||||
Sail and prod-test can run simultaneously:
|
||||
- Sail dev environment: http://localhost (port 80)
|
||||
- Production test: http://localhost:8080
|
||||
|
||||
Just make sure to use different database ports (Sail: 5432, prod-test: 5433).
|
||||
|
||||
### When to use each:
|
||||
|
||||
**Laravel Sail (docker-compose.yml):**
|
||||
- Daily development
|
||||
- Running artisan commands
|
||||
- Testing code changes
|
||||
- Hot reload with Vite
|
||||
|
||||
**Production Test (docker-compose.prod-test.yml):**
|
||||
- Before pushing to develop
|
||||
- Testing Dockerfile changes
|
||||
- Testing supervisor/nginx configs
|
||||
- Verifying production build process
|
||||
|
||||
## Next Steps After Local Success
|
||||
|
||||
Once your image works locally:
|
||||
|
||||
1. ✅ Commit and push to develop
|
||||
2. ✅ CI/CD builds the same image
|
||||
3. ✅ Deploy to Kubernetes with confidence
|
||||
4. ✅ Much faster iteration cycle!
|
||||
197
TEST_RESULTS.md
197
TEST_RESULTS.md
@@ -1,197 +0,0 @@
|
||||
# Business-Scoped Seller Routes - Test Results
|
||||
|
||||
**Test Date:** 2025-10-15
|
||||
**Tester:** Claude Code
|
||||
**Test Account:** jon@cannabrands.com
|
||||
**Business Slug:** cannabrands
|
||||
|
||||
## Test Data Summary
|
||||
- **Business:** Cannabrands (slug: `cannabrands`)
|
||||
- **Brands:** 13
|
||||
- **Products:** 312
|
||||
- **Orders:** 0 (none yet)
|
||||
- **Invoices:** 0 (none yet)
|
||||
- **Drivers:** 1
|
||||
- **Vehicles:** 2
|
||||
|
||||
---
|
||||
|
||||
## Route Tests
|
||||
|
||||
### 1. Business-Scoped Dashboard
|
||||
**URL:** `http://localhost:8000/s/cannabrands/dashboard`
|
||||
**Expected:** Dashboard with business context
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### 2. Fleet Management - Drivers
|
||||
**URL:** `http://localhost:8000/s/cannabrands/fleet/drivers`
|
||||
**Expected:** Drivers list page with business context
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### 3. Fleet Management - Vehicles
|
||||
**URL:** `http://localhost:8000/s/cannabrands/fleet/vehicles`
|
||||
**Expected:** Vehicles list page with business context
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### 4. Orders List
|
||||
**URL:** `http://localhost:8000/s/cannabrands/orders`
|
||||
**Expected:** Orders list (empty state expected)
|
||||
**Status:** ✅ PASSED (Fixed query in OrderController)
|
||||
**Notes:** Fixed relationship chain to use `whereHas('items.product.brand')`
|
||||
|
||||
### 5. Products List
|
||||
**URL:** `http://localhost:8000/s/cannabrands/products`
|
||||
**Expected:** Products list with 312 products
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### 6. Invoices List
|
||||
**URL:** `http://localhost:8000/s/cannabrands/invoices`
|
||||
**Expected:** Invoices list (empty state expected)
|
||||
**Status:** ✅ PASSED
|
||||
**Notes:** InvoiceController updated with business scope
|
||||
|
||||
### 7. Product Edit Page
|
||||
**URL:** `http://localhost:8000/s/cannabrands/products/225/edit`
|
||||
**Expected:** Product edit form with audit history
|
||||
**Status:** ✅ PASSED
|
||||
**Notes:** Enhanced UI, audit logging, checkbox handling fixed
|
||||
|
||||
---
|
||||
|
||||
## Access Control Tests
|
||||
|
||||
### Test 1: Business Slug Validation
|
||||
**Test:** Access non-existent business slug
|
||||
**URL:** `http://localhost:8000/s/invalid-slug/dashboard`
|
||||
**Expected:** 404 Not Found
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### Test 2: Unauthorized Business Access
|
||||
**Test:** User attempts to access another user's business
|
||||
**Expected:** 403 Forbidden
|
||||
**Status:** ⏳ Testing...
|
||||
**Notes:** Requires second test account
|
||||
|
||||
### Test 3: Unauthenticated Access
|
||||
**Test:** Access business-scoped route without login
|
||||
**Expected:** Redirect to login page
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
---
|
||||
|
||||
## Route Model Binding Tests
|
||||
|
||||
### Test 1: Business by Slug
|
||||
**Binding:** `{business}` → Business model by slug
|
||||
**Test URL:** `/s/cannabrands/dashboard`
|
||||
**Expected:** Resolve to Business with slug 'cannabrands'
|
||||
**Status:** ⏳ Testing...
|
||||
|
||||
### Test 2: Order by Order Number
|
||||
**Binding:** `{order}` → Order model by order_number
|
||||
**Test URL:** `/s/cannabrands/orders/{order_number}`
|
||||
**Expected:** Resolve to Order by order_number field
|
||||
**Status:** ⏳ Testing... (requires order data)
|
||||
|
||||
### Test 3: Product by ID
|
||||
**Binding:** `{product}` → Product model by ID
|
||||
**Test URL:** `/s/cannabrands/products/225/edit`
|
||||
**Expected:** Resolve to Product with ID 225
|
||||
**Status:** ✅ PASSED
|
||||
|
||||
---
|
||||
|
||||
## Critical Routes Requiring Approval Middleware
|
||||
|
||||
These routes require `approved` middleware:
|
||||
- ✅ Orders: `/s/{business}/orders`
|
||||
- ✅ Invoices: `/s/{business}/invoices`
|
||||
- ✅ Products: `/s/{business}/products`
|
||||
- ✅ Components: `/s/{business}/components`
|
||||
- ✅ Customers: `/s/{business}/customers`
|
||||
|
||||
**Test Account Status:** jon@cannabrands.com is approved ✓
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Fixes Applied
|
||||
|
||||
### Issue 1: OrderController Query Error ✅ FIXED
|
||||
**Error:** `Column "seller_business_id" does not exist`
|
||||
**Fix:** Changed query to use correct relationship chain:
|
||||
```php
|
||||
->whereHas('items.product.brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
```
|
||||
**File:** `app/Http/Controllers/OrderController.php:26`
|
||||
|
||||
### Issue 2: Invoice Model Fillable Array ✅ FIXED
|
||||
**Error:** Using deprecated `company_id` instead of `business_id`
|
||||
**Fix:** Updated fillable array in Invoice model
|
||||
**File:** `app/Models/Invoice.php`
|
||||
|
||||
### Issue 3: Invoice Routes Not Business-Scoped ✅ FIXED
|
||||
**Error:** Invoice routes not accepting business parameter
|
||||
**Fix:** Updated InvoiceController to accept Business parameter and verify ownership
|
||||
**File:** `app/Http/Controllers/Seller/InvoiceController.php`
|
||||
|
||||
### Issue 4: Product Checkbox Not Unchecking ✅ FIXED
|
||||
**Error:** Featured checkbox stays checked when unchecked and saved
|
||||
**Fix:** Added explicit checkbox handling in ProductController:
|
||||
```php
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
```
|
||||
**File:** `app/Http/Controllers/Seller/ProductController.php`
|
||||
|
||||
### Issue 5: Alpine.js FOUC on Sidebar ✅ FIXED
|
||||
**Error:** Brief flash of expanded menu items during page load
|
||||
**Fix:** Added `x-cloak` directive to sidebar menu container
|
||||
**File:** `resources/views/components/seller-sidebar.blade.php:35`
|
||||
|
||||
### Issue 6: Alpine.js FOUC on Notifications ✅ FIXED
|
||||
**Error:** Brief flash of notification dropdown during page load
|
||||
**Fix:** Added `x-cloak` directive to notification dropdown container
|
||||
**File:** `resources/views/layouts/app-with-sidebar.blade.php:67`
|
||||
|
||||
---
|
||||
|
||||
## Manual Testing Checklist
|
||||
|
||||
- [ ] Login as seller (jon@cannabrands.com)
|
||||
- [ ] Navigate to `/s/cannabrands/dashboard`
|
||||
- [ ] Test fleet management pages (drivers, vehicles)
|
||||
- [ ] Test orders page (empty state)
|
||||
- [ ] Test products list and edit
|
||||
- [ ] Test invoices page (empty state)
|
||||
- [ ] Test product audit history
|
||||
- [ ] Test checkbox toggles
|
||||
- [ ] Verify sidebar menu persistence
|
||||
- [ ] Verify notification dropdown works
|
||||
- [ ] Test accessing invalid business slug
|
||||
- [ ] Test logout and re-login flow
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Create test orders** to fully test the order management workflow
|
||||
2. **Add automated tests** for business-scoped routing and access control
|
||||
3. **Monitor production** for any route binding issues
|
||||
4. **Document** the business-scoped routing pattern for future development
|
||||
5. **Consider** adding middleware to verify business ownership on all routes
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Total Routes Tested:** 7
|
||||
**Passed:** 4
|
||||
**In Progress:** 3
|
||||
**Failed:** 0
|
||||
|
||||
**Critical Fixes Applied:** 6
|
||||
**Access Control:** ✅ Implemented via custom route model binding
|
||||
**Business Scope:** ✅ All routes accept business parameter
|
||||
**Database Queries:** ✅ Fixed to use correct relationship chains
|
||||
68
app/Events/CartUpdated.php
Normal file
68
app/Events/CartUpdated.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Cart Updated Event
|
||||
*
|
||||
* Broadcasts real-time cart count updates to authenticated users.
|
||||
* Best practices:
|
||||
* - Uses ShouldBroadcast for automatic broadcasting
|
||||
* - Broadcasts on private channel (user-specific)
|
||||
* - Includes minimal data (just count, not full cart)
|
||||
* - Uses custom event name via broadcastAs()
|
||||
*/
|
||||
class CartUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $userId,
|
||||
public int $count
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
// Private channel - only authenticated user can listen
|
||||
return [
|
||||
new PrivateChannel('user.'.$this->userId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's broadcast name.
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'CartUpdated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to broadcast.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'count' => $this->count,
|
||||
];
|
||||
}
|
||||
}
|
||||
78
app/Events/PickingProgressUpdated.php
Normal file
78
app/Events/PickingProgressUpdated.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Picking Progress Updated Event
|
||||
*
|
||||
* Broadcasts real-time picking progress updates to all workers on the same picking ticket.
|
||||
* Use case: Multiple warehouse workers picking different items from the same order simultaneously.
|
||||
*
|
||||
* Best practices:
|
||||
* - Uses ShouldBroadcast for automatic broadcasting
|
||||
* - Broadcasts on private channel (order-specific)
|
||||
* - Includes minimal data (item ID, picked quantity, progress)
|
||||
* - Uses custom event name via broadcastAs()
|
||||
*/
|
||||
class PickingProgressUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param int $orderId The order ID
|
||||
* @param int $itemId The order item ID that was updated
|
||||
* @param int $pickedQty The new picked quantity for this item
|
||||
* @param float $progress Overall picking progress percentage (0-100)
|
||||
*/
|
||||
public function __construct(
|
||||
public int $orderId,
|
||||
public int $itemId,
|
||||
public int $pickedQty,
|
||||
public float $progress
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
// Private channel - only authorized users (sellers on this order) can listen
|
||||
return [
|
||||
new PrivateChannel('picking-ticket.'.$this->orderId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's broadcast name.
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'PickingProgressUpdated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to broadcast.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'itemId' => $this->itemId,
|
||||
'pickedQty' => $this->pickedQty,
|
||||
'progress' => $this->progress,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,10 @@ class BrandResource extends Resource
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count();
|
||||
// Cache brand count for 60 seconds to reduce database queries on every page load
|
||||
return cache()->remember('brand_count', 60, function () {
|
||||
return static::getModel()::count() ?: null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
|
||||
@@ -18,6 +18,7 @@ class BrandsTable
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['business']))
|
||||
->columns([
|
||||
TextColumn::make('business.name')
|
||||
->searchable(),
|
||||
|
||||
77
app/Filament/Resources/BusinessModuleResource.php
Normal file
77
app/Filament/Resources/BusinessModuleResource.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\BusinessModuleResource\Pages\CreateBusinessModule;
|
||||
use App\Filament\Resources\BusinessModuleResource\Pages\EditBusinessModule;
|
||||
use App\Filament\Resources\BusinessModuleResource\Pages\ListBusinessModules;
|
||||
use App\Filament\Resources\BusinessModuleResource\Pages\ViewBusinessModule;
|
||||
use App\Filament\Resources\BusinessModuleResource\Schemas\BusinessModuleForm;
|
||||
use App\Filament\Resources\BusinessModuleResource\Schemas\BusinessModuleInfolist;
|
||||
use App\Filament\Resources\BusinessModuleResource\Tables\BusinessModulesTable;
|
||||
use App\Models\BusinessModule;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class BusinessModuleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BusinessModule::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCog;
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?int $navigationSort = 11;
|
||||
|
||||
protected static ?string $navigationLabel = 'Business Modules';
|
||||
|
||||
protected static ?string $modelLabel = 'Business Module';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return cache()->remember('business_module_count', 60, function () {
|
||||
return static::getModel()::active()->count() ?: null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
return auth()->check() && auth()->user()->user_type === 'admin';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return BusinessModuleForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return BusinessModuleInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return BusinessModulesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListBusinessModules::route('/'),
|
||||
'create' => CreateBusinessModule::route('/create'),
|
||||
'view' => ViewBusinessModule::route('/{record}'),
|
||||
'edit' => EditBusinessModule::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessModuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BusinessModuleResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBusinessModule extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BusinessModuleResource::class;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessModuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BusinessModuleResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBusinessModule extends EditRecord
|
||||
{
|
||||
protected static string $resource = BusinessModuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
ViewAction::make(),
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessModuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BusinessModuleResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBusinessModules extends ListRecords
|
||||
{
|
||||
protected static string $resource = BusinessModuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessModuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BusinessModuleResource;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBusinessModule extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BusinessModuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessModuleResource\Schemas;
|
||||
|
||||
use App\Models\Module;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Grid;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class BusinessModuleForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Business Module Configuration')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Select::make('business_id')
|
||||
->relationship('business', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->live()
|
||||
->helperText('Select the business'),
|
||||
|
||||
Select::make('module_key')
|
||||
->options(Module::active()->pluck('name', 'key'))
|
||||
->searchable()
|
||||
->required()
|
||||
->live()
|
||||
->helperText('Select the module to enable')
|
||||
->disabled(fn (?string $operation): bool => $operation === 'edit'),
|
||||
]),
|
||||
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Toggle::make('enabled')
|
||||
->default(true)
|
||||
->helperText('Enable or disable this module'),
|
||||
|
||||
Select::make('activated_by')
|
||||
->relationship('activatedBy', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('User who activated this module'),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Pricing & Plan')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('plan')
|
||||
->maxLength(255)
|
||||
->helperText('Plan name (e.g., basic, pro, enterprise)'),
|
||||
|
||||
TextInput::make('monthly_price')
|
||||
->numeric()
|
||||
->prefix('$')
|
||||
->step(0.01)
|
||||
->helperText('Monthly subscription price'),
|
||||
]),
|
||||
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
DateTimePicker::make('activated_at')
|
||||
->default(now())
|
||||
->helperText('When the module was activated'),
|
||||
|
||||
DateTimePicker::make('expires_at')
|
||||
->helperText('Module expiration date (optional)'),
|
||||
]),
|
||||
])
|
||||
->collapsible(),
|
||||
|
||||
Section::make('Configuration & Limits')
|
||||
->schema([
|
||||
KeyValue::make('config')
|
||||
->label('Module Configuration')
|
||||
->keyLabel('Config Key')
|
||||
->valueLabel('Config Value')
|
||||
->helperText('Custom configuration for this business')
|
||||
->reorderable(),
|
||||
|
||||
KeyValue::make('limits')
|
||||
->label('Usage Limits')
|
||||
->keyLabel('Metric')
|
||||
->valueLabel('Limit')
|
||||
->helperText('Usage limits for this business (overrides defaults)')
|
||||
->default(function (Get $get) {
|
||||
$moduleKey = $get('module_key');
|
||||
if (! $moduleKey) {
|
||||
return [];
|
||||
}
|
||||
$module = Module::where('key', $moduleKey)->first();
|
||||
|
||||
return $module?->default_limits ?? [];
|
||||
})
|
||||
->reorderable(),
|
||||
])
|
||||
->collapsible(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessModuleResource\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\Grid;
|
||||
use Filament\Infolists\Components\IconEntry;
|
||||
use Filament\Infolists\Components\KeyValueEntry;
|
||||
use Filament\Infolists\Components\Section;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class BusinessModuleInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Business Module Details')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('business.name')
|
||||
->label('Business'),
|
||||
|
||||
TextEntry::make('module_key')
|
||||
->label('Module')
|
||||
->badge()
|
||||
->color('info')
|
||||
->formatStateUsing(fn (string $state): string => \App\Models\Module::where('key', $state)->value('name') ?? $state
|
||||
),
|
||||
]),
|
||||
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
IconEntry::make('enabled')
|
||||
->boolean(),
|
||||
|
||||
IconEntry::make('is_active')
|
||||
->label('Active')
|
||||
->getStateUsing(fn ($record) => $record->isActive())
|
||||
->boolean(),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Pricing & Plan')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextEntry::make('plan')
|
||||
->badge()
|
||||
->color('success')
|
||||
->default('N/A'),
|
||||
|
||||
TextEntry::make('monthly_price')
|
||||
->money('USD'),
|
||||
|
||||
TextEntry::make('activatedBy.name')
|
||||
->label('Activated By')
|
||||
->default('N/A'),
|
||||
]),
|
||||
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('activated_at')
|
||||
->dateTime(),
|
||||
|
||||
TextEntry::make('expires_at')
|
||||
->dateTime()
|
||||
->color(fn ($state) => $state && $state->isPast() ? 'danger' : null)
|
||||
->icon(fn ($state) => $state && $state->isPast() ? 'heroicon-o-exclamation-triangle' : null)
|
||||
->default('Never'),
|
||||
]),
|
||||
])
|
||||
->collapsible(),
|
||||
|
||||
Section::make('Configuration')
|
||||
->schema([
|
||||
KeyValueEntry::make('config')
|
||||
->label('Module Configuration'),
|
||||
|
||||
KeyValueEntry::make('limits')
|
||||
->label('Usage Limits'),
|
||||
])
|
||||
->collapsible(),
|
||||
|
||||
Section::make('Timestamps')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('created_at')
|
||||
->dateTime(),
|
||||
|
||||
TextEntry::make('updated_at')
|
||||
->dateTime(),
|
||||
]),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessModuleResource\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Support\Enums\FontWeight;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class BusinessModulesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('business.name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight(FontWeight::Bold),
|
||||
|
||||
TextColumn::make('module_key')
|
||||
->label('Module')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->badge()
|
||||
->color('info')
|
||||
->formatStateUsing(fn (string $state): string => \App\Models\Module::where('key', $state)->value('name') ?? $state
|
||||
),
|
||||
|
||||
IconColumn::make('enabled')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('plan')
|
||||
->searchable()
|
||||
->badge()
|
||||
->color('success')
|
||||
->default('N/A'),
|
||||
|
||||
TextColumn::make('monthly_price')
|
||||
->money('USD')
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('activated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('activatedBy.name')
|
||||
->label('Activated By')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable()
|
||||
->color(fn ($state) => $state && $state->isPast() ? 'danger' : null)
|
||||
->icon(fn ($state) => $state && $state->isPast() ? 'heroicon-o-exclamation-triangle' : null),
|
||||
|
||||
IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->getStateUsing(fn ($record) => $record->isActive())
|
||||
->boolean(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('business_id')
|
||||
->relationship('business', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->label('Business'),
|
||||
|
||||
SelectFilter::make('module_key')
|
||||
->options(\App\Models\Module::pluck('name', 'key'))
|
||||
->label('Module')
|
||||
->searchable(),
|
||||
|
||||
TernaryFilter::make('enabled')
|
||||
->label('Enabled')
|
||||
->boolean()
|
||||
->trueLabel('Enabled only')
|
||||
->falseLabel('Disabled only')
|
||||
->native(false),
|
||||
|
||||
TernaryFilter::make('expired')
|
||||
->label('Expired')
|
||||
->queries(
|
||||
true: fn ($query) => $query->whereNotNull('expires_at')->where('expires_at', '<=', now()),
|
||||
false: fn ($query) => $query->where(function ($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
}),
|
||||
)
|
||||
->trueLabel('Expired only')
|
||||
->falseLabel('Active only')
|
||||
->native(false),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,10 @@ class BusinessResource extends Resource
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
// Count businesses pending approval
|
||||
return static::getModel()::where('status', 'submitted')->count() ?: null;
|
||||
// Count businesses pending approval (cached for 60 seconds)
|
||||
return cache()->remember('business_pending_count', 60, function () {
|
||||
return static::getModel()::where('status', 'submitted')->count() ?: null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@@ -528,6 +530,7 @@ class BusinessResource extends Resource
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['owner', 'users']))
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Name')
|
||||
|
||||
@@ -33,7 +33,10 @@ class ComponentResource extends Resource
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count();
|
||||
// Cache component count for 60 seconds to reduce database queries on every page load
|
||||
return cache()->remember('component_count', 60, function () {
|
||||
return static::getModel()::count() ?: null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
|
||||
@@ -31,8 +31,10 @@ class InvoiceResource extends Resource
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
// Count unpaid invoices
|
||||
return static::getModel()::where('payment_status', 'unpaid')->count() ?: null;
|
||||
// Cache unpaid invoice count for 60 seconds to reduce database queries on every page load
|
||||
return cache()->remember('invoice_unpaid_count', 60, function () {
|
||||
return static::getModel()::where('payment_status', 'unpaid')->count() ?: null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
|
||||
@@ -16,6 +16,7 @@ class InvoicesTable
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['order', 'business']))
|
||||
->columns([
|
||||
TextColumn::make('invoice_number')
|
||||
->searchable(),
|
||||
|
||||
75
app/Filament/Resources/ModuleResource.php
Normal file
75
app/Filament/Resources/ModuleResource.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ModuleResource\Pages\CreateModule;
|
||||
use App\Filament\Resources\ModuleResource\Pages\EditModule;
|
||||
use App\Filament\Resources\ModuleResource\Pages\ListModules;
|
||||
use App\Filament\Resources\ModuleResource\Pages\ViewModule;
|
||||
use App\Filament\Resources\ModuleResource\Schemas\ModuleForm;
|
||||
use App\Filament\Resources\ModuleResource\Schemas\ModuleInfolist;
|
||||
use App\Filament\Resources\ModuleResource\Tables\ModulesTable;
|
||||
use App\Models\Module;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class ModuleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Module::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPuzzlePiece;
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
protected static ?string $navigationLabel = 'Modules';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return cache()->remember('module_count', 60, function () {
|
||||
return static::getModel()::active()->count() ?: null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
return auth()->check() && auth()->user()->user_type === 'admin';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return ModuleForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return ModuleInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return ModulesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListModules::route('/'),
|
||||
'create' => CreateModule::route('/create'),
|
||||
'view' => ViewModule::route('/{record}'),
|
||||
'edit' => EditModule::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
11
app/Filament/Resources/ModuleResource/Pages/CreateModule.php
Normal file
11
app/Filament/Resources/ModuleResource/Pages/CreateModule.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ModuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ModuleResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateModule extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ModuleResource::class;
|
||||
}
|
||||
21
app/Filament/Resources/ModuleResource/Pages/EditModule.php
Normal file
21
app/Filament/Resources/ModuleResource/Pages/EditModule.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ModuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ModuleResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditModule extends EditRecord
|
||||
{
|
||||
protected static string $resource = ModuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
ViewAction::make(),
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/ModuleResource/Pages/ListModules.php
Normal file
19
app/Filament/Resources/ModuleResource/Pages/ListModules.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ModuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ModuleResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListModules extends ListRecords
|
||||
{
|
||||
protected static string $resource = ModuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/ModuleResource/Pages/ViewModule.php
Normal file
19
app/Filament/Resources/ModuleResource/Pages/ViewModule.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ModuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ModuleResource;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewModule extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ModuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
115
app/Filament/Resources/ModuleResource/Schemas/ModuleForm.php
Normal file
115
app/Filament/Resources/ModuleResource/Schemas/ModuleForm.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ModuleResource\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Fieldset;
|
||||
use Filament\Forms\Components\Grid;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ModuleForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Module Information')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('key')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255)
|
||||
->helperText('Unique identifier for the module (e.g., sms_gateway)')
|
||||
->disabled(fn (?string $operation): bool => $operation === 'edit'),
|
||||
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
]),
|
||||
|
||||
Textarea::make('description')
|
||||
->columnSpanFull()
|
||||
->rows(3),
|
||||
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
Select::make('category')
|
||||
->options([
|
||||
'Communication' => 'Communication',
|
||||
'Sales' => 'Sales',
|
||||
'Operations' => 'Operations',
|
||||
'Finance' => 'Finance',
|
||||
'Marketing' => 'Marketing',
|
||||
'Support' => 'Support',
|
||||
'Analytics' => 'Analytics',
|
||||
])
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
TextInput::make('icon')
|
||||
->helperText('Heroicon name (e.g., heroicon-o-cube)'),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Lower numbers appear first'),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Module Settings')
|
||||
->schema([
|
||||
Fieldset::make('Status & Features')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true)
|
||||
->helperText('Is this module available for use?'),
|
||||
|
||||
Toggle::make('is_premium')
|
||||
->label('Premium Module')
|
||||
->default(false)
|
||||
->helperText('Requires payment or subscription'),
|
||||
]),
|
||||
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Toggle::make('requires_approval')
|
||||
->label('Requires Approval')
|
||||
->default(false)
|
||||
->helperText('Admin must approve before activation'),
|
||||
|
||||
Toggle::make('enabled_by_default')
|
||||
->label('Enabled by Default')
|
||||
->default(false)
|
||||
->helperText('Automatically enabled for new businesses'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Configuration & Limits')
|
||||
->schema([
|
||||
KeyValue::make('config')
|
||||
->label('Module Configuration')
|
||||
->keyLabel('Config Key')
|
||||
->valueLabel('Config Value')
|
||||
->helperText('JSON configuration for module features'),
|
||||
|
||||
KeyValue::make('default_limits')
|
||||
->label('Default Limits')
|
||||
->keyLabel('Metric')
|
||||
->valueLabel('Limit')
|
||||
->helperText('Default usage limits (e.g., sms_per_month: 1000)'),
|
||||
])
|
||||
->collapsible(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ModuleResource\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\Grid;
|
||||
use Filament\Infolists\Components\IconEntry;
|
||||
use Filament\Infolists\Components\KeyValueEntry;
|
||||
use Filament\Infolists\Components\Section;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ModuleInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Module Information')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('name'),
|
||||
TextEntry::make('key')
|
||||
->badge()
|
||||
->color('gray'),
|
||||
]),
|
||||
|
||||
TextEntry::make('description')
|
||||
->columnSpanFull(),
|
||||
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextEntry::make('category')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'Communication' => 'info',
|
||||
'Sales' => 'success',
|
||||
'Operations' => 'warning',
|
||||
'Finance' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextEntry::make('icon'),
|
||||
|
||||
TextEntry::make('sort_order')
|
||||
->label('Sort Order'),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Module Status')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
IconEntry::make('is_active')
|
||||
->label('Active')
|
||||
->boolean(),
|
||||
|
||||
IconEntry::make('is_premium')
|
||||
->label('Premium')
|
||||
->boolean(),
|
||||
|
||||
IconEntry::make('requires_approval')
|
||||
->label('Requires Approval')
|
||||
->boolean(),
|
||||
|
||||
IconEntry::make('enabled_by_default')
|
||||
->label('Enabled by Default')
|
||||
->boolean(),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Configuration')
|
||||
->schema([
|
||||
KeyValueEntry::make('config')
|
||||
->label('Module Configuration'),
|
||||
|
||||
KeyValueEntry::make('default_limits')
|
||||
->label('Default Limits'),
|
||||
])
|
||||
->collapsible(),
|
||||
|
||||
Section::make('Timestamps')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('created_at')
|
||||
->dateTime(),
|
||||
|
||||
TextEntry::make('updated_at')
|
||||
->dateTime(),
|
||||
]),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
127
app/Filament/Resources/ModuleResource/Tables/ModulesTable.php
Normal file
127
app/Filament/Resources/ModuleResource/Tables/ModulesTable.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ModuleResource\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Support\Enums\FontWeight;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ModulesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight(FontWeight::Bold),
|
||||
|
||||
TextColumn::make('key')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->badge()
|
||||
->color('gray'),
|
||||
|
||||
TextColumn::make('category')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'Communication' => 'info',
|
||||
'Sales' => 'success',
|
||||
'Operations' => 'warning',
|
||||
'Finance' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('description')
|
||||
->limit(50)
|
||||
->tooltip(function (TextColumn $column): ?string {
|
||||
$state = $column->getState();
|
||||
|
||||
if (strlen($state) <= 50) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $state;
|
||||
}),
|
||||
|
||||
IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
|
||||
IconColumn::make('is_premium')
|
||||
->label('Premium')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
|
||||
IconColumn::make('requires_approval')
|
||||
->label('Requires Approval')
|
||||
->boolean(),
|
||||
|
||||
IconColumn::make('enabled_by_default')
|
||||
->label('Default')
|
||||
->boolean(),
|
||||
|
||||
TextColumn::make('sort_order')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
TernaryFilter::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->trueLabel('Active only')
|
||||
->falseLabel('Inactive only')
|
||||
->native(false),
|
||||
|
||||
TernaryFilter::make('is_premium')
|
||||
->label('Premium')
|
||||
->boolean()
|
||||
->trueLabel('Premium only')
|
||||
->falseLabel('Free only')
|
||||
->native(false),
|
||||
|
||||
SelectFilter::make('category')
|
||||
->options([
|
||||
'Communication' => 'Communication',
|
||||
'Sales' => 'Sales',
|
||||
'Operations' => 'Operations',
|
||||
'Finance' => 'Finance',
|
||||
'Marketing' => 'Marketing',
|
||||
'Support' => 'Support',
|
||||
'Analytics' => 'Analytics',
|
||||
])
|
||||
->native(false),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('sort_order');
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,10 @@ class OrderResource extends Resource
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
// Count pending and processing orders
|
||||
return static::getModel()::whereIn('status', ['pending', 'processing', 'confirmed'])->count() ?: null;
|
||||
// Cache active order count for 60 seconds to reduce database queries on every page load
|
||||
return cache()->remember('order_active_count', 60, function () {
|
||||
return static::getModel()::whereIn('status', ['pending', 'processing', 'confirmed'])->count() ?: null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
|
||||
@@ -15,6 +15,7 @@ class OrdersTable
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['business']))
|
||||
->columns([
|
||||
TextColumn::make('order_number')
|
||||
->label('Order #')
|
||||
|
||||
@@ -36,7 +36,10 @@ class ProductResource extends Resource
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count();
|
||||
// Cache product count for 60 seconds to reduce database queries on every page load
|
||||
return cache()->remember('product_count', 60, function () {
|
||||
return static::getModel()::count() ?: null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
|
||||
@@ -20,6 +20,7 @@ class ProductsTable
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['brand', 'strain']))
|
||||
->columns([
|
||||
ImageColumn::make('image_path')
|
||||
->label('Image')
|
||||
|
||||
@@ -36,8 +36,10 @@ class UserResource extends Resource
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
// Count inactive and suspended users
|
||||
return static::getModel()::whereIn('status', ['inactive', 'suspended'])->count() ?: null;
|
||||
// Cache inactive/suspended user count for 60 seconds to reduce database queries on every page load
|
||||
return cache()->remember('user_inactive_count', 60, function () {
|
||||
return static::getModel()::whereIn('status', ['inactive', 'suspended'])->count() ?: null;
|
||||
});
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@@ -236,6 +238,13 @@ class UserResource extends Resource
|
||||
EditAction::make()
|
||||
->label('View/Modify')
|
||||
->icon('heroicon-o-pencil'),
|
||||
Action::make('impersonate')
|
||||
->label('Impersonate')
|
||||
->icon('heroicon-o-user-circle')
|
||||
->color('warning')
|
||||
->visible(fn (User $record) => auth()->user()->canImpersonate() && $record->canBeImpersonated())
|
||||
->url(fn (User $record) => route('admin.impersonate.perform', $record))
|
||||
->openUrlInNewTab(false),
|
||||
Action::make('suspend')
|
||||
->label('Suspend')
|
||||
->icon('heroicon-o-no-symbol')
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Events\PickingProgressUpdated;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\OrderItem;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -18,8 +19,6 @@ class WorkorderController extends Controller
|
||||
public function updatePickedQuantity(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
\Log::info('Workorder update request', $request->all());
|
||||
|
||||
$validated = $request->validate([
|
||||
'id' => 'required|integer|exists:order_items,id',
|
||||
'qty' => 'required|numeric|min:0',
|
||||
@@ -46,6 +45,14 @@ class WorkorderController extends Controller
|
||||
$order->refresh();
|
||||
$orderItem->refresh();
|
||||
|
||||
// Broadcast real-time update to all workers on this picking ticket
|
||||
broadcast(new PickingProgressUpdated(
|
||||
orderId: $order->id,
|
||||
itemId: $orderItem->id,
|
||||
pickedQty: (int) $orderItem->picked_qty,
|
||||
progress: (float) $order->workorder_status
|
||||
))->toOthers();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Picked quantity updated successfully.',
|
||||
@@ -58,7 +65,6 @@ class WorkorderController extends Controller
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Workorder update failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -37,7 +37,8 @@ class ContactController extends Controller
|
||||
->orderBy('deleted_at', 'desc')
|
||||
->get();
|
||||
|
||||
return view('business.contacts.index', compact('business', 'contacts', 'archivedContacts'));
|
||||
return view('business.contacts.index', compact('business', 'contacts', 'archivedContacts'))
|
||||
->with('useToasts', true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -170,6 +170,21 @@ class LocationController extends Controller
|
||||
$business->locations()->where('id', '!=', $location->id)->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
// Prevent disabling accepts_deliveries if this is the last location that accepts deliveries
|
||||
if (! $request->boolean('accepts_deliveries') && $location->accepts_deliveries) {
|
||||
$otherDeliveryLocations = $business->locations()
|
||||
->where('id', '!=', $location->id)
|
||||
->where('accepts_deliveries', true)
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
|
||||
if ($otherDeliveryLocations === 0) {
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('error', 'You must have at least one active location that accepts deliveries for customers to place orders.');
|
||||
}
|
||||
}
|
||||
|
||||
$location->update([
|
||||
'name' => $validated['name'],
|
||||
'slug' => Str::slug($validated['name']),
|
||||
|
||||
@@ -19,15 +19,21 @@ class CartController extends Controller
|
||||
/**
|
||||
* Display the cart page or return JSON data.
|
||||
*/
|
||||
public function index(Request $request): View|JsonResponse
|
||||
public function index(\App\Models\Business $business, Request $request): View|JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
// Fetch items once - calculate totals from loaded collection
|
||||
$items = $this->cartService->getCartItems($user, $sessionId);
|
||||
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
|
||||
$tax = $this->cartService->getTax($user, $sessionId);
|
||||
$total = $this->cartService->getTotal($user, $sessionId);
|
||||
|
||||
$subtotal = $items->sum(fn ($item) => $item->quantity * ($item->product->wholesale_price ?? 0));
|
||||
|
||||
// Calculate tax based on business tax rate
|
||||
$taxRate = $business->getTaxRate() ?? 0.08;
|
||||
$tax = $subtotal * $taxRate;
|
||||
|
||||
$total = $subtotal + $tax;
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
@@ -55,7 +61,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Add item to cart (Ajax).
|
||||
*/
|
||||
public function add(Request $request): JsonResponse
|
||||
public function add(\App\Models\Business $business, Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
@@ -68,6 +74,7 @@ class CartController extends Controller
|
||||
|
||||
try {
|
||||
$cart = $this->cartService->addItem(
|
||||
$business,
|
||||
$request->integer('product_id'),
|
||||
$request->integer('quantity', 1),
|
||||
$user,
|
||||
@@ -94,7 +101,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Update cart item quantity (Ajax).
|
||||
*/
|
||||
public function update(Request $request, int $cartId): JsonResponse
|
||||
public function update(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'quantity' => 'required|integer|min:1',
|
||||
@@ -104,7 +111,7 @@ class CartController extends Controller
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
try {
|
||||
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'));
|
||||
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'), $user, $sessionId);
|
||||
|
||||
// Ensure product is loaded for JSON response
|
||||
$cart->load('product', 'brand');
|
||||
@@ -133,13 +140,13 @@ class CartController extends Controller
|
||||
/**
|
||||
* Remove item from cart (Ajax).
|
||||
*/
|
||||
public function remove(Request $request, int $cartId): JsonResponse
|
||||
public function remove(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
|
||||
{
|
||||
$this->cartService->removeItem($cartId);
|
||||
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
$this->cartService->removeItem($cartId, $user, $sessionId);
|
||||
|
||||
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
|
||||
$tax = $this->cartService->getTax($user, $sessionId);
|
||||
$total = $this->cartService->getTotal($user, $sessionId);
|
||||
@@ -158,7 +165,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Clear entire cart.
|
||||
*/
|
||||
public function clear(Request $request): JsonResponse
|
||||
public function clear(\App\Models\Business $business, Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
@@ -175,7 +182,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Get cart count (for header badge).
|
||||
*/
|
||||
public function count(Request $request): JsonResponse
|
||||
public function count(\App\Models\Business $business, Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
@@ -23,7 +23,7 @@ class CheckoutController extends Controller
|
||||
/**
|
||||
* Display the checkout page.
|
||||
*/
|
||||
public function index(Request $request): View|RedirectResponse
|
||||
public function index(Business $business, Request $request): View|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
@@ -33,7 +33,7 @@ class CheckoutController extends Controller
|
||||
|
||||
// Redirect if cart is empty
|
||||
if ($items->isEmpty()) {
|
||||
return redirect()->route('buyer.cart.index')
|
||||
return redirect()->route('buyer.business.cart.index', $business)
|
||||
->with('error', 'Your cart is empty. Add some products before checking out.');
|
||||
}
|
||||
|
||||
@@ -42,9 +42,6 @@ class CheckoutController extends Controller
|
||||
$tax = $this->cartService->getTax($user, $sessionId);
|
||||
$total = $this->cartService->getTotal($user, $sessionId);
|
||||
|
||||
// Get user's business
|
||||
$business = $user->businesses()->first();
|
||||
|
||||
// Load delivery locations (only locations that accept deliveries)
|
||||
$locations = $business
|
||||
? $business->locations()
|
||||
@@ -78,10 +75,10 @@ class CheckoutController extends Controller
|
||||
/**
|
||||
* Process the order.
|
||||
*/
|
||||
public function process(Request $request): RedirectResponse
|
||||
public function process(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'location_id' => 'required|exists:locations,id',
|
||||
'location_id' => 'required_if:delivery_method,delivery|nullable|exists:locations,id',
|
||||
'payment_terms' => 'required|in:cod,net_15,net_30,net_60,net_90',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'delivery_method' => 'required|in:delivery,pickup',
|
||||
@@ -99,35 +96,27 @@ class CheckoutController extends Controller
|
||||
$items = $this->cartService->getCartItems($user, $sessionId);
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return redirect()->route('buyer.cart.index')
|
||||
return redirect()->route('buyer.business.cart.index', $business)
|
||||
->with('error', 'Your cart is empty.');
|
||||
}
|
||||
|
||||
// Get user's business
|
||||
$business = $user->businesses()->first();
|
||||
|
||||
if (! $business) {
|
||||
return back()->with('error', 'No business associated with your account.');
|
||||
}
|
||||
|
||||
// Calculate due date based on payment terms
|
||||
$paymentTerms = $request->input('payment_terms');
|
||||
$dueDate = $this->calculateDueDate($paymentTerms);
|
||||
|
||||
// Calculate totals with payment term surcharge
|
||||
$baseSubtotal = $this->cartService->getSubtotal($user, $sessionId);
|
||||
$surchargePercent = $this->getPaymentTermSurcharge($paymentTerms);
|
||||
$surchargeAmount = $baseSubtotal * ($surchargePercent / 100);
|
||||
$subtotal = $baseSubtotal + $surchargeAmount;
|
||||
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
|
||||
$surchargePercent = Order::getSurchargePercentage($paymentTerms);
|
||||
$surcharge = $subtotal * ($surchargePercent / 100);
|
||||
|
||||
// Tax is calculated on subtotal + surcharge using business tax rate
|
||||
// (0.00 if business is tax-exempt wholesale/resale with Form 5000A)
|
||||
$taxRate = $business->getTaxRate();
|
||||
$tax = $subtotal * $taxRate;
|
||||
$total = $subtotal + $tax;
|
||||
$tax = ($subtotal + $surcharge) * $taxRate;
|
||||
$total = $subtotal + $surcharge + $tax;
|
||||
|
||||
// Create order in transaction
|
||||
$order = DB::transaction(function () use ($request, $user, $business, $items, $subtotal, $tax, $total, $paymentTerms, $dueDate) {
|
||||
$order = DB::transaction(function () use ($request, $user, $business, $items, $subtotal, $surcharge, $tax, $total, $paymentTerms, $dueDate) {
|
||||
// Generate order number
|
||||
$orderNumber = $this->generateOrderNumber();
|
||||
|
||||
@@ -138,6 +127,7 @@ class CheckoutController extends Controller
|
||||
'user_id' => $user->id,
|
||||
'location_id' => $request->input('location_id'),
|
||||
'subtotal' => $subtotal,
|
||||
'surcharge' => $surcharge,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
'status' => 'new',
|
||||
@@ -185,24 +175,24 @@ class CheckoutController extends Controller
|
||||
$sellerNotificationService->newOrderReceived($order);
|
||||
|
||||
// Redirect to success page
|
||||
return redirect()->route('buyer.checkout.success', ['order' => $order->order_number])
|
||||
return redirect()->route('buyer.business.checkout.success', ['business' => $business->slug, 'order' => $order->order_number])
|
||||
->with('success', 'Order placed successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display order confirmation page.
|
||||
*/
|
||||
public function success(Request $request, Order $order): View|RedirectResponse
|
||||
public function success(Business $business, Request $request, Order $order): View|RedirectResponse
|
||||
{
|
||||
// Load relationships
|
||||
$order->load(['items.product', 'business', 'location']);
|
||||
|
||||
// Ensure user owns this order
|
||||
if ($order->user_id !== $request->user()->id) {
|
||||
// Ensure order belongs to this business
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
return view('buyer.checkout.success', compact('order'));
|
||||
return view('buyer.checkout.success', compact('order', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,19 +221,4 @@ class CheckoutController extends Controller
|
||||
default => now()->addDays(30),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment term surcharge percentage.
|
||||
*/
|
||||
private function getPaymentTermSurcharge(string $paymentTerms): float
|
||||
{
|
||||
return match ($paymentTerms) {
|
||||
'cod' => 0.0, // COD: No surcharge
|
||||
'net_15' => 5.0, // Net 15: 5% surcharge
|
||||
'net_30' => 10.0, // Net 30: 10% surcharge
|
||||
'net_60' => 15.0, // Net 60: 15% surcharge
|
||||
'net_90' => 20.0, // Net 90: 20% surcharge
|
||||
default => 0.0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,15 +16,11 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Display a listing of the user's invoices.
|
||||
*/
|
||||
public function index()
|
||||
public function index(\App\Models\Business $business)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
$invoices = Invoice::with(['order', 'business'])
|
||||
->whereHas('order', function ($query) use ($user, $userBusinessIds) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhereIn('business_id', $userBusinessIds);
|
||||
->whereHas('order', function ($query) use ($business) {
|
||||
$query->forBusiness($business);
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
@@ -37,16 +33,16 @@ class InvoiceController extends Controller
|
||||
'overdue' => $invoices->filter(fn ($inv) => $inv->isOverdue())->count(),
|
||||
];
|
||||
|
||||
return view('buyer.invoices.index', compact('invoices', 'stats'));
|
||||
return view('buyer.invoices.index', compact('invoices', 'stats', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified invoice.
|
||||
*/
|
||||
public function show(Invoice $invoice)
|
||||
public function show(\App\Models\Business $business, Invoice $invoice)
|
||||
{
|
||||
// Authorization check
|
||||
if (! $this->canAccessInvoice($invoice)) {
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to view this invoice.');
|
||||
}
|
||||
|
||||
@@ -63,15 +59,15 @@ class InvoiceController extends Controller
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('buyer.invoices.show', compact('invoice', 'invoiceItems'));
|
||||
return view('buyer.invoices.show', compact('invoice', 'invoiceItems', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the invoice without modifications.
|
||||
*/
|
||||
public function approve(Invoice $invoice)
|
||||
public function approve(\App\Models\Business $business, Invoice $invoice)
|
||||
{
|
||||
if (! $this->canAccessInvoice($invoice)) {
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to approve this invoice.');
|
||||
}
|
||||
|
||||
@@ -93,13 +89,13 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Reject the invoice.
|
||||
*/
|
||||
public function reject(Request $request, Invoice $invoice)
|
||||
public function reject(\App\Models\Business $business, Request $request, Invoice $invoice)
|
||||
{
|
||||
$request->validate([
|
||||
'reason' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
if (! $this->canAccessInvoice($invoice)) {
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to reject this invoice.');
|
||||
}
|
||||
|
||||
@@ -116,7 +112,7 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Modify the invoice (record buyer's changes).
|
||||
*/
|
||||
public function modify(Request $request, Invoice $invoice, OrderModificationService $modificationService)
|
||||
public function modify(\App\Models\Business $business, Request $request, Invoice $invoice, OrderModificationService $modificationService)
|
||||
{
|
||||
$request->validate([
|
||||
'items' => 'required|array',
|
||||
@@ -125,7 +121,7 @@ class InvoiceController extends Controller
|
||||
'items.*.deleted' => 'required|boolean',
|
||||
]);
|
||||
|
||||
if (! $this->canAccessInvoice($invoice)) {
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Unauthorized to modify this invoice.',
|
||||
@@ -196,9 +192,9 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Download invoice PDF.
|
||||
*/
|
||||
public function downloadPdf(Invoice $invoice, InvoiceService $invoiceService): Response
|
||||
public function downloadPdf(\App\Models\Business $business, Invoice $invoice, InvoiceService $invoiceService): Response
|
||||
{
|
||||
if (! $this->canAccessInvoice($invoice)) {
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to download this invoice.');
|
||||
}
|
||||
|
||||
@@ -215,19 +211,4 @@ class InvoiceController extends Controller
|
||||
'Content-Disposition' => 'inline; filename="'.$invoice->invoice_number.'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user can access the invoice.
|
||||
*/
|
||||
protected function canAccessInvoice(Invoice $invoice): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
$order = $invoice->order;
|
||||
|
||||
return $order && (
|
||||
$order->user_id === $user->id ||
|
||||
in_array($order->business_id, $userBusinessIds)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ class NotificationController extends Controller
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return view('buyer.notifications.index', compact('notifications'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.notifications.index', compact('notifications', 'business'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,13 @@ class OrderController extends Controller
|
||||
/**
|
||||
* Display a listing of the user's orders.
|
||||
*/
|
||||
public function index()
|
||||
public function index(\App\Models\Business $business)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
$orders = Order::with(['items', 'business', 'location'])
|
||||
->where(function ($query) use ($user, $userBusinessIds) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhereIn('business_id', $userBusinessIds);
|
||||
})
|
||||
// Only show orders for this specific business
|
||||
$orders = Order::forBusiness($business)
|
||||
->with(['items', 'business', 'location'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
@@ -32,30 +29,30 @@ class OrderController extends Controller
|
||||
'delivered' => $orders->where('status', 'delivered')->count(),
|
||||
];
|
||||
|
||||
return view('buyer.orders.index', compact('orders', 'stats'));
|
||||
return view('buyer.orders.index', compact('business', 'orders', 'stats'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified order.
|
||||
*/
|
||||
public function show(Order $order)
|
||||
public function show(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
// Authorization check
|
||||
if (! $this->canAccessOrder($order)) {
|
||||
// Authorization check - order must belong to this business
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to view this order.');
|
||||
}
|
||||
|
||||
$order->load(['items.product', 'business', 'location', 'user', 'invoice', 'manifest']);
|
||||
|
||||
return view('buyer.orders.show', compact('order'));
|
||||
return view('buyer.orders.show', compact('business', 'order'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an order.
|
||||
*/
|
||||
public function accept(Order $order)
|
||||
public function accept(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
if (! $this->canAccessOrder($order)) {
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
@@ -71,9 +68,9 @@ class OrderController extends Controller
|
||||
/**
|
||||
* Cancel an order (buyer-initiated).
|
||||
*/
|
||||
public function cancel(Order $order, Request $request)
|
||||
public function cancel(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
if (! $this->canAccessOrder($order)) {
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
@@ -93,9 +90,9 @@ class OrderController extends Controller
|
||||
/**
|
||||
* Update order fulfillment method and related information.
|
||||
*/
|
||||
public function updateFulfillment(Order $order, Request $request)
|
||||
public function updateFulfillment(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
if (! $this->canAccessOrder($order)) {
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
@@ -134,9 +131,9 @@ class OrderController extends Controller
|
||||
/**
|
||||
* Download manifest PDF for an order.
|
||||
*/
|
||||
public function downloadManifestPdf(Order $order)
|
||||
public function downloadManifestPdf(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
if (! $this->canAccessOrder($order)) {
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to access this order.');
|
||||
}
|
||||
|
||||
@@ -159,16 +156,4 @@ class OrderController extends Controller
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user can access the order.
|
||||
*/
|
||||
protected function canAccessOrder(Order $order): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
return $order->user_id === $user->id ||
|
||||
in_array($order->business_id, $userBusinessIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,11 @@ class BuyerAuthController extends Controller
|
||||
*/
|
||||
public function profile()
|
||||
{
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.profile', [
|
||||
'user' => auth()->user(),
|
||||
'business' => $business,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,12 @@ class BuyerProfileController extends Controller
|
||||
: 'none',
|
||||
];
|
||||
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.profile', [
|
||||
'user' => $user,
|
||||
'verificationStatus' => $verificationStatus,
|
||||
'business' => $business,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ class BuyerSetupController extends Controller
|
||||
|
||||
if ($firstName) {
|
||||
// Check if AP contact already exists (in case user goes back and forward in wizard)
|
||||
$existingAp = Contact::where('business_id', $business->id)
|
||||
$existingAp = Contact::forBusiness($business)
|
||||
->where('contact_type', 'accounts_payable')
|
||||
->where('first_name', $firstName)
|
||||
->where('last_name', $lastName)
|
||||
|
||||
@@ -141,7 +141,7 @@ class DashboardController extends Controller
|
||||
// Get chart data for revenue visualization
|
||||
$chartData = $this->getRevenueChartData($brandIds);
|
||||
|
||||
return view('dashboard.nexus', [
|
||||
return view('seller.dashboard', [
|
||||
'user' => $user,
|
||||
'business' => $business,
|
||||
'needsOnboarding' => $needsOnboarding,
|
||||
|
||||
@@ -9,7 +9,9 @@ class DispensarySetupController extends Controller
|
||||
public function create(Request $request, $step = 1)
|
||||
{
|
||||
// TODO: Implement dispensary setup wizard
|
||||
return view('buyer.dispensary.setup', compact('step'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.dispensary.setup', compact('step', 'business'));
|
||||
}
|
||||
|
||||
public function store(Request $request, $step = 1)
|
||||
|
||||
@@ -13,7 +13,7 @@ class DriverController extends Controller
|
||||
*/
|
||||
public function index(\App\Models\Business $business)
|
||||
{
|
||||
$drivers = Driver::where('business_id', $business->id)
|
||||
$drivers = Driver::forBusiness($business)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
@@ -52,7 +52,7 @@ class DriverController extends Controller
|
||||
public function update(\App\Models\Business $business, Request $request, Driver $driver)
|
||||
{
|
||||
// Ensure driver belongs to this business
|
||||
if ($driver->business_id !== $business->id) {
|
||||
if (! $driver->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ class DriverController extends Controller
|
||||
public function destroy(\App\Models\Business $business, Driver $driver)
|
||||
{
|
||||
// Ensure driver belongs to this business
|
||||
if ($driver->business_id !== $business->id) {
|
||||
if (! $driver->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class DriverController extends Controller
|
||||
public function toggle(\App\Models\Business $business, Driver $driver)
|
||||
{
|
||||
// Ensure driver belongs to this business
|
||||
if ($driver->business_id !== $business->id) {
|
||||
if (! $driver->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FavoriteController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
public function index(Business $business, Request $request)
|
||||
{
|
||||
// TODO: Implement favorites index
|
||||
return view('buyer.favorites.index');
|
||||
return view('buyer.favorites.index', compact('business'));
|
||||
}
|
||||
|
||||
public function add(Request $request, $product)
|
||||
public function add(Business $business, Request $request, $product)
|
||||
{
|
||||
// TODO: Implement add to favorites
|
||||
return back();
|
||||
}
|
||||
|
||||
public function remove(Request $request, $product)
|
||||
public function remove(Business $business, Request $request, $product)
|
||||
{
|
||||
// TODO: Implement remove from favorites
|
||||
return back();
|
||||
|
||||
@@ -78,7 +78,9 @@ class MarketplaceController extends Controller
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +104,9 @@ class MarketplaceController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('buyer.marketplace.brands', compact('brands'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.brands', compact('brands', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +114,9 @@ class MarketplaceController extends Controller
|
||||
*/
|
||||
public function category($category)
|
||||
{
|
||||
return view('buyer.marketplace.category', compact('category'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.category', compact('category', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,7 +165,9 @@ class MarketplaceController extends Controller
|
||||
->limit(4)
|
||||
->get();
|
||||
|
||||
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'brand'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'brand', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,6 +199,8 @@ class MarketplaceController extends Controller
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
return view('buyer.marketplace.brand', compact('brand', 'featuredProducts', 'products'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.brand', compact('brand', 'featuredProducts', 'products', 'business'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ class OrderController extends Controller
|
||||
public function index(\App\Models\Business $business, Request $request): View
|
||||
{
|
||||
$query = Order::with(['business', 'user', 'items.product'])
|
||||
->whereHas('items.product.brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
->whereHas('items.product', function ($query) use ($business) {
|
||||
$query->forBusiness($business);
|
||||
})
|
||||
->whereIn('status', [
|
||||
'new',
|
||||
@@ -143,7 +143,7 @@ class OrderController extends Controller
|
||||
* Mobile-friendly interface for updating picked quantities.
|
||||
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K
|
||||
*/
|
||||
public function pick(\App\Models\Business $business, Order $pickingTicket): View
|
||||
public function pick(\App\Models\Business $business, Order $pickingTicket): View|RedirectResponse
|
||||
{
|
||||
$order = $pickingTicket; // For clarity in blade templates
|
||||
|
||||
@@ -226,12 +226,12 @@ class OrderController extends Controller
|
||||
$order->load(['business', 'location']);
|
||||
|
||||
// Load active drivers and vehicles for this business
|
||||
$drivers = \App\Models\Driver::where('business_id', $business->id)
|
||||
$drivers = \App\Models\Driver::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
|
||||
$vehicles = \App\Models\Vehicle::where('business_id', $business->id)
|
||||
$vehicles = \App\Models\Vehicle::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
@@ -397,12 +397,12 @@ class OrderController extends Controller
|
||||
]);
|
||||
|
||||
// Get active drivers and vehicles for the edit modal
|
||||
$drivers = \App\Models\Driver::where('business_id', $business->id)
|
||||
$drivers = \App\Models\Driver::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
|
||||
$vehicles = \App\Models\Vehicle::where('business_id', $business->id)
|
||||
$vehicles = \App\Models\Vehicle::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
@@ -18,7 +18,6 @@ class BrandSwitcherController extends Controller
|
||||
// If brand_id is empty, clear the session (show all brands)
|
||||
if (empty($brandId)) {
|
||||
session()->forget('selected_brand_id');
|
||||
session()->flash('success', 'Viewing all brands');
|
||||
|
||||
return back();
|
||||
}
|
||||
@@ -31,8 +30,8 @@ class BrandSwitcherController extends Controller
|
||||
return back()->with('error', 'No business associated with your account');
|
||||
}
|
||||
|
||||
$brand = Brand::where('id', $brandId)
|
||||
->where('business_id', $business->id)
|
||||
$brand = Brand::forBusiness($business)
|
||||
->where('id', $brandId)
|
||||
->first();
|
||||
|
||||
if (! $brand) {
|
||||
@@ -41,7 +40,6 @@ class BrandSwitcherController extends Controller
|
||||
|
||||
// Store selected brand in session
|
||||
session(['selected_brand_id' => $brand->id]);
|
||||
session()->flash('success', "Switched to {$brand->name}");
|
||||
|
||||
return back();
|
||||
}
|
||||
@@ -64,8 +62,8 @@ class BrandSwitcherController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
return Brand::where('id', $brandId)
|
||||
->where('business_id', $business->id)
|
||||
return Brand::forBusiness($business)
|
||||
->where('id', $brandId)
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class ComponentController extends Controller
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Build query - components are business-scoped
|
||||
$query = Component::where('business_id', $business->id)
|
||||
$query = Component::forBusiness($business)
|
||||
->orderBy('name', 'asc');
|
||||
|
||||
// Search filter
|
||||
@@ -113,7 +113,7 @@ class ComponentController extends Controller
|
||||
public function edit(Business $business, Component $component)
|
||||
{
|
||||
// Verify component belongs to this business
|
||||
if ($component->business_id !== $business->id) {
|
||||
if (! $component->belongsToBusiness($business)) {
|
||||
abort(403, 'This component does not belong to your business');
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ class ComponentController extends Controller
|
||||
public function update(Request $request, Business $business, Component $component)
|
||||
{
|
||||
// Verify component belongs to this business
|
||||
if ($component->business_id !== $business->id) {
|
||||
if (! $component->belongsToBusiness($business)) {
|
||||
abort(403, 'This component does not belong to your business');
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ class ComponentController extends Controller
|
||||
public function destroy(Business $business, Component $component)
|
||||
{
|
||||
// Verify component belongs to this business
|
||||
if ($component->business_id !== $business->id) {
|
||||
if (! $component->belongsToBusiness($business)) {
|
||||
abort(403, 'This component does not belong to your business');
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,7 @@ class InvoiceController extends Controller
|
||||
->get();
|
||||
|
||||
// Get all products from brands owned by this business with images, stock levels, and batches
|
||||
$products = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
$products = \App\Models\Product::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->with(['brand', 'images', 'availableBatches.labs'])
|
||||
->select('id', 'brand_id', 'name', 'sku', 'description', 'wholesale_price', 'msrp_price',
|
||||
@@ -79,9 +77,7 @@ class InvoiceController extends Controller
|
||||
});
|
||||
|
||||
// Get recently invoiced products (last 30 days, top 10 most common)
|
||||
$recentProducts = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
$recentProducts = \App\Models\Product::forBusiness($business)
|
||||
->whereHas('orderItems.order.invoice', function ($query) {
|
||||
$query->where('created_at', '>=', now()->subDays(30));
|
||||
})
|
||||
@@ -164,8 +160,8 @@ class InvoiceController extends Controller
|
||||
{
|
||||
// Get invoices where orders contain items from brands under this business
|
||||
$invoices = Invoice::with(['order.items.product.brand', 'business'])
|
||||
->whereHas('order.items.product.brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
->whereHas('order.items.product', function ($query) use ($business) {
|
||||
$query->forBusiness($business);
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
@@ -191,7 +187,7 @@ class InvoiceController extends Controller
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
@@ -211,7 +207,7 @@ class InvoiceController extends Controller
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
@@ -288,7 +284,7 @@ class InvoiceController extends Controller
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
@@ -345,7 +341,7 @@ class InvoiceController extends Controller
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
|
||||
@@ -16,7 +16,7 @@ class BomController extends Controller
|
||||
public function index(Request $request, Business $business, Product $product)
|
||||
{
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403, 'This product does not belong to your business.');
|
||||
}
|
||||
|
||||
@@ -28,18 +28,16 @@ class BomController extends Controller
|
||||
|
||||
// Get all available components for client-side filtering
|
||||
// (No server-side filters - Alpine.js handles filtering for better UX)
|
||||
$availableComponents = Component::where('business_id', $business->id)
|
||||
$availableComponents = Component::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get recently used components (from other products)
|
||||
$recentComponents = Component::where('business_id', $business->id)
|
||||
$recentComponents = Component::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->whereHas('products', function ($q) use ($business) {
|
||||
$q->whereHas('brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
$q->forBusiness($business);
|
||||
})
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(8)
|
||||
@@ -103,15 +101,13 @@ class BomController extends Controller
|
||||
]);
|
||||
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Verify component belongs to business
|
||||
$component = Component::findOrFail($validated['component_id']);
|
||||
if ($component->business_id !== $business->id) {
|
||||
abort(403, 'This component does not belong to your business.');
|
||||
}
|
||||
$component = Component::forBusiness($business)
|
||||
->findOrFail($validated['component_id']);
|
||||
|
||||
// Check if already attached
|
||||
if ($product->components()->where('component_id', $validated['component_id'])->exists()) {
|
||||
@@ -142,7 +138,7 @@ class BomController extends Controller
|
||||
]);
|
||||
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@@ -165,7 +161,7 @@ class BomController extends Controller
|
||||
public function detach(Business $business, Product $product, Component $component)
|
||||
{
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@@ -185,7 +181,7 @@ class BomController extends Controller
|
||||
]);
|
||||
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
@@ -204,7 +200,7 @@ class BomController extends Controller
|
||||
public function downloadPdf(Business $business, Product $product)
|
||||
{
|
||||
// Verify product belongs to business (through brand)
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403, 'This product does not belong to your business.');
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductLine;
|
||||
use App\Models\ProductPackaging;
|
||||
use App\Models\Strain;
|
||||
use App\Models\Unit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -69,7 +73,13 @@ class ProductController extends Controller
|
||||
// Get all brands for filter dropdown
|
||||
$brands = $business->brands()->orderBy('name')->get();
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'brands'));
|
||||
// Get product lines for this business with products count
|
||||
$productLines = ProductLine::where('business_id', $business->id)
|
||||
->withCount('products')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'brands', 'productLines'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,9 +123,8 @@ class ProductController extends Controller
|
||||
]);
|
||||
|
||||
// Verify brand belongs to this business
|
||||
$brand = Brand::where('id', $validated['brand_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
$brand = Brand::forBusiness($business)
|
||||
->findOrFail($validated['brand_id']);
|
||||
|
||||
// Generate slug
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
@@ -149,23 +158,101 @@ class ProductController extends Controller
|
||||
*/
|
||||
public function edit(Business $business, Product $product)
|
||||
{
|
||||
// Eager load relationships
|
||||
$product->load(['brand', 'images']);
|
||||
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
|
||||
->findOrFail($product->id);
|
||||
|
||||
// Verify product belongs to a brand under this business
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
abort(403, 'This product does not belong to your business');
|
||||
}
|
||||
// Prepare dropdown data
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$strains = Strain::all();
|
||||
$packagings = ProductPackaging::all();
|
||||
$units = Unit::all();
|
||||
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
|
||||
|
||||
$brands = $business->brands()->orderBy('name')->get();
|
||||
// Product type options (for category dropdown)
|
||||
$productTypes = [
|
||||
'flower' => 'Flower',
|
||||
'preroll' => 'Pre-Roll',
|
||||
'vape' => 'Vape',
|
||||
'concentrate' => 'Concentrate',
|
||||
'edible' => 'Edible',
|
||||
'topical' => 'Topical',
|
||||
'tincture' => 'Tincture',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
// Load audits with pagination (10 per page) for the audit history tab
|
||||
$audits = $product->audits()
|
||||
->with('user')
|
||||
->latest()
|
||||
->paginate(10);
|
||||
// Status options
|
||||
$statusOptions = [
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'discontinued' => 'Discontinued',
|
||||
];
|
||||
|
||||
return view('seller.products.edit', compact('business', 'product', 'brands', 'audits'));
|
||||
return view('seller.products.edit', compact(
|
||||
'business',
|
||||
'product',
|
||||
'brands',
|
||||
'strains',
|
||||
'packagings',
|
||||
'units',
|
||||
'productLines',
|
||||
'productTypes',
|
||||
'statusOptions'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified product (edit1 - top header layout)
|
||||
*/
|
||||
public function edit1(Business $business, Product $product)
|
||||
{
|
||||
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
|
||||
->findOrFail($product->id);
|
||||
|
||||
// Prepare dropdown data
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$strains = Strain::all();
|
||||
$packagings = ProductPackaging::all();
|
||||
$units = Unit::all();
|
||||
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
|
||||
|
||||
// Product type options (for category dropdown)
|
||||
$productTypes = [
|
||||
'flower' => 'Flower',
|
||||
'preroll' => 'Pre-Roll',
|
||||
'vape' => 'Vape',
|
||||
'concentrate' => 'Concentrate',
|
||||
'edible' => 'Edible',
|
||||
'topical' => 'Topical',
|
||||
'tincture' => 'Tincture',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
// Status options
|
||||
$statusOptions = [
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'discontinued' => 'Discontinued',
|
||||
];
|
||||
|
||||
return view('seller.products.edit1', compact(
|
||||
'business',
|
||||
'product',
|
||||
'brands',
|
||||
'strains',
|
||||
'packagings',
|
||||
'units',
|
||||
'productLines',
|
||||
'productTypes',
|
||||
'statusOptions'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,62 +260,150 @@ class ProductController extends Controller
|
||||
*/
|
||||
public function update(Request $request, Business $business, Product $product)
|
||||
{
|
||||
// Eager load brand relationship
|
||||
$product->load('brand');
|
||||
|
||||
// Verify product belongs to a brand under this business
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
abort(403, 'This product does not belong to your business');
|
||||
}
|
||||
|
||||
// Comprehensive validation
|
||||
$validated = $request->validate([
|
||||
// Basic Information
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
'name' => 'required|string|max:255',
|
||||
'sku' => 'required|string|max:100|unique:products,sku,'.$product->id,
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|string|in:flower,pre-roll,concentrate,edible,beverage,topical,tincture,vaporizer,other',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'wholesale_price' => 'required|numeric|min:0',
|
||||
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
|
||||
'sku' => 'required|string|max:100',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'type' => 'nullable|string',
|
||||
'product_line_id' => 'nullable|exists:product_lines,id',
|
||||
'unit_id' => 'required|exists:units,id',
|
||||
'sell_multiples' => 'nullable|boolean',
|
||||
'fractional_quantities' => 'nullable|boolean',
|
||||
'allow_sample' => 'nullable|boolean',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_featured' => 'nullable|boolean',
|
||||
|
||||
// Inventory - now includes threshold type
|
||||
'status' => 'required|string',
|
||||
'launch_date' => 'nullable|date',
|
||||
'quantity_on_hand' => 'nullable|integer|min:0',
|
||||
'quantity_allocated' => 'nullable|integer|min:0',
|
||||
'sync_bamboo' => 'nullable|boolean',
|
||||
'low_stock_threshold' => 'nullable|numeric|min:0',
|
||||
'low_stock_threshold_type' => 'nullable|string|in:qty,percent',
|
||||
'low_stock_alert_enabled' => 'nullable|boolean',
|
||||
'is_assembly' => 'nullable|boolean',
|
||||
'show_inventory_to_buyers' => 'nullable|boolean',
|
||||
'packaging_id' => 'nullable|exists:product_packagings,id',
|
||||
|
||||
// Pricing & Units
|
||||
'cost_per_unit' => 'nullable|numeric|min:0',
|
||||
'wholesale_price' => 'nullable|numeric|min:0',
|
||||
'msrp' => 'nullable|numeric|min:0',
|
||||
'net_weight' => 'nullable|numeric|min:0',
|
||||
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
|
||||
'units_per_case' => 'nullable|integer|min:1',
|
||||
'weight_unit' => 'nullable|string|max:20',
|
||||
'units_per_case' => 'nullable|integer|min:0',
|
||||
'cased_qty' => 'nullable|integer|min:0',
|
||||
'boxed_qty' => 'nullable|integer|min:0',
|
||||
'min_order_qty' => 'nullable|integer|min:0',
|
||||
'max_order_qty' => 'nullable|integer|min:0',
|
||||
'is_case' => 'nullable|boolean',
|
||||
'is_box' => 'nullable|boolean',
|
||||
'has_varieties' => 'nullable|boolean',
|
||||
|
||||
// Cannabis Information
|
||||
'thc_percentage' => 'nullable|numeric|min:0|max:100',
|
||||
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'strain_id' => 'nullable|exists:strains,id',
|
||||
'thc_content_mg' => 'nullable|numeric|min:0',
|
||||
'cbd_content_mg' => 'nullable|numeric|min:0',
|
||||
'strain_value' => 'nullable|numeric|min:0',
|
||||
'ingredients' => 'nullable|string',
|
||||
'effects' => 'nullable|string',
|
||||
'dosage_guidelines' => 'nullable|string',
|
||||
|
||||
// Arizona Compliance
|
||||
'arz_total_weight' => 'nullable|numeric|min:0',
|
||||
'arz_usable_mmj' => 'nullable|numeric|min:0',
|
||||
'metrc_id' => 'nullable|string|max:255',
|
||||
|
||||
// Compliance & Tracking
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'harvest_date' => 'nullable|date',
|
||||
'package_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
|
||||
// Product Details
|
||||
'description' => 'nullable|string|max:100',
|
||||
'long_description' => 'nullable|string',
|
||||
'product_link' => 'nullable|url|max:255',
|
||||
'creatives_json' => 'nullable|json',
|
||||
|
||||
// Advanced Settings
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
'is_fpr' => 'nullable|boolean',
|
||||
'is_raw_material' => 'nullable|boolean',
|
||||
'brand_display_order' => 'nullable|integer|min:0',
|
||||
'parent_product_id' => 'nullable|exists:products,id',
|
||||
'category' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
// Verify new brand belongs to this business
|
||||
// Convert checkboxes to boolean
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
$validated['sell_multiples'] = $request->has('sell_multiples');
|
||||
$validated['fractional_quantities'] = $request->has('fractional_quantities');
|
||||
$validated['allow_sample'] = $request->has('allow_sample');
|
||||
$validated['is_case'] = $request->has('is_case');
|
||||
$validated['is_box'] = $request->has('is_box');
|
||||
$validated['has_varieties'] = $request->has('has_varieties');
|
||||
$validated['sync_bamboo'] = $request->has('sync_bamboo');
|
||||
$validated['low_stock_alert_enabled'] = $request->has('low_stock_alert_enabled');
|
||||
$validated['is_assembly'] = $request->has('is_assembly');
|
||||
$validated['show_inventory_to_buyers'] = $request->has('show_inventory_to_buyers');
|
||||
$validated['is_sellable'] = $request->has('is_sellable');
|
||||
$validated['is_fpr'] = $request->has('is_fpr');
|
||||
$validated['is_raw_material'] = $request->has('is_raw_material');
|
||||
|
||||
// Store creatives JSON
|
||||
if (isset($validated['creatives_json'])) {
|
||||
$validated['creatives'] = $validated['creatives_json'];
|
||||
unset($validated['creatives_json']);
|
||||
}
|
||||
|
||||
// CRITICAL BUSINESS ISOLATION: Verify brand belongs to the business
|
||||
$brand = Brand::where('id', $validated['brand_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
|
||||
// Update slug if name changed
|
||||
if ($validated['name'] !== $product->name) {
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
}
|
||||
// CRITICAL BUSINESS ISOLATION: Ensure the product belongs to this business through brand relationship
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->findOrFail($product->id);
|
||||
|
||||
// Handle checkbox fields - set to false if not present in request
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
// BUSINESS RULE: Only one active product per brand
|
||||
if ($request->has('is_active') && $request->boolean('is_active')) {
|
||||
$existingActiveProduct = Product::where('brand_id', $validated['brand_id'])
|
||||
->where('is_active', true)
|
||||
->where('id', '!=', $product->id)
|
||||
->first();
|
||||
|
||||
if ($existingActiveProduct) {
|
||||
// Check if user wants to force-activate this product
|
||||
if ($request->has('force_activate') && $request->boolean('force_activate')) {
|
||||
// Deactivate the existing active product
|
||||
$existingActiveProduct->update(['is_active' => false]);
|
||||
} else {
|
||||
// Show error with option to force activate
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('existing_active_product', $existingActiveProduct)
|
||||
->withErrors(['is_active' => "Only one product can be active per brand at a time. '{$existingActiveProduct->name}' (SKU: {$existingActiveProduct->sku}) is currently active."]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update product
|
||||
$product->update($validated);
|
||||
|
||||
// Handle new image uploads if present
|
||||
if ($request->hasFile('images')) {
|
||||
foreach ($request->file('images') as $index => $image) {
|
||||
$path = $image->store('products', 'public');
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
'is_primary' => $product->images()->count() === 0 && $index === 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', "Product '{$product->name}' updated successfully!");
|
||||
return redirect()
|
||||
->route('seller.business.products.edit', [$business->slug, $product->id])
|
||||
->with('success', 'Product updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,11 +411,8 @@ class ProductController extends Controller
|
||||
*/
|
||||
public function destroy(Business $business, Product $product)
|
||||
{
|
||||
// Eager load brand relationship
|
||||
$product->load('brand');
|
||||
|
||||
// Verify product belongs to a brand under this business
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
// Verify product belongs to this business
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403, 'This product does not belong to your business');
|
||||
}
|
||||
|
||||
|
||||
166
app/Http/Controllers/Seller/ProductImageController.php
Normal file
166
app/Http/Controllers/Seller/ProductImageController.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductImage;
|
||||
use App\Traits\FileStorageHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ProductImageController extends Controller
|
||||
{
|
||||
use FileStorageHelper;
|
||||
|
||||
/**
|
||||
* Upload a new product image
|
||||
*/
|
||||
public function upload(Request $request, Business $business, Product $product)
|
||||
{
|
||||
// CRITICAL: Ensure product belongs to this business through brand
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($product->id);
|
||||
|
||||
// Validate image
|
||||
$request->validate([
|
||||
'image' => 'required|image|mimes:jpeg,jpg,png|max:2048|dimensions:min_width=750,min_height=384', // 2MB max, 750x384 min
|
||||
]);
|
||||
|
||||
// Check if product already has 6 images
|
||||
if ($product->images()->count() >= 6) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Maximum of 6 images allowed per product',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Store the image using trait method
|
||||
$path = $this->storeFile($request->file('image'), 'products');
|
||||
|
||||
// Determine if this should be the primary image (first one)
|
||||
$isPrimary = $product->images()->count() === 0;
|
||||
|
||||
// If setting as primary, unset other primary images
|
||||
if ($isPrimary) {
|
||||
$product->images()->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
// Create the image record
|
||||
$image = $product->images()->create([
|
||||
'path' => $path,
|
||||
'is_primary' => $isPrimary,
|
||||
'sort_order' => $product->images()->max('sort_order') + 1,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'image' => [
|
||||
'id' => $image->id,
|
||||
'path' => $image->path,
|
||||
'is_primary' => $image->is_primary,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product image
|
||||
*/
|
||||
public function delete(Business $business, Product $product, ProductImage $image)
|
||||
{
|
||||
// CRITICAL: Ensure product belongs to this business through brand
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($product->id);
|
||||
|
||||
// Ensure image belongs to this product
|
||||
if ($image->product_id !== $product->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Image not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Delete the file from storage using trait method
|
||||
$this->deleteFile($image->path);
|
||||
|
||||
// If deleting primary image, set next image as primary
|
||||
if ($image->is_primary) {
|
||||
$nextImage = $product->images()
|
||||
->where('id', '!=', $image->id)
|
||||
->orderBy('sort_order')
|
||||
->first();
|
||||
|
||||
if ($nextImage) {
|
||||
$nextImage->update(['is_primary' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
$image->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder product images
|
||||
*/
|
||||
public function reorder(Request $request, Business $business, Product $product)
|
||||
{
|
||||
// CRITICAL: Ensure product belongs to this business through brand
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($product->id);
|
||||
|
||||
$request->validate([
|
||||
'order' => 'required|array',
|
||||
'order.*' => 'required|integer|exists:product_images,id',
|
||||
]);
|
||||
|
||||
$order = $request->input('order');
|
||||
|
||||
// Update sort order and set first image as primary
|
||||
foreach ($order as $index => $imageId) {
|
||||
$image = ProductImage::where('id', $imageId)
|
||||
->where('product_id', $product->id)
|
||||
->first();
|
||||
|
||||
if ($image) {
|
||||
$image->update([
|
||||
'sort_order' => $index,
|
||||
'is_primary' => $index === 0, // First image is primary
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an image as primary
|
||||
*/
|
||||
public function setPrimary(Business $business, Product $product, ProductImage $image)
|
||||
{
|
||||
// CRITICAL: Ensure product belongs to this business through brand
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($product->id);
|
||||
|
||||
// Ensure image belongs to this product
|
||||
if ($image->product_id !== $product->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Image not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Unset all primary flags for this product
|
||||
$product->images()->update(['is_primary' => false]);
|
||||
|
||||
// Set this image as primary
|
||||
$image->update(['is_primary' => true]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/Seller/ProductLineController.php
Normal file
70
app/Http/Controllers/Seller/ProductLineController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\ProductLine;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProductLineController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255|unique:product_lines,name,NULL,id,business_id,'.$business->id,
|
||||
]);
|
||||
|
||||
ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $request->name,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
->with('success', 'Product line created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Business $business, ProductLine $productLine)
|
||||
{
|
||||
// Ensure business isolation
|
||||
if ($productLine->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255|unique:product_lines,name,'.$productLine->id.',id,business_id,'.$business->id,
|
||||
]);
|
||||
|
||||
$productLine->update([
|
||||
'name' => $request->name,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
->with('success', 'Product line updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Business $business, ProductLine $productLine)
|
||||
{
|
||||
// Ensure business isolation
|
||||
if ($productLine->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$productLine->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
->with('success', 'Product line deleted successfully.');
|
||||
}
|
||||
}
|
||||
118
app/Http/Controllers/Seller/SettingsController.php
Normal file
118
app/Http/Controllers/Seller/SettingsController.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the company information settings page.
|
||||
*/
|
||||
public function companyInformation(Business $business)
|
||||
{
|
||||
return view('seller.settings.company-information', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the company information.
|
||||
*/
|
||||
public function updateCompanyInformation(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'business_type' => 'nullable|string',
|
||||
'tin_ein' => 'nullable|string|max:20',
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'license_type' => 'nullable|string',
|
||||
'physical_address' => 'nullable|string|max:255',
|
||||
'physical_city' => 'nullable|string|max:100',
|
||||
'physical_state' => 'nullable|string|max:2',
|
||||
'physical_zipcode' => 'nullable|string|max:10',
|
||||
'business_phone' => 'nullable|string|max:20',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
]);
|
||||
|
||||
$business->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.company-information', $business->slug)
|
||||
->with('success', 'Company information updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the users management settings page.
|
||||
*/
|
||||
public function users(Business $business)
|
||||
{
|
||||
return view('seller.settings.users', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the order settings page.
|
||||
*/
|
||||
public function orders(Business $business)
|
||||
{
|
||||
return view('seller.settings.orders', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the brands management page.
|
||||
*/
|
||||
public function brands(Business $business)
|
||||
{
|
||||
return view('seller.settings.brands', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the payment settings page.
|
||||
*/
|
||||
public function payments(Business $business)
|
||||
{
|
||||
return view('seller.settings.payments', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the invoice settings page.
|
||||
*/
|
||||
public function invoices(Business $business)
|
||||
{
|
||||
return view('seller.settings.invoices', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the manage licenses page.
|
||||
*/
|
||||
public function manageLicenses(Business $business)
|
||||
{
|
||||
return view('seller.settings.manage-licenses', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the plans and billing page.
|
||||
*/
|
||||
public function plansAndBilling(Business $business)
|
||||
{
|
||||
return view('seller.settings.plans-and-billing', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the notification preferences page.
|
||||
*/
|
||||
public function notifications(Business $business)
|
||||
{
|
||||
return view('seller.settings.notifications', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the report settings page.
|
||||
*/
|
||||
public function reports(Business $business)
|
||||
{
|
||||
return view('seller.settings.reports', compact('business'));
|
||||
}
|
||||
}
|
||||
@@ -51,16 +51,22 @@ class ShopController extends Controller
|
||||
'taxes' => 731.25,
|
||||
];
|
||||
|
||||
return view('buyer.shop.index', compact('featuredBrand', 'featuredProducts', 'promos', 'cart'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.shop.index', compact('featuredBrand', 'featuredProducts', 'promos', 'cart', 'business'));
|
||||
}
|
||||
|
||||
public function brand($brand)
|
||||
{
|
||||
return view('buyer.shop.brand', compact('brand'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.shop.brand', compact('brand', 'business'));
|
||||
}
|
||||
|
||||
public function product($product)
|
||||
{
|
||||
return view('buyer.shop.product', compact('product'));
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.shop.product', compact('product', 'business'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class VehicleController extends Controller
|
||||
*/
|
||||
public function index(\App\Models\Business $business)
|
||||
{
|
||||
$vehicles = Vehicle::where('business_id', $business->id)
|
||||
$vehicles = Vehicle::forBusiness($business)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
@@ -53,7 +53,7 @@ class VehicleController extends Controller
|
||||
public function update(\App\Models\Business $business, Request $request, Vehicle $vehicle)
|
||||
{
|
||||
// Ensure vehicle belongs to this business
|
||||
if ($vehicle->business_id !== $business->id) {
|
||||
if (! $vehicle->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class VehicleController extends Controller
|
||||
public function destroy(\App\Models\Business $business, Vehicle $vehicle)
|
||||
{
|
||||
// Ensure vehicle belongs to this business
|
||||
if ($vehicle->business_id !== $business->id) {
|
||||
if (! $vehicle->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class VehicleController extends Controller
|
||||
public function toggle(\App\Models\Business $business, Vehicle $vehicle)
|
||||
{
|
||||
// Ensure vehicle belongs to this business
|
||||
if ($vehicle->business_id !== $business->id) {
|
||||
if (! $vehicle->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessViaProduct;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Batch extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessViaProduct, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -11,7 +12,7 @@ use Str;
|
||||
|
||||
class Brand extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
// Product Categories that can be organized under brands
|
||||
public const PRODUCT_CATEGORIES = [
|
||||
@@ -96,11 +97,6 @@ class Brand extends Model
|
||||
return $query->where('is_featured', true);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('is_public', true);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasModules;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -16,7 +17,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
class Business extends Model implements AuditableContract
|
||||
{
|
||||
use Auditable, HasFactory, HasUuids, SoftDeletes;
|
||||
use Auditable, HasFactory, HasModules, HasUuids, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Get the columns that should receive a unique identifier.
|
||||
@@ -191,6 +192,7 @@ class Business extends Model implements AuditableContract
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'business_user')
|
||||
->using(BusinessUser::class)
|
||||
->withPivot('contact_type', 'is_primary', 'permissions')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
158
app/Models/BusinessModule.php
Normal file
158
app/Models/BusinessModule.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class BusinessModule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'module_key',
|
||||
'enabled',
|
||||
'config',
|
||||
'limits',
|
||||
'plan',
|
||||
'monthly_price',
|
||||
'activated_at',
|
||||
'activated_by',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'enabled' => 'boolean',
|
||||
'config' => 'array',
|
||||
'limits' => 'array',
|
||||
'monthly_price' => 'decimal:2',
|
||||
'activated_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the business that owns the module.
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the module definition.
|
||||
*/
|
||||
public function module(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Module::class, 'module_key', 'key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who activated this module.
|
||||
*/
|
||||
public function activatedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'activated_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the usage records for this module.
|
||||
*/
|
||||
public function usageRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(BusinessModuleUsage::class, 'business_id', 'business_id')
|
||||
->where('module_key', $this->module_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include enabled modules.
|
||||
*/
|
||||
public function scopeEnabled(Builder $query): Builder
|
||||
{
|
||||
return $query->where('enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include active (enabled and not expired) modules.
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('enabled', true)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include expired modules.
|
||||
*/
|
||||
public function scopeExpired(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the module is active (enabled and not expired).
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->enabled && ! $this->isExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the module is expired.
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the module is enabled.
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value.
|
||||
*/
|
||||
public function getConfigValue(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->config, $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a limit value.
|
||||
*/
|
||||
public function getLimit(string $metric, ?int $default = null): ?int
|
||||
{
|
||||
return data_get($this->limits, $metric, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a configuration value.
|
||||
*/
|
||||
public function setConfigValue(string $key, mixed $value): void
|
||||
{
|
||||
$config = $this->config ?? [];
|
||||
data_set($config, $key, $value);
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a limit value.
|
||||
*/
|
||||
public function setLimit(string $metric, int $value): void
|
||||
{
|
||||
$limits = $this->limits ?? [];
|
||||
data_set($limits, $metric, $value);
|
||||
$this->limits = $limits;
|
||||
}
|
||||
}
|
||||
187
app/Models/BusinessModuleUsage.php
Normal file
187
app/Models/BusinessModuleUsage.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BusinessModuleUsage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'business_module_usage';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'module_key',
|
||||
'metric',
|
||||
'current_count',
|
||||
'limit',
|
||||
'period',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'limit_exceeded',
|
||||
'last_incremented_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'current_count' => 'integer',
|
||||
'limit' => 'integer',
|
||||
'limit_exceeded' => 'boolean',
|
||||
'period_start' => 'datetime',
|
||||
'period_end' => 'datetime',
|
||||
'last_incremented_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the business that owns the usage record.
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the module definition.
|
||||
*/
|
||||
public function module(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Module::class, 'module_key', 'key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include current period records.
|
||||
*/
|
||||
public function scopeCurrentPeriod(Builder $query): Builder
|
||||
{
|
||||
return $query->where('period_end', '>', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include exceeded limits.
|
||||
*/
|
||||
public function scopeExceeded(Builder $query): Builder
|
||||
{
|
||||
return $query->where('limit_exceeded', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the usage count.
|
||||
*/
|
||||
public function increment(int $amount = 1): bool
|
||||
{
|
||||
$this->current_count += $amount;
|
||||
$this->last_incremented_at = now();
|
||||
|
||||
// Check if limit is exceeded
|
||||
if ($this->limit !== null && $this->current_count > $this->limit) {
|
||||
$this->limit_exceeded = true;
|
||||
}
|
||||
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the usage limit is exceeded.
|
||||
*/
|
||||
public function isExceeded(): bool
|
||||
{
|
||||
if ($this->limit === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->current_count >= $this->limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the usage is within limit.
|
||||
*/
|
||||
public function withinLimit(): bool
|
||||
{
|
||||
return ! $this->isExceeded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the remaining usage.
|
||||
*/
|
||||
public function remaining(): ?int
|
||||
{
|
||||
if ($this->limit === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max(0, $this->limit - $this->current_count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the usage percentage.
|
||||
*/
|
||||
public function percentage(): ?float
|
||||
{
|
||||
if ($this->limit === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->limit === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return min(100, ($this->current_count / $this->limit) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the period has ended.
|
||||
*/
|
||||
public function isPeriodEnded(): bool
|
||||
{
|
||||
return $this->period_end->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the usage count for a new period.
|
||||
*/
|
||||
public function reset(): bool
|
||||
{
|
||||
$this->current_count = 0;
|
||||
$this->limit_exceeded = false;
|
||||
$this->last_incremented_at = null;
|
||||
|
||||
// Calculate new period dates
|
||||
[$periodStart, $periodEnd] = $this->calculatePeriodDates($this->period);
|
||||
$this->period_start = $periodStart;
|
||||
$this->period_end = $periodEnd;
|
||||
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate period start and end dates based on period type.
|
||||
*/
|
||||
protected function calculatePeriodDates(string $period): array
|
||||
{
|
||||
$start = now();
|
||||
$end = match ($period) {
|
||||
'daily' => now()->endOfDay(),
|
||||
'monthly' => now()->endOfMonth(),
|
||||
'yearly' => now()->endOfYear(),
|
||||
'lifetime' => null,
|
||||
default => now()->endOfMonth(),
|
||||
};
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset if period has ended and return the instance.
|
||||
*/
|
||||
public function resetIfNeeded(): self
|
||||
{
|
||||
if ($this->isPeriodEnded() && $this->period !== 'lifetime') {
|
||||
$this->reset();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
21
app/Models/BusinessUser.php
Normal file
21
app/Models/BusinessUser.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class BusinessUser extends Pivot
|
||||
{
|
||||
/**
|
||||
* Indicates if the IDs are auto-incrementing.
|
||||
*/
|
||||
public $incrementing = false;
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*/
|
||||
protected $casts = [
|
||||
'permissions' => 'array',
|
||||
'is_primary' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class Cart extends Model
|
||||
'product_id',
|
||||
'batch_id',
|
||||
'brand_id',
|
||||
'business_id',
|
||||
'quantity',
|
||||
'session_id',
|
||||
];
|
||||
@@ -57,6 +58,14 @@ class Cart extends Model
|
||||
return $this->belongsTo(Batch::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the business associated with this cart item.
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the line total (quantity * product price).
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -12,7 +13,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
class Component extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
@@ -115,11 +116,6 @@ class Component extends Model
|
||||
return $query->where('vendor_name', $vendorName);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeSellable($query)
|
||||
{
|
||||
return $query->where('is_sellable', true);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use DateTime;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Contact extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
// Contact Types for Cannabis Business (LeafLink-aligned)
|
||||
public const CONTACT_TYPES = [
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Driver extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
@@ -56,11 +57,6 @@ class Driver extends Model
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeForCompany($query, int $companyId)
|
||||
{
|
||||
return $query->where('business_id', $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper Methods
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Invoice extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'invoice_number',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessViaBatch;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Lab extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessViaBatch, HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use DateTime;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class License extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
// Arizona Cannabis License Types
|
||||
public const LICENSE_TYPES = [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Location extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
// Location Types (LeafLink Facilities)
|
||||
public const LOCATION_TYPES = [
|
||||
|
||||
@@ -565,8 +565,8 @@ class Manifest extends Model
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
// Return seller company address
|
||||
$seller = $this->sellerCompany;
|
||||
// Return seller business address
|
||||
$seller = $this->sellerBusiness;
|
||||
if (! $seller) {
|
||||
return 'Lab Address Not Available';
|
||||
}
|
||||
|
||||
141
app/Models/Module.php
Normal file
141
app/Models/Module.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Module extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'name',
|
||||
'description',
|
||||
'category',
|
||||
'icon',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
'is_premium',
|
||||
'requires_approval',
|
||||
'enabled_by_default',
|
||||
'config',
|
||||
'default_limits',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'is_premium' => 'boolean',
|
||||
'requires_approval' => 'boolean',
|
||||
'enabled_by_default' => 'boolean',
|
||||
'config' => 'array',
|
||||
'default_limits' => 'array',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the route key for the model.
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'key';
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include active modules.
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include premium modules.
|
||||
*/
|
||||
public function scopePremium(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_premium', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include free modules.
|
||||
*/
|
||||
public function scopeFree(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_premium', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to filter by category.
|
||||
*/
|
||||
public function scopeByCategory(Builder $query, string $category): Builder
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to order by sort order.
|
||||
*/
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this module is active.
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->is_active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this module is premium.
|
||||
*/
|
||||
public function isPremium(): bool
|
||||
{
|
||||
return $this->is_premium;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this module requires approval.
|
||||
*/
|
||||
public function requiresApproval(): bool
|
||||
{
|
||||
return $this->requires_approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this module is enabled by default.
|
||||
*/
|
||||
public function isEnabledByDefault(): bool
|
||||
{
|
||||
return $this->enabled_by_default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value.
|
||||
*/
|
||||
public function getConfigValue(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->config, $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a default limit value.
|
||||
*/
|
||||
public function getDefaultLimit(string $metric, ?int $default = null): ?int
|
||||
{
|
||||
return data_get($this->default_limits, $metric, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all business modules for this module.
|
||||
*/
|
||||
public function businessModules()
|
||||
{
|
||||
return $this->hasMany(BusinessModule::class, 'module_key', 'key');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -13,7 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Get the route key name for Laravel route model binding.
|
||||
@@ -32,6 +33,7 @@ class Order extends Model
|
||||
'contact_id',
|
||||
'location_id',
|
||||
'subtotal',
|
||||
'surcharge',
|
||||
'tax',
|
||||
'total',
|
||||
'status',
|
||||
@@ -66,6 +68,7 @@ class Order extends Model
|
||||
|
||||
protected $casts = [
|
||||
'subtotal' => 'decimal:2',
|
||||
'surcharge' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'workorder_status' => 'decimal:2',
|
||||
@@ -259,24 +262,27 @@ class Order extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workorder status and auto-transition to ready_for_invoice if 100% picked.
|
||||
* Update workorder status (completion percentage).
|
||||
* Auto-transitions between 'accepted' and 'in_progress' based on picking activity.
|
||||
* Does NOT auto-complete - lab worker must manually click "Complete Picking Ticket".
|
||||
*/
|
||||
public function updatePickingStatus(): void
|
||||
{
|
||||
$completion = $this->calculatePickingCompletion();
|
||||
$this->workorder_status = $completion;
|
||||
|
||||
// Auto-transition based on completion
|
||||
if ($completion == 100 && $this->status === 'in_progress') {
|
||||
$this->status = 'ready_for_invoice';
|
||||
$this->ready_for_invoice_at = now();
|
||||
} elseif ($completion > 0 && $completion < 100 && $this->status === 'accepted') {
|
||||
// Auto-transition between accepted and in_progress based on picking activity
|
||||
if ($completion > 0 && $this->status === 'accepted') {
|
||||
// Start picking: accepted → in_progress
|
||||
$this->status = 'in_progress';
|
||||
$this->in_progress_at = $this->in_progress_at ?? now();
|
||||
} elseif ($completion == 0 && $this->status === 'in_progress') {
|
||||
// All quantities reset to 0: in_progress → accepted
|
||||
$this->status = 'accepted';
|
||||
$this->in_progress_at = null;
|
||||
}
|
||||
// Note: Do NOT auto-transition to ready_for_invoice at 100%
|
||||
// Lab worker must manually click "Complete Picking Ticket" button
|
||||
|
||||
$this->save();
|
||||
}
|
||||
@@ -429,7 +435,7 @@ class Order extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique 5-character picking ticket number.
|
||||
* Generate unique picking ticket number with PT- prefix.
|
||||
* Character set: A-Z (excluding O, I) + digits 2-9 = 32 characters.
|
||||
* Total combinations: 32^5 = 33,554,432.
|
||||
*/
|
||||
@@ -438,7 +444,7 @@ class Order extends Model
|
||||
$characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 32 chars (no O, I, 0, 1)
|
||||
|
||||
do {
|
||||
$code = '';
|
||||
$code = 'PT-';
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$code .= $characters[random_int(0, 31)];
|
||||
}
|
||||
@@ -447,16 +453,6 @@ class Order extends Model
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted picking ticket number with PT- prefix.
|
||||
*/
|
||||
public function getFormattedPickingTicketAttribute(): ?string
|
||||
{
|
||||
return $this->picking_ticket_number
|
||||
? 'PT-'.$this->picking_ticket_number
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject order (seller-only action for NEW orders).
|
||||
* Prevents order from entering fulfillment pipeline.
|
||||
@@ -661,6 +657,19 @@ class Order extends Model
|
||||
return $this->delivery_method === 'delivery';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get surcharge percentage for given payment terms.
|
||||
*/
|
||||
public static function getSurchargePercentage(string $paymentTerms): float
|
||||
{
|
||||
return match ($paymentTerms) {
|
||||
'cod' => 0.0, // COD: No surcharge
|
||||
'net_15' => 5.0, // Net 15: 5% surcharge
|
||||
'net_30' => 10.0, // Net 30: 10% surcharge
|
||||
default => 0.0, // Default: No surcharge
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pickup driver information is complete.
|
||||
*/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user