Compare commits
301 Commits
docs/add-f
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95db76ed09 | ||
|
|
382c1cc29e | ||
|
|
edad5712fc | ||
|
|
91b2002dd6 | ||
|
|
54e52add84 | ||
|
|
dc668e24d5 | ||
|
|
910be9c14a | ||
|
|
aab2e65903 | ||
|
|
f85be8a676 | ||
|
|
fe0c6b22af | ||
|
|
06e35cb296 | ||
|
|
4b347112c6 | ||
|
|
632ddce08a | ||
|
|
35c603944f | ||
|
|
ea3ed4de0a | ||
|
|
179c9a7818 | ||
|
|
6835a19b39 | ||
|
|
3b9ddd8865 | ||
|
|
d9d8190835 | ||
|
|
8af01a6772 | ||
|
|
e11a934766 | ||
|
|
86c2e0cf1c | ||
|
|
f899e5f8cb | ||
|
|
f2b1ceebe9 | ||
|
|
b0e343f2b5 | ||
|
|
609d55d5c9 | ||
|
|
d649c8239f | ||
|
|
86b7d8db4e | ||
|
|
701534dd6b | ||
|
|
f341fc6673 | ||
|
|
103b7a6077 | ||
|
|
5a57fd1e27 | ||
|
|
6f56d21936 | ||
|
|
44cf1423e4 | ||
|
|
ceea43823b | ||
|
|
618d5aeea9 | ||
|
|
9c3e3b1c7b | ||
|
|
b3a5eebd56 | ||
|
|
dc804e8e25 | ||
|
|
20709d201f | ||
|
|
b3ae727c5a | ||
|
|
c004ee3b1e | ||
|
|
41f8bee6a6 | ||
|
|
f53124cd2e | ||
|
|
1d1ac2d520 | ||
|
|
bca2cd5c77 | ||
|
|
ff25196d51 | ||
|
|
58006d7b19 | ||
|
|
4237cf45ab | ||
|
|
5f591bee19 | ||
|
|
c9fa8d7578 | ||
|
|
7c3f5a27a3 | ||
|
|
13d2fa3ac7 | ||
|
|
fab181128a | ||
|
|
fbb1619c38 | ||
|
|
9a9bfeae35 | ||
|
|
c7f3af5f39 | ||
|
|
0db14bda0e | ||
|
|
0ff3b64f80 | ||
|
|
2ca4338e7e | ||
|
|
d905805980 | ||
|
|
2f5cb5c0e7 | ||
|
|
86fef4d021 | ||
|
|
5b95c8b365 | ||
|
|
5c1863218f | ||
|
|
ee30c65c34 | ||
|
|
d10357758d | ||
|
|
59cd1c5a6b | ||
|
|
2b865f2633 | ||
|
|
d7f79c6a5b | ||
|
|
7eb658ef6c | ||
|
|
150ecb9124 | ||
|
|
2fe3e7abd9 | ||
|
|
dc975a4206 | ||
|
|
04668d1b29 | ||
|
|
d13184819f | ||
|
|
f05211c924 | ||
|
|
5cd86ed463 | ||
|
|
7dd4cd314f | ||
|
|
29c95be27b | ||
|
|
b37cb2b5c9 | ||
|
|
a95d875564 | ||
|
|
41e65bf3b0 | ||
|
|
fbac9498fd | ||
|
|
a155999bbb | ||
|
|
9978e1efcc | ||
|
|
6fbcc1a451 | ||
|
|
3fd89291e7 | ||
|
|
e4588ec8b6 | ||
|
|
82bd313d21 | ||
|
|
ada6ede429 | ||
|
|
549bdf0e93 | ||
|
|
b8ed494c41 | ||
|
|
0f843fa0f2 | ||
|
|
01859205f5 | ||
|
|
5e4ce9f21b | ||
|
|
a9f30cdfaa | ||
|
|
09c0d1bbe8 | ||
|
|
13908d0d3a | ||
|
|
43f852b618 | ||
|
|
0df1694dad | ||
|
|
4d0c9698d6 | ||
|
|
0ed49f947c | ||
|
|
aa788e9fe2 | ||
|
|
0e1f145c45 | ||
|
|
b926a627f2 | ||
|
|
e017ddf762 | ||
|
|
6eee8d8c07 | ||
|
|
0b62d8371f | ||
|
|
60a375960f | ||
|
|
8e1162a1c9 | ||
|
|
0d4d57c51f | ||
|
|
ceb0526f0f | ||
|
|
cc2bedff41 | ||
|
|
1cfc8983a9 | ||
|
|
90dd3f415d | ||
|
|
281fc7f5a1 | ||
|
|
d20162c5b2 | ||
|
|
3318880afd | ||
|
|
0a06a02bf6 | ||
|
|
ffe059a4d5 | ||
|
|
59ed05dd53 | ||
|
|
19eee0d36f | ||
|
|
9967e39dc8 | ||
|
|
6a4bd75b33 | ||
|
|
61a680b7e3 | ||
|
|
0f248ca178 | ||
|
|
00782038d3 | ||
|
|
3c1a7da11a | ||
|
|
9833cc592d | ||
|
|
54e8ff474f | ||
|
|
efc61680c9 | ||
|
|
8a72453cc2 | ||
|
|
07c5a1e336 | ||
|
|
d16c1a3746 | ||
|
|
81745fbf70 | ||
|
|
6c3be5221b | ||
|
|
1e6cb75422 | ||
|
|
b4bc8c129f | ||
|
|
86e656a89b | ||
|
|
c7c15fa484 | ||
|
|
1e60212644 | ||
|
|
33607ff982 | ||
|
|
bb34d24e1b | ||
|
|
94e67c5955 | ||
|
|
7606484317 | ||
|
|
e57212437d | ||
|
|
c9b68ba61e | ||
|
|
bd9abe29b9 | ||
|
|
6223dcc024 | ||
|
|
3de733a528 | ||
|
|
eccaedf219 | ||
|
|
a4e465c428 | ||
|
|
b96f5d6d59 | ||
|
|
28d1701904 | ||
|
|
4cb6b87134 | ||
|
|
e3f7181558 | ||
|
|
456b44681c | ||
|
|
e60accf724 | ||
|
|
66db854ebc | ||
|
|
2d02493b24 | ||
|
|
e3c7d14001 | ||
|
|
966d381740 | ||
|
|
1eff01496b | ||
|
|
bf83c4bc63 | ||
|
|
aec4a12af8 | ||
|
|
49ef373cbe | ||
|
|
9a40e1945e | ||
|
|
99e34832a0 | ||
|
|
e1ebf245b2 | ||
|
|
10688606ca | ||
|
|
f36aad8d6d | ||
|
|
f543fe930a | ||
|
|
62be464ebe | ||
|
|
3b245b421f | ||
|
|
8f45d86315 | ||
|
|
629831cdd8 | ||
|
|
3ac21c22ec | ||
|
|
60362f5792 | ||
|
|
078e4f380c | ||
|
|
2457d81061 | ||
|
|
dec35f9eea | ||
|
|
6840f0a583 | ||
|
|
759bbe90b0 | ||
|
|
3a7e49f176 | ||
|
|
ca661b8649 | ||
|
|
430f7efe5c | ||
|
|
d06c66f703 | ||
|
|
0b2a22c5c9 | ||
|
|
33deab99b2 | ||
|
|
5696db0023 | ||
|
|
394e0ba201 | ||
|
|
d8b7230512 | ||
|
|
20b9fa8dc7 | ||
|
|
c5878de5d2 | ||
|
|
85936a643b | ||
|
|
4d50ab2fab | ||
|
|
163168d561 | ||
|
|
afab8bc2c9 | ||
|
|
492890b2d8 | ||
|
|
e907e3d610 | ||
|
|
2db314509f | ||
|
|
46314b16c0 | ||
|
|
ef49a5566d | ||
|
|
fbdd770d69 | ||
|
|
d183cf6ec1 | ||
|
|
d257f5b8a3 | ||
|
|
b73439ae90 | ||
|
|
9c1313171c | ||
|
|
8b379a3653 | ||
|
|
53fe654340 | ||
|
|
1c3f0e1efb | ||
|
|
37cc8994ad | ||
|
|
2dc6119e98 | ||
|
|
56464e0f5b | ||
|
|
a7a0ee9ce8 | ||
|
|
c8538e155c | ||
|
|
37db77cbb2 | ||
|
|
e2f4667818 | ||
|
|
2ca5cb048b | ||
|
|
6426016c2e | ||
|
|
d08d080937 | ||
|
|
8c7beccdc8 | ||
|
|
0584111357 | ||
|
|
87174f80c5 | ||
|
|
bd01908b52 | ||
|
|
af8666bd42 | ||
|
|
4f5faa5d39 | ||
|
|
2831def53a | ||
|
|
a0baf3ad39 | ||
|
|
16e002ccb9 | ||
|
|
bf0dea6ee3 | ||
|
|
602c060a0a | ||
|
|
2c0d1d5658 | ||
|
|
f8d1f9dc91 | ||
|
|
7887a695f7 | ||
|
|
654a76c5db | ||
|
|
a339d8fc75 | ||
|
|
482789ca41 | ||
|
|
28a66fba92 | ||
|
|
8903759335 | ||
|
|
ecade68740 | ||
|
|
64b77477fb | ||
|
|
1e763882c6 | ||
|
|
ddf6d2470b | ||
|
|
e538b45d5b | ||
|
|
b922ab2556 | ||
|
|
9207453164 | ||
|
|
5d17cbccfb | ||
|
|
4d46f29404 | ||
|
|
dd598ccd50 | ||
|
|
6049658ad9 | ||
|
|
96791a7611 | ||
|
|
7bffe6dbf7 | ||
|
|
7eff3f74be | ||
|
|
cc44f47a3f | ||
|
|
c19617244e | ||
|
|
18381bb2fe | ||
|
|
1dcf78621b | ||
|
|
a38906d91e | ||
|
|
603a50931b | ||
|
|
d5ddccc318 | ||
|
|
615d221c0c | ||
|
|
5227def0d8 | ||
|
|
745a41b811 | ||
|
|
4f8bafc6dd | ||
|
|
d56bc5d21a | ||
|
|
3a26392bd0 | ||
|
|
8a23f5438b | ||
|
|
1d837c0bf0 | ||
|
|
d8739a71a5 | ||
|
|
9821984630 | ||
|
|
63f1fb6bf9 | ||
|
|
7a26ae7ac9 | ||
|
|
b4a057b5f7 | ||
|
|
7e2438c44f | ||
|
|
48a80e8e76 | ||
|
|
490ef0ae0a | ||
|
|
5f99fba396 | ||
|
|
84f364de74 | ||
|
|
39c955cdc4 | ||
|
|
e02ca54187 | ||
|
|
ac46ee004b | ||
|
|
17a6eb260d | ||
|
|
5ea80366be | ||
|
|
99aa0cb980 | ||
|
|
3de53a76d0 | ||
|
|
7fa9b6aff8 | ||
|
|
19b86d9f0e | ||
|
|
62c617a8db | ||
|
|
7616c5e7f4 | ||
|
|
0406d13b92 | ||
|
|
d0ad85c943 | ||
|
|
8f41e08bc6 | ||
|
|
2c82099bdd | ||
|
|
dd967ff223 | ||
|
|
569e84562e | ||
|
|
a51398a336 | ||
|
|
6e97798f5b | ||
|
|
25181ec31b | ||
|
|
e8a1a62898 |
@@ -1,35 +0,0 @@
|
||||
# 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
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
51
.env.example
51
.env.example
@@ -8,6 +8,10 @@ APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
# Stock Notification Settings
|
||||
# Number of days before stock notification requests expire (default: 30)
|
||||
STOCK_NOTIFICATION_EXPIRATION_DAYS=30
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
@@ -77,19 +81,42 @@ 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
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# MinIO/S3 Storage Configuration
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Versioning is enabled in all environments for asset recovery
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# Production MinIO Configuration (example):
|
||||
# FILESYSTEM_DISK=s3
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ LOCAL DEVELOPMENT (Docker MinIO) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# Use local MinIO container for development (versioning enabled)
|
||||
# Access MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
|
||||
FILESYSTEM_DISK=minio
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=media
|
||||
AWS_ENDPOINT=http://minio:9000
|
||||
AWS_URL=http://localhost:9000/media
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ STAGING/DEVELOP (media-dev bucket) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# FILESYSTEM_DISK=minio
|
||||
# AWS_ACCESS_KEY_ID=<staging-access-key>
|
||||
# AWS_SECRET_ACCESS_KEY=<staging-secret-key>
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
# AWS_BUCKET=media-dev
|
||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||
# AWS_URL=https://cdn.cannabrands.app/media-dev
|
||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ PRODUCTION (media bucket) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# FILESYSTEM_DISK=minio
|
||||
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
|
||||
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
|
||||
@@ -23,10 +23,11 @@ chmod +x .githooks/*
|
||||
|
||||
### `pre-commit` - Laravel Pint Auto-formatter ✅ ENFORCED
|
||||
**What it does:**
|
||||
- Runs Laravel Pint on staged files only (`--dirty`)
|
||||
- Runs Laravel Pint on staged PHP files only (not unstaged files)
|
||||
- Auto-formats code to match team standards
|
||||
- Automatically stages formatted files
|
||||
- Automatically re-stages the formatted files
|
||||
- Fast feedback (runs in seconds)
|
||||
- Safe: Won't format or stage files you haven't explicitly added
|
||||
|
||||
**When it runs:**
|
||||
- Every time you run `git commit`
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
#!/bin/sh
|
||||
# Laravel Pint Pre-commit Hook
|
||||
# Automatically format code before committing
|
||||
# Automatically format staged PHP files before committing
|
||||
|
||||
echo "🎨 Running Laravel Pint..."
|
||||
|
||||
# Run Pint on staged files only
|
||||
./vendor/bin/pint --dirty
|
||||
# Get only staged PHP files
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
|
||||
|
||||
# Check if Pint made changes
|
||||
if ! git diff --quiet; then
|
||||
echo "✅ Code formatted! Files have been updated."
|
||||
echo " Changes have been staged automatically."
|
||||
|
||||
# Stage the formatted files
|
||||
git add -u
|
||||
|
||||
exit 0
|
||||
else
|
||||
echo "✅ Code style looks good!"
|
||||
# Exit early if no PHP files are staged
|
||||
if [ -z "$STAGED_FILES" ]; then
|
||||
echo "✅ No PHP files staged"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run Pint only on staged files
|
||||
echo "$STAGED_FILES" | xargs ./vendor/bin/pint
|
||||
|
||||
# Check if Pint made changes to any of the staged files
|
||||
CHANGED=false
|
||||
for file in $STAGED_FILES; do
|
||||
if ! git diff --quiet "$file" 2>/dev/null; then
|
||||
CHANGED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Re-stage the formatted files (only the ones that were already staged)
|
||||
if [ "$CHANGED" = true ]; then
|
||||
echo "✅ Code formatted! Files have been updated."
|
||||
echo " Changes have been staged automatically."
|
||||
echo "$STAGED_FILES" | xargs git add
|
||||
else
|
||||
echo "✅ Code style looks good!"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Pre-push hook - Runs tests before pushing (supports both Sail and K8s)
|
||||
# Pre-push hook - Optionally run tests before pushing
|
||||
# Can be skipped with: git push --no-verify
|
||||
#
|
||||
# This is OPTIONAL - CI/CD will run comprehensive tests automatically.
|
||||
# Running tests locally can catch issues faster, but it's not required.
|
||||
#
|
||||
|
||||
echo "🧪 Running tests before push..."
|
||||
echo " (Use 'git push --no-verify' to skip)"
|
||||
echo "🚀 Preparing to push..."
|
||||
echo ""
|
||||
|
||||
# Detect which environment is running
|
||||
SAIL_RUNNING=false
|
||||
K8S_RUNNING=false
|
||||
|
||||
# Check if Sail is running
|
||||
if docker ps --format '{{.Names}}' | grep -q "sail" 2>/dev/null; then
|
||||
# Check if Sail is running (use vendor/bin/sail ps which works for all project names)
|
||||
if [ -f ./vendor/bin/sail ] && ./vendor/bin/sail ps 2>/dev/null | grep -q "Up"; then
|
||||
SAIL_RUNNING=true
|
||||
echo "📦 Detected Sail environment"
|
||||
fi
|
||||
|
||||
# Check if k8s namespace exists for this worktree
|
||||
@@ -24,41 +25,46 @@ K8S_NS=$(echo "$BRANCH" | sed 's/feature\//feat-/' | sed 's/bugfix\//fix-/' | se
|
||||
|
||||
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"
|
||||
# Offer to run tests if environment is available
|
||||
if [ "$SAIL_RUNNING" = true ] || [ "$K8S_RUNNING" = true ]; then
|
||||
echo "💡 Tests will run automatically in CI/CD"
|
||||
echo ""
|
||||
read -p "Continue push anyway? (y/n) " -n 1 -r
|
||||
read -p "Run tests locally before push? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
if [ ! "$REPLY" = "y" ] && [ ! "$REPLY" = "Y" ]; then
|
||||
echo "Push aborted"
|
||||
exit 1
|
||||
echo ""
|
||||
|
||||
if [ "$REPLY" = "y" ] || [ "$REPLY" = "Y" ]; then
|
||||
echo "🧪 Running tests..."
|
||||
echo ""
|
||||
|
||||
if [ "$SAIL_RUNNING" = true ]; then
|
||||
./vendor/bin/sail artisan test --parallel
|
||||
TEST_EXIT_CODE=$?
|
||||
elif [ "$K8S_RUNNING" = true ]; then
|
||||
kubectl -n "$K8S_NS" exec deploy/web -- php artisan test
|
||||
TEST_EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
if [ $TEST_EXIT_CODE -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Tests failed!"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " 1. Fix the failing tests (recommended)"
|
||||
echo " 2. Push anyway - CI will catch failures: git push --no-verify"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ All tests passed!"
|
||||
echo ""
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check test results
|
||||
if [ $TEST_EXIT_CODE -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Tests failed!"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " 1. Fix the failing tests (recommended)"
|
||||
echo " 2. Push anyway with: git push --no-verify"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "⚡ Pushing to remote (CI will run full test suite)..."
|
||||
echo ""
|
||||
echo "✅ All tests passed! Pushing..."
|
||||
|
||||
exit 0
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -30,6 +30,9 @@ yarn-error.log
|
||||
# Node symlink (for ARM-based machines)
|
||||
/node
|
||||
|
||||
# Git worktrees directory
|
||||
/.worktrees/
|
||||
|
||||
# Database backups
|
||||
*.gz
|
||||
*.sql.gz
|
||||
@@ -58,4 +61,9 @@ core.*
|
||||
!resources/**/*.png
|
||||
!resources/**/*.jpg
|
||||
!resources/**/*.jpeg
|
||||
.claude/settings.local.json
|
||||
# Claude Code settings (personal AI preferences)
|
||||
.claude/
|
||||
|
||||
storage/tmp/*
|
||||
!storage/tmp/.gitignore
|
||||
SESSION_ACTIVE
|
||||
|
||||
@@ -35,7 +35,7 @@ steps:
|
||||
- apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libpq-dev
|
||||
- echo "Installing PHP extensions..."
|
||||
- docker-php-ext-configure gd --with-freetype --with-jpeg
|
||||
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd
|
||||
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd pcntl
|
||||
- echo "Installing Composer..."
|
||||
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
|
||||
- echo "Creating minimal .env for package discovery..."
|
||||
@@ -133,6 +133,34 @@ steps:
|
||||
- php artisan test --parallel
|
||||
- echo "Tests complete!"
|
||||
|
||||
# Validate seeders that run in dev/staging environments
|
||||
# This prevents deployment failures caused by seeder errors (e.g., fake() crashes)
|
||||
# Uses APP_ENV=development to match K8s init container behavior
|
||||
validate-seeders:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
environment:
|
||||
APP_ENV: development
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: testing
|
||||
DB_PASSWORD: testing
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
commands:
|
||||
- echo "Validating seeders (matches K8s init container)..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Running migrate:fresh --seed with APP_ENV=development..."
|
||||
- php artisan migrate:fresh --seed --force
|
||||
- echo "✅ Seeder validation complete!"
|
||||
when:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for DEV environment (develop branch)
|
||||
build-image-dev:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
|
||||
@@ -291,6 +291,42 @@ npm run changelog
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline Stages
|
||||
|
||||
The Woodpecker CI pipeline runs the following stages for every push to `develop` or `master`:
|
||||
|
||||
1. **PHP Lint** - Syntax validation
|
||||
2. **Code Style (Pint)** - Formatting check
|
||||
3. **Tests** - PHPUnit/Pest tests with `APP_ENV=testing`
|
||||
4. **Seeder Validation** - Validates seeders with `APP_ENV=development`
|
||||
5. **Docker Build** - Creates container image
|
||||
6. **Auto-Deploy** - Deploys to dev.cannabrands.app (develop branch only)
|
||||
|
||||
### Why Seeder Validation?
|
||||
|
||||
The dev environment (`dev.cannabrands.app`) runs `migrate:fresh --seed` on every K8s deployment via init container. If seeders have bugs (e.g., undefined functions, missing relationships), the deployment fails and pods crash.
|
||||
|
||||
**The Problem:**
|
||||
- Tests run with `APP_ENV=testing` which **skips DevSeeder**
|
||||
- K8s runs with `APP_ENV=development` which **runs DevSeeder**
|
||||
- Seeder bugs passed CI but crashed in K8s
|
||||
|
||||
**The Solution:**
|
||||
- Add dedicated seeder validation step with `APP_ENV=development`
|
||||
- Runs the exact same command as K8s init container
|
||||
- Catches seeder errors before deployment
|
||||
|
||||
**Time Cost:** ~20-30 seconds added to CI pipeline
|
||||
|
||||
**What It Catches:**
|
||||
- Runtime errors (e.g., `fake()` outside factory context)
|
||||
- Database constraint violations
|
||||
- Missing relationships (foreign key errors)
|
||||
- Invalid enum values
|
||||
- Seeder syntax errors
|
||||
|
||||
---
|
||||
|
||||
## Pre-Commit Checklist
|
||||
|
||||
Before committing:
|
||||
@@ -300,6 +336,7 @@ Before committing:
|
||||
|
||||
Before releasing:
|
||||
- [ ] All tests green in CI
|
||||
- [ ] **Seeder validation passed in CI**
|
||||
- [ ] Tested in dev/staging environment
|
||||
- [ ] Release notes written
|
||||
- [ ] CHANGELOG updated (auto-generated)
|
||||
|
||||
20
CLAUDE.md
20
CLAUDE.md
@@ -1,5 +1,11 @@
|
||||
# Claude Code Context
|
||||
|
||||
## 📌 IMPORTANT: Check Personal Context Files
|
||||
|
||||
**ALWAYS read `claude.kelly.md` first** - Contains personal preferences and session tracking workflow
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Critical Mistakes You Make
|
||||
|
||||
### 1. Business Isolation (MOST COMMON!)
|
||||
@@ -78,6 +84,11 @@ php artisan test --parallel # REQUIRED
|
||||
./vendor/bin/pint # REQUIRED
|
||||
```
|
||||
|
||||
**Commit Messages:**
|
||||
- ❌ **DO NOT** include Claude Code signature/attribution in commit messages
|
||||
- ❌ **DO NOT** add "🤖 Generated with Claude Code" or "Co-Authored-By: Claude"
|
||||
- ✅ Write clean, professional commit messages without AI attribution
|
||||
|
||||
**Credentials:** `{buyer,seller,admin}@example.com` / `password`
|
||||
|
||||
**Branches:** Never commit to `master`/`develop` directly - use feature branches
|
||||
@@ -104,8 +115,15 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
|
||||
---
|
||||
|
||||
## External Docs (Read When Needed)
|
||||
## Architecture Docs (Read When Needed)
|
||||
|
||||
**Custom Architecture:**
|
||||
- `.claude/DEPARTMENTS.md` - Department system, permissions, access control
|
||||
- `.claude/ROUTING.md` - Business slug routing, subdivision architecture
|
||||
- `.claude/PROCESSING.md` - Processing operations (Solventless vs BHO, conversions, wash batches)
|
||||
- `.claude/MODELS.md` - Key models, relationships, query patterns
|
||||
|
||||
**Standard Docs:**
|
||||
- `docs/URL_STRUCTURE.md` - **READ BEFORE** routing changes
|
||||
- `docs/DATABASE.md` - **READ BEFORE** migrations
|
||||
- `docs/DEVELOPMENT.md` - Local setup
|
||||
|
||||
157
CONTRIBUTING.md
157
CONTRIBUTING.md
@@ -239,6 +239,163 @@ git push origin feature/my-feature
|
||||
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:**
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -34,13 +34,18 @@ COPY public ./public
|
||||
RUN npm run build
|
||||
|
||||
# ==================== Stage 2: Composer Builder ====================
|
||||
FROM composer:2 AS composer-builder
|
||||
# Pin to PHP 8.4 - composer:2 uses latest PHP which may not be supported by dependencies yet
|
||||
FROM php:8.4-cli-alpine AS composer-builder
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install required PHP extensions for Filament
|
||||
RUN apk add --no-cache icu-dev \
|
||||
&& docker-php-ext-install intl
|
||||
# Install required PHP extensions for Filament and Horizon
|
||||
RUN apk add --no-cache icu-dev libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install intl gd pcntl zip
|
||||
|
||||
# Copy composer files
|
||||
COPY composer.json composer.lock ./
|
||||
|
||||
50
Makefile
50
Makefile
@@ -1,31 +1,26 @@
|
||||
.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
|
||||
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-setup 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
|
||||
# Find project root (handles both worktree and main repo)
|
||||
ifeq ($(IS_WORKTREE),true)
|
||||
# In a worktree - use worktree-specific path
|
||||
# In a worktree - project root is two levels up
|
||||
PROJECT_ROOT := $(shell cd ../.. && pwd)
|
||||
WORKTREE_NAME := $(shell basename $(CURDIR))
|
||||
K8S_VOLUME_PATH := /worktrees/$(WORKTREE_NAME)
|
||||
HOST_WORKTREE_PATH := $(PROJECT_ROOT)/.worktrees
|
||||
else
|
||||
# In project root - use root path
|
||||
# In project root
|
||||
PROJECT_ROOT := $(shell pwd)
|
||||
WORKTREE_NAME := root
|
||||
K8S_VOLUME_PATH := /project-root
|
||||
HOST_WORKTREE_PATH := $(PROJECT_ROOT)/.worktrees
|
||||
endif
|
||||
|
||||
# Generate namespace from branch name (feat-branch-name)
|
||||
@@ -69,6 +64,28 @@ dev-vite: ## Start Vite dev server (run after 'make dev')
|
||||
./vendor/bin/sail npm run dev
|
||||
|
||||
# ==================== K8s Local Development ====================
|
||||
k-setup: ## One-time setup: Create K3d cluster with auto-detected volume mounts
|
||||
@echo "🔧 Setting up K3d cluster 'dev' with auto-detected paths"
|
||||
@echo " Project Root: $(PROJECT_ROOT)"
|
||||
@echo " Worktrees Path: $(HOST_WORKTREE_PATH)"
|
||||
@echo ""
|
||||
@# Check if cluster already exists
|
||||
@if k3d cluster list | grep -q "^dev "; then \
|
||||
echo "⚠️ Cluster 'dev' already exists!"; \
|
||||
echo " To recreate, run: k3d cluster delete dev && make k-setup"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@# Create cluster with dynamic volume mounts
|
||||
k3d cluster create dev \
|
||||
--api-port 6443 \
|
||||
--port "80:80@loadbalancer" \
|
||||
--port "443:443@loadbalancer" \
|
||||
--volume $(HOST_WORKTREE_PATH):/worktrees \
|
||||
--volume $(PROJECT_ROOT):/project-root
|
||||
@echo ""
|
||||
@echo "✅ K3d cluster created successfully!"
|
||||
@echo " Next step: Run 'make k-dev' to start your environment"
|
||||
|
||||
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)"
|
||||
@@ -254,6 +271,13 @@ install: ## Initial project setup
|
||||
@echo " 2. Run 'make dev' to start development environment"
|
||||
@echo " 3. Run 'make migrate' to set up database"
|
||||
|
||||
setup-hooks: ## Configure git hooks for code quality
|
||||
@git config core.hooksPath .githooks
|
||||
@chmod +x .githooks/*
|
||||
@echo "✅ Git hooks configured!"
|
||||
@echo " - pre-commit: Auto-formats code with Laravel Pint"
|
||||
@echo " - pre-push: Optionally runs tests before pushing"
|
||||
|
||||
mailpit: ## Open Mailpit web UI
|
||||
@open http://localhost:8025 || xdg-open http://localhost:8025 || echo "Open http://localhost:8025 in your browser"
|
||||
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
# 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?
|
||||
155
app/Console/Commands/CleanupPermissionAuditLogs.php
Normal file
155
app/Console/Commands/CleanupPermissionAuditLogs.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PermissionAuditLog;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanupPermissionAuditLogs extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'permissions:cleanup-audit
|
||||
{--dry-run : Show what would be deleted without actually deleting}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete expired permission audit logs (non-critical logs past their expiration date)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$isForced = $this->option('force');
|
||||
|
||||
$this->info('🔍 Scanning for expired permission audit logs...');
|
||||
$this->newLine();
|
||||
|
||||
// Find expired logs
|
||||
$expiredLogs = PermissionAuditLog::expired()->get();
|
||||
|
||||
if ($expiredLogs->isEmpty()) {
|
||||
$this->info('✅ No expired audit logs found. Everything is up to date!');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Statistics
|
||||
$totalCount = $expiredLogs->count();
|
||||
$oldestLog = $expiredLogs->sortBy('created_at')->first();
|
||||
$newestLog = $expiredLogs->sortByDesc('created_at')->first();
|
||||
|
||||
// Display summary
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Expired logs found', $totalCount],
|
||||
['Oldest expired log', $oldestLog->created_at->format('Y-m-d H:i:s')],
|
||||
['Newest expired log', $newestLog->created_at->format('Y-m-d H:i:s')],
|
||||
['Date range', $oldestLog->created_at->diffForHumans($newestLog->created_at, true)],
|
||||
]
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// Show sample of logs to be deleted
|
||||
$this->info('📋 Sample of logs to be deleted:');
|
||||
$sampleLogs = $expiredLogs->take(5);
|
||||
|
||||
foreach ($sampleLogs as $log) {
|
||||
$this->line(sprintf(
|
||||
' • [%s] %s - %s (expired %s)',
|
||||
$log->created_at->format('Y-m-d'),
|
||||
$log->action_name,
|
||||
$log->targetUser?->name ?? 'Unknown User',
|
||||
$log->expires_at->diffForHumans()
|
||||
));
|
||||
}
|
||||
|
||||
if ($totalCount > 5) {
|
||||
$this->line(" ... and {$totalCount} more");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// Dry run mode
|
||||
if ($isDryRun) {
|
||||
$this->warn('🧪 DRY RUN MODE - No logs will be deleted');
|
||||
$this->info("Would delete {$totalCount} expired audit logs");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Confirmation prompt (unless forced)
|
||||
if (! $isForced) {
|
||||
$confirmed = $this->confirm(
|
||||
"Are you sure you want to delete {$totalCount} expired audit logs?",
|
||||
false
|
||||
);
|
||||
|
||||
if (! $confirmed) {
|
||||
$this->info('❌ Cleanup cancelled');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform deletion
|
||||
$this->info('🗑️ Deleting expired audit logs...');
|
||||
|
||||
$progressBar = $this->output->createProgressBar($totalCount);
|
||||
$progressBar->start();
|
||||
|
||||
$deletedCount = 0;
|
||||
$errorCount = 0;
|
||||
|
||||
foreach ($expiredLogs as $log) {
|
||||
try {
|
||||
$log->delete();
|
||||
$deletedCount++;
|
||||
} catch (\Exception $e) {
|
||||
$errorCount++;
|
||||
$this->error("Failed to delete log ID {$log->id}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
// Final summary
|
||||
if ($errorCount === 0) {
|
||||
$this->info("✅ Successfully deleted {$deletedCount} expired audit logs");
|
||||
} else {
|
||||
$this->warn("⚠️ Deleted {$deletedCount} logs with {$errorCount} errors");
|
||||
}
|
||||
|
||||
// Show remaining stats
|
||||
$remainingTotal = PermissionAuditLog::count();
|
||||
$remainingCritical = PermissionAuditLog::critical()->count();
|
||||
$remainingNonExpired = $remainingTotal - $remainingCritical;
|
||||
|
||||
$this->newLine();
|
||||
$this->info('📊 Database statistics after cleanup:');
|
||||
$this->table(
|
||||
['Category', 'Count'],
|
||||
[
|
||||
['Critical logs (kept forever)', $remainingCritical],
|
||||
['Non-critical logs (not yet expired)', $remainingNonExpired],
|
||||
['Total remaining logs', $remainingTotal],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,10 @@ class CreateTestInvoiceForApproval extends Command
|
||||
|
||||
$this->info("✓ Company: {$company->name}");
|
||||
|
||||
// Get some products
|
||||
$products = Product::where('quantity_on_hand', '>', 10)->where('is_active', true)->take(5)->get();
|
||||
// Get some products that have inventory
|
||||
$products = Product::whereHas('inventoryItems', function ($q) {
|
||||
$q->where('quantity_on_hand', '>', 10);
|
||||
})->where('is_active', true)->take(5)->get();
|
||||
if ($products->isEmpty()) {
|
||||
$this->error('No products found. Please seed products first.');
|
||||
|
||||
|
||||
289
app/Console/Commands/ImportBrandFromMySQL.php
Normal file
289
app/Console/Commands/ImportBrandFromMySQL.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class ImportBrandFromMySQL extends Command
|
||||
{
|
||||
protected $signature = 'brand:import-from-mysql {remoteName? : Remote brand name} {localName? : Local brand name (if different)}';
|
||||
|
||||
protected $description = 'Import brand data and images from remote MySQL database';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$remoteBrandName = $this->argument('remoteName') ?? 'Canna';
|
||||
$localBrandName = $this->argument('localName') ?? $remoteBrandName;
|
||||
|
||||
$this->info('Connecting to remote MySQL database...');
|
||||
|
||||
try {
|
||||
// Connect to remote MySQL with latin1 charset (Windows-1252)
|
||||
$pdo = new \PDO(
|
||||
'mysql:host=sql1.creationshop.net;dbname=hub_cannabrands;charset=latin1',
|
||||
'claude',
|
||||
'claude'
|
||||
);
|
||||
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$this->info('Connected successfully!');
|
||||
|
||||
// Fetch brand data from MySQL
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT brand_id, name, tagline, short_desc, `desc`, url,
|
||||
image, banner, address, unit_number, city, state, zip, phone,
|
||||
public, fb, insta, twitter, youtube
|
||||
FROM brands
|
||||
WHERE name = :name
|
||||
');
|
||||
$stmt->execute(['name' => $remoteBrandName]);
|
||||
$remoteBrand = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if (! $remoteBrand) {
|
||||
$this->error("Brand '{$remoteBrandName}' not found in remote database");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Found remote brand: {$remoteBrand['name']}");
|
||||
|
||||
// Find local brand by name
|
||||
$localBrand = Brand::where('name', $localBrandName)->first();
|
||||
|
||||
if (! $localBrand) {
|
||||
$this->error("Brand '{$localBrandName}' not found in local database");
|
||||
$this->info('Available brands: '.Brand::pluck('name')->implode(', '));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Found local brand: {$localBrand->name} (ID: {$localBrand->id})");
|
||||
|
||||
// Create brands directory if it doesn't exist
|
||||
if (! Storage::disk('public')->exists('brands')) {
|
||||
Storage::disk('public')->makeDirectory('brands');
|
||||
$this->info('Created brands directory');
|
||||
}
|
||||
|
||||
// Initialize Intervention Image
|
||||
$manager = new ImageManager(new Driver);
|
||||
|
||||
// Process logo image with thumbnails (save as PNG for transparency support)
|
||||
if ($remoteBrand['image']) {
|
||||
$logoPath = "brands/{$localBrand->slug}-logo.png";
|
||||
|
||||
// Read and process the original image
|
||||
$originalImage = $manager->read($remoteBrand['image']);
|
||||
|
||||
// Try to remove white background by making white pixels transparent
|
||||
// Sample corners to detect if background is white
|
||||
$width = $originalImage->width();
|
||||
$height = $originalImage->height();
|
||||
|
||||
// Use GD to manipulate pixels
|
||||
$gdImage = imagecreatefromstring($remoteBrand['image']);
|
||||
if ($gdImage !== false) {
|
||||
// Enable alpha blending
|
||||
imagealphablending($gdImage, false);
|
||||
imagesavealpha($gdImage, true);
|
||||
|
||||
// Make white and near-white pixels transparent
|
||||
for ($x = 0; $x < imagesx($gdImage); $x++) {
|
||||
for ($y = 0; $y < imagesy($gdImage); $y++) {
|
||||
$rgb = imagecolorat($gdImage, $x, $y);
|
||||
$colors = imagecolorsforindex($gdImage, $rgb);
|
||||
|
||||
// If pixel is white or very close to white (RGB > 245)
|
||||
if ($colors['red'] > 245 && $colors['green'] > 245 && $colors['blue'] > 245) {
|
||||
$transparent = imagecolorallocatealpha($gdImage, 255, 255, 255, 127);
|
||||
imagesetpixel($gdImage, $x, $y, $transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save as PNG
|
||||
ob_start();
|
||||
imagepng($gdImage);
|
||||
$processedData = ob_get_clean();
|
||||
imagedestroy($gdImage);
|
||||
|
||||
Storage::disk('public')->put($logoPath, $processedData);
|
||||
$originalImage = $manager->read($processedData);
|
||||
} else {
|
||||
// Fallback: save original as PNG
|
||||
Storage::disk('public')->put($logoPath, $originalImage->toPng());
|
||||
}
|
||||
|
||||
// Generate thumbnails optimized for retina displays (PNG for transparency)
|
||||
// Thumbnail (160x160) for list views (2x retina at 80px)
|
||||
$thumbRetina = clone $originalImage;
|
||||
$thumbRetina->scale(width: 160);
|
||||
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-thumb.png", $thumbRetina->toPng());
|
||||
|
||||
// Medium (600x600) for product cards (2x retina at 300px)
|
||||
$mediumRetina = clone $originalImage;
|
||||
$mediumRetina->scale(width: 600);
|
||||
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-medium.png", $mediumRetina->toPng());
|
||||
|
||||
// Large (1600x1600) for detail views
|
||||
$largeRetina = clone $originalImage;
|
||||
$largeRetina->scale(width: 1600);
|
||||
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-large.png", $largeRetina->toPng());
|
||||
|
||||
$localBrand->logo_path = $logoPath;
|
||||
$this->info("✓ Saved logo + thumbnails: {$logoPath} (".strlen($remoteBrand['image']).' bytes)');
|
||||
}
|
||||
|
||||
// Process banner image with thumbnails
|
||||
if ($remoteBrand['banner']) {
|
||||
$bannerPath = "brands/{$localBrand->slug}-banner.jpg";
|
||||
|
||||
// Save original
|
||||
Storage::disk('public')->put($bannerPath, $remoteBrand['banner']);
|
||||
|
||||
// Generate banner thumbnails if banner is large enough
|
||||
if (strlen($remoteBrand['banner']) > 1000) {
|
||||
$image = $manager->read($remoteBrand['banner']);
|
||||
|
||||
// Medium banner (1344px wide) for retina displays at 672px
|
||||
$mediumBanner = clone $image;
|
||||
$mediumBanner->scale(width: 1344);
|
||||
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-medium.jpg", $mediumBanner->toJpeg(quality: 92));
|
||||
|
||||
// Large banner (2560px wide) for full-width hero sections
|
||||
$largeBanner = clone $image;
|
||||
$largeBanner->scale(width: 2560);
|
||||
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-large.jpg", $largeBanner->toJpeg(quality: 92));
|
||||
}
|
||||
|
||||
$localBrand->banner_path = $bannerPath;
|
||||
$this->info("✓ Saved banner + thumbnails: {$bannerPath} (".strlen($remoteBrand['banner']).' bytes)');
|
||||
}
|
||||
|
||||
// Helper function to sanitize text (convert Windows-1252 to UTF-8)
|
||||
$sanitize = function ($text) {
|
||||
if (! $text) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
// First, convert from Windows-1252/ISO-8859-1 to UTF-8
|
||||
$text = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
|
||||
|
||||
// Replace common Windows-1252 special characters with standard equivalents
|
||||
$replacements = [
|
||||
"\xE2\x80\x98" => "'", // Left single quote
|
||||
"\xE2\x80\x99" => "'", // Right single quote (apostrophe)
|
||||
"\xE2\x80\x9C" => '"', // Left double quote
|
||||
"\xE2\x80\x9D" => '"', // Right double quote
|
||||
"\xE2\x80\x93" => '-', // En dash
|
||||
"\xE2\x80\x94" => '-', // Em dash
|
||||
"\xE2\x80\x26" => '...', // Ellipsis
|
||||
];
|
||||
|
||||
$text = str_replace(array_keys($replacements), array_values($replacements), $text);
|
||||
|
||||
return trim($text);
|
||||
};
|
||||
|
||||
// Update other brand fields
|
||||
$updates = [];
|
||||
|
||||
if ($remoteBrand['tagline']) {
|
||||
$localBrand->tagline = $sanitize($remoteBrand['tagline']);
|
||||
$updates[] = 'tagline';
|
||||
}
|
||||
|
||||
if ($remoteBrand['short_desc']) {
|
||||
$localBrand->description = $sanitize($remoteBrand['short_desc']);
|
||||
$updates[] = 'description';
|
||||
}
|
||||
|
||||
if ($remoteBrand['desc']) {
|
||||
$localBrand->long_description = $sanitize($remoteBrand['desc']);
|
||||
$updates[] = 'long_description';
|
||||
}
|
||||
|
||||
if ($remoteBrand['url']) {
|
||||
$localBrand->website_url = $remoteBrand['url'];
|
||||
$updates[] = 'website_url';
|
||||
}
|
||||
|
||||
// Address fields
|
||||
if ($remoteBrand['address']) {
|
||||
$localBrand->address = $remoteBrand['address'];
|
||||
$updates[] = 'address';
|
||||
}
|
||||
|
||||
if ($remoteBrand['unit_number']) {
|
||||
$localBrand->unit_number = $remoteBrand['unit_number'];
|
||||
$updates[] = 'unit_number';
|
||||
}
|
||||
|
||||
if ($remoteBrand['city']) {
|
||||
$localBrand->city = $remoteBrand['city'];
|
||||
$updates[] = 'city';
|
||||
}
|
||||
|
||||
if ($remoteBrand['state']) {
|
||||
$localBrand->state = $remoteBrand['state'];
|
||||
$updates[] = 'state';
|
||||
}
|
||||
|
||||
if ($remoteBrand['zip']) {
|
||||
$localBrand->zip_code = $remoteBrand['zip'];
|
||||
$updates[] = 'zip_code';
|
||||
}
|
||||
|
||||
if ($remoteBrand['phone']) {
|
||||
$localBrand->phone = $remoteBrand['phone'];
|
||||
$updates[] = 'phone';
|
||||
}
|
||||
|
||||
// Social media
|
||||
if ($remoteBrand['fb']) {
|
||||
$localBrand->facebook_url = 'https://facebook.com/'.$remoteBrand['fb'];
|
||||
$updates[] = 'facebook_url';
|
||||
}
|
||||
|
||||
if ($remoteBrand['insta']) {
|
||||
$localBrand->instagram_handle = $remoteBrand['insta'];
|
||||
$updates[] = 'instagram_handle';
|
||||
}
|
||||
|
||||
if ($remoteBrand['twitter']) {
|
||||
$localBrand->twitter_handle = $remoteBrand['twitter'];
|
||||
$updates[] = 'twitter_handle';
|
||||
}
|
||||
|
||||
if ($remoteBrand['youtube']) {
|
||||
$localBrand->youtube_url = $remoteBrand['youtube'];
|
||||
$updates[] = 'youtube_url';
|
||||
}
|
||||
|
||||
// Visibility
|
||||
$localBrand->is_public = (bool) $remoteBrand['public'];
|
||||
$updates[] = 'is_public';
|
||||
|
||||
// Save the brand
|
||||
$localBrand->save();
|
||||
|
||||
$this->info("\n✓ Successfully imported brand data!");
|
||||
$this->info('Updated fields: '.implode(', ', $updates));
|
||||
|
||||
$this->newLine();
|
||||
$this->info('View the brand at:');
|
||||
$this->line("http://localhost/s/cannabrands/brands/{$localBrand->hashid}/edit");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Error: '.$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
158
app/Console/Commands/SeedCoaData.php
Normal file
158
app/Console/Commands/SeedCoaData.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Batch;
|
||||
use App\Models\BatchCoaFile;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class SeedCoaData extends Command
|
||||
{
|
||||
protected $signature = 'seed:coa-data';
|
||||
|
||||
protected $description = 'Add COA files to existing batches for testing';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Seeding COA data for testing...');
|
||||
|
||||
// Get all active products with batches
|
||||
$products = Product::with('batches')
|
||||
->where('is_active', true)
|
||||
->whereHas('batches')
|
||||
->get();
|
||||
|
||||
if ($products->isEmpty()) {
|
||||
$this->warn('No products with batches found. Run the main seeder first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Found {$products->count()} products with batches");
|
||||
|
||||
$coaCount = 0;
|
||||
|
||||
foreach ($products as $product) {
|
||||
foreach ($product->batches as $batch) {
|
||||
// Skip if batch already has COAs
|
||||
if ($batch->coaFiles()->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create 1-2 COA files per batch
|
||||
$numCoas = rand(1, 2);
|
||||
|
||||
for ($i = 1; $i <= $numCoas; $i++) {
|
||||
$isPrimary = ($i === 1);
|
||||
|
||||
// Create a dummy PDF file
|
||||
$fileName = "COA-{$batch->batch_number}-{$i}.pdf";
|
||||
$filePath = "businesses/{$product->brand->business->uuid}/batches/{$batch->id}/coas/{$fileName}";
|
||||
|
||||
// Create dummy PDF content (just for testing)
|
||||
$pdfContent = $this->generateDummyPdf($batch, $product);
|
||||
Storage::disk('local')->put($filePath, $pdfContent);
|
||||
|
||||
// Create COA file record
|
||||
BatchCoaFile::create([
|
||||
'batch_id' => $batch->id,
|
||||
'file_name' => $fileName,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => strlen($pdfContent),
|
||||
'mime_type' => 'application/pdf',
|
||||
'is_primary' => $isPrimary,
|
||||
'display_order' => $i,
|
||||
]);
|
||||
|
||||
$coaCount++;
|
||||
}
|
||||
|
||||
$this->line(" Added {$numCoas} COA(s) for batch {$batch->batch_number}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("✓ Created {$coaCount} COA files");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function generateDummyPdf(Batch $batch, Product $product): string
|
||||
{
|
||||
// Generate a simple text-based "PDF" for testing
|
||||
// In a real system, you'd use a PDF library
|
||||
return "%PDF-1.4
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [3 0 R]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Parent 2 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
/MediaBox [0 0 612 792]
|
||||
/Contents 4 0 R
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Length 250
|
||||
>>
|
||||
stream
|
||||
BT
|
||||
/F1 12 Tf
|
||||
50 700 Td
|
||||
(CERTIFICATE OF ANALYSIS) Tj
|
||||
0 -30 Td
|
||||
(Batch Number: {$batch->batch_number}) Tj
|
||||
0 -20 Td
|
||||
(Product: {$product->name}) Tj
|
||||
0 -20 Td
|
||||
(Test Date: ".now()->format('Y-m-d').') Tj
|
||||
0 -30 Td
|
||||
(THC: 25.5%) Tj
|
||||
0 -20 Td
|
||||
(CBD: 0.8%) Tj
|
||||
0 -20 Td
|
||||
(Status: PASSED) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000317 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 5
|
||||
/Root 1 0 R
|
||||
>>
|
||||
startxref
|
||||
619
|
||||
%%EOF';
|
||||
}
|
||||
}
|
||||
225
app/Console/Commands/SeedTestOrders.php
Normal file
225
app/Console/Commands/SeedTestOrders.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use App\Models\Location;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SeedTestOrders extends Command
|
||||
{
|
||||
protected $signature = 'seed:test-orders {--clean : Delete existing test orders first}';
|
||||
|
||||
protected $description = 'Create test orders at various statuses for testing the order flow';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('clean')) {
|
||||
$this->info('Cleaning up existing test orders...');
|
||||
$testOrders = Order::where('order_number', 'like', 'TEST-%')->get();
|
||||
foreach ($testOrders as $order) {
|
||||
// Delete order items first, then the order
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Creating test orders at various statuses...');
|
||||
|
||||
// Get a buyer business (retailer) and location
|
||||
$buyerBusiness = Business::where('business_type', 'retailer')->first();
|
||||
if (! $buyerBusiness) {
|
||||
$this->error('No buyer business found. Run the main seeder first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$buyerLocation = Location::where('business_id', $buyerBusiness->id)->first();
|
||||
if (! $buyerLocation) {
|
||||
$this->error('No buyer location found. Run the main seeder first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get a buyer user
|
||||
$buyerUser = User::where('user_type', 'buyer')->first();
|
||||
if (! $buyerUser) {
|
||||
$this->error('No buyer user found. Run the main seeder first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get products with batches and COAs
|
||||
$products = Product::with(['brand.business', 'batches.coaFiles'])
|
||||
->where('is_active', true)
|
||||
->whereHas('batches.coaFiles')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
if ($products->isEmpty()) {
|
||||
$this->error('No products with COAs found. Run seed:coa-data first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$orders = [];
|
||||
|
||||
// 1. Order ready for pre-delivery review (after picking, before delivery)
|
||||
$orders[] = $this->createTestOrder(
|
||||
$buyerBusiness,
|
||||
$buyerLocation,
|
||||
$products->random(3),
|
||||
'ready_for_delivery',
|
||||
'TEST-PREDELIVERY-001',
|
||||
'Order ready for pre-delivery review (Review #1)'
|
||||
);
|
||||
|
||||
// 2. Order delivered and ready for post-delivery acceptance (Review #2)
|
||||
$orders[] = $this->createTestOrder(
|
||||
$buyerBusiness,
|
||||
$buyerLocation,
|
||||
$products->random(3),
|
||||
'delivered',
|
||||
'TEST-DELIVERED-001',
|
||||
'Order delivered and ready for acceptance (Review #2)'
|
||||
);
|
||||
|
||||
// 3. Order in progress (picking)
|
||||
$orders[] = $this->createTestOrder(
|
||||
$buyerBusiness,
|
||||
$buyerLocation,
|
||||
$products->random(2),
|
||||
'in_progress',
|
||||
'TEST-PICKING-001',
|
||||
'Order currently being picked'
|
||||
);
|
||||
|
||||
// 4. Order accepted and approved for delivery
|
||||
$orders[] = $this->createTestOrder(
|
||||
$buyerBusiness,
|
||||
$buyerLocation,
|
||||
$products->random(2),
|
||||
'approved_for_delivery',
|
||||
'TEST-APPROVED-001',
|
||||
'Order approved for delivery (passed Review #1)'
|
||||
);
|
||||
|
||||
// 5. Order out for delivery
|
||||
$orders[] = $this->createTestOrder(
|
||||
$buyerBusiness,
|
||||
$buyerLocation,
|
||||
$products->random(2),
|
||||
'out_for_delivery',
|
||||
'TEST-OUTDELIVERY-001',
|
||||
'Order out for delivery'
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('✓ Created '.count($orders).' test orders');
|
||||
$this->newLine();
|
||||
|
||||
$this->table(
|
||||
['Order Number', 'Status', 'Items', 'Description'],
|
||||
collect($orders)->map(fn ($order) => [
|
||||
$order->order_number,
|
||||
$order->status,
|
||||
$order->items->count(),
|
||||
$this->getOrderDescription($order->order_number),
|
||||
])
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('You can now test the order flow in the UI:');
|
||||
$this->line(' • Pre-delivery review: /b/'.$buyerBusiness->slug.'/orders/TEST-PREDELIVERY-001/pre-delivery-review');
|
||||
$this->line(' • Post-delivery acceptance: /b/'.$buyerBusiness->slug.'/orders/TEST-DELIVERED-001/acceptance');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function createTestOrder(
|
||||
Business $buyerBusiness,
|
||||
Location $buyerLocation,
|
||||
$products,
|
||||
string $status,
|
||||
string $orderNumber,
|
||||
string $description
|
||||
): Order {
|
||||
return DB::transaction(function () use ($buyerBusiness, $buyerLocation, $products, $status, $orderNumber) {
|
||||
// Get first product's seller business
|
||||
$sellerBusiness = $products->first()->brand->business;
|
||||
|
||||
// Calculate totals
|
||||
$subtotal = $products->sum(function ($product) {
|
||||
return $product->wholesale_price * 5; // 5 units each
|
||||
});
|
||||
|
||||
$surchargePercent = Order::getSurchargePercentage('net_30');
|
||||
$surcharge = $subtotal * ($surchargePercent / 100);
|
||||
$taxRate = $buyerBusiness->getTaxRate();
|
||||
$tax = ($subtotal + $surcharge) * $taxRate;
|
||||
$total = $subtotal + $surcharge + $tax;
|
||||
|
||||
// Create order
|
||||
$order = Order::create([
|
||||
'order_number' => $orderNumber,
|
||||
'business_id' => $buyerBusiness->id,
|
||||
'seller_business_id' => $sellerBusiness->id,
|
||||
'location_id' => $buyerLocation->id,
|
||||
'status' => $status,
|
||||
'fulfillment_method' => 'delivery',
|
||||
'payment_terms' => 'net_30',
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'surcharge' => $surcharge,
|
||||
'total' => $total,
|
||||
'notes' => 'Test order for flow testing',
|
||||
]);
|
||||
|
||||
// Create order items with batch allocation
|
||||
foreach ($products as $product) {
|
||||
$batch = $product->batches->first();
|
||||
$quantity = 5;
|
||||
|
||||
// Allocate inventory
|
||||
if ($batch) {
|
||||
$batch->allocate($quantity);
|
||||
}
|
||||
|
||||
OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'batch_id' => $batch?->id,
|
||||
'product_name' => $product->name,
|
||||
'product_sku' => $product->sku,
|
||||
'brand_name' => $product->brand->name,
|
||||
'batch_number' => $batch?->batch_number,
|
||||
'quantity' => $quantity,
|
||||
'unit_price' => $product->wholesale_price,
|
||||
'line_total' => $product->wholesale_price * $quantity,
|
||||
]);
|
||||
}
|
||||
|
||||
return $order->fresh(['items']);
|
||||
});
|
||||
}
|
||||
|
||||
private function getOrderDescription(string $orderNumber): string
|
||||
{
|
||||
return match (true) {
|
||||
str_contains($orderNumber, 'PREDELIVERY') => 'Order ready for pre-delivery review (Review #1)',
|
||||
str_contains($orderNumber, 'DELIVERED') => 'Order delivered and ready for acceptance (Review #2)',
|
||||
str_contains($orderNumber, 'PICKING') => 'Order currently being picked',
|
||||
str_contains($orderNumber, 'APPROVED') => 'Order approved for delivery (passed Review #1)',
|
||||
str_contains($orderNumber, 'OUTDELIVERY') => 'Order out for delivery',
|
||||
default => 'Test order',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
// Check for scheduled broadcasts every minute
|
||||
$schedule->job(new \App\Jobs\Marketing\ProcessScheduledBroadcastsJob)
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
56
app/Events/HighIntentBuyerDetected.php
Normal file
56
app/Events/HighIntentBuyerDetected.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class HighIntentBuyerDetected implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $sellerBusinessId,
|
||||
public int $buyerBusinessId,
|
||||
public IntentSignal $signal,
|
||||
public ?BuyerEngagementScore $engagementScore = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*/
|
||||
public function broadcastOn(): Channel
|
||||
{
|
||||
return new Channel("business.{$this->sellerBusinessId}.analytics");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to broadcast.
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'buyer_business_id' => $this->buyerBusinessId,
|
||||
'buyer_business_name' => $this->signal->buyerBusiness?->name,
|
||||
'signal_type' => $this->signal->signal_type,
|
||||
'signal_strength' => $this->signal->signal_strength,
|
||||
'product_id' => $this->signal->subject_type === 'App\Models\Product' ? $this->signal->subject_id : null,
|
||||
'total_engagement_score' => $this->engagementScore?->total_score,
|
||||
'detected_at' => $this->signal->detected_at->toIso8601String(),
|
||||
'context' => $this->signal->context,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's broadcast name.
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'high-intent-buyer-detected';
|
||||
}
|
||||
}
|
||||
446
app/Filament/Pages/AiSettings.php
Normal file
446
app/Filament/Pages/AiSettings.php
Normal file
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\AiSetting;
|
||||
use App\Services\AiClient;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class AiSettings extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
|
||||
|
||||
protected string $view = 'filament.pages.ai-settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'AI Settings (Old)';
|
||||
|
||||
protected static ?int $navigationSort = 100;
|
||||
|
||||
// Hide from navigation - replaced by AiConnectionResource
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$settings = AiSetting::getInstance();
|
||||
|
||||
$this->form->fill([
|
||||
'is_enabled' => $settings->is_enabled ?? false,
|
||||
'ai_provider' => $settings->ai_provider ?? '',
|
||||
'anthropic_api_key' => '', // Never show the full key
|
||||
'openai_api_key' => '', // Never show the full key
|
||||
'perplexity_api_key' => '', // Never show the full key
|
||||
'canva_api_key' => '', // Never show the full key
|
||||
'jasper_api_key' => '', // Never show the full key
|
||||
'anthropic_model' => $settings->anthropic_model ?? last(config('ai.providers.anthropic.models')),
|
||||
'openai_model' => $settings->openai_model ?? last(config('ai.providers.openai.models')),
|
||||
'perplexity_model' => $settings->perplexity_model ?? last(config('ai.providers.perplexity.models')),
|
||||
'canva_model' => $settings->canva_model ?? last(config('ai.providers.canva.models')),
|
||||
'jasper_model' => $settings->jasper_model ?? last(config('ai.providers.jasper.models')),
|
||||
'max_tokens_per_request' => $settings->max_tokens_per_request ?? 4096,
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
$settings = AiSetting::getInstance();
|
||||
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('AI Copilot Configuration')
|
||||
->description('Configure AI-powered content suggestions for brand settings')
|
||||
->schema([
|
||||
Toggle::make('is_enabled')
|
||||
->label('Enable AI Copilot')
|
||||
->helperText('Enable AI-powered content suggestions across the platform')
|
||||
->default(false),
|
||||
|
||||
Select::make('ai_provider')
|
||||
->label('AI Provider')
|
||||
->options([
|
||||
'anthropic' => 'Anthropic / Claude',
|
||||
'openai' => 'OpenAI / ChatGPT',
|
||||
'perplexity' => 'Perplexity',
|
||||
'canva' => 'Canva',
|
||||
'jasper' => 'Jasper',
|
||||
])
|
||||
->placeholder('Select an AI provider')
|
||||
->required()
|
||||
->live()
|
||||
->helperText('Choose your preferred AI provider'),
|
||||
|
||||
// Anthropic fields (shown when provider is 'anthropic')
|
||||
TextInput::make('anthropic_api_key')
|
||||
->label('Anthropic API Key')
|
||||
->helperText('Enter your Anthropic API key. Leave blank to keep existing key.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('sk-ant-...')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'anthropic'),
|
||||
|
||||
Select::make('anthropic_model')
|
||||
->label('Default Model')
|
||||
->options(fn () => array_combine(
|
||||
config('ai.providers.anthropic.models'),
|
||||
config('ai.providers.anthropic.models')
|
||||
))
|
||||
->default(fn () => last(config('ai.providers.anthropic.models')))
|
||||
->required()
|
||||
->helperText('Claude model to use')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'anthropic'),
|
||||
|
||||
// OpenAI fields (shown when provider is 'openai')
|
||||
TextInput::make('openai_api_key')
|
||||
->label('OpenAI API Key')
|
||||
->helperText('Enter your OpenAI API key. Leave blank to keep existing key.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('sk-...')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'openai'),
|
||||
|
||||
Select::make('openai_model')
|
||||
->label('Default Model')
|
||||
->options(fn () => array_combine(
|
||||
config('ai.providers.openai.models'),
|
||||
config('ai.providers.openai.models')
|
||||
))
|
||||
->default(fn () => last(config('ai.providers.openai.models')))
|
||||
->required()
|
||||
->helperText('ChatGPT / GPT model to use')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'openai'),
|
||||
|
||||
// Perplexity fields (shown when provider is 'perplexity')
|
||||
TextInput::make('perplexity_api_key')
|
||||
->label('Perplexity API Key')
|
||||
->helperText('Enter your Perplexity API key. Leave blank to keep existing key.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('pplx-...')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'perplexity'),
|
||||
|
||||
Select::make('perplexity_model')
|
||||
->label('Default Model')
|
||||
->options(fn () => array_combine(
|
||||
config('ai.providers.perplexity.models'),
|
||||
config('ai.providers.perplexity.models')
|
||||
))
|
||||
->default(fn () => last(config('ai.providers.perplexity.models')))
|
||||
->required()
|
||||
->helperText('Perplexity model to use')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'perplexity'),
|
||||
|
||||
// Canva fields (shown when provider is 'canva')
|
||||
TextInput::make('canva_api_key')
|
||||
->label('Canva API Key')
|
||||
->helperText('Enter your Canva API key. Leave blank to keep existing key.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('canva-...')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'canva'),
|
||||
|
||||
Select::make('canva_model')
|
||||
->label('Default Model')
|
||||
->options(fn () => array_combine(
|
||||
config('ai.providers.canva.models'),
|
||||
config('ai.providers.canva.models')
|
||||
))
|
||||
->default(fn () => last(config('ai.providers.canva.models')))
|
||||
->required()
|
||||
->helperText('Canva model/feature to use')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'canva'),
|
||||
|
||||
// Jasper fields (shown when provider is 'jasper')
|
||||
TextInput::make('jasper_api_key')
|
||||
->label('Jasper API Key')
|
||||
->helperText('Enter your Jasper API key. Leave blank to keep existing key.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('jasper-...')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'jasper'),
|
||||
|
||||
Select::make('jasper_model')
|
||||
->label('Default Model')
|
||||
->options(fn () => array_combine(
|
||||
config('ai.providers.jasper.models'),
|
||||
config('ai.providers.jasper.models')
|
||||
))
|
||||
->default(fn () => last(config('ai.providers.jasper.models')))
|
||||
->required()
|
||||
->helperText('Jasper model to use')
|
||||
->visible(fn ($get) => $get('ai_provider') === 'jasper'),
|
||||
|
||||
TextInput::make('max_tokens_per_request')
|
||||
->label('Max Tokens Per Request')
|
||||
->helperText('Maximum number of tokens to request from the AI model')
|
||||
->numeric()
|
||||
->default(4096)
|
||||
->required(),
|
||||
|
||||
// Existing Connections Summary
|
||||
Placeholder::make('connections_summary')
|
||||
->label('Existing Connections')
|
||||
->content(fn () => view('filament.components.ai-connections-summary', [
|
||||
'anthropic_configured' => $settings->anthropic_api_key_configured,
|
||||
'openai_configured' => $settings->openai_api_key_configured,
|
||||
'perplexity_configured' => $settings->perplexity_api_key_configured,
|
||||
'canva_configured' => $settings->canva_api_key_configured,
|
||||
'jasper_configured' => $settings->jasper_api_key_configured,
|
||||
])),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
|
||||
$settings = AiSetting::getInstance();
|
||||
|
||||
// Update basic settings
|
||||
$settings->is_enabled = $data['is_enabled'];
|
||||
$settings->ai_provider = $data['ai_provider'];
|
||||
$settings->max_tokens_per_request = $data['max_tokens_per_request'];
|
||||
|
||||
// Always save all model fields (preserve values when switching providers)
|
||||
$settings->anthropic_model = $data['anthropic_model'] ?? $settings->anthropic_model;
|
||||
$settings->openai_model = $data['openai_model'] ?? $settings->openai_model;
|
||||
$settings->perplexity_model = $data['perplexity_model'] ?? $settings->perplexity_model;
|
||||
$settings->canva_model = $data['canva_model'] ?? $settings->canva_model;
|
||||
$settings->jasper_model = $data['jasper_model'] ?? $settings->jasper_model;
|
||||
|
||||
// Update API keys only if provided (don't overwrite with empty string)
|
||||
if (! empty($data['anthropic_api_key'])) {
|
||||
$settings->anthropic_api_key = $data['anthropic_api_key'];
|
||||
}
|
||||
|
||||
if (! empty($data['openai_api_key'])) {
|
||||
$settings->openai_api_key = $data['openai_api_key'];
|
||||
}
|
||||
|
||||
if (! empty($data['perplexity_api_key'])) {
|
||||
$settings->perplexity_api_key = $data['perplexity_api_key'];
|
||||
}
|
||||
|
||||
if (! empty($data['canva_api_key'])) {
|
||||
$settings->canva_api_key = $data['canva_api_key'];
|
||||
}
|
||||
|
||||
if (! empty($data['jasper_api_key'])) {
|
||||
$settings->jasper_api_key = $data['jasper_api_key'];
|
||||
}
|
||||
|
||||
$settings->save();
|
||||
|
||||
// Clear the AI config cache
|
||||
app(AiClient::class)->clearCache();
|
||||
|
||||
// Refresh the form with saved data
|
||||
$this->mount();
|
||||
|
||||
Notification::make()
|
||||
->title('AI Settings saved successfully')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function testConnection(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
$provider = $data['ai_provider'];
|
||||
|
||||
if (! $provider) {
|
||||
Notification::make()
|
||||
->title('Please select a provider first')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = AiSetting::getInstance();
|
||||
|
||||
try {
|
||||
$success = false;
|
||||
$message = '';
|
||||
|
||||
switch ($provider) {
|
||||
case 'anthropic':
|
||||
if (empty($settings->anthropic_api_key)) {
|
||||
throw new \Exception('Anthropic API key not configured');
|
||||
}
|
||||
$success = $this->testAnthropicConnection($settings->anthropic_api_key);
|
||||
$message = $success ? 'Anthropic connection successful' : 'Anthropic connection failed';
|
||||
break;
|
||||
|
||||
case 'openai':
|
||||
if (empty($settings->openai_api_key)) {
|
||||
throw new \Exception('OpenAI API key not configured');
|
||||
}
|
||||
$success = $this->testOpenAiConnection($settings->openai_api_key);
|
||||
$message = $success ? 'OpenAI connection successful' : 'OpenAI connection failed';
|
||||
break;
|
||||
|
||||
case 'perplexity':
|
||||
if (empty($settings->perplexity_api_key)) {
|
||||
throw new \Exception('Perplexity API key not configured');
|
||||
}
|
||||
$success = $this->testPerplexityConnection($settings->perplexity_api_key);
|
||||
$message = $success ? 'Perplexity connection successful' : 'Perplexity connection failed';
|
||||
break;
|
||||
|
||||
case 'canva':
|
||||
if (empty($settings->canva_api_key)) {
|
||||
throw new \Exception('Canva API key not configured');
|
||||
}
|
||||
$success = $this->testCanvaConnection($settings->canva_api_key);
|
||||
$message = $success ? 'Canva connection successful' : 'Canva connection failed';
|
||||
break;
|
||||
|
||||
case 'jasper':
|
||||
if (empty($settings->jasper_api_key)) {
|
||||
throw new \Exception('Jasper API key not configured');
|
||||
}
|
||||
$success = $this->testJasperConnection($settings->jasper_api_key);
|
||||
$message = $success ? 'Jasper connection successful' : 'Jasper connection failed';
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new \Exception('Unknown provider: '.$provider);
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
Notification::make()
|
||||
->title($message)
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title($message)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title('Connection test failed')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
private function testAnthropicConnection(string $apiKey): bool
|
||||
{
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client;
|
||||
$response = $client->post('https://api.anthropic.com/v1/messages', [
|
||||
'headers' => [
|
||||
'x-api-key' => $apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'content-type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'model' => 'claude-3-5-sonnet-20241022',
|
||||
'max_tokens' => 10,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => 'Hi'],
|
||||
],
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return $response->getStatusCode() === 200;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function testOpenAiConnection(string $apiKey): bool
|
||||
{
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client;
|
||||
$response = $client->get('https://api.openai.com/v1/models', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$apiKey,
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return $response->getStatusCode() === 200;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function testPerplexityConnection(string $apiKey): bool
|
||||
{
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client;
|
||||
$response = $client->post('https://api.perplexity.ai/chat/completions', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$apiKey,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'model' => 'sonar-small',
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => 'Hi'],
|
||||
],
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return $response->getStatusCode() === 200;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function testCanvaConnection(string $apiKey): bool
|
||||
{
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client;
|
||||
$response = $client->get('https://api.canva.com/v1/users/me', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$apiKey,
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return $response->getStatusCode() === 200;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function testJasperConnection(string $apiKey): bool
|
||||
{
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client;
|
||||
$response = $client->get('https://api.jasper.ai/v1/account', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$apiKey,
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return $response->getStatusCode() === 200;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
app/Filament/Pages/MigrationHealth.php
Normal file
104
app/Filament/Pages/MigrationHealth.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class MigrationHealth extends Page
|
||||
{
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-circle-stack';
|
||||
|
||||
protected string $view = 'filament.pages.migration-health';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Migrations';
|
||||
|
||||
protected static ?int $navigationSort = 97;
|
||||
|
||||
public array $migrations = [];
|
||||
|
||||
public bool $hasPending = false;
|
||||
|
||||
public int $totalMigrations = 0;
|
||||
|
||||
public int $ranMigrations = 0;
|
||||
|
||||
public int $pendingMigrations = 0;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadMigrations();
|
||||
}
|
||||
|
||||
protected function loadMigrations(): void
|
||||
{
|
||||
// Get all migration files from database/migrations
|
||||
$migrationsPath = database_path('migrations');
|
||||
$files = File::files($migrationsPath);
|
||||
|
||||
// Get ran migrations from database
|
||||
$ranMigrations = DB::table('migrations')
|
||||
->select('migration', 'batch')
|
||||
->get()
|
||||
->keyBy('migration')
|
||||
->toArray();
|
||||
|
||||
$migrations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = $file->getFilename();
|
||||
|
||||
// Skip non-PHP files
|
||||
if (! str_ends_with($filename, '.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract migration name (without .php extension)
|
||||
$migrationName = str_replace('.php', '', $filename);
|
||||
|
||||
$ran = isset($ranMigrations[$migrationName]);
|
||||
$batch = $ran ? $ranMigrations[$migrationName]->batch : null;
|
||||
|
||||
$migrations[] = [
|
||||
'name' => $migrationName,
|
||||
'ran' => $ran,
|
||||
'batch' => $batch,
|
||||
'ran_at' => null, // migrations table doesn't have ran_at by default
|
||||
];
|
||||
|
||||
if (! $ran) {
|
||||
$this->hasPending = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort migrations by name (chronological order due to timestamp prefix)
|
||||
usort($migrations, fn ($a, $b) => strcmp($a['name'], $b['name']));
|
||||
|
||||
$this->migrations = $migrations;
|
||||
$this->totalMigrations = count($migrations);
|
||||
$this->ranMigrations = count(array_filter($migrations, fn ($m) => $m['ran']));
|
||||
$this->pendingMigrations = $this->totalMigrations - $this->ranMigrations;
|
||||
}
|
||||
|
||||
public function getStatusColor(): string
|
||||
{
|
||||
return $this->hasPending ? 'warning' : 'success';
|
||||
}
|
||||
|
||||
public function getStatusMessage(): string
|
||||
{
|
||||
if ($this->hasPending) {
|
||||
return 'Pending migrations detected. Please back up your database and run php artisan migrate from the terminal.';
|
||||
}
|
||||
|
||||
return 'All migrations are up to date.';
|
||||
}
|
||||
|
||||
public function getStatusIcon(): string
|
||||
{
|
||||
return $this->hasPending ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-check-circle';
|
||||
}
|
||||
}
|
||||
206
app/Filament/Pages/NotificationSettings.php
Normal file
206
app/Filament/Pages/NotificationSettings.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class NotificationSettings extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-envelope';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Notification Settings';
|
||||
|
||||
protected static ?int $navigationSort = 98;
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->fillForm();
|
||||
}
|
||||
|
||||
protected function fillForm(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
// Mail settings
|
||||
'mail_driver' => config('mail.default'),
|
||||
'mail_host' => config('mail.mailers.smtp.host'),
|
||||
'mail_port' => config('mail.mailers.smtp.port'),
|
||||
'mail_username' => config('mail.mailers.smtp.username'),
|
||||
'mail_password' => config('mail.mailers.smtp.password'),
|
||||
'mail_encryption' => config('mail.mailers.smtp.encryption'),
|
||||
'mail_from_address' => config('mail.from.address'),
|
||||
'mail_from_name' => config('mail.from.name'),
|
||||
|
||||
// SMS settings (Twilio example)
|
||||
'sms_enabled' => env('SMS_ENABLED', false),
|
||||
'sms_provider' => env('SMS_PROVIDER', 'twilio'),
|
||||
'twilio_sid' => env('TWILIO_SID'),
|
||||
'twilio_auth_token' => env('TWILIO_AUTH_TOKEN'),
|
||||
'twilio_phone_number' => env('TWILIO_PHONE_NUMBER'),
|
||||
|
||||
// WhatsApp settings
|
||||
'whatsapp_enabled' => env('WHATSAPP_ENABLED', false),
|
||||
'whatsapp_provider' => env('WHATSAPP_PROVIDER', 'twilio'),
|
||||
'whatsapp_business_number' => env('WHATSAPP_BUSINESS_NUMBER'),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getFormSchema(): array
|
||||
{
|
||||
return [
|
||||
Forms\Components\Tabs::make('Notification Providers')
|
||||
->tabs([
|
||||
Forms\Components\Tabs\Tab::make('Email')
|
||||
->icon('heroicon-o-envelope')
|
||||
->schema([
|
||||
Forms\Components\Section::make('Email Provider Configuration')
|
||||
->description('Configure your email provider for sending transactional emails')
|
||||
->schema([
|
||||
Forms\Components\Select::make('mail_driver')
|
||||
->label('Mail Driver')
|
||||
->options([
|
||||
'smtp' => 'SMTP',
|
||||
'sendmail' => 'Sendmail',
|
||||
'mailgun' => 'Mailgun',
|
||||
'ses' => 'Amazon SES',
|
||||
'postmark' => 'Postmark',
|
||||
])
|
||||
->required()
|
||||
->reactive(),
|
||||
Forms\Components\Grid::make(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('mail_host')
|
||||
->label('SMTP Host')
|
||||
->required()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_port')
|
||||
->label('SMTP Port')
|
||||
->required()
|
||||
->numeric()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_username')
|
||||
->label('Username')
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_password')
|
||||
->label('Password')
|
||||
->password()
|
||||
->revealable()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\Select::make('mail_encryption')
|
||||
->label('Encryption')
|
||||
->options([
|
||||
'tls' => 'TLS',
|
||||
'ssl' => 'SSL',
|
||||
'' => 'None',
|
||||
])
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_from_address')
|
||||
->label('From Address')
|
||||
->email()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('mail_from_name')
|
||||
->label('From Name')
|
||||
->required(),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('SMS')
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Forms\Components\Section::make('SMS Provider Configuration')
|
||||
->description('Configure your SMS provider for sending text messages')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('sms_enabled')
|
||||
->label('Enable SMS Notifications')
|
||||
->reactive(),
|
||||
Forms\Components\Select::make('sms_provider')
|
||||
->label('SMS Provider')
|
||||
->options([
|
||||
'twilio' => 'Twilio',
|
||||
'nexmo' => 'Vonage (Nexmo)',
|
||||
'aws_sns' => 'AWS SNS',
|
||||
])
|
||||
->required()
|
||||
->reactive()
|
||||
->visible(fn ($get) => $get('sms_enabled')),
|
||||
Forms\Components\Grid::make(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('twilio_sid')
|
||||
->label('Twilio Account SID')
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
Forms\Components\TextInput::make('twilio_auth_token')
|
||||
->label('Twilio Auth Token')
|
||||
->password()
|
||||
->revealable()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
Forms\Components\TextInput::make('twilio_phone_number')
|
||||
->label('Twilio Phone Number')
|
||||
->tel()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('WhatsApp')
|
||||
->icon('heroicon-o-chat-bubble-left-right')
|
||||
->schema([
|
||||
Forms\Components\Section::make('WhatsApp Configuration')
|
||||
->description('Configure WhatsApp Business API for sending messages')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('whatsapp_enabled')
|
||||
->label('Enable WhatsApp Notifications')
|
||||
->reactive(),
|
||||
Forms\Components\Select::make('whatsapp_provider')
|
||||
->label('WhatsApp Provider')
|
||||
->options([
|
||||
'twilio' => 'Twilio WhatsApp',
|
||||
'whatsapp_cloud' => 'WhatsApp Cloud API',
|
||||
])
|
||||
->required()
|
||||
->reactive()
|
||||
->visible(fn ($get) => $get('whatsapp_enabled')),
|
||||
Forms\Components\TextInput::make('whatsapp_business_number')
|
||||
->label('WhatsApp Business Number')
|
||||
->tel()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('whatsapp_enabled')),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFormStatePath(): ?string
|
||||
{
|
||||
return 'data';
|
||||
}
|
||||
|
||||
public function getView(): string
|
||||
{
|
||||
return 'filament.pages.notification-settings';
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
// TODO: Save settings to environment file or database
|
||||
// For now, this would require implementing a settings storage system
|
||||
|
||||
Notification::make()
|
||||
->title('Settings saved')
|
||||
->success()
|
||||
->body('Note: These settings are read from .env file. To persist changes, update your .env file.')
|
||||
->send();
|
||||
}
|
||||
}
|
||||
28
app/Filament/Pages/Queues.php
Normal file
28
app/Filament/Pages/Queues.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class Queues extends Page
|
||||
{
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-circle-stack';
|
||||
|
||||
protected string $view = 'filament.pages.queues';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Queues';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->redirect('/admin/horizon', navigate: false);
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
26
app/Filament/Pages/Telescope.php
Normal file
26
app/Filament/Pages/Telescope.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class Telescope extends Page
|
||||
{
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-magnifying-glass';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Telescope Debug Tool';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->redirect('/telescope', navigate: false);
|
||||
}
|
||||
|
||||
public function getView(): string
|
||||
{
|
||||
return 'filament.pages.telescope';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections;
|
||||
|
||||
use App\Filament\Resources\AiConnections\Pages\CreateAiConnection;
|
||||
use App\Filament\Resources\AiConnections\Pages\EditAiConnection;
|
||||
use App\Filament\Resources\AiConnections\Pages\ListAiConnections;
|
||||
use App\Filament\Resources\AiConnections\Schemas\AiConnectionForm;
|
||||
use App\Filament\Resources\AiConnections\Tables\AiConnectionsTable;
|
||||
use App\Models\AiConnection;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class AiConnectionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = AiConnection::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
protected static ?string $navigationLabel = 'AI Settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?int $navigationSort = 99;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return AiConnectionForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return AiConnectionsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListAiConnections::route('/'),
|
||||
'create' => CreateAiConnection::route('/create'),
|
||||
'edit' => EditAiConnection::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getRecordRouteBindingEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Pages;
|
||||
|
||||
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAiConnection extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AiConnectionResource::class;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Pages;
|
||||
|
||||
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditAiConnection extends EditRecord
|
||||
{
|
||||
protected static string $resource = AiConnectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
ForceDeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Pages;
|
||||
|
||||
use App\Filament\Resources\AiConnections\AiConnectionResource;
|
||||
use App\Filament\Widgets\AiStatsOverview;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAiConnections extends ListRecords
|
||||
{
|
||||
protected static string $resource = AiConnectionResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
AiStatsOverview::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class AiConnectionForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Connection Details')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Friendly name for this connection (e.g., "Primary OpenAI", "Anthropic Drafts")'),
|
||||
|
||||
Select::make('provider')
|
||||
->required()
|
||||
->options([
|
||||
'anthropic' => 'Anthropic / Claude',
|
||||
'openai' => 'OpenAI / ChatGPT',
|
||||
'perplexity' => 'Perplexity',
|
||||
'canva' => 'Canva',
|
||||
'jasper' => 'Jasper',
|
||||
])
|
||||
->live()
|
||||
->helperText('AI provider for this connection'),
|
||||
|
||||
TextInput::make('api_key')
|
||||
->required()
|
||||
->password()
|
||||
->revealable()
|
||||
->maxLength(1000)
|
||||
->helperText('API key for this provider (stored encrypted)')
|
||||
->placeholder('Enter API key...'),
|
||||
|
||||
Select::make('model')
|
||||
->options(function ($get) {
|
||||
$provider = $get('provider');
|
||||
if (!$provider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$models = config("ai.providers.{$provider}.models", []);
|
||||
return array_combine($models, $models);
|
||||
})
|
||||
->helperText('Specific model to use')
|
||||
->placeholder('Select a model')
|
||||
->visible(fn ($get) => !empty($get('provider'))),
|
||||
|
||||
TextInput::make('max_tokens')
|
||||
->numeric()
|
||||
->default(4096)
|
||||
->helperText('Maximum tokens for requests')
|
||||
->placeholder('e.g., 4096'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->helperText('Whether this connection is active and can be used')
|
||||
->default(true),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiConnections\Tables;
|
||||
|
||||
use App\Models\AiConnection;
|
||||
use App\Services\AiConnectionTestService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class AiConnectionsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('medium'),
|
||||
|
||||
BadgeColumn::make('provider')
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'anthropic' => 'Anthropic',
|
||||
'openai' => 'OpenAI',
|
||||
'perplexity' => 'Perplexity',
|
||||
'canva' => 'Canva',
|
||||
'jasper' => 'Jasper',
|
||||
default => $state,
|
||||
})
|
||||
->colors([
|
||||
'primary' => 'anthropic',
|
||||
'success' => 'openai',
|
||||
'warning' => 'perplexity',
|
||||
'danger' => 'canva',
|
||||
'info' => 'jasper',
|
||||
]),
|
||||
|
||||
TextColumn::make('model')
|
||||
->placeholder('Default')
|
||||
->limit(30),
|
||||
|
||||
BadgeColumn::make('status')
|
||||
->colors([
|
||||
'success' => 'ok',
|
||||
'danger' => 'error',
|
||||
'gray' => 'disabled',
|
||||
]),
|
||||
|
||||
TextColumn::make('recent_usage_stats.requests')
|
||||
->label('Requests')
|
||||
->numeric()
|
||||
->suffix(' reqs')
|
||||
->description('Last 30 days'),
|
||||
|
||||
TextColumn::make('recent_usage_stats.total_tokens')
|
||||
->label('Tokens')
|
||||
->numeric()
|
||||
->description('Last 30 days'),
|
||||
|
||||
TextColumn::make('last_used_at')
|
||||
->dateTime()
|
||||
->since()
|
||||
->placeholder('Never'),
|
||||
|
||||
IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('gray'),
|
||||
])
|
||||
->filters([
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('test')
|
||||
->label('Test')
|
||||
->icon('heroicon-m-bolt')
|
||||
->color('warning')
|
||||
->action(function (AiConnection $record) {
|
||||
$result = AiConnectionTestService::testConnection($record);
|
||||
|
||||
$record->update([
|
||||
'last_tested_at' => now(),
|
||||
'status' => $result->success ? 'ok' : 'error',
|
||||
'last_error' => $result->success ? null : $result->message,
|
||||
]);
|
||||
|
||||
if ($result->success) {
|
||||
Notification::make()
|
||||
->title('Connection test successful')
|
||||
->body("Response time: {$result->responseTime}ms")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Connection test failed')
|
||||
->body($result->message)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
ForceDeleteBulkAction::make(),
|
||||
RestoreBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
160
app/Filament/Resources/BatchResource.php
Normal file
160
app/Filament/Resources/BatchResource.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\Batches\Schemas\BatchForm;
|
||||
use App\Filament\Resources\Batches\Tables\BatchesTable;
|
||||
use App\Filament\Resources\BatchResource\Pages;
|
||||
use App\Models\Batch;
|
||||
use App\Services\QrCodeService;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\BulkAction;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use UnitEnum;
|
||||
|
||||
class BatchResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Batch::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
|
||||
|
||||
protected static ?string $navigationLabel = 'Batches';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return BatchForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$table = BatchesTable::configure($table);
|
||||
|
||||
// Add custom QR and COA actions
|
||||
return $table
|
||||
->recordActions(array_merge(
|
||||
$table->getRecordActions(),
|
||||
[
|
||||
Action::make('generate_qr')
|
||||
->label('Generate QR')
|
||||
->icon('heroicon-o-qr-code')
|
||||
->action(function (Batch $record) {
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->generateForBatch($record);
|
||||
|
||||
if ($result['success']) {
|
||||
Notification::make()
|
||||
->title('QR Code Generated')
|
||||
->body($result['message'])
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Failed to generate QR code')
|
||||
->body($result['message'])
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
})
|
||||
->visible(fn (Batch $record) => ! $record->qr_code_path),
|
||||
|
||||
Action::make('download_qr')
|
||||
->label('Download QR')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->url(fn (Batch $record) => route('seller.business.manufacturing.batches.qr-code.download', [
|
||||
'business' => $record->business->slug,
|
||||
'batch' => $record->id,
|
||||
]))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (Batch $record) => $record->qr_code_path),
|
||||
|
||||
Action::make('regenerate_qr')
|
||||
->label('Regenerate QR')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(function (Batch $record) {
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->regenerate($record);
|
||||
|
||||
if ($result['success']) {
|
||||
Notification::make()
|
||||
->title('QR Code Regenerated')
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Failed to regenerate QR code')
|
||||
->body($result['message'])
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
})
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Batch $record) => $record->qr_code_path),
|
||||
|
||||
Action::make('view_coa')
|
||||
->label('View COA')
|
||||
->icon('heroicon-o-document-text')
|
||||
->url(fn (Batch $record) => route('public.coa.show', ['batchNumber' => $record->batch_number]))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (Batch $record) => $record->lab !== null),
|
||||
]
|
||||
))
|
||||
->bulkActions(array_merge(
|
||||
$table->getBulkActions(),
|
||||
[
|
||||
BulkAction::make('generate_qr_codes')
|
||||
->label('Generate QR Codes')
|
||||
->icon('heroicon-o-qr-code')
|
||||
->action(function (Collection $records) {
|
||||
$qrService = app(QrCodeService::class);
|
||||
$batchIds = $records->pluck('id')->toArray();
|
||||
$result = $qrService->bulkGenerate($batchIds);
|
||||
|
||||
Notification::make()
|
||||
->title("Generated {$result['successful']} QR codes")
|
||||
->body("Failed: {$result['failed']}")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$query = parent::getEloquentQuery();
|
||||
|
||||
// Scope to user's business unless they're a super admin
|
||||
if (! auth()->user()->hasRole('super_admin')) {
|
||||
$query->where('business_id', auth()->user()->business_id);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBatches::route('/'),
|
||||
'create' => Pages\CreateBatch::route('/create'),
|
||||
'view' => Pages\ViewBatch::route('/{record}'),
|
||||
'edit' => Pages\EditBatch::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
18
app/Filament/Resources/BatchResource/Pages/CreateBatch.php
Normal file
18
app/Filament/Resources/BatchResource/Pages/CreateBatch.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BatchResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BatchResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBatch extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BatchResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['business_id'] = auth()->user()->business_id;
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
20
app/Filament/Resources/BatchResource/Pages/EditBatch.php
Normal file
20
app/Filament/Resources/BatchResource/Pages/EditBatch.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BatchResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BatchResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBatch extends EditRecord
|
||||
{
|
||||
protected static string $resource = BatchResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/BatchResource/Pages/ListBatches.php
Normal file
19
app/Filament/Resources/BatchResource/Pages/ListBatches.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BatchResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BatchResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBatches extends ListRecords
|
||||
{
|
||||
protected static string $resource = BatchResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/BatchResource/Pages/ViewBatch.php
Normal file
19
app/Filament/Resources/BatchResource/Pages/ViewBatch.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BatchResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BatchResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBatch extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BatchResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\Batches\Schemas;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
@@ -18,84 +19,144 @@ class BatchForm
|
||||
->components([
|
||||
Section::make('Batch Information')
|
||||
->schema([
|
||||
TextInput::make('batch_number')
|
||||
->label('Batch Number')
|
||||
->placeholder('Auto-generated if left blank')
|
||||
->maxLength(255)
|
||||
->helperText('Unique identifier for this batch'),
|
||||
|
||||
Select::make('product_id')
|
||||
->label('Product')
|
||||
->relationship('product', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->columnSpan(2),
|
||||
TextInput::make('batch_number')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->helperText('Unique identifier for this batch (e.g., TB-AM-240315)'),
|
||||
TextInput::make('internal_code')
|
||||
->helperText('Internal production/tracking code (optional)'),
|
||||
])
|
||||
->columns(2),
|
||||
->required(),
|
||||
|
||||
Section::make('Production Dates')
|
||||
->schema([
|
||||
DatePicker::make('production_date')
|
||||
->helperText('Date the batch was produced/manufactured'),
|
||||
DatePicker::make('harvest_date')
|
||||
->helperText('Harvest date (for flower products)'),
|
||||
DatePicker::make('package_date')
|
||||
->helperText('Date the batch was packaged'),
|
||||
DatePicker::make('expiration_date')
|
||||
->helperText('Expiration/best-by date'),
|
||||
Select::make('batch_type')
|
||||
->label('Batch Type')
|
||||
->options([
|
||||
'intake' => 'Intake',
|
||||
'production' => 'Production',
|
||||
'finished' => 'Finished',
|
||||
])
|
||||
->default('finished')
|
||||
->required()
|
||||
->helperText('Type of batch in the production process'),
|
||||
|
||||
Select::make('lab_id')
|
||||
->label('Lab Test')
|
||||
->relationship('lab', 'lab_name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Associated lab test results'),
|
||||
|
||||
Select::make('parent_batch_id')
|
||||
->label('Parent Batch')
|
||||
->relationship('parentBatch', 'batch_number')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Parent batch if this was produced from another batch'),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Inventory Management')
|
||||
->schema([
|
||||
TextInput::make('quantity_produced')
|
||||
->label('Quantity Produced')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Total units produced in this batch'),
|
||||
|
||||
TextInput::make('quantity_available')
|
||||
->label('Quantity Available')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Units currently available for sale'),
|
||||
|
||||
TextInput::make('quantity_allocated')
|
||||
->label('Quantity Allocated')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('Units reserved in pending orders (auto-calculated)'),
|
||||
|
||||
TextInput::make('quantity_sold')
|
||||
->label('Quantity Sold')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('Units already sold (auto-calculated)'),
|
||||
])
|
||||
->columns(2)
|
||||
->columns(4)
|
||||
->description('Allocated and sold quantities are automatically managed by the system.'),
|
||||
|
||||
Section::make('Status & Compliance')
|
||||
Section::make('Dates')
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->default(true)
|
||||
->helperText('Is this batch available for sale?'),
|
||||
Toggle::make('is_tested')
|
||||
->default(false)
|
||||
->helperText('Has this batch passed lab testing?'),
|
||||
Toggle::make('is_quarantined')
|
||||
->default(false)
|
||||
->helperText('Is this batch quarantined pending results?'),
|
||||
])
|
||||
->columns(3),
|
||||
DatePicker::make('production_date')
|
||||
->label('Production Date')
|
||||
->helperText('Date the batch was produced/manufactured'),
|
||||
|
||||
Section::make('Additional Information')
|
||||
DatePicker::make('intake_date')
|
||||
->label('Intake Date')
|
||||
->helperText('Date the batch was received/intake'),
|
||||
|
||||
DatePicker::make('expiration_date')
|
||||
->label('Expiration Date')
|
||||
->helperText('Expiration/best-by date'),
|
||||
|
||||
DatePicker::make('test_date')
|
||||
->label('Test Date')
|
||||
->helperText('Date of lab testing'),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Warehouse & Location')
|
||||
->schema([
|
||||
TextInput::make('warehouse_location')
|
||||
->label('Warehouse Location')
|
||||
->placeholder('e.g., Shelf A-15')
|
||||
->maxLength(255)
|
||||
->helperText('Physical location in warehouse'),
|
||||
|
||||
TextInput::make('container_type')
|
||||
->label('Container Type')
|
||||
->placeholder('e.g., Turkey Bag, Box')
|
||||
->maxLength(255)
|
||||
->helperText('Type of container batch is stored in'),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Quality & Compliance')
|
||||
->schema([
|
||||
Toggle::make('is_quarantined')
|
||||
->label('Quarantined')
|
||||
->default(false)
|
||||
->helperText('Is this batch quarantined?')
|
||||
->reactive(),
|
||||
|
||||
Textarea::make('quarantine_reason')
|
||||
->label('Quarantine Reason')
|
||||
->rows(2)
|
||||
->helperText('Reason for quarantine')
|
||||
->visible(fn (Forms\Get $get) => $get('is_quarantined'))
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('is_released_for_sale')
|
||||
->label('Released for Sale')
|
||||
->default(false)
|
||||
->helperText('Has this batch been released for sale?'),
|
||||
|
||||
Textarea::make('notes')
|
||||
->label('Notes')
|
||||
->rows(3)
|
||||
->helperText('Production notes, special handling instructions, etc.')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsible(),
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,18 +23,35 @@ class BatchesTable
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('batch_number')
|
||||
->label('Batch #')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->copyable()
|
||||
->weight('bold'),
|
||||
TextColumn::make('product.name')
|
||||
->label('Product')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->description(fn ($record) => $record->product->sku ?? null),
|
||||
->description(fn ($record) => $record->product->sku ?? null)
|
||||
->limit(30),
|
||||
TextColumn::make('batch_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'intake' => 'info',
|
||||
'production' => 'warning',
|
||||
'finished' => 'success',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('warehouse_location')
|
||||
->label('Location')
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
TextColumn::make('production_date')
|
||||
->label('Produced')
|
||||
->date()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('expiration_date')
|
||||
->date()
|
||||
->sortable()
|
||||
@@ -60,14 +77,13 @@ class BatchesTable
|
||||
->label('Status')
|
||||
->badge()
|
||||
->getStateUsing(fn ($record) => $record->is_quarantined ? 'Quarantined' :
|
||||
(! $record->is_active ? 'Inactive' :
|
||||
(! $record->is_tested ? 'Pending Test' : 'Active'))
|
||||
(! $record->is_released_for_sale ? 'Not Released' : 'Released')
|
||||
)
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'Active' => Color::Green,
|
||||
'Pending Test' => Color::Yellow,
|
||||
'Released' => Color::Green,
|
||||
'Not Released' => Color::Yellow,
|
||||
'Quarantined' => Color::Red,
|
||||
'Inactive' => Color::Gray,
|
||||
default => Color::Gray,
|
||||
}),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
@@ -80,19 +96,23 @@ class BatchesTable
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('batch_type')
|
||||
->options([
|
||||
'intake' => 'Intake',
|
||||
'production' => 'Production',
|
||||
'finished' => 'Finished',
|
||||
]),
|
||||
SelectFilter::make('product')
|
||||
->relationship('product', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
Filter::make('active')
|
||||
->query(fn (Builder $query): Builder => $query->where('is_active', true))
|
||||
Filter::make('released')
|
||||
->label('Released for Sale')
|
||||
->query(fn (Builder $query): Builder => $query->where('is_released_for_sale', true))
|
||||
->toggle(),
|
||||
Filter::make('available')
|
||||
->query(fn (Builder $query): Builder => $query->where('quantity_available', '>', 0))
|
||||
->toggle(),
|
||||
Filter::make('tested')
|
||||
->query(fn (Builder $query): Builder => $query->where('is_tested', true))
|
||||
->toggle(),
|
||||
Filter::make('quarantined')
|
||||
->query(fn (Builder $query): Builder => $query->where('is_quarantined', true))
|
||||
->toggle(),
|
||||
|
||||
@@ -57,7 +57,7 @@ class BrandResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -70,20 +70,31 @@ class BusinessResource extends Resource
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Business Name')
|
||||
->helperText('Legal entity name or division brand name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('dba_name')
|
||||
->label('DBA Name')
|
||||
->maxLength(255),
|
||||
Select::make('parent_id')
|
||||
->label('Parent Company')
|
||||
->helperText('Select if this business is managed by another company')
|
||||
->relationship('parent', 'name', fn ($query) => $query->whereNull('parent_id'))
|
||||
->searchable()
|
||||
->preload(),
|
||||
Forms\Components\CheckboxList::make('types')
|
||||
->label('Business Categories')
|
||||
->relationship('types', 'label')
|
||||
->options(\App\Models\BusinessType::active()->orderBy('sort_order')->pluck('label', 'id'))
|
||||
->helperText('Select all categories that apply to this business')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
Select::make('business_type')
|
||||
->label('Business Type')
|
||||
->required()
|
||||
->label('Business Type (Legacy)')
|
||||
->options([
|
||||
'retailer' => 'Retailer/Dispensary',
|
||||
'brand' => 'Brand/Manufacturer',
|
||||
'both' => 'Both Retailer & Brand',
|
||||
])
|
||||
->default('retailer'),
|
||||
->helperText('Legacy field - will be deprecated in favor of categories above')
|
||||
->hidden(),
|
||||
TextInput::make('business_group')
|
||||
->label('Business Group')
|
||||
->helperText('For multi-location operators (e.g., "Jars Cannabis Co.")')
|
||||
@@ -454,6 +465,125 @@ class BusinessResource extends Resource
|
||||
->columns(2),
|
||||
]),
|
||||
|
||||
Tab::make('Modules')
|
||||
->schema([
|
||||
Section::make('Premium Feature Modules')
|
||||
->description('Enable optional premium features for this business. Modules are activated on a per-business basis.')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Toggle::make('has_inventory')
|
||||
->label('Inventory Module')
|
||||
->helperText('Inventory tracking, movements, low stock alerts, location/lot tracking')
|
||||
->default(false)
|
||||
->dehydrated()
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('has_analytics')
|
||||
->label('Analytics Module (Premium)')
|
||||
->helperText('Buyer engagement tracking, intent signals, RFDI scoring, email analytics')
|
||||
->default(false)
|
||||
->dehydrated()
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('has_marketing')
|
||||
->label('Marketing Module')
|
||||
->helperText('Email campaigns, marketing automation, broadcast messages, AI content')
|
||||
->default(false)
|
||||
->dehydrated()
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('has_manufacturing')
|
||||
->label('Manufacturing Module')
|
||||
->helperText('Department management, work orders, conversion tracking, batch genealogy')
|
||||
->default(false)
|
||||
->dehydrated()
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('has_processing')
|
||||
->label('Processing Module')
|
||||
->helperText('Wash reports, yield tracking, stage conversions, equipment assignment')
|
||||
->default(false)
|
||||
->dehydrated()
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('has_compliance')
|
||||
->label('Compliance Module')
|
||||
->helperText('METRC integration, regulatory reporting, lab results, audit trails')
|
||||
->default(false)
|
||||
->dehydrated()
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('copilot_enabled')
|
||||
->label('Copilot Module')
|
||||
->helperText('AI-powered content generation and assistance across campaigns, product content, messaging, and brand editing')
|
||||
->default(false)
|
||||
->dehydrated()
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('has_accounting')
|
||||
->label('Accounting Module')
|
||||
->helperText('QuickBooks integration, invoicing, payments, expense tracking, financial reporting')
|
||||
->default(false)
|
||||
->dehydrated()
|
||||
->inline(false),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Module Information')
|
||||
->description('Module activation status and billing information')
|
||||
->schema([
|
||||
Forms\Components\Placeholder::make('active_modules_count')
|
||||
->label('Active Modules')
|
||||
->content(function ($record) {
|
||||
if (! $record) {
|
||||
return '0 modules enabled';
|
||||
}
|
||||
|
||||
$modules = [];
|
||||
if ($record->has_inventory) {
|
||||
$modules[] = 'Inventory';
|
||||
}
|
||||
if ($record->has_analytics) {
|
||||
$modules[] = 'Analytics';
|
||||
}
|
||||
if ($record->has_marketing) {
|
||||
$modules[] = 'Marketing';
|
||||
}
|
||||
if ($record->has_manufacturing) {
|
||||
$modules[] = 'Manufacturing';
|
||||
}
|
||||
if ($record->has_processing) {
|
||||
$modules[] = 'Processing';
|
||||
}
|
||||
if ($record->has_compliance) {
|
||||
$modules[] = 'Compliance';
|
||||
}
|
||||
if ($record->copilot_enabled) {
|
||||
$modules[] = 'Copilot';
|
||||
}
|
||||
if ($record->has_accounting) {
|
||||
$modules[] = 'Accounting';
|
||||
}
|
||||
|
||||
$count = count($modules);
|
||||
|
||||
if ($count === 0) {
|
||||
return new \Illuminate\Support\HtmlString(
|
||||
'<span class="text-gray-500">0 modules enabled (Basic tier)</span>'
|
||||
);
|
||||
}
|
||||
|
||||
$moduleList = implode(', ', $modules);
|
||||
|
||||
return new \Illuminate\Support\HtmlString(
|
||||
'<span class="font-semibold text-green-600">'.$count.' module'.($count !== 1 ? 's' : '').' enabled</span><br>'.
|
||||
'<span class="text-sm text-gray-600">'.$moduleList.'</span>'
|
||||
);
|
||||
}),
|
||||
])
|
||||
->columns(1),
|
||||
]),
|
||||
Tab::make('Status & Settings')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
@@ -502,9 +632,64 @@ class BusinessResource extends Resource
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Delete Business')
|
||||
->modalDescription('Are you sure you want to delete this business? This will permanently remove the business profile, all associated data, contacts, and cannot be undone.')
|
||||
->modalDescription(function ($record) {
|
||||
if (! $record) {
|
||||
return 'Business not found.';
|
||||
}
|
||||
|
||||
$hasActivity = $record->users()->exists()
|
||||
|| $record->brands()->exists()
|
||||
|| \App\Models\Order::where('business_id', $record->id)->exists()
|
||||
|| \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $record->id))->exists();
|
||||
|
||||
if ($hasActivity) {
|
||||
$reasons = [];
|
||||
if ($record->users()->exists()) {
|
||||
$reasons[] = $record->users()->count().' user(s)';
|
||||
}
|
||||
if ($record->brands()->exists()) {
|
||||
$reasons[] = $record->brands()->count().' brand(s)';
|
||||
}
|
||||
if (\App\Models\Order::where('business_id', $record->id)->exists()) {
|
||||
$reasons[] = 'purchase orders';
|
||||
}
|
||||
if (\App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $record->id))->exists()) {
|
||||
$reasons[] = 'sales orders';
|
||||
}
|
||||
|
||||
return 'This business cannot be deleted because it has active data: '.implode(', ', $reasons).'. Deletion is only allowed for businesses with no activity.';
|
||||
}
|
||||
|
||||
return 'Are you sure you want to delete this business? This will permanently remove the business profile and cannot be undone. Only businesses with no users, brands, or orders can be deleted.';
|
||||
})
|
||||
->modalSubmitActionLabel('Yes, Delete Business')
|
||||
->disabled(function ($record) {
|
||||
if (! $record) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $record->users()->exists()
|
||||
|| $record->brands()->exists()
|
||||
|| \App\Models\Order::where('business_id', $record->id)->exists()
|
||||
|| \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $record->id))->exists();
|
||||
})
|
||||
->action(function ($record) {
|
||||
// Double-check before deletion
|
||||
$hasActivity = $record->users()->exists()
|
||||
|| $record->brands()->exists()
|
||||
|| \App\Models\Order::where('business_id', $record->id)->exists()
|
||||
|| \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $record->id))->exists();
|
||||
|
||||
if ($hasActivity) {
|
||||
Notification::make()
|
||||
->title('Cannot Delete Business')
|
||||
->body('This business has active data and cannot be deleted.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$businessName = $record->name;
|
||||
$record->delete();
|
||||
|
||||
@@ -530,14 +715,37 @@ class BusinessResource extends Resource
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['owner', 'users']))
|
||||
->modifyQueryUsing(fn ($query) => $query
|
||||
->with(['owner', 'users', 'types', 'parent'])
|
||||
->orderByRaw('COALESCE(NULLIF(parent_id, 0), id), parent_id IS NOT NULL, name')
|
||||
)
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Name')
|
||||
->label('Business Name')
|
||||
->formatStateUsing(function ($record) {
|
||||
$name = $record->dba_name ?: $record->name;
|
||||
|
||||
if ($record->parent) {
|
||||
// Indent managed businesses with tree structure
|
||||
return '↳ ' . $name;
|
||||
}
|
||||
|
||||
return $name;
|
||||
})
|
||||
->description(fn ($record) => $record->parent ? 'Managed by ' . $record->parent->name : null)
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->where('name', 'like', "%{$search}%")
|
||||
->orWhere('dba_name', 'like', "%{$search}%");
|
||||
})
|
||||
->sortable(query: fn ($query, $direction) => $query->orderBy('parent_id')->orderBy('dba_name', $direction)),
|
||||
TextColumn::make('types.label')
|
||||
->label('Categories')
|
||||
->badge()
|
||||
->separator(',')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('business_type')
|
||||
->label('Type')
|
||||
->label('Type (Legacy)')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'retailer' => 'success',
|
||||
@@ -546,7 +754,26 @@ class BusinessResource extends Resource
|
||||
default => 'gray',
|
||||
})
|
||||
->searchable()
|
||||
->sortable(),
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('modules')
|
||||
->label('Active Modules')
|
||||
->formatStateUsing(function ($record) {
|
||||
$modules = [];
|
||||
if ($record->has_analytics) {
|
||||
$modules[] = 'Analytics';
|
||||
}
|
||||
if ($record->has_marketing) {
|
||||
$modules[] = 'Marketing';
|
||||
}
|
||||
if ($record->has_manufacturing) {
|
||||
$modules[] = 'Manufacturing';
|
||||
}
|
||||
|
||||
return empty($modules) ? 'None' : implode(', ', $modules);
|
||||
})
|
||||
->badge()
|
||||
->color(fn ($record) => ($record->has_analytics || $record->has_marketing || $record->has_manufacturing) ? 'success' : 'gray'),
|
||||
BadgeColumn::make('status')
|
||||
->label('Status')
|
||||
->formatStateUsing(fn (string $state): string => ucfirst(str_replace('_', ' ', $state)))
|
||||
@@ -735,112 +962,9 @@ class BusinessResource extends Resource
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
ActionGroup::make([
|
||||
EditAction::make()
|
||||
->label('View/Edit')
|
||||
->icon('heroicon-o-pencil'),
|
||||
Action::make('approve_application')
|
||||
->label('Approve Application')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Business $record) => 'Approve '.$record->name.'?')
|
||||
->modalDescription(fn (Business $record) => 'This will approve the business application and grant access to the platform.'."\n\n".
|
||||
'Business Type: '.($record->business_type ? (Business::BUSINESS_TYPES[$record->business_type] ?? $record->business_type) : 'N/A')."\n".
|
||||
'License: '.($record->license_number ?? 'N/A')."\n".
|
||||
'Submitted: '.($record->application_submitted_at ? $record->application_submitted_at->diffForHumans() : 'N/A')
|
||||
)
|
||||
->modalSubmitActionLabel('Yes, Approve Application')
|
||||
->modalIcon('heroicon-o-check-circle')
|
||||
->action(function (Business $record) {
|
||||
$record->update([
|
||||
'status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Send approval notification to business owner
|
||||
if ($record->owner) {
|
||||
$record->owner->notify(new \App\Notifications\UserApproved);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Application Approved')
|
||||
->body('Business "'.$record->name.'" has been approved successfully.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Business $record) => $record->status === 'submitted'),
|
||||
Action::make('reject_application')
|
||||
->label('Reject Application')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Business $record) => 'Reject '.$record->name.'?')
|
||||
->modalDescription(fn (Business $record) => 'This will reject the business application and notify the applicant.'."\n\n".
|
||||
'Business Type: '.($record->business_type ? (Business::BUSINESS_TYPES[$record->business_type] ?? $record->business_type) : 'N/A')."\n".
|
||||
'License: '.($record->license_number ?? 'N/A')."\n".
|
||||
'Submitted: '.($record->application_submitted_at ? $record->application_submitted_at->diffForHumans() : 'N/A')
|
||||
)
|
||||
->modalSubmitActionLabel('Yes, Reject Application')
|
||||
->modalIcon('heroicon-o-x-circle')
|
||||
->form([
|
||||
Forms\Components\Textarea::make('rejection_reason')
|
||||
->label('Rejection Reason')
|
||||
->required()
|
||||
->rows(4)
|
||||
->maxLength(1000)
|
||||
->helperText('Provide a clear reason for rejection. This will be sent to the applicant.'),
|
||||
])
|
||||
->action(function (Business $record, array $data) {
|
||||
$record->update([
|
||||
'status' => 'rejected',
|
||||
'rejected_at' => now(),
|
||||
'rejection_reason' => $data['rejection_reason'],
|
||||
]);
|
||||
|
||||
// Send rejection notification to business owner
|
||||
if ($record->owner) {
|
||||
$record->owner->notify(new \App\Notifications\ApplicationRejectedNotification($record, $data['rejection_reason']));
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Application Rejected')
|
||||
->body('Business "'.$record->name.'" has been rejected.')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Business $record) => $record->status === 'submitted'),
|
||||
])
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('white'),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkAction::make('approve_applications')
|
||||
->label('Approve Selected')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records) {
|
||||
$approved = 0;
|
||||
$records->each(function (Business $record) use (&$approved) {
|
||||
if ($record->status === 'submitted') {
|
||||
$record->update([
|
||||
'status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
]);
|
||||
$approved++;
|
||||
}
|
||||
});
|
||||
|
||||
Notification::make()
|
||||
->title('Applications Approved')
|
||||
->body("Successfully approved {$approved} business applications.")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
EditAction::make()
|
||||
->label('View/Edit')
|
||||
->icon('heroicon-o-pencil'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ class ComponentResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
183
app/Filament/Resources/DatabaseBackupResource.php
Normal file
183
app/Filament/Resources/DatabaseBackupResource.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\DatabaseBackupResource\Pages;
|
||||
use App\Models\DatabaseBackup;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class DatabaseBackupResource extends Resource
|
||||
{
|
||||
protected static ?string $model = DatabaseBackup::class;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-circle-stack';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Database Backups';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Backup Details')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Forms\Components\Select::make('connection')
|
||||
->label('Database Connection')
|
||||
->options([
|
||||
'pgsql' => 'Main Database (PostgreSQL)',
|
||||
'old_crm' => 'Legacy CRM (MySQL)',
|
||||
])
|
||||
->required()
|
||||
->default('pgsql')
|
||||
->live(),
|
||||
|
||||
Forms\Components\Select::make('type')
|
||||
->label('Backup Type')
|
||||
->options([
|
||||
'manual' => 'Manual',
|
||||
'pre_edit' => 'Pre-Edit',
|
||||
'scheduled' => 'Scheduled',
|
||||
'imported' => 'Imported',
|
||||
'pre_migration' => 'Pre-Migration',
|
||||
])
|
||||
->required()
|
||||
->default('manual'),
|
||||
]),
|
||||
|
||||
Forms\Components\Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->placeholder('Optional: Why are you creating this backup?')
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make('Advanced Options (PostgreSQL Only)')
|
||||
->schema([
|
||||
Forms\Components\Checkbox::make('include_global_objects')
|
||||
->label('Include Global Objects (Full Cluster Backup)')
|
||||
->helperText('Uses pg_dumpall to backup ALL databases, roles, tablespaces, and global objects. WARNING: Much larger file size.')
|
||||
->default(false),
|
||||
|
||||
Forms\Components\Checkbox::make('include_roles')
|
||||
->label('Include Roles & Permissions')
|
||||
->helperText('Backup will exclude ownership and privilege information (for portability)')
|
||||
->default(false)
|
||||
->disabled(fn ($get) => $get('include_global_objects')),
|
||||
])
|
||||
->visible(fn ($get) => $get('connection') === 'pgsql')
|
||||
->collapsed(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('connection_label')
|
||||
->label('Database')
|
||||
->sortable(query: fn ($query, $direction) => $query->orderBy('connection', $direction)),
|
||||
|
||||
TextColumn::make('type')
|
||||
->badge()
|
||||
->colors([
|
||||
'success' => 'scheduled',
|
||||
'warning' => 'pre_migration',
|
||||
'info' => 'manual',
|
||||
'gray' => 'pre_edit',
|
||||
'primary' => 'imported',
|
||||
]),
|
||||
|
||||
TextColumn::make('formatted_size')
|
||||
->label('Size')
|
||||
->sortable(query: fn ($query, $direction) => $query->orderBy('size', $direction)),
|
||||
|
||||
TextColumn::make('triggered_by')
|
||||
->label('Triggered By')
|
||||
->getStateUsing(function ($record) {
|
||||
if (! $record->creator) {
|
||||
return 'System (Scheduled)';
|
||||
}
|
||||
return $record->creator->name.' ('.$record->creator->email.')';
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('creator', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->dateTime('M j, Y g:i A')
|
||||
->sortable()
|
||||
->default(true),
|
||||
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->colors([
|
||||
'success' => 'ok',
|
||||
'danger' => ['corrupted', 'failed'],
|
||||
'warning' => 'missing',
|
||||
'info' => 'pending',
|
||||
'gray' => 'processing',
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('connection')
|
||||
->options([
|
||||
'pgsql' => 'Main Database (PostgreSQL)',
|
||||
'old_crm' => 'Legacy CRM (MySQL)',
|
||||
]),
|
||||
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
->options([
|
||||
'manual' => 'Manual',
|
||||
'pre_edit' => 'Pre-Edit',
|
||||
'scheduled' => 'Scheduled',
|
||||
'imported' => 'Imported',
|
||||
'pre_migration' => 'Pre-Migration',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('download')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->action(fn (DatabaseBackup $record) => $record->download()),
|
||||
|
||||
Action::make('verify')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->action(function (DatabaseBackup $record) {
|
||||
$service = app(\App\Services\DatabaseBackupService::class);
|
||||
$service->verifyBackup($record);
|
||||
})
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Verify Backup')
|
||||
->modalDescription('This will check if the backup file exists and is valid.')
|
||||
->successNotificationTitle('Backup verified successfully'),
|
||||
|
||||
DeleteAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListDatabaseBackups::route('/'),
|
||||
'create' => Pages\CreateDatabaseBackup::route('/create'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DatabaseBackupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DatabaseBackupResource;
|
||||
use App\Jobs\CreateDatabaseBackup as CreateDatabaseBackupJob;
|
||||
use App\Models\DatabaseBackup;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateDatabaseBackup extends CreateRecord
|
||||
{
|
||||
protected static string $resource = DatabaseBackupResource::class;
|
||||
|
||||
protected function handleRecordCreation(array $data): \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
try {
|
||||
// Create backup record with pending status
|
||||
$backup = DatabaseBackup::create([
|
||||
'connection' => $data['connection'],
|
||||
'type' => $data['type'],
|
||||
'reason' => $data['reason'] ?? null,
|
||||
'disk' => config('filesystems.default'),
|
||||
'path' => '', // Will be set by job
|
||||
'size' => null, // Will be set by job
|
||||
'status' => 'pending',
|
||||
'created_by_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Dispatch job to create backup asynchronously
|
||||
CreateDatabaseBackupJob::dispatch(
|
||||
$backup,
|
||||
$data['include_roles'] ?? false,
|
||||
$data['include_global_objects'] ?? false
|
||||
);
|
||||
|
||||
// Show success notification
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Backup Queued')
|
||||
->body('Database backup has been queued. Check Horizon for progress.')
|
||||
->send();
|
||||
|
||||
return $backup;
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Failed to Queue Backup')
|
||||
->body($e->getMessage())
|
||||
->send();
|
||||
|
||||
$this->halt();
|
||||
}
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getCreatedNotificationTitle(): ?string
|
||||
{
|
||||
return null; // We handle notification above
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DatabaseBackupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DatabaseBackupResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListDatabaseBackups extends ListRecords
|
||||
{
|
||||
protected static string $resource = DatabaseBackupResource::class;
|
||||
|
||||
// Poll every 5 seconds to show new backups automatically
|
||||
protected ?string $pollingInterval = '5s';
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
63
app/Filament/Resources/EmailTemplateResource.php
Normal file
63
app/Filament/Resources/EmailTemplateResource.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\EmailTemplateResource\Pages\CreateEmailTemplate;
|
||||
use App\Filament\Resources\EmailTemplateResource\Pages\EditEmailTemplate;
|
||||
use App\Filament\Resources\EmailTemplateResource\Pages\ListEmailTemplates;
|
||||
use App\Filament\Resources\EmailTemplateResource\Pages\ViewEmailTemplate;
|
||||
use App\Filament\Resources\EmailTemplateResource\Schemas\EmailTemplateForm;
|
||||
use App\Filament\Resources\EmailTemplateResource\Schemas\EmailTemplateInfolist;
|
||||
use App\Filament\Resources\EmailTemplateResource\Tables\EmailTemplatesTable;
|
||||
use App\Models\EmailTemplate;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class EmailTemplateResource extends Resource
|
||||
{
|
||||
protected static ?string $model = EmailTemplate::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-envelope';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
protected static ?string $navigationLabel = 'Email Templates';
|
||||
|
||||
protected static ?string $modelLabel = 'Email Template';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Email Templates';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
// Count inactive templates
|
||||
return static::getModel()::where('is_active', false)->count() ?: null;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return EmailTemplateForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return EmailTemplateInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return EmailTemplatesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListEmailTemplates::route('/'),
|
||||
'create' => CreateEmailTemplate::route('/create'),
|
||||
'view' => ViewEmailTemplate::route('/{record}'),
|
||||
'edit' => EditEmailTemplate::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmailTemplateResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateEmailTemplate extends CreateRecord
|
||||
{
|
||||
protected static string $resource = EmailTemplateResource::class;
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmailTemplateResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditEmailTemplate extends EditRecord
|
||||
{
|
||||
protected static string $resource = EmailTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
ViewAction::make(),
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmailTemplateResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEmailTemplates extends ListRecords
|
||||
{
|
||||
protected static string $resource = EmailTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmailTemplateResource;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewEmailTemplate extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EmailTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Schemas;
|
||||
|
||||
use App\Models\EmailTemplate;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class EmailTemplateForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(1)
|
||||
->components([
|
||||
Section::make('Template Details')
|
||||
->schema([
|
||||
TextInput::make('key')
|
||||
->label('Template Key')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->regex('/^[a-z0-9_-]+$/')
|
||||
->helperText('Lowercase alphanumeric characters, hyphens and underscores only')
|
||||
->disabled(fn ($context) => $context === 'edit')
|
||||
->dehydrated(fn ($context) => $context === 'create')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('name')
|
||||
->label('Template Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('subject')
|
||||
->label('Email Subject')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
|
||||
Textarea::make('description')
|
||||
->label('Description')
|
||||
->helperText('Describe when this template is used')
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('available_variables')
|
||||
->label('Available Variables')
|
||||
->helperText('Comma-separated list (e.g., verification_url, email, logo_url)')
|
||||
->afterStateHydrated(function (TextInput $component, $state) {
|
||||
if (is_array($state)) {
|
||||
$component->state(implode(', ', $state));
|
||||
}
|
||||
})
|
||||
->dehydrateStateUsing(function ($state) {
|
||||
if (empty($state)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map('trim', explode(',', $state));
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
Checkbox::make('is_active')
|
||||
->label('Template is Active')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Email Content')
|
||||
->schema([
|
||||
Textarea::make('body_html')
|
||||
->label('HTML Body')
|
||||
->required()
|
||||
->rows(25)
|
||||
->helperText('Use {{ $variable }} syntax for dynamic content')
|
||||
->columnSpanFull()
|
||||
->extraAttributes(['style' => 'font-family: monospace; font-size: 13px;']),
|
||||
|
||||
Textarea::make('body_text')
|
||||
->label('Plain Text Body (Optional)')
|
||||
->rows(15)
|
||||
->helperText('Plain text fallback for email clients that don\'t support HTML')
|
||||
->columnSpanFull()
|
||||
->extraAttributes(['style' => 'font-family: monospace; font-size: 13px;']),
|
||||
]),
|
||||
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
Placeholder::make('created_at')
|
||||
->label('Created At')
|
||||
->content(fn (?EmailTemplate $record): string => $record?->created_at?->diffForHumans() ?? '-'),
|
||||
|
||||
Placeholder::make('updated_at')
|
||||
->label('Last Updated')
|
||||
->content(fn (?EmailTemplate $record): string => $record?->updated_at?->diffForHumans() ?? '-'),
|
||||
])
|
||||
->columns(2)
|
||||
->hidden(fn ($context) => $context === 'create'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\IconEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class EmailTemplateInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextEntry::make('name')
|
||||
->label('Template Name')
|
||||
->columnSpan(1),
|
||||
|
||||
TextEntry::make('key')
|
||||
->label('Template Key')
|
||||
->badge()
|
||||
->copyable()
|
||||
->copyMessage('Key copied!')
|
||||
->copyMessageDuration(1500)
|
||||
->columnSpan(1),
|
||||
|
||||
TextEntry::make('subject')
|
||||
->label('Email Subject')
|
||||
->columnSpan(2),
|
||||
|
||||
TextEntry::make('description')
|
||||
->label('Description')
|
||||
->columnSpan(2)
|
||||
->placeholder('No description provided'),
|
||||
|
||||
TextEntry::make('available_variables')
|
||||
->label('Available Variables')
|
||||
->badge()
|
||||
->separator(',')
|
||||
->columnSpan(2)
|
||||
->placeholder('No variables defined'),
|
||||
|
||||
IconEntry::make('is_active')
|
||||
->label('Status')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('danger')
|
||||
->columnSpan(1),
|
||||
|
||||
TextEntry::make('created_at')
|
||||
->label('Created')
|
||||
->dateTime()
|
||||
->since()
|
||||
->columnSpan(1),
|
||||
|
||||
TextEntry::make('updated_at')
|
||||
->label('Last Updated')
|
||||
->dateTime()
|
||||
->since()
|
||||
->columnSpan(1),
|
||||
|
||||
ViewEntry::make('preview')
|
||||
->label('HTML Preview')
|
||||
->viewData(fn ($record) => [
|
||||
'html' => $record->body_html,
|
||||
])
|
||||
->view('filament.email-template-preview')
|
||||
->columnSpan(2),
|
||||
|
||||
TextEntry::make('body_html')
|
||||
->label('HTML Source')
|
||||
->formatStateUsing(fn ($state) => new HtmlString('<pre class="text-xs font-mono bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-x-auto whitespace-pre-wrap">'.htmlspecialchars($state).'</pre>'))
|
||||
->columnSpan(2),
|
||||
|
||||
TextEntry::make('body_text')
|
||||
->label('Plain Text Version')
|
||||
->formatStateUsing(fn ($state) => new HtmlString('<pre class="text-xs font-mono bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-x-auto whitespace-pre-wrap">'.htmlspecialchars($state ?: 'No plain text version').'</pre>'))
|
||||
->columnSpan(2)
|
||||
->hidden(fn ($record) => empty($record->body_text)),
|
||||
])
|
||||
->columns(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class EmailTemplatesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Template Name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('key')
|
||||
->label('Key')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->fontFamily('mono')
|
||||
->size('sm')
|
||||
->copyable()
|
||||
->copyMessage('Key copied!')
|
||||
->copyMessageDuration(1500),
|
||||
|
||||
TextColumn::make('subject')
|
||||
->label('Subject')
|
||||
->searchable()
|
||||
->limit(50)
|
||||
->wrap(),
|
||||
|
||||
IconColumn::make('is_active')
|
||||
->label('Status')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('danger')
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('updated_at')
|
||||
->label('Last Updated')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->since()
|
||||
->size('sm'),
|
||||
])
|
||||
->defaultSort('name')
|
||||
->filters([
|
||||
SelectFilter::make('is_active')
|
||||
->label('Status')
|
||||
->options([
|
||||
true => 'Active',
|
||||
false => 'Inactive',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
157
app/Filament/Resources/FailedJobResource.php
Normal file
157
app/Filament/Resources/FailedJobResource.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource\Pages;
|
||||
use App\Models\FailedJob;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FailedJobResource extends Resource
|
||||
{
|
||||
protected static ?string $model = FailedJob::class;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||
|
||||
protected static ?string $navigationLabel = 'Failed Jobs';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?int $navigationSort = 99;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('failed_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('ID')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('queue')
|
||||
->badge()
|
||||
->color('info')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('connection')
|
||||
->badge()
|
||||
->color('gray')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('payload')
|
||||
->label('Job Type')
|
||||
->getStateUsing(function ($record) {
|
||||
$payload = json_decode($record->payload, true);
|
||||
$displayName = $payload['displayName'] ?? 'Unknown';
|
||||
// Extract just the class name
|
||||
if (str_contains($displayName, '\\')) {
|
||||
return class_basename($displayName);
|
||||
}
|
||||
|
||||
return $displayName;
|
||||
})
|
||||
->badge()
|
||||
->color('warning')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('exception')
|
||||
->label('Error')
|
||||
->limit(100)
|
||||
->tooltip(fn ($record) => $record->exception)
|
||||
->wrap()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('failed_at')
|
||||
->label('Failed At')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->since()
|
||||
->description(fn ($record) => $record->failed_at),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('queue')
|
||||
->options(function () {
|
||||
return FailedJob::distinct()
|
||||
->pluck('queue', 'queue')
|
||||
->toArray();
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action(function ($record) {
|
||||
Artisan::call('queue:retry', ['id' => [$record->id]]);
|
||||
})
|
||||
->successNotificationTitle('Job queued for retry'),
|
||||
Action::make('view_details')
|
||||
->label('View Details')
|
||||
->icon('heroicon-o-eye')
|
||||
->modalHeading('Failed Job Details')
|
||||
->modalContent(function ($record) {
|
||||
$payload = json_decode($record->payload, true);
|
||||
|
||||
return view('filament.resources.failed-job.view-details', [
|
||||
'record' => $record,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
})
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close'),
|
||||
Action::make('delete')
|
||||
->label('Delete')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(fn ($record) => $record->delete())
|
||||
->successNotificationTitle('Failed job deleted'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkAction::make('retry_selected')
|
||||
->label('Retry Selected')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action(function ($records) {
|
||||
foreach ($records as $record) {
|
||||
Artisan::call('queue:retry', ['id' => [$record->id]]);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion()
|
||||
->successNotificationTitle('Selected jobs queued for retry'),
|
||||
BulkAction::make('delete_selected')
|
||||
->label('Delete Selected')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function ($records) {
|
||||
foreach ($records as $record) {
|
||||
$record->delete();
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion()
|
||||
->successNotificationTitle('Selected jobs deleted'),
|
||||
])
|
||||
->poll('30s')
|
||||
->emptyStateHeading('No Failed Jobs')
|
||||
->emptyStateDescription('All jobs are processing successfully!')
|
||||
->emptyStateIcon('heroicon-o-check-circle');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListFailedJobs::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FailedJobResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ListFailedJobs extends ListRecords
|
||||
{
|
||||
protected static string $resource = FailedJobResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('retry_all')
|
||||
->label('Retry All Failed Jobs')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Retry All Failed Jobs?')
|
||||
->modalDescription('This will attempt to retry all failed jobs in the queue.')
|
||||
->action(function () {
|
||||
Artisan::call('queue:retry', ['id' => ['all']]);
|
||||
})
|
||||
->successNotificationTitle('All failed jobs queued for retry')
|
||||
->visible(fn () => DB::table('failed_jobs')->count() > 0),
|
||||
Actions\Action::make('flush_all')
|
||||
->label('Delete All Failed Jobs')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Delete All Failed Jobs?')
|
||||
->modalDescription('This will permanently delete all failed job records. This action cannot be undone.')
|
||||
->action(function () {
|
||||
Artisan::call('queue:flush');
|
||||
})
|
||||
->successNotificationTitle('All failed jobs deleted')
|
||||
->visible(fn () => DB::table('failed_jobs')->count() > 0),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
FailedJobResource\Widgets\FailedJobsStatsWidget::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FailedJobResource\Widgets;
|
||||
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FailedJobsStatsWidget extends BaseWidget
|
||||
{
|
||||
protected function getStats(): array
|
||||
{
|
||||
$totalFailed = DB::table('failed_jobs')->count();
|
||||
$failedToday = DB::table('failed_jobs')
|
||||
->whereDate('failed_at', today())
|
||||
->count();
|
||||
$failedThisWeek = DB::table('failed_jobs')
|
||||
->where('failed_at', '>=', now()->startOfWeek())
|
||||
->count();
|
||||
|
||||
// Get most common failed job type
|
||||
$commonFailure = DB::table('failed_jobs')
|
||||
->select('payload')
|
||||
->get()
|
||||
->map(function ($job) {
|
||||
$payload = json_decode($job->payload, true);
|
||||
|
||||
return $payload['displayName'] ?? 'Unknown';
|
||||
})
|
||||
->countBy()
|
||||
->sortDesc()
|
||||
->first();
|
||||
|
||||
return [
|
||||
Stat::make('Total Failed Jobs', $totalFailed)
|
||||
->description('All time')
|
||||
->descriptionIcon('heroicon-m-exclamation-triangle')
|
||||
->color($totalFailed > 0 ? 'danger' : 'success'),
|
||||
Stat::make('Failed Today', $failedToday)
|
||||
->description(now()->format('M d, Y'))
|
||||
->descriptionIcon('heroicon-m-calendar')
|
||||
->color($failedToday > 0 ? 'warning' : 'success'),
|
||||
Stat::make('Failed This Week', $failedThisWeek)
|
||||
->description('Since '.now()->startOfWeek()->format('M d'))
|
||||
->descriptionIcon('heroicon-m-chart-bar')
|
||||
->color($failedThisWeek > 0 ? 'warning' : 'success'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getPollingInterval(): ?string
|
||||
{
|
||||
return '30s';
|
||||
}
|
||||
}
|
||||
90
app/Filament/Resources/LabResource.php
Normal file
90
app/Filament/Resources/LabResource.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\LabResource\Pages;
|
||||
use App\Filament\Resources\LabResource\Schemas\LabForm;
|
||||
use App\Filament\Resources\LabResource\Tables\LabsTable;
|
||||
use App\Models\Lab;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class LabResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Lab::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-beaker';
|
||||
|
||||
protected static ?string $navigationLabel = 'Lab Tests';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return LabForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return LabsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$query = parent::getEloquentQuery();
|
||||
|
||||
// Scope to user's business products and batches unless they're a super admin
|
||||
if (auth()->check() && ! auth()->user()->hasRole('super_admin')) {
|
||||
$businessId = auth()->user()->business_id;
|
||||
|
||||
$query->where(function ($q) use ($businessId) {
|
||||
// Include labs for products owned by this business
|
||||
$q->whereHas('product', function ($productQuery) use ($businessId) {
|
||||
$productQuery->whereHas('brand', function ($brandQuery) use ($businessId) {
|
||||
$brandQuery->where('business_id', $businessId);
|
||||
});
|
||||
})
|
||||
// OR labs for batches owned by this business
|
||||
->orWhereHas('batch', function ($batchQuery) use ($businessId) {
|
||||
$batchQuery->where('business_id', $businessId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListLabs::route('/'),
|
||||
'create' => Pages\CreateLab::route('/create'),
|
||||
'view' => Pages\ViewLab::route('/{record}'),
|
||||
'edit' => Pages\EditLab::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
// Show count of recent lab tests (last 30 days)
|
||||
return cache()->remember('recent_lab_tests_count', 300, function () {
|
||||
$query = static::getEloquentQuery();
|
||||
|
||||
return $query->where('test_date', '>=', now()->subDays(30))
|
||||
->count() ?: null;
|
||||
});
|
||||
}
|
||||
}
|
||||
11
app/Filament/Resources/LabResource/Pages/CreateLab.php
Normal file
11
app/Filament/Resources/LabResource/Pages/CreateLab.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Pages;
|
||||
|
||||
use App\Filament\Resources\LabResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateLab extends CreateRecord
|
||||
{
|
||||
protected static string $resource = LabResource::class;
|
||||
}
|
||||
20
app/Filament/Resources/LabResource/Pages/EditLab.php
Normal file
20
app/Filament/Resources/LabResource/Pages/EditLab.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Pages;
|
||||
|
||||
use App\Filament\Resources\LabResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditLab extends EditRecord
|
||||
{
|
||||
protected static string $resource = LabResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/LabResource/Pages/ListLabs.php
Normal file
19
app/Filament/Resources/LabResource/Pages/ListLabs.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Pages;
|
||||
|
||||
use App\Filament\Resources\LabResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListLabs extends ListRecords
|
||||
{
|
||||
protected static string $resource = LabResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/LabResource/Pages/ViewLab.php
Normal file
19
app/Filament/Resources/LabResource/Pages/ViewLab.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Pages;
|
||||
|
||||
use App\Filament\Resources\LabResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewLab extends ViewRecord
|
||||
{
|
||||
protected static string $resource = LabResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
298
app/Filament/Resources/LabResource/Schemas/LabForm.php
Normal file
298
app/Filament/Resources/LabResource/Schemas/LabForm.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Schemas;
|
||||
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
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\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class LabForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Tabs::make('Lab Test Information')
|
||||
->tabs([
|
||||
Tab::make('Basic Information')
|
||||
->schema([
|
||||
Section::make('Test Details')
|
||||
->schema([
|
||||
Select::make('product_id')
|
||||
->label('Product')
|
||||
->relationship('product', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Product this test is for'),
|
||||
|
||||
Select::make('batch_id')
|
||||
->label('Batch')
|
||||
->relationship('batch', 'batch_number')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Specific batch tested'),
|
||||
|
||||
TextInput::make('lab_name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Testing laboratory name'),
|
||||
|
||||
TextInput::make('lab_license_number')
|
||||
->label('Lab License #')
|
||||
->maxLength(255)
|
||||
->helperText('State license number'),
|
||||
|
||||
DatePicker::make('test_date')
|
||||
->required()
|
||||
->default(now())
|
||||
->helperText('Date test was performed'),
|
||||
|
||||
TextInput::make('batch_number')
|
||||
->label('Lab Batch Number')
|
||||
->maxLength(255)
|
||||
->helperText('Internal lab tracking number'),
|
||||
|
||||
TextInput::make('sample_id')
|
||||
->label('Sample ID')
|
||||
->maxLength(255)
|
||||
->helperText('Sample identification'),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
|
||||
Tab::make('Cannabinoids')
|
||||
->schema([
|
||||
Section::make('Primary Cannabinoids')
|
||||
->schema([
|
||||
TextInput::make('thc_percentage')
|
||||
->label('THC %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('thca_percentage')
|
||||
->label('THCA %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('cbd_percentage')
|
||||
->label('CBD %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('cbda_percentage')
|
||||
->label('CBDA %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
])
|
||||
->columns(4),
|
||||
|
||||
Section::make('Minor Cannabinoids')
|
||||
->schema([
|
||||
TextInput::make('cbg_percentage')
|
||||
->label('CBG %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('cbn_percentage')
|
||||
->label('CBN %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('thcv_percentage')
|
||||
->label('THCV %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('cbdv_percentage')
|
||||
->label('CBDV %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
])
|
||||
->columns(4),
|
||||
|
||||
Section::make('Calculated Totals')
|
||||
->schema([
|
||||
TextInput::make('total_thc')
|
||||
->label('Total THC')
|
||||
->numeric()
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->suffix('%')
|
||||
->helperText('Auto-calculated from THC + (THCA × 0.877)'),
|
||||
|
||||
TextInput::make('total_cbd')
|
||||
->label('Total CBD')
|
||||
->numeric()
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->suffix('%')
|
||||
->helperText('Auto-calculated from CBD + (CBDA × 0.877)'),
|
||||
|
||||
TextInput::make('total_cannabinoids')
|
||||
->label('Total Cannabinoids')
|
||||
->numeric()
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->suffix('%')
|
||||
->helperText('Sum of all cannabinoids'),
|
||||
])
|
||||
->columns(3)
|
||||
->description('These values are automatically calculated on save'),
|
||||
]),
|
||||
|
||||
Tab::make('Terpenes')
|
||||
->schema([
|
||||
Repeater::make('terpenes')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->helperText('Terpene name (e.g., Myrcene)'),
|
||||
|
||||
TextInput::make('percentage')
|
||||
->required()
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->step(0.001)
|
||||
->suffix('%')
|
||||
->helperText('Percentage'),
|
||||
])
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->helperText('Add terpene profile data'),
|
||||
]),
|
||||
|
||||
Tab::make('Compliance Tests')
|
||||
->schema([
|
||||
Section::make('Safety Tests')
|
||||
->schema([
|
||||
Toggle::make('pesticides_pass')
|
||||
->label('Pesticides Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('heavy_metals_pass')
|
||||
->label('Heavy Metals Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('microbials_pass')
|
||||
->label('Microbials Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('mycotoxins_pass')
|
||||
->label('Mycotoxins Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('residual_solvents_pass')
|
||||
->label('Residual Solvents Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('foreign_material_pass')
|
||||
->label('Foreign Material Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
])
|
||||
->columns(3)
|
||||
->description('All tests must pass for overall compliance'),
|
||||
|
||||
Section::make('Additional Tests')
|
||||
->schema([
|
||||
TextInput::make('moisture_content')
|
||||
->label('Moisture Content %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
Toggle::make('compliance_pass')
|
||||
->label('Overall Compliance Pass')
|
||||
->default(true)
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('Auto-calculated from all safety tests'),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
|
||||
Tab::make('COA Files')
|
||||
->schema([
|
||||
Section::make('Certificate of Analysis Files')
|
||||
->schema([
|
||||
Repeater::make('coaFiles')
|
||||
->relationship()
|
||||
->schema([
|
||||
FileUpload::make('file_path')
|
||||
->label('File')
|
||||
->required()
|
||||
->directory('compliance/coas')
|
||||
->acceptedFileTypes(['application/pdf', 'image/*'])
|
||||
->maxSize(10240),
|
||||
|
||||
TextInput::make('description')
|
||||
->maxLength(255)
|
||||
->helperText('Optional description'),
|
||||
|
||||
Toggle::make('is_primary')
|
||||
->label('Primary COA')
|
||||
->inline(false),
|
||||
])
|
||||
->columns(3)
|
||||
->collapsible()
|
||||
->helperText('Upload COA files (PDF or images)'),
|
||||
|
||||
TextInput::make('certificate_url')
|
||||
->label('External COA URL')
|
||||
->url()
|
||||
->maxLength(255)
|
||||
->helperText('Link to COA on external site (optional)'),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('Notes')
|
||||
->schema([
|
||||
Textarea::make('notes')
|
||||
->rows(5)
|
||||
->columnSpanFull()
|
||||
->helperText('Additional notes about this test'),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
136
app/Filament/Resources/LabResource/Tables/LabsTable.php
Normal file
136
app/Filament/Resources/LabResource/Tables/LabsTable.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class LabsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('lab_name')
|
||||
->label('Lab')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('product.name')
|
||||
->label('Product')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('batch.batch_number')
|
||||
->label('Batch')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('test_date')
|
||||
->date('M d, Y')
|
||||
->sortable()
|
||||
->color(fn ($record) => $record->test_date < now()->subDays(90) ? Color::Orange : null),
|
||||
|
||||
TextColumn::make('total_thc')
|
||||
->label('THC')
|
||||
->numeric(decimalPlaces: 2)
|
||||
->suffix('%')
|
||||
->sortable()
|
||||
->color(fn ($state) => $state > 20 ? Color::Green : ($state > 15 ? Color::Amber : Color::Gray)),
|
||||
|
||||
TextColumn::make('total_cbd')
|
||||
->label('CBD')
|
||||
->numeric(decimalPlaces: 2)
|
||||
->suffix('%')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('total_cannabinoids')
|
||||
->label('Total')
|
||||
->numeric(decimalPlaces: 2)
|
||||
->suffix('%')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
IconColumn::make('compliance_pass')
|
||||
->label('Compliance')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor(Color::Green)
|
||||
->falseColor(Color::Red)
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('terpene_profile')
|
||||
->label('Top Terpenes')
|
||||
->limit(40)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('test_date', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('lab_name')
|
||||
->options(function () {
|
||||
return \App\Models\Lab::query()
|
||||
->distinct('lab_name')
|
||||
->pluck('lab_name', 'lab_name')
|
||||
->toArray();
|
||||
})
|
||||
->searchable(),
|
||||
|
||||
SelectFilter::make('product')
|
||||
->relationship('product', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
SelectFilter::make('batch')
|
||||
->relationship('batch', 'batch_number')
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
TernaryFilter::make('compliance_pass')
|
||||
->label('Compliant'),
|
||||
|
||||
Filter::make('recent')
|
||||
->label('Recent (Last 30 days)')
|
||||
->query(fn (Builder $query): Builder => $query->where('test_date', '>=', now()->subDays(30)))
|
||||
->toggle(),
|
||||
|
||||
Filter::make('high_thc')
|
||||
->label('High THC (>20%)')
|
||||
->query(fn (Builder $query): Builder => $query->where('total_thc', '>', 20))
|
||||
->toggle(),
|
||||
|
||||
Filter::make('high_cbd')
|
||||
->label('High CBD (>10%)')
|
||||
->query(fn (Builder $query): Builder => $query->where('total_cbd', '>', 10))
|
||||
->toggle(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,13 @@ class ModulesTable
|
||||
->sortable()
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'Communication' => 'info',
|
||||
'Sales' => 'success',
|
||||
'Operations' => 'warning',
|
||||
'Finance' => 'danger',
|
||||
'Core' => 'info',
|
||||
'Finance' => 'success',
|
||||
'Growth' => 'warning',
|
||||
'Intelligence' => 'purple',
|
||||
'Operations' => 'primary',
|
||||
'Regulatory' => 'danger',
|
||||
'Sales' => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
@@ -103,13 +106,13 @@ class ModulesTable
|
||||
|
||||
SelectFilter::make('category')
|
||||
->options([
|
||||
'Communication' => 'Communication',
|
||||
'Sales' => 'Sales',
|
||||
'Operations' => 'Operations',
|
||||
'Core' => 'Core',
|
||||
'Finance' => 'Finance',
|
||||
'Marketing' => 'Marketing',
|
||||
'Support' => 'Support',
|
||||
'Analytics' => 'Analytics',
|
||||
'Growth' => 'Growth',
|
||||
'Intelligence' => 'Intelligence',
|
||||
'Operations' => 'Operations',
|
||||
'Regulatory' => 'Regulatory',
|
||||
'Sales' => 'Sales',
|
||||
])
|
||||
->native(false),
|
||||
])
|
||||
|
||||
@@ -59,6 +59,7 @@ class OrderResource extends Resource
|
||||
{
|
||||
return [
|
||||
RelationManagers\ItemsRelationManager::class,
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ class ProductResource extends Resource
|
||||
BatchesRelationManager::class,
|
||||
ComponentsRelationManager::class,
|
||||
VarietiesRelationManager::class,
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,22 @@ class UserResource extends Resource
|
||||
'suspended' => 'Suspended',
|
||||
])
|
||||
->default('active'),
|
||||
TextInput::make('password')
|
||||
->label('Password')
|
||||
->password()
|
||||
->required(fn ($record) => $record === null)
|
||||
->dehydrated(fn ($state) => filled($state))
|
||||
->minLength(8)
|
||||
->maxLength(255)
|
||||
->helperText('Leave blank to keep current password when editing')
|
||||
->visible(fn ($livewire) => $livewire instanceof CreateUser),
|
||||
TextInput::make('password_confirmation')
|
||||
->label('Confirm Password')
|
||||
->password()
|
||||
->required(fn ($record) => $record === null && filled($record?->password))
|
||||
->dehydrated(false)
|
||||
->same('password')
|
||||
->visible(fn ($livewire) => $livewire instanceof CreateUser),
|
||||
])->columns(2),
|
||||
|
||||
Section::make('Business Association')
|
||||
@@ -242,7 +258,7 @@ class UserResource extends Resource
|
||||
->label('Impersonate')
|
||||
->icon('heroicon-o-user-circle')
|
||||
->color('warning')
|
||||
->visible(fn (User $record) => auth()->user()->canImpersonate() && $record->canBeImpersonated())
|
||||
->visible(fn (User $record) => auth()->user()?->canImpersonate() && $record->canBeImpersonated())
|
||||
->url(fn (User $record) => route('admin.impersonate.perform', $record))
|
||||
->openUrlInNewTab(false),
|
||||
Action::make('suspend')
|
||||
|
||||
@@ -4,8 +4,18 @@ namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (isset($data['password'])) {
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
54
app/Filament/Widgets/AiStatsOverview.php
Normal file
54
app/Filament/Widgets/AiStatsOverview.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\AiConnectionUsage;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class AiStatsOverview extends StatsOverviewWidget
|
||||
{
|
||||
protected function getStats(): array
|
||||
{
|
||||
// Get stats for last 30 days
|
||||
$startDate = now()->subDays(30);
|
||||
$endDate = now();
|
||||
|
||||
$stats = AiConnectionUsage::whereBetween('date', [$startDate->toDateString(), $endDate->toDateString()])
|
||||
->selectRaw('
|
||||
SUM(requests) as total_requests,
|
||||
SUM(input_tokens) as total_input_tokens,
|
||||
SUM(output_tokens) as total_output_tokens,
|
||||
SUM(cost_cents) as total_cost_cents,
|
||||
COUNT(DISTINCT ai_connection_id) as active_connections
|
||||
')
|
||||
->first();
|
||||
|
||||
$totalRequests = $stats->total_requests ?? 0;
|
||||
$totalTokens = ($stats->total_input_tokens ?? 0) + ($stats->total_output_tokens ?? 0);
|
||||
$totalCostDollars = ($stats->total_cost_cents ?? 0) / 100;
|
||||
$activeConnections = $stats->active_connections ?? 0;
|
||||
|
||||
return [
|
||||
Stat::make('Total Requests', number_format($totalRequests))
|
||||
->description('Last 30 days')
|
||||
->descriptionIcon('heroicon-m-arrow-trending-up')
|
||||
->color('success'),
|
||||
|
||||
Stat::make('Total Tokens', number_format($totalTokens))
|
||||
->description('Input + Output')
|
||||
->descriptionIcon('heroicon-m-cpu-chip')
|
||||
->color('info'),
|
||||
|
||||
Stat::make('Total Spend', '$' . number_format($totalCostDollars, 2))
|
||||
->description('Last 30 days')
|
||||
->descriptionIcon('heroicon-m-currency-dollar')
|
||||
->color('warning'),
|
||||
|
||||
Stat::make('Active Connections', number_format($activeConnections))
|
||||
->description('Used in last 30 days')
|
||||
->descriptionIcon('heroicon-m-link')
|
||||
->color('primary'),
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/Filament/Widgets/FailedJobsAlert.php
Normal file
34
app/Filament/Widgets/FailedJobsAlert.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class FailedJobsAlert extends Widget
|
||||
{
|
||||
protected string $view = 'filament.widgets.failed-jobs-alert';
|
||||
|
||||
protected int | string | array $columnSpan = 'full';
|
||||
|
||||
protected static ?int $sort = -10; // Show at the top
|
||||
|
||||
public function getFailedJobsCount(): int
|
||||
{
|
||||
try {
|
||||
// Get failed jobs count from Horizon's Redis storage
|
||||
$connection = config('horizon.use', 'default');
|
||||
$failedJobs = Redis::connection($connection)->zcard('failed_jobs');
|
||||
|
||||
return $failedJobs ?? 0;
|
||||
} catch (\Exception $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function isVisible(): bool
|
||||
{
|
||||
// Only show if there are failed jobs
|
||||
return $this->getFailedJobsCount() > 0;
|
||||
}
|
||||
}
|
||||
143
app/Helpers/BusinessHelper.php
Normal file
143
app/Helpers/BusinessHelper.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\PermissionService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class BusinessHelper
|
||||
{
|
||||
/**
|
||||
* Get current business context from session or user's primary business
|
||||
*/
|
||||
public static function current(): ?Business
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$businessId = session('current_business_id');
|
||||
|
||||
if ($businessId) {
|
||||
return Business::find($businessId);
|
||||
}
|
||||
|
||||
// Fall back to user's primary business if no session is set
|
||||
return Auth::user()->primaryBusiness();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a permission for current business
|
||||
*
|
||||
* This method now uses PermissionService internally for better architecture
|
||||
* while maintaining backward compatibility with existing code.
|
||||
*
|
||||
* @param string $permission Permission key (e.g. 'analytics.overview')
|
||||
*/
|
||||
public static function hasPermission(string $permission): bool
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use PermissionService for permission checking
|
||||
$permissionService = app(PermissionService::class);
|
||||
|
||||
return $permissionService->check($user, $permission, $business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is owner or admin for current business
|
||||
*/
|
||||
public static function isOwnerOrAdmin(): bool
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Super admin
|
||||
if ($user->user_type === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Business owner
|
||||
return $business->owner_user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's role template for current business
|
||||
*/
|
||||
public static function getRoleTemplate(): ?string
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$businessUser = $user->businesses()
|
||||
->where('businesses.id', $business->id)
|
||||
->first();
|
||||
|
||||
return $businessUser?->pivot->role_template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's permissions array for current business
|
||||
*/
|
||||
public static function getPermissions(): array
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use PermissionService for cached permission retrieval
|
||||
$permissionService = app(PermissionService::class);
|
||||
|
||||
return $permissionService->getUserPermissions($user, $business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current business has a specific module enabled
|
||||
*
|
||||
* @param string $module Module name (sales, manufacturing, compliance)
|
||||
*/
|
||||
public static function hasModule(string $module): bool
|
||||
{
|
||||
$business = self::current();
|
||||
|
||||
return match ($module) {
|
||||
'sales' => true, // Sales is always enabled (base product)
|
||||
'manufacturing' => $business?->has_manufacturing ?? false,
|
||||
'compliance' => $business?->has_compliance ?? false,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
app/Helpers/helpers.php
Normal file
24
app/Helpers/helpers.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\BusinessHelper;
|
||||
|
||||
if (! function_exists('currentBusiness')) {
|
||||
function currentBusiness()
|
||||
{
|
||||
return BusinessHelper::current();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('currentBusinessId')) {
|
||||
function currentBusinessId()
|
||||
{
|
||||
return BusinessHelper::currentId();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('hasBusinessPermission')) {
|
||||
function hasBusinessPermission(string $permission): bool
|
||||
{
|
||||
return BusinessHelper::hasPermission($permission);
|
||||
}
|
||||
}
|
||||
110
app/Http/Controllers/Admin/QuickSwitchController.php
Normal file
110
app/Http/Controllers/Admin/QuickSwitchController.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class QuickSwitchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Ensure only admins can access
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
$this->middleware(function ($request, $next) {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
abort(403, 'Not authenticated');
|
||||
}
|
||||
|
||||
$manager = app(\Lab404\Impersonate\Services\ImpersonateManager::class);
|
||||
|
||||
// If impersonating, check if the impersonator can impersonate
|
||||
// Otherwise check if the current user can impersonate
|
||||
$canAccess = $manager->isImpersonating()
|
||||
? $manager->getImpersonator()->canImpersonate()
|
||||
: $user->canImpersonate();
|
||||
|
||||
if (! $canAccess) {
|
||||
abort(403, 'Only administrators can access this feature. Please login as an admin.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show quick switch menu for testing
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// Get all seller users for quick switching
|
||||
$users = User::where('user_type', 'seller')
|
||||
->with('businesses')
|
||||
->orderBy('email')
|
||||
->get();
|
||||
|
||||
return view('admin.quick-switch', compact('users'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick switch to user using impersonation (maintains admin session)
|
||||
*/
|
||||
public function switch(Request $request, User $user)
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
$manager = app(\Lab404\Impersonate\Services\ImpersonateManager::class);
|
||||
|
||||
// Get the actual admin user (might be the impersonator)
|
||||
$admin = $manager->isImpersonating()
|
||||
? $manager->getImpersonator()
|
||||
: $currentUser;
|
||||
|
||||
if (! $user->canBeImpersonated()) {
|
||||
abort(403, 'This user cannot be impersonated');
|
||||
}
|
||||
|
||||
// If already impersonating someone, leave that impersonation first
|
||||
if ($manager->isImpersonating()) {
|
||||
$manager->leave();
|
||||
}
|
||||
|
||||
// Use impersonation instead of session replacement
|
||||
// This allows multiple tabs with different impersonated users
|
||||
$manager->take($admin, $user, 'web');
|
||||
|
||||
// Redirect based on user type and business
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
if ($business && $business->isParentCompany()) {
|
||||
return redirect()->route('seller.business.executive.dashboard', $business->slug);
|
||||
} elseif ($business) {
|
||||
return redirect()->route('seller.business.dashboard', $business->slug);
|
||||
}
|
||||
|
||||
return redirect()->route('seller.dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch back to admin (leave impersonation)
|
||||
*/
|
||||
public function backToAdmin()
|
||||
{
|
||||
$manager = app(\Lab404\Impersonate\Services\ImpersonateManager::class);
|
||||
|
||||
if (! $manager->isImpersonating()) {
|
||||
return redirect()->route('filament.admin.pages.dashboard')
|
||||
->with('info', 'You are not currently impersonating anyone');
|
||||
}
|
||||
|
||||
// Leave impersonation
|
||||
$manager->leave();
|
||||
|
||||
return redirect()->route('filament.admin.pages.dashboard')
|
||||
->with('success', 'Returned to admin panel');
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Analytics/AnalyticsDashboardController.php
Normal file
101
app/Http/Controllers/Analytics/AnalyticsDashboardController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\AnalyticsEvent;
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Analytics\UserSession;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnalyticsDashboardController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.overview')) {
|
||||
abort(403, 'Unauthorized to view analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30'); // days
|
||||
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Key metrics
|
||||
$metrics = [
|
||||
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
|
||||
'total_page_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->sum('page_views'),
|
||||
'total_product_views' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->count(),
|
||||
'unique_products_viewed' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->distinct('product_id')
|
||||
->count('product_id'),
|
||||
'high_intent_signals' => IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
|
||||
->where('signal_strength', '>=', IntentSignal::STRENGTH_HIGH)
|
||||
->count(),
|
||||
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('last_interaction_at', '>=', $startDate)->count(),
|
||||
];
|
||||
|
||||
// Traffic trend (daily breakdown)
|
||||
$trafficTrend = AnalyticsEvent::forBusiness($business->id)->where('created_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(created_at) as date'),
|
||||
DB::raw('COUNT(*) as total_events'),
|
||||
DB::raw('COUNT(DISTINCT session_id) as unique_sessions')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top products by views
|
||||
$topProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->select('product_id', DB::raw('COUNT(*) as view_count'))
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('view_count')
|
||||
->limit(10)
|
||||
->with('product')
|
||||
->get();
|
||||
|
||||
// High-value buyers
|
||||
$highValueBuyers = BuyerEngagementScore::forBusiness($business->id)->highValue()
|
||||
->active()
|
||||
->orderByDesc('score')
|
||||
->limit(10)
|
||||
->with('buyerBusiness')
|
||||
->get();
|
||||
|
||||
// Recent high-intent signals
|
||||
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
|
||||
->where('detected_at', '>=', now()->subHours(24))
|
||||
->orderByDesc('detected_at')
|
||||
->limit(10)
|
||||
->with(['buyerBusiness', 'user'])
|
||||
->get();
|
||||
|
||||
// Engagement score distribution
|
||||
$engagementDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
|
||||
DB::raw('CASE
|
||||
WHEN score >= 80 THEN \'Very High\'
|
||||
WHEN score >= 60 THEN \'High\'
|
||||
WHEN score >= 40 THEN \'Medium\'
|
||||
ELSE \'Low\'
|
||||
END as score_range'),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('score_range')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.dashboard', compact(
|
||||
'business',
|
||||
'period',
|
||||
'metrics',
|
||||
'trafficTrend',
|
||||
'topProducts',
|
||||
'highValueBuyers',
|
||||
'recentIntentSignals',
|
||||
'engagementDistribution'
|
||||
));
|
||||
}
|
||||
}
|
||||
194
app/Http/Controllers/Analytics/BuyerIntelligenceController.php
Normal file
194
app/Http/Controllers/Analytics/BuyerIntelligenceController.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BuyerIntelligenceController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// TODO: Re-enable when permission system is implemented
|
||||
// if (! hasBusinessPermission('analytics.buyers')) {
|
||||
// abort(403, 'Unauthorized to view buyer intelligence');
|
||||
// }
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$filter = $request->input('filter', 'all'); // all, high-value, at-risk, new
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Overall buyer metrics
|
||||
$metrics = [
|
||||
'total_buyers' => BuyerEngagementScore::forBusiness($business->id)->count(),
|
||||
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->active()->count(),
|
||||
'high_value_buyers' => BuyerEngagementScore::forBusiness($business->id)->highValue()->count(),
|
||||
'at_risk_buyers' => BuyerEngagementScore::forBusiness($business->id)->atRisk()->count(),
|
||||
'new_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('first_interaction_at', '>=', now()->subDays(30))->count(),
|
||||
];
|
||||
|
||||
// Build query based on filter
|
||||
$buyersQuery = BuyerEngagementScore::forBusiness($business->id);
|
||||
|
||||
match ($filter) {
|
||||
'high-value' => $buyersQuery->highValue(),
|
||||
'at-risk' => $buyersQuery->atRisk(),
|
||||
'new' => $buyersQuery->where('first_interaction_at', '>=', now()->subDays(30)),
|
||||
default => $buyersQuery,
|
||||
};
|
||||
|
||||
$buyers = $buyersQuery->orderByDesc('score')
|
||||
->with('buyerBusiness')
|
||||
->paginate(20);
|
||||
|
||||
// Engagement score distribution
|
||||
$scoreDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
|
||||
DB::raw("CASE
|
||||
WHEN score >= 80 THEN 'Very High (80-100)'
|
||||
WHEN score >= 60 THEN 'High (60-79)'
|
||||
WHEN score >= 40 THEN 'Medium (40-59)'
|
||||
WHEN score >= 20 THEN 'Low (20-39)'
|
||||
ELSE 'Very Low (0-19)'
|
||||
END as score_range"),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('score_range')
|
||||
->get();
|
||||
|
||||
// Tier distribution
|
||||
$tierDistribution = BuyerEngagementScore::forBusiness($business->id)->select('score_tier')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('score_tier')
|
||||
->get();
|
||||
|
||||
// Recent high-intent signals
|
||||
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
|
||||
->where('detected_at', '>=', now()->subDays(7))
|
||||
->orderByDesc('detected_at')
|
||||
->with(['buyerBusiness', 'user'])
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Intent signal breakdown
|
||||
$signalBreakdown = IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
|
||||
->select('signal_type')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->selectRaw('AVG(signal_strength) as avg_strength')
|
||||
->groupBy('signal_type')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.buyers', compact(
|
||||
'business',
|
||||
'period',
|
||||
'filter',
|
||||
'metrics',
|
||||
'buyers',
|
||||
'scoreDistribution',
|
||||
'tierDistribution',
|
||||
'recentIntentSignals',
|
||||
'signalBreakdown'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $buyer)
|
||||
{
|
||||
// TODO: Re-enable when permission system is implemented
|
||||
// if (! hasBusinessPermission('analytics.buyers')) {
|
||||
// abort(403, 'Unauthorized to view buyer intelligence');
|
||||
// }
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '90'); // Default to 90 days for buyer detail
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Get engagement score
|
||||
$engagementScore = BuyerEngagementScore::forBusiness($business->id)->where('buyer_business_id', $buyer->id)->first();
|
||||
|
||||
// Activity timeline
|
||||
$activityTimeline = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(viewed_at) as date'),
|
||||
DB::raw('COUNT(*) as product_views'),
|
||||
DB::raw('COUNT(DISTINCT product_id) as unique_products'),
|
||||
DB::raw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Products viewed
|
||||
$productsViewed = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select('product_id')
|
||||
->selectRaw('COUNT(*) as view_count')
|
||||
->selectRaw('MAX(viewed_at) as last_viewed')
|
||||
->selectRaw('AVG(time_on_page) as avg_time')
|
||||
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('view_count')
|
||||
->with('product')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Intent signals
|
||||
$intentSignals = IntentSignal::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
|
||||
->where('detected_at', '>=', $startDate)
|
||||
->orderByDesc('detected_at')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
// Email engagement
|
||||
$emailEngagement = DB::table('email_interactions')
|
||||
->join('users', 'email_interactions.recipient_user_id', '=', 'users.id')
|
||||
->join('business_user', 'users.id', '=', 'business_user.user_id')
|
||||
->where('email_interactions.business_id', $business->id)
|
||||
->where('business_user.business_id', $buyer->id)
|
||||
->where('email_interactions.sent_at', '>=', $startDate)
|
||||
->selectRaw('COUNT(*) as total_sent')
|
||||
->selectRaw('SUM(open_count) as total_opens')
|
||||
->selectRaw('SUM(click_count) as total_clicks')
|
||||
->selectRaw('AVG(engagement_score) as avg_engagement')
|
||||
->first();
|
||||
|
||||
// Order history
|
||||
$orderHistory = DB::table('orders')
|
||||
->where('seller_business_id', $business->id)
|
||||
->where('buyer_business_id', $buyer->id)
|
||||
->select(
|
||||
DB::raw('DATE(created_at) as date'),
|
||||
DB::raw('COUNT(*) as order_count'),
|
||||
DB::raw('SUM(total) as revenue')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
$totalOrders = DB::table('orders')
|
||||
->where('seller_business_id', $business->id)
|
||||
->where('buyer_business_id', $buyer->id)
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->selectRaw('SUM(total) as total_revenue')
|
||||
->selectRaw('AVG(total) as avg_order_value')
|
||||
->first();
|
||||
|
||||
return view('seller.analytics.buyer-detail', compact(
|
||||
'buyer',
|
||||
'period',
|
||||
'engagementScore',
|
||||
'activityTimeline',
|
||||
'productsViewed',
|
||||
'intentSignals',
|
||||
'emailEngagement',
|
||||
'orderHistory',
|
||||
'totalOrders'
|
||||
));
|
||||
}
|
||||
}
|
||||
173
app/Http/Controllers/Analytics/MarketingAnalyticsController.php
Normal file
173
app/Http/Controllers/Analytics/MarketingAnalyticsController.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\EmailCampaign;
|
||||
use App\Models\Analytics\EmailInteraction;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MarketingAnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.marketing')) {
|
||||
abort(403, 'Unauthorized to view marketing analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Campaign overview metrics
|
||||
$metrics = [
|
||||
'total_campaigns' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->count(),
|
||||
'total_sent' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_sent'),
|
||||
'total_delivered' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_delivered'),
|
||||
'total_opened' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_opened'),
|
||||
'total_clicked' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_clicked'),
|
||||
];
|
||||
|
||||
// Calculate average rates
|
||||
$metrics['avg_open_rate'] = $metrics['total_delivered'] > 0
|
||||
? round(($metrics['total_opened'] / $metrics['total_delivered']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
$metrics['avg_click_rate'] = $metrics['total_delivered'] > 0
|
||||
? round(($metrics['total_clicked'] / $metrics['total_delivered']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
// Campaign performance
|
||||
$campaigns = EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)
|
||||
->orderByDesc('sent_at')
|
||||
->with('emailInteractions')
|
||||
->paginate(20);
|
||||
|
||||
// Email engagement over time
|
||||
$engagementTrend = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(sent_at) as date'),
|
||||
DB::raw('COUNT(*) as sent'),
|
||||
DB::raw('SUM(CASE WHEN first_opened_at IS NOT NULL THEN 1 ELSE 0 END) as opened'),
|
||||
DB::raw('SUM(CASE WHEN first_clicked_at IS NOT NULL THEN 1 ELSE 0 END) as clicked')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top performing campaigns
|
||||
$topCampaigns = EmailCampaign::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->where('total_sent', '>', 0)
|
||||
->orderByRaw('(total_clicked / total_sent) DESC')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Email client breakdown
|
||||
$emailClients = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->whereNotNull('email_client')
|
||||
->select('email_client')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('email_client')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
// Device type breakdown
|
||||
$deviceTypes = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->whereNotNull('device_type')
|
||||
->select('device_type')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('device_type')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
// Engagement score distribution
|
||||
$engagementScores = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw("CASE
|
||||
WHEN engagement_score >= 80 THEN 'High'
|
||||
WHEN engagement_score >= 50 THEN 'Medium'
|
||||
WHEN engagement_score > 0 THEN 'Low'
|
||||
ELSE 'None'
|
||||
END as score_range"),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('score_range')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.marketing', compact(
|
||||
'business',
|
||||
'period',
|
||||
'metrics',
|
||||
'campaigns',
|
||||
'engagementTrend',
|
||||
'topCampaigns',
|
||||
'emailClients',
|
||||
'deviceTypes',
|
||||
'engagementScores'
|
||||
));
|
||||
}
|
||||
|
||||
public function campaign(Request $request, EmailCampaign $campaign)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.marketing')) {
|
||||
abort(403, 'Unauthorized to view marketing analytics');
|
||||
}
|
||||
|
||||
// Verify campaign belongs to user's business
|
||||
if ($campaign->business_id !== currentBusinessId()) {
|
||||
abort(403, 'Unauthorized to view this campaign');
|
||||
}
|
||||
|
||||
// Campaign metrics
|
||||
$metrics = [
|
||||
'total_sent' => $campaign->total_sent,
|
||||
'total_delivered' => $campaign->total_delivered,
|
||||
'total_bounced' => $campaign->total_bounced,
|
||||
'total_opened' => $campaign->total_opened,
|
||||
'total_clicked' => $campaign->total_clicked,
|
||||
'open_rate' => $campaign->open_rate,
|
||||
'click_rate' => $campaign->click_rate,
|
||||
'bounce_rate' => $campaign->total_sent > 0
|
||||
? round(($campaign->total_bounced / $campaign->total_sent) * 100, 2)
|
||||
: 0,
|
||||
];
|
||||
|
||||
// Interaction timeline
|
||||
$timeline = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
|
||||
->select(
|
||||
DB::raw('DATE(sent_at) as date'),
|
||||
DB::raw('SUM(open_count) as opens'),
|
||||
DB::raw('SUM(click_count) as clicks')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top engaged recipients
|
||||
$topRecipients = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
|
||||
->orderByDesc('engagement_score')
|
||||
->limit(20)
|
||||
->with('recipientUser')
|
||||
->get();
|
||||
|
||||
// Click breakdown by URL
|
||||
$clicksByUrl = DB::table('email_clicks')
|
||||
->join('email_interactions', 'email_clicks.email_interaction_id', '=', 'email_interactions.id')
|
||||
->where('email_interactions.campaign_id', $campaign->id)
|
||||
->select('email_clicks.url', 'email_clicks.link_identifier')
|
||||
->selectRaw('COUNT(*) as click_count')
|
||||
->selectRaw('COUNT(DISTINCT email_clicks.email_interaction_id) as unique_clicks')
|
||||
->groupBy('email_clicks.url', 'email_clicks.link_identifier')
|
||||
->orderByDesc('click_count')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.campaign-detail', compact(
|
||||
'campaign',
|
||||
'metrics',
|
||||
'timeline',
|
||||
'topRecipients',
|
||||
'clicksByUrl'
|
||||
));
|
||||
}
|
||||
}
|
||||
164
app/Http/Controllers/Analytics/ProductAnalyticsController.php
Normal file
164
app/Http/Controllers/Analytics/ProductAnalyticsController.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Helpers\BusinessHelper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProductAnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.products')) {
|
||||
abort(403, 'Unauthorized to view product analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Product performance metrics
|
||||
$productMetrics = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->select('product_id')
|
||||
->selectRaw('COUNT(*) as total_views')
|
||||
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
|
||||
->selectRaw('AVG(time_on_page) as avg_time_on_page')
|
||||
->selectRaw('SUM(CASE WHEN zoomed_image = true THEN 1 ELSE 0 END) as zoomed_count')
|
||||
->selectRaw('SUM(CASE WHEN watched_video = true THEN 1 ELSE 0 END) as video_views')
|
||||
->selectRaw('SUM(CASE WHEN downloaded_spec = true THEN 1 ELSE 0 END) as spec_downloads')
|
||||
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('total_views')
|
||||
->with('product.brand')
|
||||
->paginate(20);
|
||||
|
||||
// Product view trend
|
||||
$viewTrend = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(viewed_at) as date'),
|
||||
DB::raw('COUNT(*) as views'),
|
||||
DB::raw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// High engagement products (quality over quantity)
|
||||
$highEngagementProducts = ProductView::forBusiness($business->id)->highEngagement()
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select('product_id')
|
||||
->selectRaw('COUNT(*) as engagement_count')
|
||||
->selectRaw('AVG(time_on_page) as avg_time')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('engagement_count')
|
||||
->limit(10)
|
||||
->with('product')
|
||||
->get();
|
||||
|
||||
// Products with most cart additions (high intent)
|
||||
$topCartProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->where('added_to_cart', true)
|
||||
->select('product_id')
|
||||
->selectRaw('COUNT(*) as cart_count')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('cart_count')
|
||||
->limit(10)
|
||||
->with('product')
|
||||
->get();
|
||||
|
||||
// Engagement breakdown
|
||||
$engagementBreakdown = [
|
||||
'zoomed_image' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('zoomed_image', true)->count(),
|
||||
'watched_video' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('watched_video', true)->count(),
|
||||
'downloaded_spec' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('downloaded_spec', true)->count(),
|
||||
'added_to_cart' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_cart', true)->count(),
|
||||
'added_to_wishlist' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_wishlist', true)->count(),
|
||||
];
|
||||
|
||||
return view('seller.analytics.products', compact(
|
||||
'business',
|
||||
'period',
|
||||
'productMetrics',
|
||||
'viewTrend',
|
||||
'highEngagementProducts',
|
||||
'topCartProducts',
|
||||
'engagementBreakdown'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Product $product)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.products')) {
|
||||
abort(403, 'Unauthorized to view product analytics');
|
||||
}
|
||||
|
||||
// Verify product belongs to user's business brands
|
||||
$sellerBusiness = BusinessHelper::fromProduct($product);
|
||||
if ($sellerBusiness->id !== currentBusinessId()) {
|
||||
abort(403, 'Unauthorized to view this product');
|
||||
}
|
||||
|
||||
$period = $request->input('period', '30');
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Product-specific metrics
|
||||
$metrics = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->selectRaw('COUNT(*) as total_views')
|
||||
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
|
||||
->selectRaw('COUNT(DISTINCT session_id) as unique_sessions')
|
||||
->selectRaw('AVG(time_on_page) as avg_time_on_page')
|
||||
->selectRaw('MAX(time_on_page) as max_time_on_page')
|
||||
->selectRaw('SUM(CASE WHEN zoomed_image = true THEN 1 ELSE 0 END) as zoomed_count')
|
||||
->selectRaw('SUM(CASE WHEN watched_video = true THEN 1 ELSE 0 END) as video_views')
|
||||
->selectRaw('SUM(CASE WHEN downloaded_spec = true THEN 1 ELSE 0 END) as spec_downloads')
|
||||
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions')
|
||||
->first();
|
||||
|
||||
// View trend
|
||||
$viewTrend = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(viewed_at) as date'),
|
||||
DB::raw('COUNT(*) as views')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top buyers viewing this product
|
||||
$topBuyers = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->whereNotNull('buyer_business_id')
|
||||
->select('buyer_business_id')
|
||||
->selectRaw('COUNT(*) as view_count')
|
||||
->selectRaw('MAX(viewed_at) as last_viewed')
|
||||
->groupBy('buyer_business_id')
|
||||
->orderByDesc('view_count')
|
||||
->limit(10)
|
||||
->with('buyerBusiness')
|
||||
->get();
|
||||
|
||||
// Traffic sources
|
||||
$trafficSources = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select('source')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('source')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.product-detail', compact(
|
||||
'product',
|
||||
'period',
|
||||
'metrics',
|
||||
'viewTrend',
|
||||
'topBuyers',
|
||||
'trafficSources'
|
||||
));
|
||||
}
|
||||
}
|
||||
160
app/Http/Controllers/Analytics/SalesAnalyticsController.php
Normal file
160
app/Http/Controllers/Analytics/SalesAnalyticsController.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\UserSession;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SalesAnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.sales')) {
|
||||
abort(403, 'Unauthorized to view sales analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Sales funnel metrics
|
||||
$funnelMetrics = [
|
||||
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
|
||||
'sessions_with_product_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('product_views', '>', 0)
|
||||
->count(),
|
||||
'sessions_with_cart' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('interactions', '>', 0)
|
||||
->count(),
|
||||
'checkout_initiated' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('interactions', '>', 2)
|
||||
->count(),
|
||||
'orders_completed' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('converted', true)
|
||||
->count(),
|
||||
];
|
||||
|
||||
// Calculate conversion rates
|
||||
$funnelMetrics['product_view_rate'] = $funnelMetrics['total_sessions'] > 0
|
||||
? round(($funnelMetrics['sessions_with_product_views'] / $funnelMetrics['total_sessions']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
$funnelMetrics['cart_rate'] = $funnelMetrics['sessions_with_product_views'] > 0
|
||||
? round(($funnelMetrics['sessions_with_cart'] / $funnelMetrics['sessions_with_product_views']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
$funnelMetrics['checkout_rate'] = $funnelMetrics['sessions_with_cart'] > 0
|
||||
? round(($funnelMetrics['checkout_initiated'] / $funnelMetrics['sessions_with_cart']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
$funnelMetrics['conversion_rate'] = $funnelMetrics['checkout_initiated'] > 0
|
||||
? round(($funnelMetrics['orders_completed'] / $funnelMetrics['checkout_initiated']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
// Sales metrics from orders table
|
||||
// Note: orders.business_id is the buyer's business
|
||||
// To get seller's orders, join through order_items → products → brands
|
||||
$salesMetrics = DB::table('orders')
|
||||
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||
->join('brands', 'products.brand_id', '=', 'brands.id')
|
||||
->where('brands.business_id', $business->id)
|
||||
->where('orders.created_at', '>=', $startDate)
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as total_orders')
|
||||
->selectRaw('SUM(order_items.line_total) as total_revenue')
|
||||
->selectRaw('AVG(orders.total) as avg_order_value')
|
||||
->selectRaw('COUNT(DISTINCT orders.business_id) as unique_buyers')
|
||||
->first();
|
||||
|
||||
// Revenue trend
|
||||
$revenueTrend = DB::table('orders')
|
||||
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||
->join('brands', 'products.brand_id', '=', 'brands.id')
|
||||
->where('brands.business_id', $business->id)
|
||||
->where('orders.created_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(orders.created_at) as date'),
|
||||
DB::raw('COUNT(DISTINCT orders.id) as orders'),
|
||||
DB::raw('SUM(order_items.line_total) as revenue')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Conversion funnel trend
|
||||
$conversionTrend = UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(started_at) as date'),
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
DB::raw('SUM(CASE WHEN product_views > 0 THEN 1 ELSE 0 END) as with_views'),
|
||||
DB::raw('SUM(CASE WHEN interactions > 0 THEN 1 ELSE 0 END) as with_interactions'),
|
||||
DB::raw('SUM(CASE WHEN converted = true THEN 1 ELSE 0 END) as conversions'),
|
||||
DB::raw('SUM(CASE WHEN converted = true THEN 1 ELSE 0 END) as orders')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top revenue products
|
||||
$topProducts = DB::table('order_items')
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||
->join('brands', 'products.brand_id', '=', 'brands.id')
|
||||
->where('brands.business_id', $business->id)
|
||||
->where('orders.created_at', '>=', $startDate)
|
||||
->select('products.id', 'products.name')
|
||||
->selectRaw('SUM(order_items.quantity) as units_sold')
|
||||
->selectRaw('SUM(order_items.line_total) as revenue')
|
||||
->groupBy('products.id', 'products.name')
|
||||
->orderByDesc('revenue')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Session abandonment analysis (sessions with interactions but no conversion)
|
||||
$cartAbandonment = [
|
||||
'total_interactive_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('interactions', '>', 0)
|
||||
->count(),
|
||||
'abandoned_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('interactions', '>', 0)
|
||||
->where('converted', false)
|
||||
->count(),
|
||||
];
|
||||
|
||||
$cartAbandonment['abandonment_rate'] = $cartAbandonment['total_interactive_sessions'] > 0
|
||||
? round(($cartAbandonment['abandoned_sessions'] / $cartAbandonment['total_interactive_sessions']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
// Top buyers by revenue
|
||||
$topBuyers = DB::table('orders')
|
||||
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||
->join('brands', 'products.brand_id', '=', 'brands.id')
|
||||
->join('businesses', 'orders.business_id', '=', 'businesses.id')
|
||||
->where('brands.business_id', $business->id)
|
||||
->where('orders.created_at', '>=', $startDate)
|
||||
->select('businesses.id', 'businesses.name')
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
|
||||
->selectRaw('SUM(order_items.line_total) as total_revenue')
|
||||
->selectRaw('AVG(orders.total) as avg_order_value')
|
||||
->groupBy('businesses.id', 'businesses.name')
|
||||
->orderByDesc('total_revenue')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.sales', compact(
|
||||
'business',
|
||||
'period',
|
||||
'funnelMetrics',
|
||||
'salesMetrics',
|
||||
'revenueTrend',
|
||||
'conversionTrend',
|
||||
'topProducts',
|
||||
'cartAbandonment',
|
||||
'topBuyers'
|
||||
));
|
||||
}
|
||||
}
|
||||
190
app/Http/Controllers/Analytics/TrackingController.php
Normal file
190
app/Http/Controllers/Analytics/TrackingController.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Services\AnalyticsTracker;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TrackingController extends Controller
|
||||
{
|
||||
protected AnalyticsTracker $tracker;
|
||||
|
||||
public function __construct(AnalyticsTracker $tracker)
|
||||
{
|
||||
$this->tracker = $tracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize or update session
|
||||
*/
|
||||
public function session(Request $request)
|
||||
{
|
||||
try {
|
||||
$session = $this->tracker->startSession();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'session_id' => $session->session_id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Analytics session tracking failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Session tracking failed',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track various analytics events
|
||||
*/
|
||||
public function track(Request $request)
|
||||
{
|
||||
try {
|
||||
$eventType = $request->input('event_type');
|
||||
|
||||
switch ($eventType) {
|
||||
case 'page_view':
|
||||
$this->trackPageView($request);
|
||||
break;
|
||||
|
||||
case 'product_view':
|
||||
$this->trackProductView($request);
|
||||
break;
|
||||
|
||||
case 'page_engagement':
|
||||
$this->trackPageEngagement($request);
|
||||
break;
|
||||
|
||||
case 'click':
|
||||
$this->trackClick($request);
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->trackGenericEvent($request);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Analytics tracking failed', [
|
||||
'event_type' => $request->input('event_type'),
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Tracking failed',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*/
|
||||
protected function trackPageView(Request $request): void
|
||||
{
|
||||
$this->tracker->updateSessionPageView();
|
||||
|
||||
$this->tracker->trackEvent(
|
||||
'page_view',
|
||||
'navigation',
|
||||
'view',
|
||||
null,
|
||||
null,
|
||||
[
|
||||
'url' => $request->input('url'),
|
||||
'title' => $request->input('title'),
|
||||
'referrer' => $request->input('referrer'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track product view with engagement signals
|
||||
*/
|
||||
protected function trackProductView(Request $request): void
|
||||
{
|
||||
$productId = $request->input('product_id');
|
||||
|
||||
if (! $productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$product = Product::find($productId);
|
||||
|
||||
if (! $product) {
|
||||
return;
|
||||
}
|
||||
|
||||
$signals = [
|
||||
'time_on_page' => $request->input('time_on_page'),
|
||||
'scroll_depth' => $request->input('scroll_depth'),
|
||||
'zoomed_image' => $request->boolean('zoomed_image'),
|
||||
'watched_video' => $request->boolean('watched_video'),
|
||||
'downloaded_spec' => $request->boolean('downloaded_spec'),
|
||||
'added_to_cart' => $request->boolean('added_to_cart'),
|
||||
'added_to_wishlist' => $request->boolean('added_to_wishlist'),
|
||||
];
|
||||
|
||||
$this->tracker->trackProductView($product, $signals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track generic page engagement
|
||||
*/
|
||||
protected function trackPageEngagement(Request $request): void
|
||||
{
|
||||
$this->tracker->updateSessionPageView();
|
||||
|
||||
$this->tracker->trackEvent(
|
||||
'page_engagement',
|
||||
'engagement',
|
||||
'interact',
|
||||
null,
|
||||
null,
|
||||
[
|
||||
'time_on_page' => $request->input('time_on_page'),
|
||||
'scroll_depth' => $request->input('scroll_depth'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track click event
|
||||
*/
|
||||
protected function trackClick(Request $request): void
|
||||
{
|
||||
$this->tracker->trackClick(
|
||||
$request->input('element_type', 'unknown'),
|
||||
$request->input('element_id'),
|
||||
$request->input('element_label'),
|
||||
$request->input('url'),
|
||||
[
|
||||
'timestamp' => $request->input('timestamp'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track generic event
|
||||
*/
|
||||
protected function trackGenericEvent(Request $request): void
|
||||
{
|
||||
$this->tracker->trackEvent(
|
||||
$request->input('event_type', 'custom'),
|
||||
$request->input('category', 'general'),
|
||||
$request->input('action', 'action'),
|
||||
$request->input('subject_id'),
|
||||
$request->input('subject_type'),
|
||||
$request->input('metadata', [])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,20 +30,29 @@ class UnifiedAuthenticatedSessionController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
// Smart routing based on user type
|
||||
// Log admin users into the admin guard for Filament access
|
||||
if ($user->user_type === 'admin') {
|
||||
Auth::guard('admin')->login($user);
|
||||
}
|
||||
|
||||
// Smart routing based on user type - use intended() to preserve redirect URL
|
||||
switch ($user->user_type) {
|
||||
case 'buyer':
|
||||
return redirect()->route('buyer.dashboard');
|
||||
return redirect()->intended(route('buyer.dashboard'));
|
||||
|
||||
case 'seller':
|
||||
return redirect()->route('seller.dashboard');
|
||||
return redirect()->intended(route('seller.dashboard'));
|
||||
|
||||
case 'admin':
|
||||
return redirect('/admin');
|
||||
return redirect()->intended('/admin');
|
||||
|
||||
case 'both':
|
||||
// For users with both types, default to seller dashboard
|
||||
return redirect()->intended(route('seller.dashboard'));
|
||||
|
||||
default:
|
||||
// Fallback for users without proper type
|
||||
return redirect()->route('buyer.profile');
|
||||
return redirect()->intended(route('buyer.profile'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
@@ -25,14 +28,57 @@ class UserController extends Controller
|
||||
|
||||
// Load users with their pivot data (contact_type, is_primary, permissions)
|
||||
$users = $business->users()
|
||||
->withPivot('contact_type', 'is_primary', 'permissions')
|
||||
->withPivot('contact_type', 'is_primary', 'permissions', 'role')
|
||||
->orderBy('is_primary', 'desc')
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
|
||||
// Available analytics permissions
|
||||
$analyticsPermissions = [
|
||||
'analytics.overview' => 'Access main analytics dashboard',
|
||||
'analytics.products' => 'View product performance analytics',
|
||||
'analytics.marketing' => 'View marketing and email analytics',
|
||||
'analytics.sales' => 'View sales intelligence and pipeline',
|
||||
'analytics.buyers' => 'View buyer intelligence and engagement',
|
||||
'analytics.export' => 'Export analytics data',
|
||||
];
|
||||
|
||||
return view('business.users.index', [
|
||||
'business' => $business,
|
||||
'users' => $users,
|
||||
'analyticsPermissions' => $analyticsPermissions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user permissions.
|
||||
*/
|
||||
public function updatePermissions(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$business = auth()->user()->businesses()->first();
|
||||
|
||||
if (! $business) {
|
||||
return response()->json(['error' => 'No business found'], 404);
|
||||
}
|
||||
|
||||
// Verify user belongs to this business
|
||||
if (! $business->users->contains($user->id)) {
|
||||
return response()->json(['error' => 'User not found in this business'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'permissions' => 'array',
|
||||
'permissions.*' => 'string',
|
||||
]);
|
||||
|
||||
// Update permissions in pivot table
|
||||
$business->users()->updateExistingPivot($user->id, [
|
||||
'permissions' => $validated['permissions'] ?? [],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Permissions updated successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
185
app/Http/Controllers/Business/UserPermissionsController.php
Normal file
185
app/Http/Controllers/Business/UserPermissionsController.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\PermissionService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UserPermissionsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionService $permissionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Update user permissions via AJAX
|
||||
*/
|
||||
public function update(Request $request, string $businessSlug, int $userId)
|
||||
{
|
||||
try {
|
||||
$business = currentBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Business not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Only owners and admins can manage permissions
|
||||
if (auth()->user()->user_type !== 'admin' && $business->owner_user_id !== auth()->id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You do not have permission to manage user permissions',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$user = User::findOrFail($userId);
|
||||
|
||||
// Verify user belongs to this business
|
||||
if (! $user->businesses()->where('businesses.id', $business->id)->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'User does not belong to this business',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Prevent owner from modifying their own permissions
|
||||
if ($user->id === $business->owner_user_id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Cannot modify owner permissions',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'permissions' => 'array',
|
||||
'permissions.*' => 'string',
|
||||
'role_template' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$permissions = $validated['permissions'] ?? [];
|
||||
$roleTemplate = $validated['role_template'] ?? null;
|
||||
|
||||
// Set permissions using PermissionService
|
||||
$success = $this->permissionService->setPermissions(
|
||||
user: $user,
|
||||
permissions: $permissions,
|
||||
business: $business,
|
||||
roleTemplate: $roleTemplate,
|
||||
reason: 'Updated by '.auth()->user()->name.' via permissions modal'
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
Log::info('User permissions updated', [
|
||||
'business_id' => $business->id,
|
||||
'target_user_id' => $user->id,
|
||||
'actor_user_id' => auth()->id(),
|
||||
'permissions_count' => count($permissions),
|
||||
'role_template' => $roleTemplate,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Permissions updated successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update permissions',
|
||||
], 500);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating user permissions', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
'business_slug' => $businessSlug,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while updating permissions',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a role template to a user
|
||||
*/
|
||||
public function applyTemplate(Request $request, string $businessSlug, int $userId)
|
||||
{
|
||||
try {
|
||||
$business = currentBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Business not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Only owners and admins can manage permissions
|
||||
if (auth()->user()->user_type !== 'admin' && $business->owner_user_id !== auth()->id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You do not have permission to manage user permissions',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$user = User::findOrFail($userId);
|
||||
|
||||
// Verify user belongs to this business
|
||||
if (! $user->businesses()->where('businesses.id', $business->id)->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'User does not belong to this business',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'template_key' => 'required|string',
|
||||
'merge' => 'boolean',
|
||||
]);
|
||||
|
||||
$templateKey = $validated['template_key'];
|
||||
$merge = $validated['merge'] ?? false;
|
||||
|
||||
// Apply role template
|
||||
$permissions = $this->permissionService->applyRoleTemplate(
|
||||
user: $user,
|
||||
templateKey: $templateKey,
|
||||
business: $business,
|
||||
merge: $merge
|
||||
);
|
||||
|
||||
if ($permissions === null) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Role template not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Role template applied successfully',
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error applying role template', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
'business_slug' => $businessSlug,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while applying role template',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/Http/Controllers/Buyer/BackorderController.php
Normal file
143
app/Http/Controllers/Buyer/BackorderController.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\ProcessBackorderRequest;
|
||||
use App\Models\Product;
|
||||
use App\Services\BackorderService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class BackorderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BackorderService $backorderService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a backorder
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'quantity' => 'required|integer|min:1',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$business = $user->businesses->first(); // Assuming user has at least one business
|
||||
|
||||
if (! $business) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You must have a business account to place backorders.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$product = Product::findOrFail($request->product_id);
|
||||
|
||||
// Check if product is actually out of stock
|
||||
if ($product->isInStock()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This product is currently in stock. Please add it to your cart instead.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Dispatch the job to process backorder in the background
|
||||
ProcessBackorderRequest::dispatch(
|
||||
userId: $user->id,
|
||||
buyerBusinessId: $business->id,
|
||||
productId: $product->id,
|
||||
quantity: $request->quantity,
|
||||
notes: $request->notes
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Backorder placed successfully! We will create an order automatically when inventory becomes available.',
|
||||
'backorder' => [
|
||||
'product_id' => $product->id,
|
||||
'product_name' => $product->name,
|
||||
'quantity' => $request->quantity,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to create backorder. Please try again.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's backorders
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
$business = $user->businesses->first();
|
||||
|
||||
if (! $business) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No business found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$backorders = \App\Models\Backorder::where('business_id', $business->id)
|
||||
->with(['product.brand', 'order'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'backorders' => $backorders->map(function ($backorder) {
|
||||
return [
|
||||
'id' => $backorder->id,
|
||||
'product' => [
|
||||
'name' => $backorder->product->name,
|
||||
'sku' => $backorder->product->sku,
|
||||
'brand_name' => $backorder->product->brand->name,
|
||||
],
|
||||
'quantity' => $backorder->quantity,
|
||||
'status' => $backorder->status,
|
||||
'order_number' => $backorder->order?->order_number,
|
||||
'created_at' => $backorder->created_at->toDateString(),
|
||||
'converted_at' => $backorder->converted_at?->toDateString(),
|
||||
];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a backorder
|
||||
*/
|
||||
public function cancel(Request $request, int $backorderId): JsonResponse
|
||||
{
|
||||
$cancelled = $this->backorderService->cancelBackorder($backorderId, auth()->id());
|
||||
|
||||
if ($cancelled) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Backorder cancelled successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Backorder not found or already processed.',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Buyer/BrandBrowseController.php
Normal file
63
app/Http/Controllers/Buyer/BrandBrowseController.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BrandBrowseController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show brand menu for buyers to browse and order
|
||||
* This is the main product browsing interface for buyers
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
public function browse(Request $request, string $businessSlug, string $brandHashid)
|
||||
{
|
||||
// Manually resolve business and brand (cross-tenant access allowed)
|
||||
// Buyers can browse ANY seller's brand menu
|
||||
$business = Business::where('slug', $businessSlug)->firstOrFail();
|
||||
$brand = Brand::where('hashid', $brandHashid)
|
||||
->where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
// Load brand with business relationship
|
||||
$brand->load('business');
|
||||
|
||||
// Get products organized by product line
|
||||
$products = $brand->products()
|
||||
->with(['strain', 'images', 'productLine'])
|
||||
->where('is_active', true)
|
||||
->orderBy('product_line_id')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Group products by product line
|
||||
$productsByLine = $products->groupBy(function ($product) {
|
||||
return $product->productLine ? $product->productLine->name : 'Other Products';
|
||||
});
|
||||
|
||||
// Get other brands from same business
|
||||
$otherBrands = $business
|
||||
->brands()
|
||||
->where('id', '!=', $brand->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
// Mark this as buyer view
|
||||
$isSeller = false;
|
||||
|
||||
return view('seller.brands.preview', compact(
|
||||
'business',
|
||||
'brand',
|
||||
'products',
|
||||
'productsByLine',
|
||||
'otherBrands',
|
||||
'isSeller'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,14 @@ class CartController extends Controller
|
||||
// Fetch items once - calculate totals from loaded collection
|
||||
$items = $this->cartService->getCartItems($user, $sessionId);
|
||||
|
||||
$subtotal = $items->sum(fn ($item) => $item->quantity * ($item->product->wholesale_price ?? 0));
|
||||
$subtotal = $items->sum(function ($item) {
|
||||
$product = $item->product;
|
||||
$regularPrice = $product->wholesale_price ?? $product->msrp ?? 0;
|
||||
$hasSalePrice = $product->sale_price && $product->sale_price < $regularPrice;
|
||||
$unitPrice = $hasSalePrice ? $product->sale_price : $regularPrice;
|
||||
|
||||
return $item->quantity * $unitPrice;
|
||||
});
|
||||
|
||||
// Calculate tax based on business tax rate
|
||||
$taxRate = $business->getTaxRate() ?? 0.08;
|
||||
@@ -101,7 +108,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Update cart item quantity (Ajax).
|
||||
*/
|
||||
public function update(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
|
||||
public function update(\App\Models\Business $business, Request $request, string $cartId): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'quantity' => 'required|integer|min:1',
|
||||
@@ -111,11 +118,17 @@ class CartController extends Controller
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
try {
|
||||
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'), $user, $sessionId);
|
||||
$cart = $this->cartService->updateQuantity((int) $cartId, $request->integer('quantity'), $user, $sessionId);
|
||||
|
||||
// Ensure product is loaded for JSON response
|
||||
$cart->load('product', 'brand');
|
||||
|
||||
// Calculate unit price (respecting sale pricing)
|
||||
$product = $cart->product;
|
||||
$regularPrice = $product->wholesale_price ?? $product->msrp ?? 0;
|
||||
$hasSalePrice = $product->sale_price && $product->sale_price < $regularPrice;
|
||||
$unitPrice = $hasSalePrice ? $product->sale_price : $regularPrice;
|
||||
|
||||
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
|
||||
$tax = $this->cartService->getTax($user, $sessionId);
|
||||
$total = $this->cartService->getTotal($user, $sessionId);
|
||||
@@ -124,6 +137,7 @@ class CartController extends Controller
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'cart_item' => $cart,
|
||||
'unit_price' => $unitPrice,
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
@@ -140,7 +154,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Remove item from cart (Ajax).
|
||||
*/
|
||||
public function remove(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
|
||||
public function remove(\App\Models\Business $business, Request $request, string $cartId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
@@ -8,10 +8,12 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Services\CartService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CheckoutController extends Controller
|
||||
@@ -78,7 +80,7 @@ class CheckoutController extends Controller
|
||||
public function process(Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'location_id' => 'required_if:delivery_method,delivery|nullable|exists:locations,id',
|
||||
'location_id' => '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',
|
||||
@@ -104,94 +106,162 @@ class CheckoutController extends Controller
|
||||
$paymentTerms = $request->input('payment_terms');
|
||||
$dueDate = $this->calculateDueDate($paymentTerms);
|
||||
|
||||
// Calculate totals with payment term surcharge
|
||||
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
|
||||
$surchargePercent = Order::getSurchargePercentage($paymentTerms);
|
||||
$surcharge = $subtotal * ($surchargePercent / 100);
|
||||
// Group cart items by brand
|
||||
$itemsByBrand = $items->groupBy(function ($item) {
|
||||
return $item->product->brand_id ?? 'unknown';
|
||||
});
|
||||
|
||||
// Tax is calculated on subtotal + surcharge using business tax rate
|
||||
// (0.00 if business is tax-exempt wholesale/resale with Form 5000A)
|
||||
// Remove items with unknown brand
|
||||
$itemsByBrand = $itemsByBrand->filter(function ($items, $brandId) {
|
||||
return $brandId !== 'unknown';
|
||||
});
|
||||
|
||||
// Generate order group ID to link all orders from this checkout
|
||||
$orderGroupId = 'OG-'.strtoupper(Str::random(12));
|
||||
|
||||
// Tax rate for buyer's business
|
||||
$taxRate = $business->getTaxRate();
|
||||
$tax = ($subtotal + $surcharge) * $taxRate;
|
||||
$total = $subtotal + $surcharge + $tax;
|
||||
$surchargePercent = Order::getSurchargePercentage($paymentTerms);
|
||||
|
||||
// Create order in transaction
|
||||
$order = DB::transaction(function () use ($request, $user, $business, $items, $subtotal, $surcharge, $tax, $total, $paymentTerms, $dueDate) {
|
||||
// Generate order number
|
||||
$orderNumber = $this->generateOrderNumber();
|
||||
// Create orders in transaction (one per brand)
|
||||
$orders = DB::transaction(function () use (
|
||||
$request,
|
||||
$user,
|
||||
$business,
|
||||
$itemsByBrand,
|
||||
$taxRate,
|
||||
$surchargePercent,
|
||||
$paymentTerms,
|
||||
$dueDate,
|
||||
$orderGroupId
|
||||
) {
|
||||
$createdOrders = [];
|
||||
|
||||
// Create order
|
||||
$order = Order::create([
|
||||
'order_number' => $orderNumber,
|
||||
'business_id' => $business->id,
|
||||
'user_id' => $user->id,
|
||||
'location_id' => $request->input('location_id'),
|
||||
'subtotal' => $subtotal,
|
||||
'surcharge' => $surcharge,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
'status' => 'new',
|
||||
'created_by' => 'buyer', // Buyer-initiated order
|
||||
'payment_terms' => $paymentTerms,
|
||||
'due_date' => $dueDate,
|
||||
'notes' => $request->input('notes'),
|
||||
'delivery_method' => $request->input('delivery_method', 'delivery'),
|
||||
'pickup_driver_first_name' => $request->input('pickup_driver_first_name'),
|
||||
'pickup_driver_last_name' => $request->input('pickup_driver_last_name'),
|
||||
'pickup_driver_license' => $request->input('pickup_driver_license'),
|
||||
'pickup_driver_phone' => $request->input('pickup_driver_phone'),
|
||||
'pickup_vehicle_plate' => $request->input('pickup_vehicle_plate'),
|
||||
]);
|
||||
foreach ($itemsByBrand as $brandId => $brandItems) {
|
||||
// Get seller business ID from the brand
|
||||
$sellerBusinessId = $brandItems->first()->product->brand->business_id;
|
||||
|
||||
// Create order items from cart
|
||||
foreach ($items as $item) {
|
||||
OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_id' => $item->batch_id,
|
||||
'batch_number' => $item->batch?->batch_number,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_price' => $item->product->wholesale_price,
|
||||
'line_total' => $item->quantity * $item->product->wholesale_price,
|
||||
'product_name' => $item->product->name,
|
||||
'product_sku' => $item->product->sku,
|
||||
'brand_name' => $item->brand->name ?? '',
|
||||
// Calculate totals for this brand's items
|
||||
$brandSubtotal = $brandItems->sum(function ($item) {
|
||||
return $item->quantity * $item->product->wholesale_price;
|
||||
});
|
||||
|
||||
$brandSurcharge = $brandSubtotal * ($surchargePercent / 100);
|
||||
$brandTax = ($brandSubtotal + $brandSurcharge) * $taxRate;
|
||||
$brandTotal = $brandSubtotal + $brandSurcharge + $brandTax;
|
||||
|
||||
// Generate order number
|
||||
$orderNumber = $this->generateOrderNumber();
|
||||
|
||||
// Create order for this brand
|
||||
$order = Order::create([
|
||||
'order_number' => $orderNumber,
|
||||
'order_group_id' => $orderGroupId,
|
||||
'business_id' => $business->id,
|
||||
'seller_business_id' => $sellerBusinessId,
|
||||
'user_id' => $user->id,
|
||||
'location_id' => $request->input('location_id'),
|
||||
'subtotal' => $brandSubtotal,
|
||||
'surcharge' => $brandSurcharge,
|
||||
'tax' => $brandTax,
|
||||
'total' => $brandTotal,
|
||||
'status' => 'new',
|
||||
'created_by' => 'buyer',
|
||||
'payment_terms' => $paymentTerms,
|
||||
'due_date' => $dueDate,
|
||||
'notes' => $request->input('notes'),
|
||||
'delivery_method' => $request->input('delivery_method', 'delivery'),
|
||||
'pickup_driver_first_name' => $request->input('pickup_driver_first_name'),
|
||||
'pickup_driver_last_name' => $request->input('pickup_driver_last_name'),
|
||||
'pickup_driver_license' => $request->input('pickup_driver_license'),
|
||||
'pickup_driver_phone' => $request->input('pickup_driver_phone'),
|
||||
'pickup_vehicle_plate' => $request->input('pickup_vehicle_plate'),
|
||||
]);
|
||||
|
||||
// If batch selected, allocate inventory from that batch
|
||||
if ($item->batch_id && $item->batch) {
|
||||
$item->batch->allocate($item->quantity);
|
||||
// Create order items for this brand
|
||||
foreach ($brandItems as $item) {
|
||||
OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_id' => $item->batch_id,
|
||||
'batch_number' => $item->batch?->batch_number,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_price' => $item->product->wholesale_price,
|
||||
'line_total' => $item->quantity * $item->product->wholesale_price,
|
||||
'product_name' => $item->product->name,
|
||||
'product_sku' => $item->product->sku,
|
||||
'brand_name' => $item->brand->name ?? '',
|
||||
]);
|
||||
|
||||
// If batch selected, allocate inventory from that batch
|
||||
if ($item->batch_id && $item->batch) {
|
||||
$item->batch->allocate($item->quantity);
|
||||
}
|
||||
}
|
||||
|
||||
$createdOrders[] = $order;
|
||||
}
|
||||
|
||||
return $order;
|
||||
return collect($createdOrders);
|
||||
});
|
||||
|
||||
// Clear the cart
|
||||
$this->cartService->clear($user, $sessionId);
|
||||
|
||||
// Notify sellers of new order
|
||||
// Notify sellers of new orders (wrapped in try-catch to prevent email failures from blocking checkout)
|
||||
$sellerNotificationService = app(\App\Services\SellerNotificationService::class);
|
||||
$sellerNotificationService->newOrderReceived($order);
|
||||
foreach ($orders as $order) {
|
||||
try {
|
||||
$sellerNotificationService->newOrderReceived($order);
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't block the checkout
|
||||
\Log::error('Failed to send seller notification email', [
|
||||
'order_id' => $order->id,
|
||||
'order_number' => $order->order_number,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to success page
|
||||
return redirect()->route('buyer.business.checkout.success', ['business' => $business->slug, 'order' => $order->order_number])
|
||||
->with('success', 'Order placed successfully!');
|
||||
// Redirect to orders page with success message
|
||||
if ($orders->count() > 1) {
|
||||
return redirect()->route('buyer.business.orders.index', ['business' => $business->slug])
|
||||
->with('success', "Success! Seller has been notified of your {$orders->count()} orders and will be in contact with you. Thank you for your business!");
|
||||
}
|
||||
|
||||
// Single order - redirect to order details page with session flag to show success banner
|
||||
return redirect()->route('buyer.business.orders.show', ['business' => $business->slug, 'order' => $orders->first()->order_number])
|
||||
->with('order_created', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display order confirmation page.
|
||||
* Handles both single orders (by order_number) and order groups (by order_group_id).
|
||||
*/
|
||||
public function success(Business $business, Request $request, Order $order): View|RedirectResponse
|
||||
public function success(Business $business, Request $request, string $order): View|RedirectResponse
|
||||
{
|
||||
// Load relationships
|
||||
$order->load(['items.product', 'business', 'location']);
|
||||
// Check if this is an order group ID (starts with OG-)
|
||||
if (str_starts_with($order, 'OG-')) {
|
||||
// Load all orders in this group
|
||||
$orders = Order::where('order_group_id', $order)
|
||||
->where('business_id', $business->id)
|
||||
->with(['items.product', 'business', 'location', 'sellerBusiness'])
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
// Ensure order belongs to this business
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized');
|
||||
if ($orders->isEmpty()) {
|
||||
abort(404, 'Order group not found');
|
||||
}
|
||||
|
||||
return view('buyer.checkout.success-group', compact('orders', 'business'));
|
||||
}
|
||||
|
||||
// Single order - find by order_number
|
||||
$order = Order::where('order_number', $order)
|
||||
->where('business_id', $business->id)
|
||||
->with(['items.product', 'business', 'location'])
|
||||
->firstOrFail();
|
||||
|
||||
return view('buyer.checkout.success', compact('order', 'business'));
|
||||
}
|
||||
|
||||
@@ -221,4 +291,82 @@ class CheckoutController extends Controller
|
||||
default => now()->addDays(30),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process checkout and create orders (one per brand).
|
||||
* New route for order splitting logic.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'cart' => 'required|array|min:1',
|
||||
'cart.*.product_id' => 'required|exists:products,id',
|
||||
'cart.*.quantity' => 'required|integer|min:1',
|
||||
'delivery_window_id' => 'nullable|exists:delivery_windows,id',
|
||||
'delivery_window_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$business = $request->user()->businesses()->first();
|
||||
|
||||
if (! $business) {
|
||||
return redirect()->back()->with('error', 'No business associated with your account');
|
||||
}
|
||||
|
||||
// Load products and group by brand
|
||||
$productIds = collect($validated['cart'])->pluck('product_id');
|
||||
$products = Product::with('brand.business')
|
||||
->whereIn('id', $productIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Group cart items by brand
|
||||
$itemsByBrand = collect($validated['cart'])->groupBy(function ($item) use ($products) {
|
||||
$product = $products->get($item['product_id']);
|
||||
|
||||
return $product->brand_id;
|
||||
});
|
||||
|
||||
// Generate unique group ID for all orders in this checkout
|
||||
$orderGroupId = 'checkout_'.Str::uuid();
|
||||
|
||||
$createdOrders = [];
|
||||
|
||||
// Create one order per brand
|
||||
foreach ($itemsByBrand as $brandId => $items) {
|
||||
// Get seller business ID from the brand
|
||||
$product = $products->get($items[0]['product_id']);
|
||||
$sellerBusinessId = $product->brand->business_id;
|
||||
|
||||
$order = Order::create([
|
||||
'order_number' => $this->generateOrderNumber(),
|
||||
'business_id' => $business->id,
|
||||
'seller_business_id' => $sellerBusinessId,
|
||||
'order_group_id' => $orderGroupId,
|
||||
'status' => 'new',
|
||||
'created_by' => 'buyer',
|
||||
'delivery_window_id' => $validated['delivery_window_id'] ?? null,
|
||||
'delivery_window_date' => $validated['delivery_window_date'] ?? null,
|
||||
]);
|
||||
|
||||
// Create order items
|
||||
foreach ($items as $item) {
|
||||
$product = $products->get($item['product_id']);
|
||||
|
||||
$order->items()->create([
|
||||
'product_id' => $product->id,
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $product->wholesale_price,
|
||||
'line_total' => $item['quantity'] * $product->wholesale_price,
|
||||
'product_name' => $product->name,
|
||||
'product_sku' => $product->sku,
|
||||
]);
|
||||
}
|
||||
|
||||
$createdOrders[] = $order;
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', count($createdOrders).' order(s) created successfully');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\OrderItem;
|
||||
use App\Services\InvoiceService;
|
||||
use App\Services\OrderModificationService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -48,145 +45,7 @@ class InvoiceController extends Controller
|
||||
|
||||
$invoice->load(['order.items', 'business']);
|
||||
|
||||
// Prepare invoice items data for Alpine.js
|
||||
$invoiceItems = $invoice->order->items->map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'quantity' => $item->picked_qty,
|
||||
'originalQuantity' => $item->picked_qty,
|
||||
'unit_price' => $item->unit_price,
|
||||
'deleted' => false,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('buyer.invoices.show', compact('invoice', 'invoiceItems', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the invoice without modifications.
|
||||
*/
|
||||
public function approve(\App\Models\Business $business, Invoice $invoice)
|
||||
{
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to approve this invoice.');
|
||||
}
|
||||
|
||||
if (! $invoice->canBeEditedByBuyer()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This invoice cannot be approved at this time.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$invoice->buyerApprove(auth()->user());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Invoice approved successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the invoice.
|
||||
*/
|
||||
public function reject(\App\Models\Business $business, Request $request, Invoice $invoice)
|
||||
{
|
||||
$request->validate([
|
||||
'reason' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to reject this invoice.');
|
||||
}
|
||||
|
||||
if (! $invoice->canBeEditedByBuyer()) {
|
||||
return back()->with('error', 'This invoice cannot be rejected at this time.');
|
||||
}
|
||||
|
||||
$invoice->buyerReject(auth()->user(), $request->reason);
|
||||
|
||||
return redirect()->route('buyer.invoices.index')
|
||||
->with('success', 'Invoice rejected successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify the invoice (record buyer's changes).
|
||||
*/
|
||||
public function modify(\App\Models\Business $business, Request $request, Invoice $invoice, OrderModificationService $modificationService)
|
||||
{
|
||||
$request->validate([
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|exists:order_items,id',
|
||||
'items.*.quantity' => 'required|integer|min:0',
|
||||
'items.*.deleted' => 'required|boolean',
|
||||
]);
|
||||
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Unauthorized to modify this invoice.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
if (! $invoice->canBeEditedByBuyer()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This invoice cannot be modified at this time.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Record all changes
|
||||
$hasChanges = false;
|
||||
foreach ($request->items as $itemData) {
|
||||
$item = OrderItem::find($itemData['id']);
|
||||
|
||||
// Skip if item doesn't belong to this order
|
||||
if ($item->order_id !== $invoice->order_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for deletion
|
||||
if ($itemData['deleted'] && ! $item->deleted_at) {
|
||||
$modificationService->recordItemDeletion($invoice, $item, auth()->user());
|
||||
$hasChanges = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for quantity change
|
||||
if ($itemData['quantity'] != $item->picked_qty) {
|
||||
// Validate: can only reduce, not increase
|
||||
if ($itemData['quantity'] > $item->picked_qty) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You can only reduce quantities, not increase them.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$modificationService->recordItemChange(
|
||||
$invoice,
|
||||
$item,
|
||||
['quantity' => $itemData['quantity']],
|
||||
auth()->user()
|
||||
);
|
||||
$hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $hasChanges) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No changes detected.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Update invoice status to buyer_modified
|
||||
$invoice->buyerModify();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Changes saved successfully. The seller will review your modifications.',
|
||||
]);
|
||||
return view('buyer.invoices.show', compact('invoice', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DeliveryWindow;
|
||||
use App\Models\Order;
|
||||
use App\Services\DeliveryWindowService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private DeliveryWindowService $deliveryWindowService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the user's orders.
|
||||
*/
|
||||
@@ -42,7 +50,7 @@ class OrderController extends Controller
|
||||
abort(403, 'Unauthorized to view this order.');
|
||||
}
|
||||
|
||||
$order->load(['items.product', 'business', 'location', 'user', 'invoice', 'manifest']);
|
||||
$order->load(['items.product.brand', 'business', 'location', 'user', 'invoice', 'manifest', 'deliveryWindow', 'pendingCancellationRequest']);
|
||||
|
||||
return view('buyer.orders.show', compact('business', 'order'));
|
||||
}
|
||||
@@ -65,8 +73,31 @@ class OrderController extends Controller
|
||||
return back()->with('success', "Order {$order->order_number} has been accepted.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Request cancellation of an order (buyer-initiated).
|
||||
*/
|
||||
public function requestCancellation(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
if (! $order->canRequestCancellation()) {
|
||||
return back()->with('error', 'This order cannot have a cancellation request at this stage.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'reason' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
$order->requestCancellation(auth()->user(), $validated['reason']);
|
||||
|
||||
return back()->with('success', "Cancellation request submitted for order {$order->order_number}. The seller will review your request.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an order (buyer-initiated).
|
||||
* NOTE: This is the old direct cancel method, kept for backward compatibility.
|
||||
*/
|
||||
public function cancel(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
@@ -156,4 +187,433 @@ class OrderController extends Controller
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order's delivery window
|
||||
*/
|
||||
public function updateDeliveryWindow(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
|
||||
{
|
||||
// Ensure order belongs to buyer's business
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow updates for delivery orders at approved_for_delivery status
|
||||
if ($order->status !== 'approved_for_delivery') {
|
||||
abort(422, 'Delivery window can only be set after buyer has approved the order for delivery');
|
||||
}
|
||||
|
||||
// Only delivery orders need delivery windows
|
||||
if (! $order->isDelivery()) {
|
||||
abort(422, 'Delivery window can only be set for delivery orders');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'delivery_window_id' => 'required|exists:delivery_windows,id',
|
||||
'delivery_window_date' => 'required|date|after_or_equal:today',
|
||||
'location_id' => 'required|exists:locations,id',
|
||||
]);
|
||||
|
||||
$window = DeliveryWindow::findOrFail($validated['delivery_window_id']);
|
||||
|
||||
// Get seller's business ID from the first order item's product's brand
|
||||
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
|
||||
|
||||
if (! $sellerBusinessId) {
|
||||
abort(422, 'Unable to determine seller business for this order');
|
||||
}
|
||||
|
||||
// Ensure window belongs to the SELLER's business
|
||||
if ($window->business_id !== $sellerBusinessId) {
|
||||
abort(422, 'Delivery window does not belong to seller business');
|
||||
}
|
||||
|
||||
$date = Carbon::parse($validated['delivery_window_date']);
|
||||
|
||||
// Validate that location belongs to buyer's business
|
||||
$location = \App\Models\Location::where('id', $validated['location_id'])
|
||||
->where('business_id', $business->id)
|
||||
->where('accepts_deliveries', true)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $location) {
|
||||
abort(422, 'Invalid delivery location');
|
||||
}
|
||||
|
||||
// Validate using service
|
||||
if (! $this->deliveryWindowService->validateWindowSelection($window, $date)) {
|
||||
abort(422, 'Invalid delivery window selection');
|
||||
}
|
||||
|
||||
$this->deliveryWindowService->updateOrderWindow($order, $window, $date, $validated['location_id']);
|
||||
|
||||
return redirect()
|
||||
->route('buyer.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Delivery scheduled for '.$date->format('l, F j, Y').' at '.$location->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available delivery windows for an order's seller business.
|
||||
*/
|
||||
public function getAvailableDeliveryWindows(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
// Ensure order belongs to buyer's business
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
$date = $request->query('date');
|
||||
if (! $date) {
|
||||
return response()->json(['error' => 'Date parameter required'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$selectedDate = Carbon::parse($date);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => 'Invalid date format'], 400);
|
||||
}
|
||||
|
||||
$dayOfWeek = $selectedDate->dayOfWeek;
|
||||
|
||||
// Get seller's business ID from the first order item's product's brand
|
||||
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
|
||||
|
||||
if (! $sellerBusinessId) {
|
||||
return response()->json(['windows' => []]);
|
||||
}
|
||||
|
||||
// Fetch active delivery windows for the seller on this day
|
||||
$windows = DeliveryWindow::where('business_id', $sellerBusinessId)
|
||||
->where('day_of_week', $dayOfWeek)
|
||||
->where('is_active', true)
|
||||
->orderBy('start_time')
|
||||
->get()
|
||||
->map(function ($window) {
|
||||
return [
|
||||
'id' => $window->id,
|
||||
'day_name' => $window->day_name,
|
||||
'time_range' => $window->time_range,
|
||||
'start_time' => $window->start_time,
|
||||
'end_time' => $window->end_time,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['windows' => $windows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show pre-delivery approval form (Review #1: After picking, before delivery).
|
||||
* Buyer reviews order with COAs and can approve/reject entire line items.
|
||||
*/
|
||||
public function showPreDeliveryApproval(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
// Authorization check
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to access this order.');
|
||||
}
|
||||
|
||||
// Only ready_for_delivery orders can be reviewed
|
||||
if ($order->status !== 'ready_for_delivery') {
|
||||
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Only orders ready for delivery can be reviewed.');
|
||||
}
|
||||
|
||||
// Load relationships including COAs
|
||||
$order->load([
|
||||
'items.product.brand',
|
||||
'items.batch.coaFiles' => function ($query) {
|
||||
$query->orderBy('is_primary', 'desc')->orderBy('display_order');
|
||||
},
|
||||
'business',
|
||||
'location',
|
||||
]);
|
||||
|
||||
return view('buyer.orders.pre-delivery-review', compact('business', 'order'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pre-delivery approval (Review #1).
|
||||
* Buyer can approve order or reject specific line items.
|
||||
*/
|
||||
public function processPreDeliveryApproval(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
|
||||
{
|
||||
// Authorization check
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
// Only ready_for_delivery orders can be approved
|
||||
if ($order->status !== 'ready_for_delivery') {
|
||||
return back()->with('error', 'Only orders ready for delivery can be reviewed.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'action' => 'required|in:approve,reject',
|
||||
'rejected_items' => 'nullable|array',
|
||||
'rejected_items.*' => 'exists:order_items,id',
|
||||
'rejection_reason' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Validate that at least one line item remains active when approving with rejections
|
||||
if ($validated['action'] === 'approve' && ! empty($validated['rejected_items'])) {
|
||||
$totalItems = $order->items()->count();
|
||||
$rejectedItemsCount = count($validated['rejected_items']);
|
||||
|
||||
if ($rejectedItemsCount >= $totalItems) {
|
||||
return back()->withErrors([
|
||||
'rejected_items' => 'You cannot reject all items. If you wish to cancel the entire order, please use the "Request cancellation" option below.',
|
||||
])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$rejectedProductNames = [];
|
||||
|
||||
\DB::transaction(function () use ($order, $validated, &$rejectedProductNames) {
|
||||
if ($validated['action'] === 'reject') {
|
||||
// Reject entire order
|
||||
$order->update([
|
||||
'status' => 'rejected',
|
||||
'rejected_at' => now(),
|
||||
'rejected_reason' => $validated['rejection_reason'] ?? 'Order rejected by buyer during review',
|
||||
]);
|
||||
|
||||
// Return all inventory to stock
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->batch_id && $item->batch) {
|
||||
$item->batch->deallocate($item->quantity);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Approve with optional item rejections
|
||||
if (! empty($validated['rejected_items'])) {
|
||||
|
||||
// Mark rejected items (keep in database for history)
|
||||
foreach ($validated['rejected_items'] as $itemId) {
|
||||
$item = $order->items()->find($itemId);
|
||||
if ($item) {
|
||||
$rejectedProductNames[] = $item->product_name;
|
||||
|
||||
// Return inventory
|
||||
if ($item->batch_id && $item->batch) {
|
||||
$item->batch->deallocate($item->quantity);
|
||||
}
|
||||
|
||||
// Mark item as rejected (don't delete - preserve history)
|
||||
$item->update(['pre_delivery_status' => 'rejected']);
|
||||
|
||||
// Delete related PickingTicketItems to prevent picking
|
||||
\App\Models\PickingTicketItem::where('order_item_id', $item->id)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Add rejection instruction to the fulfillment work order
|
||||
if (! empty($rejectedProductNames) && $order->fulfillmentWorkOrder) {
|
||||
$rejectionMessage = 'Buyer rejected: '.implode(', ', $rejectedProductNames).'. Pull and restock these items.';
|
||||
|
||||
$order->fulfillmentWorkOrder->update([
|
||||
'instructions' => $order->fulfillmentWorkOrder->instructions
|
||||
? $order->fulfillmentWorkOrder->instructions."\n\n".$rejectionMessage
|
||||
: $rejectionMessage,
|
||||
]);
|
||||
}
|
||||
|
||||
// Check for empty picking tickets and delete them
|
||||
if ($order->fulfillmentWorkOrder) {
|
||||
foreach ($order->fulfillmentWorkOrder->pickingTickets as $ticket) {
|
||||
if ($ticket->items()->count() === 0) {
|
||||
$ticket->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate order totals based on non-rejected items only
|
||||
$order->refresh();
|
||||
$order->load('items');
|
||||
|
||||
$activeItems = $order->items->where('pre_delivery_status', '!=', 'rejected');
|
||||
$subtotal = $activeItems->sum('line_total');
|
||||
$surchargePercent = \App\Models\Order::getSurchargePercentage($order->payment_terms);
|
||||
$surcharge = $subtotal * ($surchargePercent / 100);
|
||||
$taxRate = $order->business->getTaxRate();
|
||||
$tax = ($subtotal + $surcharge) * $taxRate;
|
||||
$total = $subtotal + $surcharge + $tax;
|
||||
|
||||
$order->update([
|
||||
'subtotal' => $subtotal,
|
||||
'surcharge' => $surcharge,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if any non-rejected items remain
|
||||
$activeItemsCount = $order->items()->where(function ($q) {
|
||||
$q->whereNull('pre_delivery_status')->orWhere('pre_delivery_status', '!=', 'rejected');
|
||||
})->count();
|
||||
|
||||
if ($activeItemsCount === 0) {
|
||||
$order->update([
|
||||
'status' => 'rejected',
|
||||
'rejected_at' => now(),
|
||||
'rejected_reason' => 'All items rejected by buyer during review',
|
||||
]);
|
||||
} else {
|
||||
// Mark as approved for delivery
|
||||
$order->update([
|
||||
'status' => 'approved_for_delivery',
|
||||
'buyer_approved_at' => now(),
|
||||
'buyer_approved_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Notify seller if items were rejected
|
||||
if (! empty($rejectedProductNames)) {
|
||||
try {
|
||||
$sellerNotificationService = app(\App\Services\SellerNotificationService::class);
|
||||
$sellerNotificationService->itemsRejectedDuringReview($order, $rejectedProductNames);
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't block the approval process
|
||||
\Log::error('Failed to send seller notification for rejected items', [
|
||||
'order_id' => $order->id,
|
||||
'order_number' => $order->order_number,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$message = match ($order->status) {
|
||||
'rejected' => 'Order rejected. Items have been returned to inventory.',
|
||||
'approved_for_delivery' => empty($validated['rejected_items'])
|
||||
? 'Order approved for delivery!'
|
||||
: 'Order approved with '.count($validated['rejected_items']).' item(s) removed.',
|
||||
default => 'Order updated.',
|
||||
};
|
||||
|
||||
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show delivery acceptance form for buyer to accept/reject items (Review #2: After delivery).
|
||||
*/
|
||||
public function showAcceptance(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
// Authorization check
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to access this order.');
|
||||
}
|
||||
|
||||
// Only delivered orders can be accepted
|
||||
if ($order->status !== 'delivered') {
|
||||
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Only delivered orders can be accepted.');
|
||||
}
|
||||
|
||||
// Load relationships including COAs
|
||||
$order->load([
|
||||
'items.product.brand',
|
||||
'items.batch.coaFiles' => function ($query) {
|
||||
$query->orderBy('is_primary', 'desc')->orderBy('display_order');
|
||||
},
|
||||
'business',
|
||||
'location',
|
||||
]);
|
||||
|
||||
return view('buyer.orders.accept', compact('business', 'order'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process delivery acceptance (accept/reject line items).
|
||||
*/
|
||||
public function processAcceptance(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
|
||||
{
|
||||
// Authorization check
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
// Only delivered orders can be accepted
|
||||
if ($order->status !== 'delivered') {
|
||||
return back()->with('error', 'Only delivered orders can be accepted.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array',
|
||||
'items.*.accepted_qty' => 'required|integer|min:0',
|
||||
'items.*.rejected_qty' => 'required|integer|min:0',
|
||||
'items.*.rejection_reason' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Custom validation: accepted + rejected must equal ordered quantity
|
||||
$order->load('items');
|
||||
foreach ($validated['items'] as $itemId => $itemData) {
|
||||
$orderItem = $order->items->firstWhere('id', $itemId);
|
||||
|
||||
if (! $orderItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalQty = $itemData['accepted_qty'] + $itemData['rejected_qty'];
|
||||
if ($totalQty !== $orderItem->quantity) {
|
||||
return back()->withErrors([
|
||||
"items.{$itemId}" => "Accepted and rejected quantities must equal ordered quantity ({$orderItem->quantity})",
|
||||
]);
|
||||
}
|
||||
|
||||
// Validate rejection reason is provided when items are rejected
|
||||
if ($itemData['rejected_qty'] > 0 && empty($itemData['rejection_reason'])) {
|
||||
return back()->withErrors([
|
||||
"items.{$itemId}.rejection_reason" => 'Rejection reason is required when rejecting items',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Update each order item with acceptance data
|
||||
\DB::transaction(function () use ($order, $validated) {
|
||||
foreach ($validated['items'] as $itemId => $itemData) {
|
||||
$orderItem = $order->items->firstWhere('id', $itemId);
|
||||
|
||||
if ($orderItem) {
|
||||
$orderItem->update([
|
||||
'accepted_qty' => $itemData['accepted_qty'],
|
||||
'rejected_qty' => $itemData['rejected_qty'],
|
||||
'rejection_reason' => $itemData['rejection_reason'] ?? null,
|
||||
]);
|
||||
|
||||
// Return rejected items to inventory if batch is set
|
||||
if ($itemData['rejected_qty'] > 0 && $orderItem->batch_id && $orderItem->batch) {
|
||||
$orderItem->batch->deallocate($itemData['rejected_qty']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final order status
|
||||
$hasRejections = collect($validated['items'])->some(fn ($item) => $item['rejected_qty'] > 0);
|
||||
$allRejected = collect($validated['items'])->every(fn ($item) => $item['rejected_qty'] === ($order->items->firstWhere('id', array_search($item, $validated['items']))->quantity ?? 0));
|
||||
|
||||
if ($allRejected) {
|
||||
$order->update([
|
||||
'status' => 'rejected',
|
||||
'rejected_at' => now(),
|
||||
'rejected_reason' => 'All items rejected by buyer',
|
||||
]);
|
||||
} else {
|
||||
$order->markBuyerApproved();
|
||||
|
||||
// Create invoice based on accepted quantities
|
||||
$invoiceService = app(\App\Services\InvoiceService::class);
|
||||
$invoiceService->createFromDelivery($order);
|
||||
}
|
||||
});
|
||||
|
||||
$message = $order->status === 'rejected'
|
||||
? 'Order rejected. All items have been returned to inventory.'
|
||||
: 'Order accepted successfully. Invoice has been generated.';
|
||||
|
||||
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
|
||||
->with('success', $message);
|
||||
}
|
||||
}
|
||||
|
||||
163
app/Http/Controllers/Buyer/StockNotificationController.php
Normal file
163
app/Http/Controllers/Buyer/StockNotificationController.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Services\StockNotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class StockNotificationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected StockNotificationService $stockNotificationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Subscribe to stock notification
|
||||
*/
|
||||
public function subscribe(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'email' => 'nullable|email',
|
||||
'phone_number' => 'nullable|string',
|
||||
'whatsapp' => 'nullable|string',
|
||||
'notification_method' => 'nullable|in:email,sms,whatsapp,all',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$product = Product::findOrFail($request->product_id);
|
||||
|
||||
// Check if product is actually out of stock
|
||||
if ($product->isInStock()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This product is currently in stock.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$userId = auth()->check() ? auth()->id() : null;
|
||||
$email = $request->email ?? auth()->user()?->email;
|
||||
$notificationMethod = $request->notification_method ?? 'email';
|
||||
|
||||
// Determine phone number based on notification method
|
||||
// For WhatsApp, use whatsapp field; for SMS use phone_number; for all, prefer phone_number
|
||||
$phoneNumber = null;
|
||||
if ($notificationMethod === 'whatsapp') {
|
||||
$phoneNumber = $request->whatsapp ?? auth()->user()?->phone_number;
|
||||
} elseif ($notificationMethod === 'sms') {
|
||||
$phoneNumber = $request->phone_number ?? auth()->user()?->phone_number;
|
||||
} elseif ($notificationMethod === 'all') {
|
||||
// For 'all', we'll store the primary phone number
|
||||
$phoneNumber = $request->phone_number ?? $request->whatsapp ?? auth()->user()?->phone_number;
|
||||
}
|
||||
|
||||
// Validate that we have at least one contact method
|
||||
if (! $email && ! $phoneNumber) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Please provide contact information.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Check if user already has a pending notification for this product
|
||||
if ($userId) {
|
||||
$existing = \App\Models\StockNotification::where('user_id', $userId)
|
||||
->where('product_id', $product->id)
|
||||
->pending()
|
||||
->notExpired()
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You already have a pending notification for this product.',
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
$notification = $this->stockNotificationService->createNotification(
|
||||
productId: $product->id,
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
phoneNumber: $phoneNumber,
|
||||
notificationMethod: $notificationMethod
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'You will be notified when this product is back in stock!',
|
||||
'notification' => [
|
||||
'id' => $notification->id,
|
||||
'product_name' => $product->name,
|
||||
'expires_at' => $notification->expires_at->toDateString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a stock notification
|
||||
*/
|
||||
public function cancel(Request $request, int $notificationId): JsonResponse
|
||||
{
|
||||
$userId = auth()->check() ? auth()->id() : null;
|
||||
|
||||
$cancelled = $this->stockNotificationService->cancelNotification($notificationId, $userId);
|
||||
|
||||
if ($cancelled) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Notification cancelled successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Notification not found or already processed.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's pending notifications
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Authentication required.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$notifications = $this->stockNotificationService->getUserNotifications($user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'notifications' => $notifications->map(function ($notification) {
|
||||
return [
|
||||
'id' => $notification->id,
|
||||
'product' => [
|
||||
'id' => $notification->product->id,
|
||||
'name' => $notification->product->name,
|
||||
'sku' => $notification->product->sku,
|
||||
'brand_name' => $notification->product->brand->name,
|
||||
],
|
||||
'notification_method' => $notification->notification_method,
|
||||
'created_at' => $notification->created_at->toDateString(),
|
||||
'expires_at' => $notification->expires_at->toDateString(),
|
||||
];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
95
app/Http/Controllers/CoaController.php
Normal file
95
app/Http/Controllers/CoaController.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CoaController extends Controller
|
||||
{
|
||||
/**
|
||||
* Download or generate COA PDF.
|
||||
* - Production/Staging: Serves real uploaded file from MinIO/S3
|
||||
* - Local/Development: Generates placeholder PDF on-demand
|
||||
*/
|
||||
public function download(int $coaId): Response
|
||||
{
|
||||
$coa = DB::table('batch_coa_files')->find($coaId);
|
||||
|
||||
if (! $coa) {
|
||||
abort(404, 'COA not found');
|
||||
}
|
||||
|
||||
$batch = DB::table('batches')->find($coa->batch_id);
|
||||
|
||||
if (! $batch) {
|
||||
abort(404, 'Batch not found');
|
||||
}
|
||||
|
||||
// Production/Staging: Serve real uploaded file from storage
|
||||
if (app()->environment(['production', 'staging'])) {
|
||||
if (! $coa->file_path) {
|
||||
abort(404, 'COA file not uploaded yet');
|
||||
}
|
||||
|
||||
if (! Storage::disk('public')->exists($coa->file_path)) {
|
||||
abort(404, 'COA file not found in storage');
|
||||
}
|
||||
|
||||
return Storage::disk('public')->response($coa->file_path);
|
||||
}
|
||||
|
||||
// Local/Development: Generate placeholder PDF on-demand
|
||||
$pdf = Pdf::loadHTML($this->getPlaceholderHtml($batch));
|
||||
|
||||
return response($pdf->output(), 200)
|
||||
->header('Content-Type', 'application/pdf')
|
||||
->header('Content-Disposition', 'inline; filename="COA-'.$batch->batch_number.'.pdf"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate simple placeholder HTML for development COAs.
|
||||
*/
|
||||
protected function getPlaceholderHtml(object $batch): string
|
||||
{
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 100px 50px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.batch {
|
||||
font-size: 24px;
|
||||
color: #666;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.note {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-top: 50px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>COA REPORT</h1>
|
||||
<p class="batch">Batch: {$batch->batch_number}</p>
|
||||
<p class="note">Placeholder COA for development purposes</p>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/Concerns/HandlesPrecognition.php
Normal file
49
app/Http/Controllers/Concerns/HandlesPrecognition.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Trait HandlesPrecognition
|
||||
*
|
||||
* Adds Laravel Precognition support to controllers for real-time form validation.
|
||||
*
|
||||
* Usage in controller:
|
||||
* ```php
|
||||
* use HandlesPrecognition;
|
||||
*
|
||||
* public function store(Request $request, Business $business)
|
||||
* {
|
||||
* // Handle precognition validation
|
||||
* if ($this->isPrecognitive($request)) {
|
||||
* return;
|
||||
* }
|
||||
*
|
||||
* // Your normal validation and logic
|
||||
* $validated = $request->validate([...]);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
trait HandlesPrecognition
|
||||
{
|
||||
/**
|
||||
* Check if the request is a precognitive validation request
|
||||
*/
|
||||
protected function isPrecognitive(Request $request): bool
|
||||
{
|
||||
return $request->isPrecognitive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle precognitive validation and return early if needed
|
||||
* This method can be called at the start of store/update methods
|
||||
*/
|
||||
protected function handlePrecognition(Request $request): void
|
||||
{
|
||||
if ($request->isPrecognitive()) {
|
||||
// Laravel automatically handles the validation response
|
||||
// No need to explicitly return anything
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,23 @@ class DashboardController extends Controller
|
||||
$isPending = $business->status === 'submitted';
|
||||
$isRejected = $business->status === 'rejected';
|
||||
|
||||
// Get user's departments to determine which metrics to show
|
||||
$userDepartments = $user->departments ?? collect();
|
||||
$departmentCodes = $userDepartments->pluck('code');
|
||||
|
||||
// Determine dashboard type based on departments
|
||||
$hasSolventless = $departmentCodes->intersect(['LAZ-SOLV', 'CRG-SOLV'])->isNotEmpty();
|
||||
$hasSales = $departmentCodes->intersect(['CBD-SALES', 'CBD-MKTG'])->isNotEmpty();
|
||||
$hasDelivery = $departmentCodes->contains('CRG-DELV');
|
||||
$isOwner = $business->owner_user_id === $user->id;
|
||||
$isSuperAdmin = $user->hasRole('super-admin');
|
||||
|
||||
// Dashboard blocks determined ONLY by department groups (not by ownership or admin role)
|
||||
// Users see data for their assigned departments - add user to department for access
|
||||
$showSalesMetrics = $hasSales;
|
||||
$showProcessingMetrics = $hasSolventless;
|
||||
$showFleetMetrics = $hasDelivery;
|
||||
|
||||
// Get filtered brand IDs for multi-tenancy
|
||||
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
|
||||
|
||||
@@ -56,25 +73,27 @@ class DashboardController extends Controller
|
||||
$previousStart = now()->subDays(60);
|
||||
$previousEnd = now()->subDays(30);
|
||||
|
||||
// Get order IDs that have items matching our brands
|
||||
$currentOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
|
||||
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$currentStart, $currentEnd]))
|
||||
->pluck('order_id')
|
||||
->unique();
|
||||
// Get order IDs and revenue in single optimized queries using joins
|
||||
$currentStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->whereBetween('orders.created_at', [$currentStart, $currentEnd])
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
|
||||
->first();
|
||||
|
||||
$previousOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
|
||||
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$previousStart, $previousEnd]))
|
||||
->pluck('order_id')
|
||||
->unique();
|
||||
$previousStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
|
||||
->first();
|
||||
|
||||
// Revenue
|
||||
$currentRevenue = \App\Models\Order::whereIn('id', $currentOrderIds)->sum('total') / 100;
|
||||
$previousRevenue = \App\Models\Order::whereIn('id', $previousOrderIds)->sum('total') / 100;
|
||||
$currentRevenue = ($currentStats->revenue ?? 0) / 100;
|
||||
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
|
||||
$revenueChange = $previousRevenue > 0 ? (($currentRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
|
||||
|
||||
// Orders count
|
||||
$currentOrders = $currentOrderIds->count();
|
||||
$previousOrders = $previousOrderIds->count();
|
||||
$currentOrders = $currentStats->order_count ?? 0;
|
||||
$previousOrders = $previousStats->order_count ?? 0;
|
||||
$ordersChange = $previousOrders > 0 ? (($currentOrders - $previousOrders) / $previousOrders) * 100 : 0;
|
||||
|
||||
// Products count (active products for selected brand(s))
|
||||
@@ -141,18 +160,37 @@ class DashboardController extends Controller
|
||||
// Get chart data for revenue visualization
|
||||
$chartData = $this->getRevenueChartData($brandIds);
|
||||
|
||||
// Get processing metrics if user is in solventless departments
|
||||
$processingData = null;
|
||||
if ($showProcessingMetrics) {
|
||||
$processingData = $this->getProcessingMetrics($business, $userDepartments);
|
||||
}
|
||||
|
||||
// Get fleet metrics if user is in delivery department
|
||||
$fleetData = null;
|
||||
if ($showFleetMetrics) {
|
||||
$fleetData = $this->getFleetMetrics($business);
|
||||
}
|
||||
|
||||
return view('seller.dashboard', [
|
||||
'user' => $user,
|
||||
'business' => $business,
|
||||
'needsOnboarding' => $needsOnboarding,
|
||||
'isPending' => $isPending,
|
||||
'isRejected' => $isRejected,
|
||||
'isOwner' => $isOwner,
|
||||
'dashboardData' => $dashboardData,
|
||||
'progressData' => $progressData,
|
||||
'progressSummary' => $progressSummary,
|
||||
'chartData' => $chartData,
|
||||
'invoiceStats' => $stats,
|
||||
'recentInvoices' => $recentInvoices,
|
||||
'showSalesMetrics' => $showSalesMetrics,
|
||||
'showProcessingMetrics' => $showProcessingMetrics,
|
||||
'showFleetMetrics' => $showFleetMetrics,
|
||||
'processingData' => $processingData,
|
||||
'fleetData' => $fleetData,
|
||||
'userDepartments' => $userDepartments,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -188,16 +226,11 @@ class DashboardController extends Controller
|
||||
$start = now()->sub($count, $unit)->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
// Get all order IDs for the period
|
||||
$orderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
|
||||
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$start, $end]))
|
||||
->pluck('order_id')
|
||||
->unique();
|
||||
|
||||
// Get orders with dates
|
||||
$orders = \App\Models\Order::whereIn('id', $orderIds)
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as date, SUM(total) as revenue')
|
||||
// Optimized query using join instead of subquery
|
||||
$orders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->whereBetween('orders.created_at', [$start, $end])
|
||||
->selectRaw('DATE(orders.created_at) as date, SUM(orders.total) as revenue')
|
||||
->groupBy('date')
|
||||
->orderBy('date', 'asc')
|
||||
->get();
|
||||
@@ -272,4 +305,426 @@ class DashboardController extends Controller
|
||||
'values' => $values,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processing/manufacturing metrics for solventless departments
|
||||
*/
|
||||
private function getProcessingMetrics(Business $business, $userDepartments): array
|
||||
{
|
||||
$solventlessDepts = $userDepartments->whereIn('code', ['LAZ-SOLV', 'CRG-SOLV']);
|
||||
$departmentIds = $solventlessDepts->pluck('id');
|
||||
|
||||
// Current period (last 30 days)
|
||||
$currentStart = now()->subDays(30);
|
||||
$previousStart = now()->subDays(60);
|
||||
$previousEnd = now()->subDays(30);
|
||||
|
||||
// Get wash reports (hash washes) - using Conversion model
|
||||
$currentWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', $currentStart)
|
||||
->count();
|
||||
|
||||
$previousWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereBetween('created_at', [$previousStart, $previousEnd])
|
||||
->count();
|
||||
|
||||
$washesChange = $previousWashes > 0 ? (($currentWashes - $previousWashes) / $previousWashes) * 100 : 0;
|
||||
|
||||
// Average Yield (calculate from metadata)
|
||||
$currentWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', $currentStart)
|
||||
->get();
|
||||
|
||||
$currentYield = $currentWashesWithYield->avg(function ($conversion) {
|
||||
$stage1 = $conversion->getStage1Data();
|
||||
$stage2 = $conversion->getStage2Data();
|
||||
if (! $stage1 || ! $stage2) {
|
||||
return 0;
|
||||
}
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
|
||||
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
}) ?? 0;
|
||||
|
||||
$previousWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereBetween('created_at', [$previousStart, $previousEnd])
|
||||
->get();
|
||||
|
||||
$previousYield = $previousWashesWithYield->avg(function ($conversion) {
|
||||
$stage1 = $conversion->getStage1Data();
|
||||
$stage2 = $conversion->getStage2Data();
|
||||
if (! $stage1 || ! $stage2) {
|
||||
return 0;
|
||||
}
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
|
||||
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
}) ?? 0;
|
||||
|
||||
$yieldChange = $previousYield > 0 ? (($currentYield - $previousYield) / $previousYield) * 100 : 0;
|
||||
|
||||
// Active Work Orders
|
||||
$activeWorkOrders = \App\Models\WorkOrder::where('business_id', $business->id)
|
||||
->whereIn('department_id', $departmentIds)
|
||||
->where('status', 'in_progress')
|
||||
->count();
|
||||
|
||||
// Completed Work Orders (30 days)
|
||||
$currentCompletedOrders = \App\Models\WorkOrder::where('business_id', $business->id)
|
||||
->whereIn('department_id', $departmentIds)
|
||||
->where('status', 'completed')
|
||||
->where('updated_at', '>=', $currentStart)
|
||||
->count();
|
||||
|
||||
$previousCompletedOrders = \App\Models\WorkOrder::where('business_id', $business->id)
|
||||
->whereIn('department_id', $departmentIds)
|
||||
->where('status', 'completed')
|
||||
->whereBetween('updated_at', [$previousStart, $previousEnd])
|
||||
->count();
|
||||
|
||||
$completedChange = $previousCompletedOrders > 0 ? (($currentCompletedOrders - $previousCompletedOrders) / $previousCompletedOrders) * 100 : 0;
|
||||
|
||||
// Get strain performance data
|
||||
$strainPerformance = $this->getStrainPerformanceData($business, $currentStart);
|
||||
|
||||
// Get Idle Fresh Frozen data
|
||||
$idleFreshFrozen = $this->getIdleFreshFrozen($business);
|
||||
|
||||
// Get current user's subdivision prefixes (first 3 chars of department codes)
|
||||
$userSubdivisions = auth()->user()->departments()
|
||||
->pluck('code')
|
||||
->map(fn ($code) => substr($code, 0, 3))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
// Get all user IDs in the same subdivisions
|
||||
$allowedOperatorIds = \App\Models\User::whereHas('departments', function ($q) use ($userSubdivisions) {
|
||||
$q->whereIn(\DB::raw('SUBSTRING(code, 1, 3)'), $userSubdivisions->toArray());
|
||||
})->pluck('id');
|
||||
|
||||
// Get Active Washes data
|
||||
$activeWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'in_progress')
|
||||
->whereIn('operator_user_id', $allowedOperatorIds)
|
||||
->with(['operator.departments'])
|
||||
->orderBy('started_at', 'desc')
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'washes' => [
|
||||
'current' => $currentWashes,
|
||||
'previous' => $previousWashes,
|
||||
'change' => round($washesChange, 1),
|
||||
],
|
||||
'yield' => [
|
||||
'current' => number_format($currentYield, 1),
|
||||
'previous' => number_format($previousYield, 1),
|
||||
'change' => round($yieldChange, 1),
|
||||
],
|
||||
'active_orders' => [
|
||||
'current' => $activeWorkOrders,
|
||||
'previous' => $activeWorkOrders, // No historical tracking
|
||||
'change' => 0,
|
||||
],
|
||||
'completed_orders' => [
|
||||
'current' => $currentCompletedOrders,
|
||||
'previous' => $previousCompletedOrders,
|
||||
'change' => round($completedChange, 1),
|
||||
],
|
||||
'strain_performance' => $strainPerformance,
|
||||
'idle_fresh_frozen' => $idleFreshFrozen,
|
||||
'active_washes' => $activeWashes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get idle Fresh Frozen components ready for processing
|
||||
*/
|
||||
private function getIdleFreshFrozen(Business $business): array
|
||||
{
|
||||
// Find Fresh Frozen category
|
||||
$ffCategory = \App\Models\ComponentCategory::where('business_id', $business->id)
|
||||
->where('slug', 'fresh-frozen')
|
||||
->first();
|
||||
|
||||
if (! $ffCategory) {
|
||||
return [
|
||||
'components' => collect([]),
|
||||
'total_count' => 0,
|
||||
'total_weight' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Get all Fresh Frozen components with inventory
|
||||
$components = \App\Models\Component::where('business_id', $business->id)
|
||||
->where('component_category_id', $ffCategory->id)
|
||||
->where('quantity_on_hand', '>', 0)
|
||||
->where('is_active', true)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(5) // Show top 5 on dashboard
|
||||
->get();
|
||||
|
||||
// Add past performance data for each component
|
||||
$componentsWithPerformance = $components->map(function ($component) use ($business) {
|
||||
$strainName = str_replace(' - Fresh Frozen', '', $component->name);
|
||||
|
||||
// Get past washes for this strain
|
||||
$pastWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereJsonContains('metadata->stage_1->strain', $strainName)
|
||||
->orderBy('completed_at', 'desc')
|
||||
->take(10)
|
||||
->get();
|
||||
|
||||
if ($pastWashes->isEmpty()) {
|
||||
$component->past_performance = [
|
||||
'has_data' => false,
|
||||
'wash_count' => 0,
|
||||
'avg_yield' => null,
|
||||
'avg_hash_quality' => null,
|
||||
];
|
||||
} else {
|
||||
// Calculate average yield
|
||||
$avgYield = $pastWashes->avg(function ($wash) {
|
||||
$stage1 = $wash->getStage1Data();
|
||||
$stage2 = $wash->getStage2Data();
|
||||
if (! $stage1 || ! $stage2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
|
||||
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
});
|
||||
|
||||
// Calculate average hash quality (Stage 2) - defensive extraction
|
||||
$qualityGrades = [];
|
||||
foreach ($pastWashes as $wash) {
|
||||
$stage2 = $wash->getStage2Data();
|
||||
if (! $stage2 || ! isset($stage2['yields'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check all yield types for quality data (handles both hash and rosin structures)
|
||||
foreach ($stage2['yields'] as $yieldType => $yieldData) {
|
||||
if (isset($yieldData['quality']) && $yieldData['quality']) {
|
||||
$qualityGrades[] = $yieldData['quality'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only include quality if we have the data
|
||||
if (empty($qualityGrades)) {
|
||||
$component->past_performance = [
|
||||
'has_data' => true, // Has wash data
|
||||
'wash_count' => $pastWashes->count(),
|
||||
'avg_yield' => round($avgYield, 1),
|
||||
'avg_hash_quality' => null, // No quality data tracked
|
||||
];
|
||||
} else {
|
||||
$avgQuality = $this->calculateAverageQuality($qualityGrades);
|
||||
$component->past_performance = [
|
||||
'has_data' => true,
|
||||
'wash_count' => $pastWashes->count(),
|
||||
'avg_yield' => round($avgYield, 1),
|
||||
'avg_hash_quality' => $avgQuality['letter'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $component;
|
||||
});
|
||||
|
||||
return [
|
||||
'components' => $componentsWithPerformance,
|
||||
'total_count' => $componentsWithPerformance->count(),
|
||||
'total_weight' => $componentsWithPerformance->sum('quantity_on_hand'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strain-specific performance metrics for processing department
|
||||
*/
|
||||
private function getStrainPerformanceData(Business $business, $startDate): array
|
||||
{
|
||||
// Get all completed washes for the period
|
||||
$washes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', $startDate)
|
||||
->get();
|
||||
|
||||
// Group by strain and calculate metrics
|
||||
$strainData = [];
|
||||
foreach ($washes as $wash) {
|
||||
$stage1 = $wash->getStage1Data();
|
||||
$stage2 = $wash->getStage2Data();
|
||||
|
||||
if (! $stage1 || ! $stage2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strain = $stage1['strain'] ?? 'Unknown';
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
|
||||
if (! isset($strainData[$strain])) {
|
||||
$strainData[$strain] = [
|
||||
'strain' => $strain,
|
||||
'wash_count' => 0,
|
||||
'total_input' => 0,
|
||||
'total_output' => 0,
|
||||
'yields' => [],
|
||||
'hash_stage1_quality_grades' => [],
|
||||
'hash_stage2_quality_grades' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$strainData[$strain]['wash_count']++;
|
||||
$strainData[$strain]['total_input'] += $startingWeight;
|
||||
$strainData[$strain]['total_output'] += $totalYield;
|
||||
|
||||
// Calculate yield percentage as number
|
||||
$yieldPercentage = $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
$strainData[$strain]['yields'][] = $yieldPercentage;
|
||||
|
||||
// Collect quality grades from Stage 1 (hash - initial assessment)
|
||||
if (isset($stage1['quality_grades'])) {
|
||||
foreach ($stage1['quality_grades'] as $micron => $grade) {
|
||||
if ($grade) {
|
||||
$strainData[$strain]['hash_stage1_quality_grades'][] = $grade;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect quality grades from Stage 2 (hash - final assessment after drying)
|
||||
if (isset($stage2['yields'])) {
|
||||
foreach ($stage2['yields'] as $type => $data) {
|
||||
if (isset($data['quality']) && $data['quality']) {
|
||||
$strainData[$strain]['hash_stage2_quality_grades'][] = $data['quality'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages and format data
|
||||
$results = [];
|
||||
foreach ($strainData as $strain => $data) {
|
||||
$avgYield = count($data['yields']) > 0 ? array_sum($data['yields']) / count($data['yields']) : 0;
|
||||
|
||||
// Calculate average quality grades
|
||||
// Stage 1: Initial hash assessment during washing
|
||||
// Stage 2: Final hash assessment after drying
|
||||
$hashStage1Quality = $this->calculateAverageQuality($data['hash_stage1_quality_grades']);
|
||||
$hashStage2Quality = $this->calculateAverageQuality($data['hash_stage2_quality_grades']);
|
||||
|
||||
$results[] = [
|
||||
'strain' => $strain,
|
||||
'wash_count' => $data['wash_count'],
|
||||
'total_input_g' => round($data['total_input'], 2),
|
||||
'total_output_g' => round($data['total_output'], 2),
|
||||
'avg_yield_percentage' => round($avgYield, 2),
|
||||
'avg_input_per_wash' => $data['wash_count'] > 0 ? round($data['total_input'] / $data['wash_count'], 2) : 0,
|
||||
'avg_output_per_wash' => $data['wash_count'] > 0 ? round($data['total_output'] / $data['wash_count'], 2) : 0,
|
||||
'avg_hash_quality' => $hashStage1Quality['letter'], // Stage 1 assessment
|
||||
'avg_rosin_quality' => $hashStage2Quality['letter'], // Stage 2 assessment (still called rosin for backward compat with views)
|
||||
'hash_quality_score' => $hashStage1Quality['score'],
|
||||
'rosin_quality_score' => $hashStage2Quality['score'], // Actually hash Stage 2 score
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by wash count (most processed strains first)
|
||||
usort($results, function ($a, $b) {
|
||||
return $b['wash_count'] - $a['wash_count'];
|
||||
});
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average quality grade from array of letter grades
|
||||
*
|
||||
* @param array $grades Array of letter grades (A, B, C, D, F)
|
||||
* @return array ['letter' => 'A', 'score' => 4.0]
|
||||
*/
|
||||
private function calculateAverageQuality(array $grades): array
|
||||
{
|
||||
if (empty($grades)) {
|
||||
return ['letter' => null, 'score' => null];
|
||||
}
|
||||
|
||||
// Convert letters to numeric scores
|
||||
$gradeMap = ['A' => 4, 'B' => 3, 'C' => 2, 'D' => 1, 'F' => 0];
|
||||
$scores = array_map(fn ($grade) => $gradeMap[$grade] ?? 0, $grades);
|
||||
|
||||
$avgScore = array_sum($scores) / count($scores);
|
||||
|
||||
// Convert back to letter grade
|
||||
if ($avgScore >= 3.5) {
|
||||
$letter = 'A';
|
||||
} elseif ($avgScore >= 2.5) {
|
||||
$letter = 'B';
|
||||
} elseif ($avgScore >= 1.5) {
|
||||
$letter = 'C';
|
||||
} elseif ($avgScore >= 0.5) {
|
||||
$letter = 'D';
|
||||
} else {
|
||||
$letter = 'F';
|
||||
}
|
||||
|
||||
return ['letter' => $letter, 'score' => round($avgScore, 2)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fleet/delivery metrics
|
||||
*/
|
||||
private function getFleetMetrics(Business $business): array
|
||||
{
|
||||
// Current metrics
|
||||
$totalDrivers = \App\Models\Driver::where('business_id', $business->id)->count();
|
||||
$activeVehicles = \App\Models\Vehicle::where('business_id', $business->id)
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
$totalVehicles = \App\Models\Vehicle::where('business_id', $business->id)->count();
|
||||
|
||||
// Deliveries today (would need Delivery model - placeholder)
|
||||
$deliveriesToday = 0;
|
||||
|
||||
return [
|
||||
'drivers' => [
|
||||
'current' => $totalDrivers,
|
||||
'previous' => $totalDrivers,
|
||||
'change' => 0,
|
||||
],
|
||||
'active_vehicles' => [
|
||||
'current' => $activeVehicles,
|
||||
'previous' => $activeVehicles,
|
||||
'change' => 0,
|
||||
],
|
||||
'total_vehicles' => [
|
||||
'current' => $totalVehicles,
|
||||
'previous' => $totalVehicles,
|
||||
'change' => 0,
|
||||
],
|
||||
'deliveries_today' => [
|
||||
'current' => $deliveriesToday,
|
||||
'previous' => 0,
|
||||
'change' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
129
app/Http/Controllers/ImageController.php
Normal file
129
app/Http/Controllers/ImageController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class ImageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Serve a brand logo at a specific size
|
||||
* URL: /images/brand-logo/{brand}/{width?}
|
||||
*/
|
||||
public function brandLogo(Brand $brand, ?int $width = null)
|
||||
{
|
||||
if (! $brand->logo_path || ! Storage::disk('public')->exists($brand->logo_path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// If no width specified, return original
|
||||
if (! $width) {
|
||||
$path = storage_path('app/public/'.$brand->logo_path);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
|
||||
// Map common widths to pre-generated sizes (retina-optimized)
|
||||
$sizeNames = [
|
||||
160 => 'thumb', // 2x retina for 80px display
|
||||
600 => 'medium', // 2x retina for 300px display
|
||||
1600 => 'large', // 2x retina for 800px display
|
||||
];
|
||||
|
||||
// Check if pre-generated thumbnail exists (from import)
|
||||
if (isset($sizeNames[$width])) {
|
||||
// Try PNG first, then JPG (for backwards compatibility)
|
||||
$ext = pathinfo($brand->logo_path, PATHINFO_EXTENSION);
|
||||
$pregenPath = str_replace('.'.$ext, '-'.$sizeNames[$width].'.'.$ext, $brand->logo_path);
|
||||
if (Storage::disk('public')->exists($pregenPath)) {
|
||||
$path = storage_path('app/public/'.$pregenPath);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cached dynamic thumbnail exists
|
||||
$ext = pathinfo($brand->logo_path, PATHINFO_EXTENSION);
|
||||
$thumbnailName = str_replace('.'.$ext, "-{$width}w.{$ext}", basename($brand->logo_path));
|
||||
$thumbnailPath = 'brands/cache/'.$thumbnailName;
|
||||
|
||||
if (! Storage::disk('public')->exists($thumbnailPath)) {
|
||||
// Generate thumbnail on-the-fly
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->read(storage_path('app/public/'.$brand->logo_path));
|
||||
$image->scale(width: $width);
|
||||
|
||||
// Cache the thumbnail
|
||||
if (! Storage::disk('public')->exists('brands/cache')) {
|
||||
Storage::disk('public')->makeDirectory('brands/cache');
|
||||
}
|
||||
|
||||
// Save as PNG or JPEG based on original format
|
||||
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
|
||||
Storage::disk('public')->put($thumbnailPath, $encoded);
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a brand banner at a specific width
|
||||
* URL: /images/brand-banner/{brand}/{width?}
|
||||
*/
|
||||
public function brandBanner(Brand $brand, ?int $width = null)
|
||||
{
|
||||
if (! $brand->banner_path || ! Storage::disk('public')->exists($brand->banner_path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// If no width specified, return original
|
||||
if (! $width) {
|
||||
$path = storage_path('app/public/'.$brand->banner_path);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
|
||||
// Map common widths to pre-generated sizes (retina-optimized)
|
||||
$sizeNames = [
|
||||
1344 => 'medium', // 2x retina for 672px display
|
||||
2560 => 'large', // 2x retina for 1280px display
|
||||
];
|
||||
|
||||
// Check if pre-generated thumbnail exists (from import)
|
||||
if (isset($sizeNames[$width])) {
|
||||
$pregenPath = str_replace('.jpg', '-'.$sizeNames[$width].'.jpg', $brand->banner_path);
|
||||
if (Storage::disk('public')->exists($pregenPath)) {
|
||||
$path = storage_path('app/public/'.$pregenPath);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cached dynamic thumbnail exists
|
||||
$thumbnailName = str_replace('.jpg', "-{$width}w.jpg", basename($brand->banner_path));
|
||||
$thumbnailPath = 'brands/cache/'.$thumbnailName;
|
||||
|
||||
if (! Storage::disk('public')->exists($thumbnailPath)) {
|
||||
// Generate thumbnail on-the-fly
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->read(storage_path('app/public/'.$brand->banner_path));
|
||||
$image->scale(width: $width);
|
||||
|
||||
// Cache the thumbnail
|
||||
if (! Storage::disk('public')->exists('brands/cache')) {
|
||||
Storage::disk('public')->makeDirectory('brands/cache');
|
||||
}
|
||||
|
||||
Storage::disk('public')->put($thumbnailPath, $image->toJpeg(quality: 90));
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,13 @@ class MarketplaceController extends Controller
|
||||
$query->where('wholesale_price', '<=', $priceMax);
|
||||
}
|
||||
|
||||
// In stock filter
|
||||
// In stock filter (using batch-based inventory)
|
||||
if ($request->input('in_stock')) {
|
||||
$query->where('quantity_on_hand', '>', 0);
|
||||
$query->whereHas('batches', function ($q) {
|
||||
$q->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->where('quantity_available', '>', 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Sorting
|
||||
@@ -135,12 +139,11 @@ class MarketplaceController extends Controller
|
||||
->with([
|
||||
'brand',
|
||||
'strain',
|
||||
'labs',
|
||||
'labs' => function ($q) {
|
||||
$q->latest('test_date');
|
||||
},
|
||||
'availableBatches' => function ($query) {
|
||||
$query->with(['labs' => function ($q) {
|
||||
$q->latest('test_date');
|
||||
}])
|
||||
->orderBy('production_date', 'desc')
|
||||
$query->orderBy('production_date', 'desc')
|
||||
->orderBy('created_at', 'desc');
|
||||
},
|
||||
])
|
||||
|
||||
@@ -4,9 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DeliveryWindow;
|
||||
use App\Models\Manifest;
|
||||
use App\Models\Order;
|
||||
use App\Services\DeliveryWindowService;
|
||||
use App\Services\ManifestService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -16,6 +19,10 @@ use Illuminate\View\View;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private DeliveryWindowService $deliveryWindowService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display list of orders for sellers.
|
||||
* Shows all orders including new, in-progress, completed, rejected, and cancelled.
|
||||
@@ -30,11 +37,12 @@ class OrderController extends Controller
|
||||
'new',
|
||||
'accepted',
|
||||
'in_progress',
|
||||
'ready_for_invoice',
|
||||
'awaiting_invoice_approval',
|
||||
'ready_for_manifest',
|
||||
'ready_for_delivery',
|
||||
'approved_for_delivery',
|
||||
'out_for_delivery',
|
||||
'delivered',
|
||||
'completed',
|
||||
'rejected',
|
||||
'cancelled',
|
||||
])
|
||||
@@ -82,11 +90,66 @@ class OrderController extends Controller
|
||||
*/
|
||||
public function show(\App\Models\Business $business, Order $order): View
|
||||
{
|
||||
$order->load(['business', 'user', 'location', 'items.product.brand']);
|
||||
$order->load([
|
||||
'business',
|
||||
'user',
|
||||
'location',
|
||||
'items.product.brand',
|
||||
'audits' => function ($query) {
|
||||
$query->with('user')->orderBy('created_at', 'desc');
|
||||
},
|
||||
'pendingCancellationRequest.requestedBy',
|
||||
'cancellationRequests',
|
||||
'cancellationRequests.audits' => function ($query) {
|
||||
$query->with('user')->orderBy('created_at', 'desc');
|
||||
},
|
||||
'cancellationRequests.requestedBy',
|
||||
'cancellationRequests.reviewedBy',
|
||||
]);
|
||||
|
||||
return view('seller.orders.show', compact('order', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a cancellation request (seller action).
|
||||
*/
|
||||
public function approveCancellationRequest(\App\Models\Business $business, Order $order, \App\Models\OrderCancellationRequest $cancellationRequest)
|
||||
{
|
||||
if (! $cancellationRequest->isPending()) {
|
||||
return back()->with('error', 'This cancellation request has already been reviewed.');
|
||||
}
|
||||
|
||||
if ($cancellationRequest->order_id !== $order->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$cancellationRequest->approve(auth()->user());
|
||||
|
||||
return back()->with('success', "Cancellation request approved. Order {$order->order_number} has been cancelled.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny a cancellation request (seller action).
|
||||
*/
|
||||
public function denyCancellationRequest(\App\Models\Business $business, Order $order, \App\Models\OrderCancellationRequest $cancellationRequest, Request $request)
|
||||
{
|
||||
if (! $cancellationRequest->isPending()) {
|
||||
return back()->with('error', 'This cancellation request has already been reviewed.');
|
||||
}
|
||||
|
||||
if ($cancellationRequest->order_id !== $order->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'notes' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
$cancellationRequest->deny(auth()->user(), $validated['notes']);
|
||||
|
||||
return back()->with('success', 'Cancellation request denied.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a new order (seller accepting buyer's order).
|
||||
*/
|
||||
@@ -138,14 +201,40 @@ class OrderController extends Controller
|
||||
return back()->with('success', "Order {$order->order_number} has been cancelled.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve order for delivery (after buyer selects delivery method).
|
||||
*/
|
||||
public function approveForDelivery(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
try {
|
||||
$order->approveForDelivery();
|
||||
|
||||
return back()->with('success', 'Order approved for delivery. You can now schedule delivery/pickup.');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show picking ticket interface for warehouse/lab staff.
|
||||
* 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|RedirectResponse
|
||||
public function pick(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket): View|RedirectResponse
|
||||
{
|
||||
$order = $pickingTicket; // For clarity in blade templates
|
||||
// Handle both old (Order) and new (PickingTicket) systems
|
||||
if ($pickingTicket instanceof \App\Models\PickingTicket) {
|
||||
$ticket = $pickingTicket;
|
||||
$order = $ticket->fulfillmentWorkOrder->order;
|
||||
|
||||
// Load relationships for the ticket
|
||||
$ticket->load(['items.orderItem.product', 'department']);
|
||||
|
||||
return view('seller.orders.pick', compact('order', 'ticket', 'business'));
|
||||
}
|
||||
|
||||
// Old system: Order model
|
||||
$order = $pickingTicket;
|
||||
|
||||
// Only allow picking for accepted or in_progress orders
|
||||
if (! in_array($order->status, ['accepted', 'in_progress'])) {
|
||||
@@ -158,54 +247,141 @@ class OrderController extends Controller
|
||||
return view('seller.orders.pick', compact('order', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start picking a ticket - marks ticket as in_progress and logs who started it.
|
||||
*/
|
||||
public function startPick(\App\Models\Business $business, \App\Models\PickingTicket $pickingTicket): RedirectResponse
|
||||
{
|
||||
$ticket = $pickingTicket;
|
||||
$order = $ticket->fulfillmentWorkOrder->order;
|
||||
|
||||
// Only allow starting if ticket is pending
|
||||
if ($ticket->status !== 'pending') {
|
||||
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
|
||||
->with('error', 'This picking ticket has already been started.');
|
||||
}
|
||||
|
||||
// Start the ticket and track who started it
|
||||
$ticket->update([
|
||||
'status' => 'in_progress',
|
||||
'started_at' => now(),
|
||||
'picker_id' => auth()->id(), // Track who started picking
|
||||
]);
|
||||
|
||||
// Update order status to in_progress if it's still accepted
|
||||
if ($order->status === 'accepted') {
|
||||
$order->startPicking();
|
||||
}
|
||||
|
||||
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
|
||||
->with('success', 'Picking started! You can now begin picking items.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark workorder as complete and auto-generate invoice.
|
||||
* Allows partial fulfillment - invoice will reflect actual picked quantities.
|
||||
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K/complete
|
||||
*/
|
||||
public function complete(\App\Models\Business $business, Order $pickingTicket)
|
||||
public function complete(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
|
||||
{
|
||||
$order = $pickingTicket; // For clarity
|
||||
// Handle new PickingTicket system
|
||||
if ($pickingTicket instanceof \App\Models\PickingTicket) {
|
||||
$ticket = $pickingTicket;
|
||||
$order = $ticket->fulfillmentWorkOrder->order;
|
||||
|
||||
// Mark this ticket as complete
|
||||
$ticket->complete();
|
||||
|
||||
// PickingTicket->complete() handles:
|
||||
// - Setting ticket status to 'completed'
|
||||
// - Checking if all tickets are complete
|
||||
// - Advancing order to ready_for_delivery if all tickets done
|
||||
// The order status flow is now: accepted -> in_progress -> ready_for_delivery
|
||||
|
||||
return redirect()->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Picking ticket completed successfully!');
|
||||
}
|
||||
|
||||
// Handle old single picking ticket system (Order model)
|
||||
$order = $pickingTicket;
|
||||
|
||||
// Calculate final workorder status based on picked quantities
|
||||
$order->updatePickingStatus();
|
||||
$order->refresh();
|
||||
|
||||
// Recalculate order totals based on picked quantities
|
||||
$subtotal = 0;
|
||||
foreach ($order->items as $item) {
|
||||
// Update line total based on picked quantity
|
||||
$newLineTotal = $item->unit_price * $item->picked_qty;
|
||||
$item->update(['line_total' => $newLineTotal]);
|
||||
$subtotal += $newLineTotal;
|
||||
}
|
||||
|
||||
// Update order totals
|
||||
$tax = $subtotal * 0.0; // TODO: Calculate tax based on company settings
|
||||
$total = $subtotal + $tax;
|
||||
|
||||
$order->update([
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
'status' => 'ready_for_invoice',
|
||||
'ready_for_invoice_at' => now(),
|
||||
]);
|
||||
|
||||
// Automatically generate invoice for buyer approval
|
||||
$invoiceService = app(\App\Services\InvoiceService::class);
|
||||
$invoice = $invoiceService->generateFromOrder($order);
|
||||
|
||||
// Update order to awaiting invoice approval status
|
||||
$order->update([
|
||||
'status' => 'awaiting_invoice_approval',
|
||||
'invoiced_at' => now(),
|
||||
]);
|
||||
|
||||
// Invoice is now ready for buyer approval with approval_status = 'pending_buyer_approval'
|
||||
// NOTE: Do NOT auto-advance to ready_for_delivery
|
||||
// Seller must manually click "Mark Order Ready for Buyer Review" button
|
||||
|
||||
return redirect()->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Picking ticket completed! Invoice has been generated based on fulfilled quantities.');
|
||||
->with('success', 'Picking ticket completed! You can now mark the order ready for buyer review.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open a completed picking ticket to allow editing.
|
||||
*/
|
||||
public function reopen(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
|
||||
{
|
||||
// Handle new PickingTicket system
|
||||
if ($pickingTicket instanceof \App\Models\PickingTicket) {
|
||||
$ticket = $pickingTicket;
|
||||
$order = $ticket->fulfillmentWorkOrder->order;
|
||||
|
||||
// Only allow re-opening if seller hasn't marked order ready for buyer review yet
|
||||
// Once seller clicks "Mark Order Ready for Buyer Review", picking is locked
|
||||
if (! in_array($order->status, ['accepted', 'in_progress'])) {
|
||||
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
|
||||
->with('error', 'Cannot re-open ticket - seller has already marked this order ready for buyer review.');
|
||||
}
|
||||
|
||||
// Re-open the ticket
|
||||
$ticket->update([
|
||||
'status' => 'in_progress',
|
||||
'completed_at' => null,
|
||||
]);
|
||||
|
||||
// If work order was marked as complete, recalculate status
|
||||
if ($ticket->fulfillmentWorkOrder && $ticket->fulfillmentWorkOrder->status === 'completed') {
|
||||
$workOrderService = app(\App\Services\FulfillmentWorkOrderService::class);
|
||||
$workOrderService->recalculateWorkOrderStatus($ticket->fulfillmentWorkOrder);
|
||||
}
|
||||
|
||||
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
|
||||
->with('success', 'Picking ticket re-opened successfully. You can now make changes.');
|
||||
}
|
||||
|
||||
// Handle old system
|
||||
$order = $pickingTicket;
|
||||
|
||||
return redirect()->route('seller.business.pick', [$business->slug, $order->picking_ticket_number])
|
||||
->with('error', 'Re-opening tickets is only supported for the new picking ticket system.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display picking ticket as PDF in browser.
|
||||
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K/pdf
|
||||
*/
|
||||
public function downloadPickingTicketPdf(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
|
||||
{
|
||||
// Handle both old (Order) and new (PickingTicket) systems
|
||||
if ($pickingTicket instanceof \App\Models\PickingTicket) {
|
||||
$ticket = $pickingTicket;
|
||||
$order = $ticket->fulfillmentWorkOrder->order;
|
||||
|
||||
// Load relationships for the ticket
|
||||
$ticket->load(['items.orderItem.product.brand', 'department']);
|
||||
|
||||
$pdf = \PDF::loadView('seller.orders.pick-pdf', compact('order', 'ticket', 'business'));
|
||||
|
||||
return $pdf->stream('picking-ticket-'.$ticket->ticket_number.'.pdf');
|
||||
}
|
||||
|
||||
// Old system: Order model
|
||||
$order = $pickingTicket;
|
||||
$order->load(['business', 'user', 'location', 'items.product.brand']);
|
||||
|
||||
$pdf = \PDF::loadView('seller.orders.pick-pdf', compact('order', 'business'));
|
||||
|
||||
return $pdf->stream('picking-ticket-'.$order->picking_ticket_number.'.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -621,4 +797,389 @@ class OrderController extends Controller
|
||||
'delivery_url' => $deliveryUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update pickup date for an order
|
||||
*/
|
||||
public function updatePickupDate(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
// Ensure order can be accessed by this business (seller)
|
||||
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
|
||||
if ($sellerBusinessId !== $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow updates for pickup orders at ready_for_delivery or approved_for_delivery status
|
||||
if (! in_array($order->status, ['ready_for_delivery', 'approved_for_delivery'])) {
|
||||
abort(422, 'Pickup date can only be set when order is ready for pickup');
|
||||
}
|
||||
|
||||
if (! $order->isPickup()) {
|
||||
abort(422, 'Pickup date can only be set for pickup orders');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'pickup_date' => 'required|date|after_or_equal:today',
|
||||
]);
|
||||
|
||||
$order->update([
|
||||
'pickup_date' => $validated['pickup_date'],
|
||||
]);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Pickup date updated successfully',
|
||||
'pickup_date' => $order->pickup_date->format('l, F j, Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Pickup date updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark order as ready for delivery (seller action).
|
||||
* Only available when all picking tickets are completed.
|
||||
*/
|
||||
public function markReadyForDelivery(\App\Models\Business $business, Order $order): RedirectResponse
|
||||
{
|
||||
// Verify business owns this order
|
||||
$isSellerOrder = $order->items()->whereHas('product.brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->exists();
|
||||
|
||||
if (! $isSellerOrder) {
|
||||
abort(403, 'Unauthorized access to this order');
|
||||
}
|
||||
|
||||
// Only allow when order is accepted or in_progress
|
||||
if (! in_array($order->status, ['accepted', 'in_progress'])) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Order cannot be marked as ready for delivery from current status');
|
||||
}
|
||||
|
||||
// Verify all items have been picked (workorder at 100% OR all picking tickets completed)
|
||||
// Note: We check picking tickets first because there may be short-picks where workorder < 100%
|
||||
// but the warehouse has completed all tickets (meaning they picked everything available)
|
||||
if (! $order->allPickingTicketsCompleted() && $order->workorder_status < 100) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'All order items must be picked before marking order ready for delivery');
|
||||
}
|
||||
|
||||
// Mark order as ready for delivery
|
||||
$success = $order->markReadyForDelivery();
|
||||
|
||||
if ($success) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Order marked as ready for delivery. Buyer has been notified.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Failed to mark order as ready for delivery');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available delivery windows for a specific date (for sellers).
|
||||
*/
|
||||
public function getAvailableDeliveryWindows(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
$date = $request->query('date');
|
||||
if (! $date) {
|
||||
return response()->json(['error' => 'Date parameter required'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$selectedDate = \Carbon\Carbon::parse($date);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => 'Invalid date format'], 400);
|
||||
}
|
||||
|
||||
$dayOfWeek = $selectedDate->dayOfWeek;
|
||||
|
||||
// Fetch active delivery windows for the seller's business on this day
|
||||
$windows = \App\Models\DeliveryWindow::where('business_id', $business->id)
|
||||
->where('day_of_week', $dayOfWeek)
|
||||
->where('is_active', true)
|
||||
->orderBy('start_time')
|
||||
->get()
|
||||
->map(function ($window) {
|
||||
return [
|
||||
'id' => $window->id,
|
||||
'day_name' => $window->day_name,
|
||||
'time_range' => $window->time_range,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['windows' => $windows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order's delivery window (seller action).
|
||||
*/
|
||||
public function updateDeliveryWindow(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
|
||||
if ($sellerBusinessId !== $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow updates for delivery orders at approved_for_delivery status
|
||||
if ($order->status !== 'approved_for_delivery') {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Delivery window can only be set after buyer has approved the order for delivery');
|
||||
}
|
||||
|
||||
// Only delivery orders need delivery windows
|
||||
if (! $order->isDelivery()) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Delivery window can only be set for delivery orders');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'delivery_window_id' => 'required|exists:delivery_windows,id',
|
||||
'delivery_window_date' => 'required|date|after_or_equal:today',
|
||||
]);
|
||||
|
||||
$window = DeliveryWindow::findOrFail($validated['delivery_window_id']);
|
||||
|
||||
// Ensure window belongs to seller's business
|
||||
if ($window->business_id !== $business->id) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Delivery window does not belong to your business');
|
||||
}
|
||||
|
||||
$date = Carbon::parse($validated['delivery_window_date']);
|
||||
|
||||
// Validate using service
|
||||
if (! $this->deliveryWindowService->validateWindowSelection($window, $date)) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Invalid delivery window selection');
|
||||
}
|
||||
|
||||
$this->deliveryWindowService->updateOrderWindow($order, $window, $date);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Delivery window updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark order as out for delivery (for delivery orders at approved_for_delivery status).
|
||||
*/
|
||||
public function markOutForDelivery(\App\Models\Business $business, Order $order): RedirectResponse
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow for delivery orders at approved_for_delivery status
|
||||
if ($order->status !== 'approved_for_delivery') {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Order must be approved for delivery before marking as out for delivery');
|
||||
}
|
||||
|
||||
if (! $order->isDelivery()) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'This action is only available for delivery orders');
|
||||
}
|
||||
|
||||
// Require delivery window to be set
|
||||
if (! $order->deliveryWindow || ! $order->delivery_window_date) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Please schedule a delivery window before marking order as out for delivery');
|
||||
}
|
||||
|
||||
// Update order status
|
||||
$order->update([
|
||||
'status' => 'out_for_delivery',
|
||||
'out_for_delivery_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Order marked as out for delivery');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm pickup complete (for pickup orders at approved_for_delivery status).
|
||||
*/
|
||||
public function confirmPickup(\App\Models\Business $business, Order $order): RedirectResponse
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow for pickup orders at approved_for_delivery status
|
||||
if ($order->status !== 'approved_for_delivery') {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Order must be approved for delivery before confirming pickup');
|
||||
}
|
||||
|
||||
if (! $order->isPickup()) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'This action is only available for pickup orders');
|
||||
}
|
||||
|
||||
// Require pickup date to be set
|
||||
if (! $order->pickup_date) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Please set a pickup date before confirming pickup completion');
|
||||
}
|
||||
|
||||
// Update order status to delivered (pickup complete)
|
||||
$order->update([
|
||||
'status' => 'delivered',
|
||||
'delivered_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Pickup confirmed! Order marked as delivered.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm delivery complete (for delivery orders).
|
||||
*/
|
||||
public function confirmDelivery(\App\Models\Business $business, Order $order): RedirectResponse
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow for delivery orders at out_for_delivery status
|
||||
if ($order->status !== 'out_for_delivery') {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Order must be out for delivery before confirming delivery completion');
|
||||
}
|
||||
|
||||
if (! $order->isDelivery()) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'This action is only available for delivery orders');
|
||||
}
|
||||
|
||||
// Update order status to delivered
|
||||
$order->update([
|
||||
'status' => 'delivered',
|
||||
'delivered_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Delivery confirmed! Order marked as delivered. You can now finalize the order.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize order after delivery - confirm actual delivered quantities and complete the order.
|
||||
*/
|
||||
public function finalizeOrder(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
if ($order->items->first()?->product?->brand?->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow finalization for delivered orders
|
||||
if ($order->status !== 'delivered') {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Order must be delivered before it can be finalized');
|
||||
}
|
||||
|
||||
// Validate the request
|
||||
$validated = $request->validate([
|
||||
'delivery_notes' => 'nullable|string|max:5000',
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|exists:order_items,id',
|
||||
'items.*.delivered_qty' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
\DB::transaction(function () use ($order, $validated) {
|
||||
|
||||
foreach ($validated['items'] as $itemData) {
|
||||
$orderItem = $order->items()->findOrFail($itemData['id']);
|
||||
$deliveredQty = (float) $itemData['delivered_qty'];
|
||||
$pickedQty = (float) $orderItem->picked_qty;
|
||||
|
||||
// Calculate rejected quantity (items that were picked but not delivered)
|
||||
$rejectedQty = $pickedQty - $deliveredQty;
|
||||
|
||||
// Update the order item with delivered quantity and acceptance data
|
||||
// delivered_qty = what seller confirmed was delivered
|
||||
// accepted_qty = same as delivered_qty (what will be invoiced)
|
||||
// rejected_qty = what was picked but not delivered/accepted
|
||||
$orderItem->update([
|
||||
'delivered_qty' => $deliveredQty,
|
||||
'accepted_qty' => $deliveredQty,
|
||||
'rejected_qty' => $rejectedQty,
|
||||
]);
|
||||
|
||||
// Return rejected items to inventory if any
|
||||
if ($rejectedQty > 0 && $orderItem->batch_id && $orderItem->batch) {
|
||||
$orderItem->batch->increment('quantity_available', $rejectedQty);
|
||||
}
|
||||
}
|
||||
|
||||
// Update order with finalization details
|
||||
$order->update([
|
||||
'delivery_notes' => $validated['delivery_notes'],
|
||||
'finalized_at' => now(),
|
||||
'finalized_by_user_id' => Auth::id(),
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
// Recalculate line totals for each item based on delivered quantities
|
||||
$newSubtotal = 0;
|
||||
foreach ($order->items as $item) {
|
||||
$deliveredQty = $item->delivered_qty ?? $item->picked_qty;
|
||||
$lineTotal = $deliveredQty * $item->unit_price;
|
||||
|
||||
$item->update(['line_total' => $lineTotal]);
|
||||
$newSubtotal += $lineTotal;
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'subtotal' => $newSubtotal,
|
||||
'total' => $newSubtotal + ($order->tax ?? 0) + ($order->delivery_fee ?? 0),
|
||||
]);
|
||||
|
||||
// Refresh order to get updated items with delivered_qty
|
||||
$order->refresh();
|
||||
|
||||
// Generate final invoice based on delivered quantities
|
||||
$invoiceService = app(\App\Services\InvoiceService::class);
|
||||
$invoiceService->generateFromOrder($order);
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Order finalized successfully. Final invoice generated.');
|
||||
}
|
||||
}
|
||||
|
||||
118
app/Http/Controllers/PublicCoaController.php
Normal file
118
app/Http/Controllers/PublicCoaController.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Batch;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PublicCoaController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display COA information for a specific batch
|
||||
* Public route: /coa/{batchNumber}
|
||||
*/
|
||||
public function show(string $batchNumber)
|
||||
{
|
||||
// Find batch by batch number
|
||||
$batch = Batch::where('batch_number', $batchNumber)
|
||||
->with(['product', 'lab.coaFiles', 'business'])
|
||||
->first();
|
||||
|
||||
if (! $batch) {
|
||||
abort(404, 'Batch not found');
|
||||
}
|
||||
|
||||
// Get lab test and COA files
|
||||
$lab = $batch->lab;
|
||||
|
||||
if (! $lab) {
|
||||
abort(404, 'No lab test available for this batch');
|
||||
}
|
||||
|
||||
// Get all COA files
|
||||
$coaFiles = $lab->getAllCoas();
|
||||
$primaryCoa = $lab->getPrimaryCoa();
|
||||
|
||||
return view('public.coa.show', [
|
||||
'batch' => $batch,
|
||||
'lab' => $lab,
|
||||
'coaFiles' => $coaFiles,
|
||||
'primaryCoa' => $primaryCoa,
|
||||
'product' => $batch->product,
|
||||
'business' => $batch->business,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a specific COA file
|
||||
*/
|
||||
public function download(string $batchNumber, int $coaFileId)
|
||||
{
|
||||
// Find batch
|
||||
$batch = Batch::where('batch_number', $batchNumber)
|
||||
->with('lab.coaFiles')
|
||||
->first();
|
||||
|
||||
if (! $batch || ! $batch->lab) {
|
||||
abort(404, 'Batch or lab test not found');
|
||||
}
|
||||
|
||||
// Find COA file
|
||||
$coaFile = $batch->lab->coaFiles()->find($coaFileId);
|
||||
|
||||
if (! $coaFile) {
|
||||
abort(404, 'COA file not found');
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (! $coaFile->exists()) {
|
||||
abort(404, 'File not found in storage');
|
||||
}
|
||||
|
||||
// Download the file
|
||||
return Storage::download($coaFile->file_path, $coaFile->file_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* View a specific COA file inline (for PDFs)
|
||||
*/
|
||||
public function view(string $batchNumber, int $coaFileId): StreamedResponse
|
||||
{
|
||||
// Find batch
|
||||
$batch = Batch::where('batch_number', $batchNumber)
|
||||
->with('lab.coaFiles')
|
||||
->first();
|
||||
|
||||
if (! $batch || ! $batch->lab) {
|
||||
abort(404, 'Batch or lab test not found');
|
||||
}
|
||||
|
||||
// Find COA file
|
||||
$coaFile = $batch->lab->coaFiles()->find($coaFileId);
|
||||
|
||||
if (! $coaFile) {
|
||||
abort(404, 'COA file not found');
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (! $coaFile->exists()) {
|
||||
abort(404, 'File not found in storage');
|
||||
}
|
||||
|
||||
// Stream the file for inline viewing
|
||||
return Storage::response($coaFile->file_path, $coaFile->file_name, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$coaFile->file_name.'"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy route support: /retail/labs/{batchNumber}
|
||||
* Redirects to new COA route
|
||||
*/
|
||||
public function legacyShow(string $batchNumber)
|
||||
{
|
||||
return redirect()->route('public.coa.show', ['batchNumber' => $batchNumber], 301);
|
||||
}
|
||||
}
|
||||
33
app/Http/Controllers/Seller/BackorderController.php
Normal file
33
app/Http/Controllers/Seller/BackorderController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Services\BackorderService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BackorderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BackorderService $backorderService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show backorders landing page (under Transactions)
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Get backorders for this seller business
|
||||
$backorders = $this->backorderService->getBackordersForSeller($business);
|
||||
|
||||
// Get stats
|
||||
$stats = $this->backorderService->getBackorderStats($business);
|
||||
|
||||
return view('seller.backorders.index', [
|
||||
'business' => $business,
|
||||
'backorders' => $backorders,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
}
|
||||
364
app/Http/Controllers/Seller/BatchController.php
Normal file
364
app/Http/Controllers/Seller/BatchController.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Services\QrCodeService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BatchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of batches for the business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Build query for batches
|
||||
$query = Batch::where('business_id', $business->id)
|
||||
->with(['product.brand', 'coaFiles'])
|
||||
->orderBy('production_date', 'desc');
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('batch_number', 'LIKE', "%{$search}%")
|
||||
->orWhere('test_id', 'LIKE', "%{$search}%")
|
||||
->orWhere('lot_number', 'LIKE', "%{$search}%")
|
||||
->orWhereHas('product', function ($productQuery) use ($search) {
|
||||
$productQuery->where('name', 'LIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$batches = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Separate active and inactive batches
|
||||
$activeBatches = $batches->filter(fn ($batch) => $batch->is_active);
|
||||
$inactiveBatches = $batches->filter(fn ($batch) => ! $batch->is_active);
|
||||
|
||||
return view('seller.batches.index', compact('business', 'batches', 'activeBatches', 'inactiveBatches'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new batch
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Get products owned by this business
|
||||
$products = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->orderBy('name', 'asc')->get();
|
||||
|
||||
// For the new architecture, components are products (the view expects $components)
|
||||
$components = $products;
|
||||
|
||||
// Get existing component batches that can be used as sources for homogenized batches
|
||||
$componentBatches = Batch::where('business_id', $business->id)
|
||||
->where('quantity_remaining', '>', 0)
|
||||
->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->with('component')
|
||||
->orderBy('batch_number')
|
||||
->get();
|
||||
|
||||
return view('seller.batches.create', compact('business', 'products', 'components', 'componentBatches'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created batch
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
|
||||
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
|
||||
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
|
||||
'production_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
'test_id' => 'nullable|string|max:100',
|
||||
'lot_number' => 'nullable|string|max:100',
|
||||
'lab_name' => 'nullable|string|max:255',
|
||||
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'notes' => 'nullable|string',
|
||||
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
||||
]);
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
|
||||
// Set business_id
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['is_active'] = true; // New batches are active by default
|
||||
|
||||
// Create batch (calculations happen in model boot method)
|
||||
$batch = Batch::create($validated);
|
||||
|
||||
// Handle COA file uploads
|
||||
if ($request->hasFile('coa_files')) {
|
||||
foreach ($request->file('coa_files') as $index => $file) {
|
||||
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
|
||||
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
||||
$filePath = $file->storeAs($storagePath, $fileName, 'public');
|
||||
|
||||
$batch->coaFiles()->create([
|
||||
'file_name' => $file->getClientOriginalName(),
|
||||
'file_path' => $filePath,
|
||||
'file_type' => $file->getClientOriginalExtension(),
|
||||
'file_size' => $file->getSize(),
|
||||
'is_primary' => $index === 0,
|
||||
'display_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-generate QR code for the new batch (with brand logo if available)
|
||||
$qrService = app(QrCodeService::class);
|
||||
$qrService->generateWithLogo($batch);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified batch
|
||||
*/
|
||||
public function edit(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Get products owned by this business
|
||||
$products = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->orderBy('name', 'asc')->get();
|
||||
|
||||
$batch->load('coaFiles');
|
||||
|
||||
return view('seller.batches.edit', compact('business', 'batch', 'products'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified batch
|
||||
*/
|
||||
public function update(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
|
||||
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
|
||||
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number,'.$batch->id,
|
||||
'production_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
'test_id' => 'nullable|string|max:100',
|
||||
'lot_number' => 'nullable|string|max:100',
|
||||
'lab_name' => 'nullable|string|max:255',
|
||||
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'notes' => 'nullable|string',
|
||||
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
||||
]);
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
|
||||
// Update batch (calculations happen in model boot method)
|
||||
$batch->update($validated);
|
||||
|
||||
// Handle new COA file uploads
|
||||
if ($request->hasFile('coa_files')) {
|
||||
$existingFilesCount = $batch->coaFiles()->count();
|
||||
foreach ($request->file('coa_files') as $index => $file) {
|
||||
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
|
||||
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
||||
$filePath = $file->storeAs($storagePath, $fileName, 'public');
|
||||
|
||||
$batch->coaFiles()->create([
|
||||
'file_name' => $file->getClientOriginalName(),
|
||||
'file_path' => $filePath,
|
||||
'file_type' => $file->getClientOriginalExtension(),
|
||||
'file_size' => $file->getSize(),
|
||||
'is_primary' => $existingFilesCount === 0 && $index === 0,
|
||||
'display_order' => $existingFilesCount + $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified batch
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Delete associated COA files from storage
|
||||
foreach ($batch->coaFiles as $coaFile) {
|
||||
if (Storage::disk('public')->exists($coaFile->file_path)) {
|
||||
Storage::disk('public')->delete($coaFile->file_path);
|
||||
}
|
||||
}
|
||||
|
||||
$batch->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code for a batch
|
||||
*/
|
||||
public function generateQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->generateWithLogo($batch);
|
||||
|
||||
// Refresh batch to get updated qr_code_path
|
||||
$batch->refresh();
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
|
||||
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download QR code for a batch
|
||||
*/
|
||||
public function downloadQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$download = $qrService->download($batch);
|
||||
|
||||
if (! $download) {
|
||||
return back()->with('error', 'QR code not found');
|
||||
}
|
||||
|
||||
return $download;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate QR code for a batch
|
||||
*/
|
||||
public function regenerateQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->regenerate($batch);
|
||||
|
||||
// Refresh batch to get updated qr_code_path
|
||||
$batch->refresh();
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
|
||||
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete QR code for a batch
|
||||
*/
|
||||
public function deleteQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->delete($batch);
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk generate QR codes for multiple batches
|
||||
*/
|
||||
public function bulkGenerateQrCodes(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'batch_ids' => 'required|array',
|
||||
'batch_ids.*' => 'exists:batches,id',
|
||||
]);
|
||||
|
||||
// Verify all batches belong to this business
|
||||
$batches = Batch::whereIn('id', $validated['batch_ids'])
|
||||
->where('business_id', $business->id)
|
||||
->get();
|
||||
|
||||
if ($batches->count() !== count($validated['batch_ids'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Some batches do not belong to this business',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->bulkGenerate($validated['batch_ids']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
438
app/Http/Controllers/Seller/BrandController.php
Normal file
438
app/Http/Controllers/Seller/BrandController.php
Normal file
@@ -0,0 +1,438 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Concerns\HandlesPrecognition;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BrandController extends Controller
|
||||
{
|
||||
use HandlesPrecognition;
|
||||
|
||||
/**
|
||||
* Display a listing of brands for the business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Get brands for this business and parent company (if division)
|
||||
$brands = Brand::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.brands.index', compact('business', 'brands'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new brand
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
return view('seller.brands.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Nexus version of the brand create form (demo/test)
|
||||
*/
|
||||
public function createNexus(Business $business)
|
||||
{
|
||||
return view('seller.brands.create-nexus', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Nexus version of the brand edit form (demo/test)
|
||||
*/
|
||||
public function editNexus(Business $business, Brand $brand)
|
||||
{
|
||||
return view('seller.brands.edit-nexus', compact('business', 'brand'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created brand in storage
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
// Handle Precognition validation requests
|
||||
if ($this->isPrecognitive($request)) {
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => 'nullable|string|max:45',
|
||||
'description' => 'nullable|string|max:300',
|
||||
'long_description' => 'nullable|string|max:1000',
|
||||
'brand_announcement' => 'nullable|string|max:500',
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'unit_number' => 'nullable|string|max:50',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:2',
|
||||
'zip_code' => 'nullable|string|max:10',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
'banner' => 'nullable|image|max:4096',
|
||||
'is_public' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'instagram_handle' => 'nullable|string|max:255',
|
||||
'facebook_url' => 'nullable|url|max:255',
|
||||
'twitter_handle' => 'nullable|string|max:255',
|
||||
'youtube_url' => 'nullable|url|max:255',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => 'nullable|string|max:45',
|
||||
'description' => 'nullable|string|max:300',
|
||||
'long_description' => 'nullable|string|max:1000',
|
||||
'brand_announcement' => 'nullable|string|max:500',
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'unit_number' => 'nullable|string|max:50',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:2',
|
||||
'zip_code' => 'nullable|string|max:10',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
'banner' => 'nullable|image|max:4096',
|
||||
'is_public' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'instagram_handle' => 'nullable|string|max:255',
|
||||
'facebook_url' => 'nullable|url|max:255',
|
||||
'twitter_handle' => 'nullable|string|max:255',
|
||||
'youtube_url' => 'nullable|url|max:255',
|
||||
]);
|
||||
|
||||
// Clean and normalize website URL - strip any protocol user entered, then add https://
|
||||
if ($request->filled('website_url')) {
|
||||
$url = $validated['website_url'];
|
||||
|
||||
// Strip http:// or https:// if user entered it
|
||||
$url = preg_replace('#^https?://#i', '', $url);
|
||||
|
||||
// Strip any leading/trailing whitespace
|
||||
$url = trim($url);
|
||||
|
||||
// Validate that we have a valid domain format
|
||||
if (! empty($url) && ! filter_var('https://'.$url, FILTER_VALIDATE_URL)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->withErrors(['website_url' => 'Please enter a valid website URL (e.g., example.com)']);
|
||||
}
|
||||
|
||||
// Add https:// prefix
|
||||
$validated['website_url'] = ! empty($url) ? 'https://'.$url : null;
|
||||
}
|
||||
|
||||
// Generate slug from name
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
|
||||
// Handle logo upload
|
||||
if ($request->hasFile('logo')) {
|
||||
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
|
||||
}
|
||||
|
||||
// Handle banner upload
|
||||
if ($request->hasFile('banner')) {
|
||||
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
|
||||
}
|
||||
|
||||
// Set boolean defaults
|
||||
$validated['is_public'] = $request->boolean('is_public');
|
||||
$validated['is_featured'] = $request->boolean('is_featured');
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
// Create brand
|
||||
$brand = $business->brands()->create($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand created successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified brand (read-only view)
|
||||
*/
|
||||
public function show(Business $business, Brand $brand)
|
||||
{
|
||||
// Ensure brand belongs to this business or parent company (if division)
|
||||
$allowedBusinessIds = [$business->id];
|
||||
if ($business->parent_id) {
|
||||
$allowedBusinessIds[] = $business->parent_id;
|
||||
}
|
||||
|
||||
if (! in_array($brand->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'This brand does not belong to your business.');
|
||||
}
|
||||
|
||||
// Load relationships
|
||||
$brand->load(['business', 'products']);
|
||||
|
||||
return view('seller.brands.show', compact('business', 'brand'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview the brand as it would appear to buyers
|
||||
*/
|
||||
public function preview(Business $business, Brand $brand)
|
||||
{
|
||||
// Ensure brand belongs to this business or parent company (if division)
|
||||
$allowedBusinessIds = [$business->id];
|
||||
if ($business->parent_id) {
|
||||
$allowedBusinessIds[] = $business->parent_id;
|
||||
}
|
||||
|
||||
if (! in_array($brand->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'This brand does not belong to your business.');
|
||||
}
|
||||
|
||||
// Load relationships including active products with images, strain, unit, and product line
|
||||
$brand->load([
|
||||
'business',
|
||||
'products' => function ($query) {
|
||||
$query->where('is_active', true)
|
||||
->with(['images', 'strain', 'unit', 'productLine'])
|
||||
->orderBy('name');
|
||||
},
|
||||
]);
|
||||
|
||||
// Get other brands from the same business
|
||||
$otherBrands = Brand::where('business_id', $brand->business_id)
|
||||
->where('id', '!=', $brand->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Group products by product line
|
||||
$productsByLine = $brand->products->groupBy(function ($product) {
|
||||
return $product->productLine->name ?? 'Uncategorized';
|
||||
});
|
||||
|
||||
// Allow viewing as buyer with ?as=buyer query parameter (for testing)
|
||||
$isSeller = request()->query('as') !== 'buyer';
|
||||
|
||||
return view('seller.brands.preview', compact('business', 'brand', 'otherBrands', 'productsByLine', 'isSeller'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified brand
|
||||
*/
|
||||
public function edit(Business $business, Brand $brand)
|
||||
{
|
||||
// Ensure brand belongs to this business or parent company (if division)
|
||||
$allowedBusinessIds = [$business->id];
|
||||
if ($business->parent_id) {
|
||||
$allowedBusinessIds[] = $business->parent_id;
|
||||
}
|
||||
|
||||
if (! in_array($brand->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'This brand does not belong to your business.');
|
||||
}
|
||||
|
||||
return view('seller.brands.edit', compact('business', 'brand'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified brand in storage
|
||||
*/
|
||||
public function update(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
// Ensure brand belongs to this business or parent company (if division)
|
||||
$allowedBusinessIds = [$business->id];
|
||||
if ($business->parent_id) {
|
||||
$allowedBusinessIds[] = $business->parent_id;
|
||||
}
|
||||
|
||||
if (! in_array($brand->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'This brand does not belong to your business.');
|
||||
}
|
||||
|
||||
// Handle Precognition validation requests
|
||||
if ($this->isPrecognitive($request)) {
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => 'nullable|string|max:45',
|
||||
'description' => 'nullable|string|max:300',
|
||||
'long_description' => 'nullable|string|max:1000',
|
||||
'brand_announcement' => 'nullable|string|max:500',
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'unit_number' => 'nullable|string|max:50',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:2',
|
||||
'zip_code' => 'nullable|string|max:10',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
'banner' => 'nullable|image|max:4096',
|
||||
'remove_logo' => 'boolean',
|
||||
'remove_banner' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'instagram_handle' => 'nullable|string|max:255',
|
||||
'facebook_url' => 'nullable|url|max:255',
|
||||
'twitter_handle' => 'nullable|string|max:255',
|
||||
'youtube_url' => 'nullable|url|max:255',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => 'nullable|string|max:45',
|
||||
'description' => 'nullable|string|max:300',
|
||||
'long_description' => 'nullable|string|max:1000',
|
||||
'brand_announcement' => 'nullable|string|max:500',
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'unit_number' => 'nullable|string|max:50',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:2',
|
||||
'zip_code' => 'nullable|string|max:10',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
'banner' => 'nullable|image|max:4096',
|
||||
'remove_logo' => 'boolean',
|
||||
'remove_banner' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'instagram_handle' => 'nullable|string|max:255',
|
||||
'facebook_url' => 'nullable|url|max:255',
|
||||
'twitter_handle' => 'nullable|string|max:255',
|
||||
'youtube_url' => 'nullable|url|max:255',
|
||||
]);
|
||||
|
||||
// Clean and normalize website URL - strip any protocol user entered, then add https://
|
||||
if ($request->filled('website_url')) {
|
||||
$url = $validated['website_url'];
|
||||
|
||||
// Strip http:// or https:// if user entered it
|
||||
$url = preg_replace('#^https?://#i', '', $url);
|
||||
|
||||
// Strip any leading/trailing whitespace
|
||||
$url = trim($url);
|
||||
|
||||
// Validate that we have a valid domain format
|
||||
if (! empty($url) && ! filter_var('https://'.$url, FILTER_VALIDATE_URL)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->withErrors(['website_url' => 'Please enter a valid website URL (e.g., example.com)']);
|
||||
}
|
||||
|
||||
// Add https:// prefix
|
||||
$validated['website_url'] = ! empty($url) ? 'https://'.$url : null;
|
||||
} else {
|
||||
$validated['website_url'] = null;
|
||||
}
|
||||
|
||||
// Update slug if name changed
|
||||
if ($validated['name'] !== $brand->name) {
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
}
|
||||
|
||||
// Handle logo removal
|
||||
if ($request->boolean('remove_logo') && $brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
$validated['logo_path'] = null;
|
||||
}
|
||||
|
||||
// Handle logo upload
|
||||
if ($request->hasFile('logo')) {
|
||||
// Delete old logo
|
||||
if ($brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
}
|
||||
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
|
||||
}
|
||||
|
||||
// Handle banner removal
|
||||
if ($request->boolean('remove_banner') && $brand->banner_path) {
|
||||
Storage::disk('public')->delete($brand->banner_path);
|
||||
$validated['banner_path'] = null;
|
||||
}
|
||||
|
||||
// Handle banner upload
|
||||
if ($request->hasFile('banner')) {
|
||||
// Delete old banner
|
||||
if ($brand->banner_path) {
|
||||
Storage::disk('public')->delete($brand->banner_path);
|
||||
}
|
||||
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
|
||||
}
|
||||
|
||||
// Set boolean defaults
|
||||
$validated['is_public'] = $request->boolean('is_public');
|
||||
$validated['is_featured'] = $request->boolean('is_featured');
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
// Remove form-only fields
|
||||
unset($validated['remove_logo'], $validated['remove_banner']);
|
||||
|
||||
// Update brand
|
||||
$brand->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified brand from storage
|
||||
*/
|
||||
public function destroy(Business $business, Brand $brand)
|
||||
{
|
||||
// Ensure brand belongs to this business or parent company (if division)
|
||||
$allowedBusinessIds = [$business->id];
|
||||
if ($business->parent_id) {
|
||||
$allowedBusinessIds[] = $business->parent_id;
|
||||
}
|
||||
|
||||
if (! in_array($brand->business_id, $allowedBusinessIds)) {
|
||||
abort(403, 'This brand does not belong to your business.');
|
||||
}
|
||||
|
||||
// Check user has permission (only company-owner or company-manager can delete)
|
||||
if (! auth()->user()->hasAnyRole(['company-owner', 'company-manager'])) {
|
||||
abort(403, 'You do not have permission to delete brands.');
|
||||
}
|
||||
|
||||
// Check if brand has any products with sales/orders
|
||||
$hasProductsWithSales = $brand->products()
|
||||
->whereHas('orderItems')
|
||||
->exists();
|
||||
|
||||
if ($hasProductsWithSales) {
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('error', 'Cannot delete brand - it has products with sales activity.');
|
||||
}
|
||||
|
||||
// Delete logo and banner files
|
||||
if ($brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
}
|
||||
if ($brand->banner_path) {
|
||||
Storage::disk('public')->delete($brand->banner_path);
|
||||
}
|
||||
|
||||
$brand->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand deleted successfully!');
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Seller/BrandPreviewController.php
Normal file
60
app/Http/Controllers/Seller/BrandPreviewController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BrandPreviewController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show brand menu preview for sellers
|
||||
* This allows sellers to preview how buyers will see their brand menu
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
public function preview(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
// Verify the brand belongs to the business (business isolation)
|
||||
if ($brand->business_id !== $business->id) {
|
||||
abort(404, 'Brand not found for this business');
|
||||
}
|
||||
|
||||
// Load brand with business relationship
|
||||
$brand->load('business');
|
||||
|
||||
// Get products organized by product line
|
||||
$products = $brand->products()
|
||||
->with(['strain', 'images', 'productLine'])
|
||||
->where('is_active', true)
|
||||
->orderBy('product_line_id')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Group products by product line
|
||||
$productsByLine = $products->groupBy(function ($product) {
|
||||
return $product->productLine ? $product->productLine->name : 'Other Products';
|
||||
});
|
||||
|
||||
// Get other brands from same business
|
||||
$otherBrands = $business
|
||||
->brands()
|
||||
->where('id', '!=', $brand->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
// Mark this as seller view
|
||||
$isSeller = true;
|
||||
|
||||
return view('seller.brands.preview', compact(
|
||||
'business',
|
||||
'brand',
|
||||
'products',
|
||||
'productsByLine',
|
||||
'otherBrands',
|
||||
'isSeller'
|
||||
));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user