Compare commits
263 Commits
fix/produc
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782e6797d8 | ||
|
|
09ff7b27a5 | ||
|
|
fe994205f2 | ||
|
|
cf80a8b681 | ||
|
|
5bdd6a2225 | ||
|
|
93678e59bc | ||
|
|
14c30dad03 | ||
|
|
08d49b9b67 | ||
|
|
036ae5c6f6 | ||
|
|
4c45805390 | ||
|
|
fc943afb36 | ||
|
|
25ae50dcd6 | ||
|
|
d1422efe87 | ||
|
|
45da5d075d | ||
|
|
bdc54da4ad | ||
|
|
cb6dc5e433 | ||
|
|
3add610e85 | ||
|
|
183a22c475 | ||
|
|
f2297d62f2 | ||
|
|
6b994147c3 | ||
|
|
a6d9e203c2 | ||
|
|
f652c19b24 | ||
|
|
76ce86fb41 | ||
|
|
5a22f7dbb6 | ||
|
|
5b8809b962 | ||
|
|
cdf982ed39 | ||
|
|
aac83a084c | ||
|
|
5f9613290d | ||
|
|
c92cd230d5 | ||
|
|
bcbfdd3c91 | ||
|
|
3c21093e66 | ||
|
|
e4e0a19873 | ||
|
|
5b0503abf5 | ||
|
|
cc8aab7ee1 | ||
|
|
2c1f7d093f | ||
|
|
11a07692ad | ||
|
|
05754c9d5b | ||
|
|
e26da88f22 | ||
|
|
b703b27676 | ||
|
|
5dffd96187 | ||
|
|
9ff1f2b37b | ||
|
|
23ad9f2824 | ||
|
|
b90cb829c9 | ||
|
|
69d9174314 | ||
|
|
c350ecbb3c | ||
|
|
06098c7013 | ||
|
|
709321383c | ||
|
|
7506466c38 | ||
|
|
c84455a11b | ||
|
|
2e7fff135c | ||
|
|
a1a8e3ee9c | ||
|
|
72ab5d8baa | ||
|
|
fc715c6022 | ||
|
|
32a00493f8 | ||
|
|
ffc9405c34 | ||
|
|
732c46cabb | ||
|
|
1906a28347 | ||
|
|
5de445d1cf | ||
|
|
9855d41869 | ||
|
|
319c5cc4d5 | ||
|
|
44af326ebb | ||
|
|
4f4e96dd84 | ||
|
|
fcc428b9f1 | ||
|
|
fc9580470e | ||
|
|
bc9aaf745d | ||
|
|
9df385e2e1 | ||
|
|
102b2c1803 | ||
|
|
6705a8916a | ||
|
|
3e71c35c9e | ||
|
|
c772431475 | ||
|
|
c6b8d76373 | ||
|
|
6ce095c60a | ||
|
|
45ed3818fa | ||
|
|
461b37ab30 | ||
|
|
88167995b1 | ||
|
|
e8622765a2 | ||
|
|
983752a396 | ||
|
|
b338571fb9 | ||
|
|
7990c2ab55 | ||
|
|
e419c57072 | ||
|
|
05144fe04b | ||
|
|
23aa144f85 | ||
|
|
bdf56d3d95 | ||
|
|
de1574f920 | ||
|
|
ff2f9ba64c | ||
|
|
362cb8091b | ||
|
|
0af81efac0 | ||
|
|
c705ef0cd0 | ||
|
|
ae5d7bf47a | ||
|
|
76ced26127 | ||
|
|
e98ad5d611 | ||
|
|
37a6eb67b2 | ||
|
|
2bf97fe4e1 | ||
|
|
f9130d1c67 | ||
|
|
a542112361 | ||
|
|
b2108651d8 | ||
|
|
b81b3ea8eb | ||
|
|
3ae38ff5ee | ||
|
|
5218a44701 | ||
|
|
6234cea62c | ||
|
|
6e26a65f12 | ||
|
|
86532e27fe | ||
|
|
f2b8d04d03 | ||
|
|
615dbd3ee6 | ||
|
|
fb6cf407d4 | ||
|
|
9fcfc8b484 | ||
|
|
829d4c6b6c | ||
|
|
60e1564c0f | ||
|
|
a42d1bc3c8 | ||
|
|
729234bd7f | ||
|
|
d718741cd3 | ||
|
|
1e7e1b5934 | ||
|
|
3ac4358c0b | ||
|
|
cc997cfa20 | ||
|
|
37dd49f9ec | ||
|
|
e16281e237 | ||
|
|
64479a5c84 | ||
|
|
5b1b085e06 | ||
|
|
e0caa83325 | ||
|
|
90bc7f3907 | ||
|
|
b7fb6c5a66 | ||
|
|
0d38f6dc5e | ||
|
|
8c4b424eb6 | ||
|
|
2cf335d019 | ||
|
|
9f0678a17c | ||
|
|
ad9c41dd28 | ||
|
|
1732bcbee2 | ||
|
|
96276cc118 | ||
|
|
dc69033ca4 | ||
|
|
bcf25eba38 | ||
|
|
9116d9b055 | ||
|
|
b7a3b5c924 | ||
|
|
5b9be3368a | ||
|
|
5c7ea61937 | ||
|
|
29a8bdc85f | ||
|
|
8116de4659 | ||
|
|
578753235d | ||
|
|
8eef5c265e | ||
|
|
1fe1749d6f | ||
|
|
a9c7b3034c | ||
|
|
0d17575f56 | ||
|
|
9366f099ec | ||
|
|
b3edc4bf87 | ||
|
|
00aa796daf | ||
|
|
9153d4e950 | ||
|
|
c7250e26e2 | ||
|
|
49677fefdc | ||
|
|
bebb3874f9 | ||
|
|
a79ffe343f | ||
|
|
283420e898 | ||
|
|
6dd53f17ae | ||
|
|
08dc3b389a | ||
|
|
57e81c002d | ||
|
|
523ea5093e | ||
|
|
a77a5b1b11 | ||
|
|
3842ffd893 | ||
|
|
c0c3c2a754 | ||
|
|
486c16d0fa | ||
|
|
1c2afe416f | ||
|
|
cf30040161 | ||
|
|
df48d581ee | ||
|
|
f489b8e789 | ||
|
|
88768334aa | ||
|
|
55ec2b833d | ||
|
|
b503cc284f | ||
|
|
550da56b4e | ||
|
|
327aec34cc | ||
|
|
14cb5194e8 | ||
|
|
a33de047fd | ||
|
|
04f09f2cd4 | ||
|
|
d87d22ab27 | ||
|
|
d7fa02aeff | ||
|
|
c3f81b10f1 | ||
|
|
2424e35435 | ||
| a48b76a1f4 | |||
| 2417dedce2 | |||
| a6e934e4a4 | |||
|
|
0aa2cf4ee3 | ||
| fdba05140b | |||
| 0b29cac5eb | |||
| cc7cf86ea9 | |||
| 7143222cd0 | |||
|
|
e6c8fd8c3c | ||
|
|
cea7ca5119 | ||
|
|
a849e9cd34 | ||
|
|
fcb0a158ea | ||
|
|
7614ed0fdd | ||
|
|
6c96aaa11b | ||
| 7b5f3db26a | |||
|
|
51047fc315 | ||
|
|
dff1475550 | ||
| 9fdeaaa7b2 | |||
|
|
f1827aba18 | ||
|
|
39aa92d116 | ||
|
|
7e82c3d343 | ||
|
|
7020f51ac7 | ||
| 737eed473e | |||
|
|
4c8412a47b | ||
|
|
093bcb6e58 | ||
|
|
5fc6e008a5 | ||
|
|
0591eabfee | ||
|
|
3451a4b86a | ||
|
|
9c321b86c1 | ||
|
|
1f08ea8f12 | ||
|
|
de3faece35 | ||
|
|
370bb99e8f | ||
|
|
62f71d5c8d | ||
|
|
239a0ff2c0 | ||
|
|
660f982d71 | ||
|
|
3321f8e593 | ||
|
|
3984307e44 | ||
|
|
9c5b8f3cfb | ||
|
|
d2a3a05ea1 | ||
|
|
eac1d4cb0a | ||
|
|
a0c0dafe34 | ||
|
|
91b7e0c0e0 | ||
|
|
c2692a3e86 | ||
|
|
ad2c680cda | ||
|
|
d46d587687 | ||
|
|
f06bc254c8 | ||
|
|
ad517a6332 | ||
|
|
6cb74eab7f | ||
|
|
2ea43f7c8b | ||
|
|
90ae8dcf23 | ||
|
|
9648247fe3 | ||
|
|
fd30bb4f27 | ||
|
|
9e83341c89 | ||
|
|
93e521f440 | ||
|
|
ec9853c571 | ||
|
|
636bdafc9e | ||
|
|
c7d6ee5e21 | ||
|
|
496ca61489 | ||
|
|
a812380b32 | ||
|
|
9bb0f6d373 | ||
|
|
df7c41887c | ||
|
|
00410478c0 | ||
|
|
a943a412ac | ||
|
|
6550ecff12 | ||
|
|
c72c73e88c | ||
|
|
d4ec430790 | ||
|
|
5cce19d849 | ||
|
|
6ae2be604f | ||
|
|
11edda5411 | ||
|
|
44d21fa146 | ||
|
|
798476e991 | ||
|
|
bad6c24597 | ||
|
|
5b7898f478 | ||
|
|
9cc582b869 | ||
|
|
db2386b8a9 | ||
|
|
ac70cc0247 | ||
|
|
eb95528b76 | ||
|
|
879d1c61df | ||
|
|
0af6db4461 | ||
|
|
0f5901e55f | ||
|
|
8fcc3629bd | ||
|
|
8995c60d88 | ||
|
|
c4e178a900 | ||
|
|
6688bbf8a1 | ||
|
|
bb5f2c8aaa | ||
|
|
a9d0f328a8 | ||
|
|
3b769905b7 | ||
|
|
f7727d8c17 | ||
|
|
6d7eb4f151 |
@@ -8,8 +8,8 @@ node_modules
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
|
|
||||||
# Composer
|
# Composer (NOT excluded - Dockerfile.fast needs pre-built vendor)
|
||||||
/vendor
|
# /vendor
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
@@ -58,7 +58,7 @@ docker-compose.*.yml
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/public/build
|
# /public/build - NOT excluded, Dockerfile.fast needs pre-built assets
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.env.backup
|
.env.backup
|
||||||
|
|||||||
53
.env.example
53
.env.example
@@ -24,12 +24,13 @@ LOG_STACK=single
|
|||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# PostgreSQL: 10.100.6.50:5432
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
DB_HOST=pgsql
|
DB_HOST=10.100.6.50
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_DATABASE=cannabrands_app
|
DB_DATABASE=cannabrands_dev
|
||||||
DB_USERNAME=sail
|
DB_USERNAME=cannabrands
|
||||||
DB_PASSWORD=password
|
DB_PASSWORD=SpDyCannaBrands2024
|
||||||
|
|
||||||
SESSION_DRIVER=redis
|
SESSION_DRIVER=redis
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
@@ -66,9 +67,10 @@ CACHE_PREFIX=
|
|||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
# Redis: 10.100.9.50:6379
|
||||||
REDIS_CLIENT=phpredis
|
REDIS_CLIENT=phpredis
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=10.100.9.50
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=SpDyR3d1s2024!
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
MAIL_MAILER=smtp
|
MAIL_MAILER=smtp
|
||||||
@@ -88,43 +90,18 @@ MAIL_FROM_NAME="${APP_NAME}"
|
|||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||||
# │ LOCAL DEVELOPMENT (Docker MinIO) │
|
# │ MinIO (S3-Compatible Storage) │
|
||||||
# └─────────────────────────────────────────────────────────────────────┘
|
# └─────────────────────────────────────────────────────────────────────┘
|
||||||
# Use local MinIO container for development (versioning enabled)
|
# Server: 10.100.9.80:9000 | Console: 10.100.9.80:9001
|
||||||
# Access MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
|
|
||||||
FILESYSTEM_DISK=minio
|
FILESYSTEM_DISK=minio
|
||||||
AWS_ACCESS_KEY_ID=minioadmin
|
AWS_ACCESS_KEY_ID=cannabrands-app
|
||||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
AWS_SECRET_ACCESS_KEY=cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
AWS_BUCKET=media
|
AWS_BUCKET=cannabrands
|
||||||
AWS_ENDPOINT=http://minio:9000
|
AWS_ENDPOINT=http://10.100.9.80:9000
|
||||||
AWS_URL=http://localhost:9000/media
|
AWS_URL=http://10.100.9.80:9000/cannabrands
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=true
|
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
|
|
||||||
# AWS_BUCKET=media
|
|
||||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
|
||||||
# AWS_URL=https://cdn.cannabrands.app/media
|
|
||||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
# Woodpecker CI/CD Pipeline for Cannabrands Hub
|
# Woodpecker CI/CD Pipeline for Cannabrands Hub
|
||||||
# Documentation: https://woodpecker-ci.org/docs/intro
|
# Optimized for fast deploys (~8-10 min)
|
||||||
#
|
#
|
||||||
# 2-Environment Workflow (Optimized for small team):
|
# Optimizations:
|
||||||
# - develop branch → dev.cannabrands.app (integration/testing)
|
# - Parallel composer + frontend builds
|
||||||
# - master branch → cannabrands.app (production)
|
# - Split tests (unit + feature run in parallel)
|
||||||
# - tags (2025.X) → cannabrands.app (versioned production releases)
|
# - Dependency caching (npm + composer)
|
||||||
|
# - Single-stage Dockerfile.fast
|
||||||
|
# - Kaniko layer caching
|
||||||
#
|
#
|
||||||
# Pipeline Strategy:
|
# External Services:
|
||||||
# - PRs: Run tests (lint, style, phpunit) IN PARALLEL
|
# - PostgreSQL: 10.100.6.50:5432 (cannabrands_dev)
|
||||||
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
|
# - Redis: 10.100.9.50:6379
|
||||||
# - Tags: Build versioned release
|
# - MinIO: 10.100.9.80:9000
|
||||||
#
|
# - Docker Registry: git.spdy.io (for k8s pulls)
|
||||||
# Optimization Notes:
|
|
||||||
# - php-lint, code-style, and tests run in parallel after composer install
|
|
||||||
# - Uses parallel-lint for faster PHP syntax checking
|
|
||||||
# - PostgreSQL tuned for CI (fsync disabled)
|
|
||||||
# - Cache rebuild only on merge builds
|
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [develop, master]
|
- branch: [develop, master]
|
||||||
event: push
|
event: push
|
||||||
- event: [pull_request, tag]
|
- event: [pull_request, tag]
|
||||||
|
|
||||||
# Use explicit git clone plugin to fix auth issues
|
|
||||||
# The default clone was failing with "could not read Username"
|
|
||||||
clone:
|
clone:
|
||||||
git:
|
git:
|
||||||
image: woodpeckerci/plugin-git
|
image: woodpeckerci/plugin-git
|
||||||
@@ -34,422 +29,273 @@ clone:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
# ============================================
|
# ============================================
|
||||||
# DEPENDENCY INSTALLATION (Sequential)
|
# PARALLEL: Composer + Frontend (with caching)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# Restore Composer cache
|
|
||||||
restore-composer-cache:
|
|
||||||
image: meltwater/drone-cache:dev
|
|
||||||
settings:
|
|
||||||
backend: "filesystem"
|
|
||||||
restore: true
|
|
||||||
cache_key: "composer-{{ checksum \"composer.lock\" }}"
|
|
||||||
archive_format: "gzip"
|
|
||||||
mount:
|
|
||||||
- "vendor"
|
|
||||||
volumes:
|
|
||||||
- /tmp/woodpecker-cache:/tmp/cache
|
|
||||||
|
|
||||||
# Install dependencies (uses pre-built Laravel image with all extensions)
|
|
||||||
composer-install:
|
composer-install:
|
||||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||||
depends_on:
|
|
||||||
- restore-composer-cache
|
|
||||||
commands:
|
commands:
|
||||||
- echo "Creating minimal .env for package discovery..."
|
|
||||||
- |
|
- |
|
||||||
cat > .env << 'EOF'
|
cat > .env << 'EOF'
|
||||||
APP_NAME="Cannabrands Hub"
|
APP_NAME="Cannabrands Hub"
|
||||||
APP_ENV=testing
|
APP_ENV=development
|
||||||
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||||
APP_DEBUG=true
|
|
||||||
CACHE_STORE=array
|
|
||||||
SESSION_DRIVER=array
|
|
||||||
QUEUE_CONNECTION=sync
|
|
||||||
DB_CONNECTION=pgsql
|
|
||||||
DB_HOST=postgres
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_DATABASE=testing
|
|
||||||
DB_USERNAME=testing
|
|
||||||
DB_PASSWORD=testing
|
|
||||||
EOF
|
EOF
|
||||||
- echo "Checking for cached dependencies..."
|
# Restore composer cache if available
|
||||||
- |
|
- mkdir -p /root/.composer/cache
|
||||||
if [ -d "vendor" ] && [ -f "vendor/autoload.php" ]; then
|
- if [ -d .composer-cache ]; then cp -r .composer-cache/* /root/.composer/cache/ 2>/dev/null || true; fi
|
||||||
echo "✅ Restored vendor from cache"
|
# Clean vendor and bootstrap cache to force fresh install
|
||||||
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
- rm -rf vendor bootstrap/cache/*.php
|
||||||
else
|
- composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||||
echo "📦 Installing fresh dependencies (cache miss)"
|
# Verify test command is available
|
||||||
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
- php artisan list test | head -5
|
||||||
fi
|
# Save cache for next build
|
||||||
- echo "✅ Composer dependencies ready!"
|
- mkdir -p .composer-cache && cp -r /root/.composer/cache/* .composer-cache/ 2>/dev/null || true
|
||||||
|
- echo "✅ Composer done"
|
||||||
|
|
||||||
# Rebuild Composer cache (only on merge builds, not PRs)
|
build-frontend:
|
||||||
rebuild-composer-cache:
|
image: 10.100.9.70:5000/library/node:22-alpine
|
||||||
image: meltwater/drone-cache:dev
|
environment:
|
||||||
depends_on:
|
VITE_REVERB_APP_KEY: 6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU=
|
||||||
- composer-install
|
VITE_REVERB_HOST: dev.cannabrands.app
|
||||||
settings:
|
VITE_REVERB_PORT: "443"
|
||||||
backend: "filesystem"
|
VITE_REVERB_SCHEME: https
|
||||||
rebuild: true
|
npm_config_cache: .npm-cache
|
||||||
cache_key: "composer-{{ checksum \"composer.lock\" }}"
|
commands:
|
||||||
archive_format: "gzip"
|
# Use cached node_modules if available
|
||||||
mount:
|
- npm ci --prefer-offline
|
||||||
- "vendor"
|
- npm run build
|
||||||
volumes:
|
- echo "✅ Frontend built"
|
||||||
- /tmp/woodpecker-cache:/tmp/cache
|
|
||||||
when:
|
|
||||||
branch: [develop, master]
|
|
||||||
event: push
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# PR CHECKS (Run in Parallel for Speed)
|
# PR CHECKS (Parallel: lint, style, tests)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# PHP Syntax Check - Uses parallel-lint for 5-10x speed improvement
|
|
||||||
php-lint:
|
php-lint:
|
||||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||||
depends_on:
|
depends_on:
|
||||||
- composer-install
|
- composer-install
|
||||||
commands:
|
commands:
|
||||||
- echo "Checking PHP syntax (parallel)..."
|
|
||||||
- ./vendor/bin/parallel-lint app routes database config --colors --blame
|
- ./vendor/bin/parallel-lint app routes database config --colors --blame
|
||||||
- echo "✅ PHP syntax check complete!"
|
|
||||||
when:
|
when:
|
||||||
event: pull_request
|
event: pull_request
|
||||||
|
|
||||||
# Run Laravel Pint (code style)
|
|
||||||
code-style:
|
code-style:
|
||||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||||
depends_on:
|
depends_on:
|
||||||
- composer-install
|
- composer-install
|
||||||
commands:
|
commands:
|
||||||
- echo "Checking code style with Laravel Pint..."
|
|
||||||
- ./vendor/bin/pint --test
|
- ./vendor/bin/pint --test
|
||||||
- echo "✅ Code style check complete!"
|
|
||||||
when:
|
when:
|
||||||
event: pull_request
|
event: pull_request
|
||||||
|
|
||||||
# Run PHPUnit Tests
|
# Split tests: Unit tests (with DB - some unit tests use factories)
|
||||||
# Note: Uses array cache/session for speed and isolation (Laravel convention)
|
tests-unit:
|
||||||
# Redis + Reverb services used for real-time broadcasting tests
|
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||||
tests:
|
|
||||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- composer-install
|
- composer-install
|
||||||
when:
|
when:
|
||||||
event: pull_request
|
event: pull_request
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: testing
|
APP_ENV: testing
|
||||||
BROADCAST_CONNECTION: reverb
|
|
||||||
CACHE_STORE: array
|
CACHE_STORE: array
|
||||||
SESSION_DRIVER: array
|
SESSION_DRIVER: array
|
||||||
QUEUE_CONNECTION: sync
|
QUEUE_CONNECTION: sync
|
||||||
DB_CONNECTION: pgsql
|
DB_CONNECTION: pgsql
|
||||||
DB_HOST: postgres
|
DB_HOST: 10.100.6.50
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
DB_DATABASE: testing
|
DB_DATABASE: cannabrands_test
|
||||||
DB_USERNAME: testing
|
DB_USERNAME: cannabrands
|
||||||
DB_PASSWORD: testing
|
DB_PASSWORD: SpDyCannaBrands2024
|
||||||
REDIS_HOST: redis
|
|
||||||
REVERB_APP_ID: test-app-id
|
|
||||||
REVERB_APP_KEY: test-key
|
|
||||||
REVERB_APP_SECRET: test-secret
|
|
||||||
REVERB_HOST: localhost
|
|
||||||
REVERB_PORT: 8080
|
|
||||||
REVERB_SCHEME: http
|
|
||||||
commands:
|
commands:
|
||||||
- echo "Setting up Laravel environment..."
|
|
||||||
- cp .env.example .env
|
- cp .env.example .env
|
||||||
- php artisan key:generate
|
- php artisan key:generate
|
||||||
- echo "Waiting for PostgreSQL to be ready..."
|
- php artisan test --testsuite=Unit
|
||||||
- |
|
- echo "✅ Unit tests passed"
|
||||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
|
||||||
if pg_isready -h postgres -p 5432 -U testing 2>/dev/null; then
|
|
||||||
echo "✅ PostgreSQL is ready!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "Waiting for postgres... attempt $i/10"
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
- echo "Starting Reverb server in background..."
|
|
||||||
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
|
|
||||||
- sleep 2
|
|
||||||
- echo "Running tests in parallel..."
|
|
||||||
- php artisan test --parallel
|
|
||||||
- echo "✅ Tests complete!"
|
|
||||||
|
|
||||||
# ============================================
|
# Split tests: Feature tests (with DB)
|
||||||
# MERGE BUILD STEPS (Sequential, after PR passes)
|
tests-feature:
|
||||||
# ============================================
|
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||||
|
|
||||||
# Validate migrations before deployment
|
|
||||||
# Only runs pending migrations - never fresh or seed
|
|
||||||
validate-migrations:
|
|
||||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- composer-install
|
- composer-install
|
||||||
|
when:
|
||||||
|
event: pull_request
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: production
|
APP_ENV: testing
|
||||||
DB_CONNECTION: pgsql
|
|
||||||
DB_HOST: postgres
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_DATABASE: testing
|
|
||||||
DB_USERNAME: testing
|
|
||||||
DB_PASSWORD: testing
|
|
||||||
CACHE_STORE: array
|
CACHE_STORE: array
|
||||||
SESSION_DRIVER: array
|
SESSION_DRIVER: array
|
||||||
QUEUE_CONNECTION: sync
|
QUEUE_CONNECTION: sync
|
||||||
|
DB_CONNECTION: pgsql
|
||||||
|
DB_HOST: 10.100.7.50
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_DATABASE: cannabrands_test
|
||||||
|
DB_USERNAME: cannabrands
|
||||||
|
DB_PASSWORD: SpDyCannaBrands2024
|
||||||
|
REDIS_HOST: 10.100.9.50
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
REDIS_PASSWORD: SpDyR3d1s2024!
|
||||||
commands:
|
commands:
|
||||||
- echo "Validating migrations..."
|
|
||||||
- cp .env.example .env
|
- cp .env.example .env
|
||||||
- php artisan key:generate
|
- php artisan key:generate
|
||||||
- echo "Running pending migrations only..."
|
- php artisan test --testsuite=Feature
|
||||||
- php artisan migrate --force
|
- echo "✅ Feature tests passed"
|
||||||
- echo "✅ Migration validation complete!"
|
|
||||||
|
# ============================================
|
||||||
|
# BUILD & DEPLOY
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Create Docker config for registry auth (runs before Kaniko)
|
||||||
|
setup-registry-auth:
|
||||||
|
image: alpine
|
||||||
|
depends_on:
|
||||||
|
- composer-install
|
||||||
|
- build-frontend
|
||||||
|
environment:
|
||||||
|
REGISTRY_USER:
|
||||||
|
from_secret: registry_user
|
||||||
|
REGISTRY_PASSWORD:
|
||||||
|
from_secret: registry_password
|
||||||
|
commands:
|
||||||
|
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
|
||||||
|
- |
|
||||||
|
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
|
||||||
|
{"auths":{"registry.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
|
||||||
|
EOF
|
||||||
|
- echo "Auth config created"
|
||||||
when:
|
when:
|
||||||
branch: [develop, master]
|
branch: [develop, master]
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
# Build and push Docker image for DEV environment (develop branch)
|
|
||||||
build-image-dev:
|
build-image-dev:
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||||
depends_on:
|
depends_on:
|
||||||
- validate-migrations
|
- setup-registry-auth
|
||||||
settings:
|
commands:
|
||||||
registry: code.cannabrands.app
|
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||||
repo: code.cannabrands.app/cannabrands/hub
|
- |
|
||||||
username:
|
/kaniko/executor \
|
||||||
from_secret: gitea_username
|
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||||
password:
|
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||||
from_secret: gitea_token
|
--destination=registry.spdy.io/cannabrands/hub:dev \
|
||||||
tags:
|
--destination=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||||
- dev # Latest dev build → dev.cannabrands.app
|
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||||
- dev-${CI_COMMIT_SHA:0:7} # Unique dev tag with SHA
|
--build-arg=APP_VERSION=dev \
|
||||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
|
--registry-mirror=10.100.9.70:5000 \
|
||||||
- ${CI_COMMIT_BRANCH} # Branch name (develop)
|
--insecure-registry=10.100.9.70:5000 \
|
||||||
build_args:
|
--cache=true \
|
||||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
--cache-ttl=168h \
|
||||||
APP_VERSION: "dev"
|
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache
|
||||||
VITE_REVERB_APP_KEY: "6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU="
|
|
||||||
VITE_REVERB_HOST: "dev.cannabrands.app"
|
|
||||||
VITE_REVERB_PORT: "443"
|
|
||||||
VITE_REVERB_SCHEME: "https"
|
|
||||||
cache_from:
|
|
||||||
- code.cannabrands.app/cannabrands/hub:buildcache-dev
|
|
||||||
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-dev
|
|
||||||
platforms: linux/amd64
|
|
||||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
|
||||||
provenance: false
|
|
||||||
when:
|
when:
|
||||||
branch: develop
|
branch: develop
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
# Auto-deploy to dev.cannabrands.app (develop branch only)
|
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
image: bitnami/kubectl:latest
|
image: 10.100.9.70:5000/bitnami/kubectl:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-image-dev
|
- build-image-dev
|
||||||
environment:
|
environment:
|
||||||
KUBECONFIG_CONTENT:
|
KUBECONFIG_CONTENT:
|
||||||
from_secret: kubeconfig_dev
|
from_secret: kubeconfig_dev
|
||||||
commands:
|
commands:
|
||||||
- echo "🚀 Auto-deploying to dev.cannabrands.app..."
|
|
||||||
- echo "Commit SHA${CI_COMMIT_SHA:0:7}"
|
|
||||||
- echo ""
|
|
||||||
# Setup kubeconfig
|
|
||||||
- mkdir -p ~/.kube
|
- mkdir -p ~/.kube
|
||||||
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
||||||
- chmod 600 ~/.kube/config
|
- chmod 600 ~/.kube/config
|
||||||
# Update deployment to use new SHA-tagged image (both app and init containers)
|
|
||||||
- |
|
- |
|
||||||
kubectl set image deployment/cannabrands-hub \
|
kubectl set image deployment/cannabrands-hub \
|
||||||
app=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
app=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||||
migrate=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
migrate=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||||
-n cannabrands-dev
|
-n cannabrands-dev
|
||||||
# Wait for rollout to complete (timeout 5 minutes)
|
|
||||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
|
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
|
||||||
# Verify deployment health
|
- echo "✅ Deployed to dev.cannabrands.app"
|
||||||
- |
|
|
||||||
echo ""
|
|
||||||
echo "✅ Deployment successful!"
|
|
||||||
echo "Pod status:"
|
|
||||||
kubectl get pods -n cannabrands-dev -l app=cannabrands-hub
|
|
||||||
echo ""
|
|
||||||
echo "Image deployed:"
|
|
||||||
kubectl get deployment cannabrands-hub -n cannabrands-dev -o jsonpath='{.spec.template.spec.containers[0].image}'
|
|
||||||
echo ""
|
|
||||||
when:
|
when:
|
||||||
branch: develop
|
branch: develop
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
# Build and push Docker image for PRODUCTION (master branch)
|
|
||||||
build-image-production:
|
build-image-production:
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||||
depends_on:
|
depends_on:
|
||||||
- validate-migrations
|
- setup-registry-auth
|
||||||
settings:
|
commands:
|
||||||
registry: code.cannabrands.app
|
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||||
repo: code.cannabrands.app/cannabrands/hub
|
- |
|
||||||
username:
|
/kaniko/executor \
|
||||||
from_secret: gitea_username
|
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||||
password:
|
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||||
from_secret: gitea_token
|
--destination=git.spdy.io/cannabrands/hub:latest \
|
||||||
tags:
|
--destination=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||||
- latest # Latest production build
|
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||||
- prod-${CI_COMMIT_SHA:0:7} # Unique prod tag with SHA
|
--build-arg=APP_VERSION=production \
|
||||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
|
--cache=true \
|
||||||
- ${CI_COMMIT_BRANCH} # Branch name (master)
|
--cache-ttl=168h \
|
||||||
build_args:
|
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
|
||||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
--insecure \
|
||||||
APP_VERSION: "production"
|
--insecure-pull \
|
||||||
cache_from:
|
--skip-tls-verify
|
||||||
- code.cannabrands.app/cannabrands/hub:buildcache-prod
|
|
||||||
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-prod
|
|
||||||
platforms: linux/amd64
|
|
||||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
|
||||||
provenance: false
|
|
||||||
when:
|
when:
|
||||||
branch: master
|
branch: master
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
# Deploy to production (master branch)
|
|
||||||
deploy-production:
|
deploy-production:
|
||||||
image: bitnami/kubectl:latest
|
image: 10.100.9.70:5000/bitnami/kubectl:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-image-production
|
- build-image-production
|
||||||
environment:
|
environment:
|
||||||
KUBECONFIG_CONTENT:
|
KUBECONFIG_CONTENT:
|
||||||
from_secret: kubeconfig_prod
|
from_secret: kubeconfig_prod
|
||||||
commands:
|
commands:
|
||||||
- echo "🚀 Deploying to PRODUCTION (cannabrands.app)..."
|
|
||||||
- echo "Commit SHA ${CI_COMMIT_SHA:0:7}"
|
|
||||||
- mkdir -p ~/.kube
|
- mkdir -p ~/.kube
|
||||||
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
||||||
- chmod 600 ~/.kube/config
|
- chmod 600 ~/.kube/config
|
||||||
- |
|
- |
|
||||||
kubectl set image deployment/cannabrands-hub \
|
kubectl set image deployment/cannabrands-hub \
|
||||||
app=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
app=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||||
migrate=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
migrate=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||||
-n cannabrands-prod
|
-n cannabrands-prod
|
||||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
|
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
|
||||||
- |
|
- echo "✅ Deployed to cannabrands.app"
|
||||||
echo ""
|
|
||||||
echo "✅ PRODUCTION deployment successful!"
|
|
||||||
echo "Pod status:"
|
|
||||||
kubectl get pods -n cannabrands-prod -l app=cannabrands-hub
|
|
||||||
when:
|
when:
|
||||||
branch: master
|
branch: master
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
# Build and push Docker image for tagged releases (optional versioned releases)
|
# For tags, setup auth first
|
||||||
build-image-release:
|
setup-registry-auth-release:
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: alpine
|
||||||
depends_on:
|
depends_on:
|
||||||
- composer-install
|
- composer-install
|
||||||
settings:
|
- build-frontend
|
||||||
registry: code.cannabrands.app
|
environment:
|
||||||
repo: code.cannabrands.app/cannabrands/hub
|
REGISTRY_USER:
|
||||||
username:
|
from_secret: registry_user
|
||||||
from_secret: gitea_username
|
REGISTRY_PASSWORD:
|
||||||
password:
|
from_secret: registry_password
|
||||||
from_secret: gitea_token
|
commands:
|
||||||
tags:
|
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
|
||||||
- ${CI_COMMIT_TAG} # CalVer tag (e.g., 2025.10.1)
|
- |
|
||||||
- latest # Latest stable release
|
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
|
||||||
build_args:
|
{"auths":{"git.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
|
||||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
EOF
|
||||||
APP_VERSION: "${CI_COMMIT_TAG}"
|
|
||||||
cache_images:
|
|
||||||
- code.cannabrands.app/cannabrands/hub:buildcache-prod
|
|
||||||
platforms: linux/amd64
|
|
||||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
|
||||||
provenance: false
|
|
||||||
when:
|
when:
|
||||||
event: tag
|
event: tag
|
||||||
|
|
||||||
# Success notification
|
build-image-release:
|
||||||
success:
|
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||||
image: alpine:latest
|
depends_on:
|
||||||
when:
|
- setup-registry-auth-release
|
||||||
- evaluate: 'CI_PIPELINE_STATUS == "success"'
|
|
||||||
commands:
|
commands:
|
||||||
- echo "✅ Pipeline completed successfully!"
|
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||||
- echo "All checks passed for commit ${CI_COMMIT_SHA:0:7}"
|
|
||||||
- |
|
- |
|
||||||
if [ "${CI_PIPELINE_EVENT}" = "tag" ]; then
|
/kaniko/executor \
|
||||||
echo ""
|
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||||
echo "🎉 PRODUCTION RELEASE BUILD COMPLETE"
|
--destination=git.spdy.io/cannabrands/hub:${CI_COMMIT_TAG} \
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
--destination=git.spdy.io/cannabrands/hub:latest \
|
||||||
echo "Version: ${CI_COMMIT_TAG}"
|
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||||
echo "Registry: code.cannabrands.app/cannabrands/hub"
|
--build-arg=APP_VERSION=${CI_COMMIT_TAG} \
|
||||||
echo ""
|
--cache=true \
|
||||||
echo "Available as:"
|
--cache-ttl=168h \
|
||||||
echo " - code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
|
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
|
||||||
echo " - code.cannabrands.app/cannabrands/hub:latest"
|
--insecure \
|
||||||
echo ""
|
--insecure-pull \
|
||||||
echo "🚀 Deploy to PRODUCTION (cannabrands.app):"
|
--skip-tls-verify
|
||||||
echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
|
when:
|
||||||
echo " docker-compose -f docker-compose.production.yml up -d"
|
event: tag
|
||||||
echo ""
|
|
||||||
echo "⚠️ This is a CUSTOMER-FACING release!"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "master" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo "🚀 PRODUCTION DEPLOYED"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo "Site: https://cannabrands.app"
|
|
||||||
echo "Image: prod-${CI_COMMIT_SHA:0:7}"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "develop" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo "🚀 DEV BUILD + AUTO-DEPLOY COMPLETE"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo "Branch: develop"
|
|
||||||
echo "Commit: ${CI_COMMIT_SHA:0:7}"
|
|
||||||
echo ""
|
|
||||||
echo "✅ Built & Tagged:"
|
|
||||||
echo " - code.cannabrands.app/cannabrands/hub:dev"
|
|
||||||
echo " - code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7}"
|
|
||||||
echo " - code.cannabrands.app/cannabrands/hub:sha-${CI_COMMIT_SHA:0:7}"
|
|
||||||
echo ""
|
|
||||||
echo "✅ Auto-Deployed to Kubernetes:"
|
|
||||||
echo " - Environment: dev.cannabrands.app"
|
|
||||||
echo " - Namespace: cannabrands-dev"
|
|
||||||
echo " - Image: dev-${CI_COMMIT_SHA:0:7}"
|
|
||||||
echo ""
|
|
||||||
echo "🧪 Test your changes:"
|
|
||||||
echo " - Visit: https://dev.cannabrands.app"
|
|
||||||
echo " - Login: admin@example.com / password"
|
|
||||||
echo " - Check: https://dev.cannabrands.app/telescope"
|
|
||||||
echo ""
|
|
||||||
echo "Ready for production? Open PR: develop → master"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
elif [ "${CI_PIPELINE_EVENT}" = "pull_request" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo "✅ PR CHECKS PASSED"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo "Ready to merge to master for production deployment."
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Services for tests (optimized for CI speed)
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:15
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: testing
|
|
||||||
POSTGRES_PASSWORD: testing
|
|
||||||
POSTGRES_DB: testing
|
|
||||||
# CI-optimized settings via environment (faster writes, safe for ephemeral test DB)
|
|
||||||
POSTGRES_INITDB_ARGS: "--data-checksums"
|
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
commands:
|
|
||||||
- redis-server --bind 0.0.0.0
|
|
||||||
|
|||||||
@@ -69,14 +69,14 @@ git push origin develop
|
|||||||
|
|
||||||
**Before (Mutable Tags - Problematic):**
|
**Before (Mutable Tags - Problematic):**
|
||||||
```
|
```
|
||||||
code.cannabrands.app/cannabrands/hub:dev # Overwritten each build
|
git.spdy.io/cannabrands/hub:dev # Overwritten each build
|
||||||
```
|
```
|
||||||
|
|
||||||
**After (Immutable Tags - Best Practice):**
|
**After (Immutable Tags - Best Practice):**
|
||||||
```
|
```
|
||||||
code.cannabrands.app/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
|
git.spdy.io/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
|
||||||
code.cannabrands.app/cannabrands/hub:dev # Latest dev (convenience)
|
git.spdy.io/cannabrands/hub:dev # Latest dev (convenience)
|
||||||
code.cannabrands.app/cannabrands/hub:sha-a28d5b5 # Generic SHA
|
git.spdy.io/cannabrands/hub:sha-a28d5b5 # Generic SHA
|
||||||
```
|
```
|
||||||
|
|
||||||
### Auto-Deploy Flow
|
### Auto-Deploy Flow
|
||||||
@@ -109,14 +109,14 @@ If a deployment breaks dev, roll back to the previous version:
|
|||||||
kubectl get deployment cannabrands-hub -n cannabrands-dev \
|
kubectl get deployment cannabrands-hub -n cannabrands-dev \
|
||||||
-o jsonpath='{.spec.template.spec.containers[0].image}'
|
-o jsonpath='{.spec.template.spec.containers[0].image}'
|
||||||
|
|
||||||
# Output: code.cannabrands.app/cannabrands/hub:dev-a28d5b5
|
# Output: git.spdy.io/cannabrands/hub:dev-a28d5b5
|
||||||
|
|
||||||
# 2. Check git log for previous commit
|
# 2. Check git log for previous commit
|
||||||
git log --oneline develop | head -5
|
git log --oneline develop | head -5
|
||||||
|
|
||||||
# 3. Rollback to previous SHA
|
# 3. Rollback to previous SHA
|
||||||
kubectl set image deployment/cannabrands-hub \
|
kubectl set image deployment/cannabrands-hub \
|
||||||
app=code.cannabrands.app/cannabrands/hub:dev-PREVIOUS_SHA \
|
app=git.spdy.io/cannabrands/hub:dev-PREVIOUS_SHA \
|
||||||
-n cannabrands-dev
|
-n cannabrands-dev
|
||||||
|
|
||||||
# 4. Verify rollback
|
# 4. Verify rollback
|
||||||
@@ -156,7 +156,7 @@ deploy-staging:
|
|||||||
- chmod 600 ~/.kube/config
|
- chmod 600 ~/.kube/config
|
||||||
- |
|
- |
|
||||||
kubectl set image deployment/cannabrands-hub \
|
kubectl set image deployment/cannabrands-hub \
|
||||||
app=code.cannabrands.app/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
|
app=git.spdy.io/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
|
||||||
-n cannabrands-staging
|
-n cannabrands-staging
|
||||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-staging --timeout=300s
|
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-staging --timeout=300s
|
||||||
when:
|
when:
|
||||||
@@ -207,7 +207,7 @@ kubectl logs -n cannabrands-dev deployment/cannabrands-hub --tail=100
|
|||||||
cannabrands-hub-7d85986845-gnkbv 1/1 Running 0 45s
|
cannabrands-hub-7d85986845-gnkbv 1/1 Running 0 45s
|
||||||
|
|
||||||
Image deployed:
|
Image deployed:
|
||||||
code.cannabrands.app/cannabrands/hub:dev-a28d5b5
|
git.spdy.io/cannabrands/hub:dev-a28d5b5
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ steps:
|
|||||||
build-image:
|
build-image:
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
registry: code.cannabrands.app
|
registry: git.spdy.io
|
||||||
repo: code.cannabrands.app/cannabrands/hub
|
repo: git.spdy.io/cannabrands/hub
|
||||||
tags: [latest, ${CI_COMMIT_SHA:0:8}]
|
tags: [latest, ${CI_COMMIT_SHA:0:8}]
|
||||||
when:
|
when:
|
||||||
branch: master
|
branch: master
|
||||||
@@ -68,7 +68,7 @@ steps:
|
|||||||
```bash
|
```bash
|
||||||
# On production server
|
# On production server
|
||||||
ssh cannabrands-prod
|
ssh cannabrands-prod
|
||||||
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
|
docker pull git.spdy.io/cannabrands/hub:bef77df8
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
# Or use deployment tool like Ansible, Deployer, etc.
|
# Or use deployment tool like Ansible, Deployer, etc.
|
||||||
```
|
```
|
||||||
@@ -108,7 +108,7 @@ steps:
|
|||||||
from_secret: ssh_private_key
|
from_secret: ssh_private_key
|
||||||
script:
|
script:
|
||||||
- cd /var/www/cannabrands
|
- cd /var/www/cannabrands
|
||||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||||
- docker-compose up -d
|
- docker-compose up -d
|
||||||
- docker exec cannabrands php artisan migrate --force
|
- docker exec cannabrands php artisan migrate --force
|
||||||
- docker exec cannabrands php artisan config:cache
|
- docker exec cannabrands php artisan config:cache
|
||||||
@@ -160,7 +160,7 @@ steps:
|
|||||||
from_secret: ssh_private_key
|
from_secret: ssh_private_key
|
||||||
script:
|
script:
|
||||||
- cd /var/www/cannabrands
|
- cd /var/www/cannabrands
|
||||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||||
- docker-compose up -d
|
- docker-compose up -d
|
||||||
when:
|
when:
|
||||||
branch: develop
|
branch: develop
|
||||||
@@ -176,7 +176,7 @@ steps:
|
|||||||
from_secret: ssh_private_key
|
from_secret: ssh_private_key
|
||||||
script:
|
script:
|
||||||
- cd /var/www/cannabrands
|
- cd /var/www/cannabrands
|
||||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||||
- docker-compose up -d
|
- docker-compose up -d
|
||||||
when:
|
when:
|
||||||
branch: master
|
branch: master
|
||||||
@@ -367,7 +367,7 @@ Production:
|
|||||||
```bash
|
```bash
|
||||||
# Quick rollback (under 2 minutes)
|
# Quick rollback (under 2 minutes)
|
||||||
ssh cannabrands-prod
|
ssh cannabrands-prod
|
||||||
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_COMMIT_SHA
|
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_COMMIT_SHA
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
# Database rollback (if migrations ran)
|
# Database rollback (if migrations ran)
|
||||||
@@ -536,8 +536,8 @@ steps:
|
|||||||
build-image:
|
build-image:
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
registry: code.cannabrands.app
|
registry: git.spdy.io
|
||||||
repo: code.cannabrands.app/cannabrands/hub
|
repo: git.spdy.io/cannabrands/hub
|
||||||
tags:
|
tags:
|
||||||
- ${CI_COMMIT_BRANCH}
|
- ${CI_COMMIT_BRANCH}
|
||||||
- ${CI_COMMIT_SHA:0:8}
|
- ${CI_COMMIT_SHA:0:8}
|
||||||
@@ -559,7 +559,7 @@ steps:
|
|||||||
from_secret: staging_ssh_key
|
from_secret: staging_ssh_key
|
||||||
script:
|
script:
|
||||||
- cd /var/www/cannabrands
|
- cd /var/www/cannabrands
|
||||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||||
- docker-compose up -d
|
- docker-compose up -d
|
||||||
- docker exec cannabrands php artisan migrate --force
|
- docker exec cannabrands php artisan migrate --force
|
||||||
- docker exec cannabrands php artisan config:cache
|
- docker exec cannabrands php artisan config:cache
|
||||||
@@ -582,7 +582,7 @@ steps:
|
|||||||
- echo "To deploy to production:"
|
- echo "To deploy to production:"
|
||||||
- echo " ssh cannabrands-prod"
|
- echo " ssh cannabrands-prod"
|
||||||
- echo " cd /var/www/cannabrands"
|
- echo " cd /var/www/cannabrands"
|
||||||
- echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
- echo " docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
||||||
- echo " docker-compose up -d"
|
- echo " docker-compose up -d"
|
||||||
- echo ""
|
- echo ""
|
||||||
- echo "⚠️ Remember: Check deployment checklist first!"
|
- echo "⚠️ Remember: Check deployment checklist first!"
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ Push to master → Woodpecker runs:
|
|||||||
→ Build Docker image
|
→ Build Docker image
|
||||||
→ Tag: cannabrands-hub:c165bf9 (commit SHA)
|
→ Tag: cannabrands-hub:c165bf9 (commit SHA)
|
||||||
→ Tag: cannabrands-hub:latest
|
→ Tag: cannabrands-hub:latest
|
||||||
→ Push to code.cannabrands.app/cannabrands/hub
|
→ Push to git.spdy.io/cannabrands/hub
|
||||||
→ Image ready, no deployment yet
|
→ Image ready, no deployment yet
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ CMD ["php-fpm"]
|
|||||||
### Staging Deployment:
|
### Staging Deployment:
|
||||||
```bash
|
```bash
|
||||||
# Pull the same image
|
# Pull the same image
|
||||||
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
|
docker pull git.spdy.io/cannabrands/hub:c165bf9
|
||||||
|
|
||||||
# Run with staging environment
|
# Run with staging environment
|
||||||
docker run \
|
docker run \
|
||||||
@@ -186,13 +186,13 @@ docker run \
|
|||||||
-e DB_DATABASE=cannabrands_staging \
|
-e DB_DATABASE=cannabrands_staging \
|
||||||
-e APP_DEBUG=true \
|
-e APP_DEBUG=true \
|
||||||
-e MAIL_MAILER=log \
|
-e MAIL_MAILER=log \
|
||||||
code.cannabrands.app/cannabrands/hub:c165bf9
|
git.spdy.io/cannabrands/hub:c165bf9
|
||||||
```
|
```
|
||||||
|
|
||||||
### Production Deployment:
|
### Production Deployment:
|
||||||
```bash
|
```bash
|
||||||
# Pull THE EXACT SAME IMAGE
|
# Pull THE EXACT SAME IMAGE
|
||||||
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
|
docker pull git.spdy.io/cannabrands/hub:c165bf9
|
||||||
|
|
||||||
# Run with production environment
|
# Run with production environment
|
||||||
docker run \
|
docker run \
|
||||||
@@ -201,7 +201,7 @@ docker run \
|
|||||||
-e DB_DATABASE=cannabrands_production \
|
-e DB_DATABASE=cannabrands_production \
|
||||||
-e APP_DEBUG=false \
|
-e APP_DEBUG=false \
|
||||||
-e MAIL_MAILER=smtp \
|
-e MAIL_MAILER=smtp \
|
||||||
code.cannabrands.app/cannabrands/hub:c165bf9
|
git.spdy.io/cannabrands/hub:c165bf9
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key point**: Notice it's the **exact same image** (`c165bf9`), only environment variables differ.
|
**Key point**: Notice it's the **exact same image** (`c165bf9`), only environment variables differ.
|
||||||
@@ -218,7 +218,7 @@ docker run \
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: code.cannabrands.app/cannabrands/hub:latest
|
image: git.spdy.io/cannabrands/hub:latest
|
||||||
env_file:
|
env_file:
|
||||||
- .env.staging # Staging-specific vars
|
- .env.staging # Staging-specific vars
|
||||||
ports:
|
ports:
|
||||||
@@ -253,7 +253,7 @@ secrets:
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: code.cannabrands.app/cannabrands/hub:c165bf9 # Specific SHA
|
image: git.spdy.io/cannabrands/hub:c165bf9 # Specific SHA
|
||||||
env_file:
|
env_file:
|
||||||
- .env.production # Production-specific vars
|
- .env.production # Production-specific vars
|
||||||
ports:
|
ports:
|
||||||
@@ -301,7 +301,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: code.cannabrands.app/cannabrands/hub:c165bf9
|
image: git.spdy.io/cannabrands/hub:c165bf9
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: app-config-staging # Different per namespace
|
name: app-config-staging # Different per namespace
|
||||||
@@ -350,8 +350,8 @@ steps:
|
|||||||
build-and-publish:
|
build-and-publish:
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
registry: code.cannabrands.app
|
registry: git.spdy.io
|
||||||
repo: code.cannabrands.app/cannabrands/hub
|
repo: git.spdy.io/cannabrands/hub
|
||||||
tags:
|
tags:
|
||||||
- latest # Always overwrite
|
- latest # Always overwrite
|
||||||
- ${CI_COMMIT_SHA:0:8} # Immutable SHA
|
- ${CI_COMMIT_SHA:0:8} # Immutable SHA
|
||||||
@@ -384,7 +384,7 @@ Date: 2025-01-15 14:30:00 PST
|
|||||||
Image: cannabrands-hub:c165bf9
|
Image: cannabrands-hub:c165bf9
|
||||||
Deployed by: jon@cannabrands.com
|
Deployed by: jon@cannabrands.com
|
||||||
Approved by: compliance@cannabrands.com
|
Approved by: compliance@cannabrands.com
|
||||||
Git commit: https://code.cannabrands.app/.../c165bf9
|
Git commit: https://git.spdy.io/.../c165bf9
|
||||||
Changes: Invoice picking workflow update
|
Changes: Invoice picking workflow update
|
||||||
Tests passed: ✅ 28/28
|
Tests passed: ✅ 28/28
|
||||||
Staging tested: ✅ 2 hours
|
Staging tested: ✅ 2 hours
|
||||||
@@ -424,7 +424,7 @@ Rollback image: cannabrands-hub:a1b2c3d
|
|||||||
```bash
|
```bash
|
||||||
# On production server
|
# On production server
|
||||||
ssh cannabrands-prod
|
ssh cannabrands-prod
|
||||||
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
|
docker pull git.spdy.io/cannabrands/hub:c165bf9
|
||||||
docker-compose -f docker-compose.production.yml up -d
|
docker-compose -f docker-compose.production.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -487,14 +487,14 @@ steps:
|
|||||||
security-scan:
|
security-scan:
|
||||||
image: aquasec/trivy
|
image: aquasec/trivy
|
||||||
commands:
|
commands:
|
||||||
- trivy image code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
- trivy image git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Sign Images (Advanced)
|
### 4. Sign Images (Advanced)
|
||||||
|
|
||||||
Use Cosign to cryptographically sign images:
|
Use Cosign to cryptographically sign images:
|
||||||
```bash
|
```bash
|
||||||
cosign sign code.cannabrands.app/cannabrands/hub:c165bf9
|
cosign sign git.spdy.io/cannabrands/hub:c165bf9
|
||||||
```
|
```
|
||||||
|
|
||||||
Compliance benefit: Prove image hasn't been tampered with.
|
Compliance benefit: Prove image hasn't been tampered with.
|
||||||
@@ -507,10 +507,10 @@ Compliance benefit: Prove image hasn't been tampered with.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List recent deployments
|
# List recent deployments
|
||||||
docker images code.cannabrands.app/cannabrands/hub
|
docker images git.spdy.io/cannabrands/hub
|
||||||
|
|
||||||
# Rollback to previous version
|
# Rollback to previous version
|
||||||
docker pull code.cannabrands.app/cannabrands/hub:a1b2c3d
|
docker pull git.spdy.io/cannabrands/hub:a1b2c3d
|
||||||
docker-compose -f docker-compose.production.yml up -d
|
docker-compose -f docker-compose.production.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -531,7 +531,7 @@ deploy:
|
|||||||
# Before risky deployment
|
# Before risky deployment
|
||||||
git tag -a v1.5.2-stable -m "Last known good version"
|
git tag -a v1.5.2-stable -m "Last known good version"
|
||||||
docker tag cannabrands-hub:current cannabrands-hub:v1.5.2-stable
|
docker tag cannabrands-hub:current cannabrands-hub:v1.5.2-stable
|
||||||
docker push code.cannabrands.app/cannabrands/hub:v1.5.2-stable
|
docker push git.spdy.io/cannabrands/hub:v1.5.2-stable
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -254,25 +254,25 @@ WORKDIR /woodpecker/src
|
|||||||
|
|
||||||
**Build and push to Gitea:**
|
**Build and push to Gitea:**
|
||||||
```bash
|
```bash
|
||||||
docker build -f docker/ci-php.Dockerfile -t code.cannabrands.app/cannabrands/ci-php:8.3 .
|
docker build -f docker/ci-php.Dockerfile -t git.spdy.io/cannabrands/ci-php:8.3 .
|
||||||
docker push code.cannabrands.app/cannabrands/ci-php:8.3
|
docker push git.spdy.io/cannabrands/ci-php:8.3
|
||||||
```
|
```
|
||||||
|
|
||||||
**Update `.woodpecker/.ci.yml`:**
|
**Update `.woodpecker/.ci.yml`:**
|
||||||
```yaml
|
```yaml
|
||||||
steps:
|
steps:
|
||||||
php-lint:
|
php-lint:
|
||||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||||
commands:
|
commands:
|
||||||
- find app routes database -name "*.php" -exec php -l {} \;
|
- find app routes database -name "*.php" -exec php -l {} \;
|
||||||
|
|
||||||
composer-install:
|
composer-install:
|
||||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||||
commands:
|
commands:
|
||||||
- composer install --no-interaction --prefer-dist --optimize-autoloader
|
- composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
code-style:
|
code-style:
|
||||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||||
commands:
|
commands:
|
||||||
- ./vendor/bin/pint --test
|
- ./vendor/bin/pint --test
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: code.cannabrands.app/cannabrands/hub:latest
|
image: git.spdy.io/cannabrands/hub:latest
|
||||||
container_name: cannabrands_app
|
container_name: cannabrands_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -204,8 +204,8 @@ steps:
|
|||||||
build-image:
|
build-image:
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
settings:
|
settings:
|
||||||
registry: code.cannabrands.app
|
registry: git.spdy.io
|
||||||
repo: code.cannabrands.app/cannabrands/hub
|
repo: git.spdy.io/cannabrands/hub
|
||||||
username:
|
username:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
password:
|
password:
|
||||||
@@ -564,7 +564,7 @@ docker images | grep cannabrands
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pull previous commit's image
|
# Pull previous commit's image
|
||||||
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_SHA
|
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_SHA
|
||||||
|
|
||||||
# Update docker-compose.yml to use specific tag
|
# Update docker-compose.yml to use specific tag
|
||||||
docker compose up -d app
|
docker compose up -d app
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ Once you implement production deployments, Woodpecker will:
|
|||||||
|
|
||||||
Your images will be available at:
|
Your images will be available at:
|
||||||
```
|
```
|
||||||
code.cannabrands.app/cannabrands/hub
|
git.spdy.io/cannabrands/hub
|
||||||
```
|
```
|
||||||
|
|
||||||
**View packages**: https://code.cannabrands.app/Cannabrands/hub/-/packages
|
**View packages**: https://git.spdy.io/Cannabrands/hub/-/packages
|
||||||
|
|
||||||
## Step 1: Enable Gitea Package Registry
|
## Step 1: Enable Gitea Package Registry
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ First, verify the registry is enabled on your Gitea instance:
|
|||||||
|
|
||||||
1. **Check as admin**: Admin → Site Administration → Configuration
|
1. **Check as admin**: Admin → Site Administration → Configuration
|
||||||
2. **Look for**: `[packages]` section with `ENABLED = true`
|
2. **Look for**: `[packages]` section with `ENABLED = true`
|
||||||
3. **Test**: Visit https://code.cannabrands.app/-/packages
|
3. **Test**: Visit https://git.spdy.io/-/packages
|
||||||
|
|
||||||
If not enabled, ask your Gitea admin to enable it in `app.ini`:
|
If not enabled, ask your Gitea admin to enable it in `app.ini`:
|
||||||
```ini
|
```ini
|
||||||
@@ -61,8 +61,8 @@ steps:
|
|||||||
build-and-publish:
|
build-and-publish:
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
registry: code.cannabrands.app
|
registry: git.spdy.io
|
||||||
repo: code.cannabrands.app/cannabrands/hub
|
repo: git.spdy.io/cannabrands/hub
|
||||||
tags:
|
tags:
|
||||||
- latest
|
- latest
|
||||||
- ${CI_COMMIT_SHA:0:8}
|
- ${CI_COMMIT_SHA:0:8}
|
||||||
@@ -136,15 +136,15 @@ Once images are published, you can pull them on your production servers:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Login to Gitea registry
|
# Login to Gitea registry
|
||||||
docker login code.cannabrands.app
|
docker login git.spdy.io
|
||||||
# Username: your-gitea-username
|
# Username: your-gitea-username
|
||||||
# Password: your-personal-access-token
|
# Password: your-personal-access-token
|
||||||
|
|
||||||
# Pull latest image
|
# Pull latest image
|
||||||
docker pull code.cannabrands.app/cannabrands/hub:latest
|
docker pull git.spdy.io/cannabrands/hub:latest
|
||||||
|
|
||||||
# Or pull specific commit
|
# Or pull specific commit
|
||||||
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
|
docker pull git.spdy.io/cannabrands/hub:bef77df8
|
||||||
```
|
```
|
||||||
|
|
||||||
## Image Tagging Strategy
|
## Image Tagging Strategy
|
||||||
@@ -218,8 +218,8 @@ steps:
|
|||||||
build-and-publish:
|
build-and-publish:
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
registry: code.cannabrands.app
|
registry: git.spdy.io
|
||||||
repo: code.cannabrands.app/cannabrands/hub
|
repo: git.spdy.io/cannabrands/hub
|
||||||
tags:
|
tags:
|
||||||
- latest
|
- latest
|
||||||
- ${CI_COMMIT_SHA:0:8}
|
- ${CI_COMMIT_SHA:0:8}
|
||||||
@@ -236,7 +236,7 @@ steps:
|
|||||||
notify-deploy:
|
notify-deploy:
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
commands:
|
commands:
|
||||||
- echo "✅ New image published: code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
- echo "✅ New image published: git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
||||||
- echo "Ready for deployment to production!"
|
- echo "Ready for deployment to production!"
|
||||||
when:
|
when:
|
||||||
- branch: master
|
- branch: master
|
||||||
@@ -271,8 +271,8 @@ services:
|
|||||||
- Subsequent builds will work fine
|
- Subsequent builds will work fine
|
||||||
|
|
||||||
**Images not appearing in Gitea packages**
|
**Images not appearing in Gitea packages**
|
||||||
- Check Gitea packages are enabled: https://code.cannabrands.app/-/packages
|
- Check Gitea packages are enabled: https://git.spdy.io/-/packages
|
||||||
- Verify registry URL is `code.cannabrands.app` (not `ci.cannabrands.app`)
|
- Verify registry URL is `git.spdy.io` (not `ci.cannabrands.app`)
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ git push origin 2025.11.3
|
|||||||
|
|
||||||
### Step 3: Wait for CI Build (2-4 minutes)
|
### Step 3: Wait for CI Build (2-4 minutes)
|
||||||
|
|
||||||
Watch at: `code.cannabrands.app/cannabrands/hub/pipelines`
|
Watch at: `git.spdy.io/cannabrands/hub/pipelines`
|
||||||
|
|
||||||
CI will automatically:
|
CI will automatically:
|
||||||
- Run tests
|
- Run tests
|
||||||
@@ -113,7 +113,7 @@ git push origin master
|
|||||||
```bash
|
```bash
|
||||||
# Deploy specific version
|
# Deploy specific version
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:2025.11.3
|
app=git.spdy.io/cannabrands/hub:2025.11.3
|
||||||
|
|
||||||
# Watch deployment
|
# Watch deployment
|
||||||
kubectl rollout status deployment/cannabrands
|
kubectl rollout status deployment/cannabrands
|
||||||
@@ -131,7 +131,7 @@ kubectl get pods
|
|||||||
```bash
|
```bash
|
||||||
# Option 1: Rollback to previous version
|
# Option 1: Rollback to previous version
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:2025.11.2
|
app=git.spdy.io/cannabrands/hub:2025.11.2
|
||||||
|
|
||||||
# Option 2: Kubernetes automatic rollback
|
# Option 2: Kubernetes automatic rollback
|
||||||
kubectl rollout undo deployment/cannabrands
|
kubectl rollout undo deployment/cannabrands
|
||||||
@@ -154,7 +154,7 @@ git push origin 2025.11.4
|
|||||||
|
|
||||||
# 4. Deploy when confident
|
# 4. Deploy when confident
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:2025.11.4
|
app=git.spdy.io/cannabrands/hub:2025.11.4
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -170,7 +170,7 @@ master → Branch tracking
|
|||||||
|
|
||||||
**Use in K3s dev/staging:**
|
**Use in K3s dev/staging:**
|
||||||
```yaml
|
```yaml
|
||||||
image: code.cannabrands.app/cannabrands/hub:latest-dev
|
image: git.spdy.io/cannabrands/hub:latest-dev
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ stable → Latest production release
|
|||||||
|
|
||||||
**Use in K3s production:**
|
**Use in K3s production:**
|
||||||
```yaml
|
```yaml
|
||||||
image: code.cannabrands.app/cannabrands/hub:2025.11.3
|
image: git.spdy.io/cannabrands/hub:2025.11.3
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ docker build -t cannabrands:test .
|
|||||||
### View CI Status
|
### View CI Status
|
||||||
```bash
|
```bash
|
||||||
# Visit Woodpecker
|
# Visit Woodpecker
|
||||||
open https://code.cannabrands.app/cannabrands/hub/pipelines
|
open https://git.spdy.io/cannabrands/hub/pipelines
|
||||||
|
|
||||||
# Or check latest build
|
# Or check latest build
|
||||||
# (Visit Gitea → Repository → Pipelines)
|
# (Visit Gitea → Repository → Pipelines)
|
||||||
@@ -227,7 +227,7 @@ open https://code.cannabrands.app/cannabrands/hub/pipelines
|
|||||||
### CI Build Failing
|
### CI Build Failing
|
||||||
```bash
|
```bash
|
||||||
# Check Woodpecker logs
|
# Check Woodpecker logs
|
||||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||||
|
|
||||||
# Run tests locally first
|
# Run tests locally first
|
||||||
./vendor/bin/sail artisan test
|
./vendor/bin/sail artisan test
|
||||||
@@ -362,8 +362,8 @@ Before deploying:
|
|||||||
- Pair with senior dev for first release
|
- Pair with senior dev for first release
|
||||||
|
|
||||||
### CI/CD
|
### CI/CD
|
||||||
- Woodpecker: `code.cannabrands.app/cannabrands/hub`
|
- Woodpecker: `git.spdy.io/cannabrands/hub`
|
||||||
- Gitea: `code.cannabrands.app/cannabrands/hub`
|
- Gitea: `git.spdy.io/cannabrands/hub`
|
||||||
- K3s Dashboard: (ask devops for link)
|
- K3s Dashboard: (ask devops for link)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -371,13 +371,13 @@ Before deploying:
|
|||||||
## Important URLs
|
## Important URLs
|
||||||
|
|
||||||
**Code Repository:**
|
**Code Repository:**
|
||||||
https://code.cannabrands.app/cannabrands/hub
|
https://git.spdy.io/cannabrands/hub
|
||||||
|
|
||||||
**CI/CD Pipeline:**
|
**CI/CD Pipeline:**
|
||||||
https://code.cannabrands.app/cannabrands/hub/pipelines
|
https://git.spdy.io/cannabrands/hub/pipelines
|
||||||
|
|
||||||
**Container Registry:**
|
**Container Registry:**
|
||||||
https://code.cannabrands.app/-/packages/container/cannabrands%2Fhub
|
https://git.spdy.io/-/packages/container/cannabrands%2Fhub
|
||||||
|
|
||||||
**Documentation:**
|
**Documentation:**
|
||||||
`.woodpecker/` directory in repository
|
`.woodpecker/` directory in repository
|
||||||
@@ -430,7 +430,7 @@ Closes #42"
|
|||||||
| Deploy | `kubectl set image deployment/cannabrands app=...:2025.11.1` |
|
| Deploy | `kubectl set image deployment/cannabrands app=...:2025.11.1` |
|
||||||
| Rollback | `kubectl set image deployment/cannabrands app=...:2025.11.0` |
|
| Rollback | `kubectl set image deployment/cannabrands app=...:2025.11.0` |
|
||||||
| Check version | `kubectl get deployment cannabrands -o jsonpath='{.spec.template.spec.containers[0].image}'` |
|
| Check version | `kubectl get deployment cannabrands -o jsonpath='{.spec.template.spec.containers[0].image}'` |
|
||||||
| View builds | Visit `code.cannabrands.app/cannabrands/hub/pipelines` |
|
| View builds | Visit `git.spdy.io/cannabrands/hub/pipelines` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ git push origin master
|
|||||||
2. Tests run (PHP lint, Pint, PHPUnit)
|
2. Tests run (PHP lint, Pint, PHPUnit)
|
||||||
3. Docker image builds (if tests pass)
|
3. Docker image builds (if tests pass)
|
||||||
4. Tagged as: latest-dev, dev-c658193, master
|
4. Tagged as: latest-dev, dev-c658193, master
|
||||||
5. Pushed to code.cannabrands.app/cannabrands/hub
|
5. Pushed to git.spdy.io/cannabrands/hub
|
||||||
6. Available in K3s dev namespace (manual or auto-pull)
|
6. Available in K3s dev namespace (manual or auto-pull)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ git push origin master
|
|||||||
**Use in K3s:**
|
**Use in K3s:**
|
||||||
```yaml
|
```yaml
|
||||||
# dev/staging namespace
|
# dev/staging namespace
|
||||||
image: code.cannabrands.app/cannabrands/hub:latest-dev
|
image: git.spdy.io/cannabrands/hub:latest-dev
|
||||||
imagePullPolicy: Always # Always pull newest
|
imagePullPolicy: Always # Always pull newest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ git push origin 2025.11.1
|
|||||||
**Use in K3s:**
|
**Use in K3s:**
|
||||||
```yaml
|
```yaml
|
||||||
# production namespace
|
# production namespace
|
||||||
image: code.cannabrands.app/cannabrands/hub:2025.11.1
|
image: git.spdy.io/cannabrands/hub:2025.11.1
|
||||||
imagePullPolicy: IfNotPresent # Pin to specific version
|
imagePullPolicy: IfNotPresent # Pin to specific version
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ git push origin master
|
|||||||
./vendor/bin/sail artisan test
|
./vendor/bin/sail artisan test
|
||||||
|
|
||||||
# Check CI is green
|
# Check CI is green
|
||||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||||
|
|
||||||
# Test in staging/dev environment
|
# Test in staging/dev environment
|
||||||
# Verify key workflows work
|
# Verify key workflows work
|
||||||
@@ -264,12 +264,12 @@ git push origin 2025.11.3
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Watch Woodpecker build
|
# Watch Woodpecker build
|
||||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||||
|
|
||||||
# Wait for success (2-4 minutes)
|
# Wait for success (2-4 minutes)
|
||||||
# CI will build and push:
|
# CI will build and push:
|
||||||
# - code.cannabrands.app/cannabrands/hub:2025.11.3
|
# - git.spdy.io/cannabrands/hub:2025.11.3
|
||||||
# - code.cannabrands.app/cannabrands/hub:stable
|
# - git.spdy.io/cannabrands/hub:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 5. Deploy to Production (When Ready)
|
#### 5. Deploy to Production (When Ready)
|
||||||
@@ -277,7 +277,7 @@ git push origin 2025.11.3
|
|||||||
```bash
|
```bash
|
||||||
# Deploy new version
|
# Deploy new version
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:2025.11.3
|
app=git.spdy.io/cannabrands/hub:2025.11.3
|
||||||
|
|
||||||
# Watch rollout
|
# Watch rollout
|
||||||
kubectl rollout status deployment/cannabrands
|
kubectl rollout status deployment/cannabrands
|
||||||
@@ -328,11 +328,11 @@ git push origin master
|
|||||||
```bash
|
```bash
|
||||||
# Option 1: Rollback to specific version
|
# Option 1: Rollback to specific version
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:2025.11.2
|
app=git.spdy.io/cannabrands/hub:2025.11.2
|
||||||
|
|
||||||
# Option 2: Use previous stable
|
# Option 2: Use previous stable
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:stable
|
app=git.spdy.io/cannabrands/hub:stable
|
||||||
|
|
||||||
# Note: 'stable' is updated on every release
|
# Note: 'stable' is updated on every release
|
||||||
# So if you just deployed 2025.11.3, 'stable' points to 2025.11.3
|
# So if you just deployed 2025.11.3, 'stable' points to 2025.11.3
|
||||||
@@ -367,7 +367,7 @@ git push origin 2025.11.4
|
|||||||
|
|
||||||
# Deploy
|
# Deploy
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:2025.11.4
|
app=git.spdy.io/cannabrands/hub:2025.11.4
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
**Current tagging strategy:**
|
**Current tagging strategy:**
|
||||||
```
|
```
|
||||||
code.cannabrands.app/cannabrands/hub:latest # Always changes
|
git.spdy.io/cannabrands/hub:latest # Always changes
|
||||||
code.cannabrands.app/cannabrands/hub:c658193 # Commit SHA (meaningless)
|
git.spdy.io/cannabrands/hub:c658193 # Commit SHA (meaningless)
|
||||||
code.cannabrands.app/cannabrands/hub:master # Branch name (changes)
|
git.spdy.io/cannabrands/hub:master # Branch name (changes)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Issues:**
|
**Issues:**
|
||||||
@@ -143,8 +143,8 @@ The CI pipeline now builds images with version metadata for both development and
|
|||||||
build-image-dev:
|
build-image-dev:
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
settings:
|
settings:
|
||||||
registry: code.cannabrands.app
|
registry: git.spdy.io
|
||||||
repo: code.cannabrands.app/cannabrands/hub
|
repo: git.spdy.io/cannabrands/hub
|
||||||
tags:
|
tags:
|
||||||
- dev # Latest dev build
|
- dev # Latest dev build
|
||||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA
|
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA
|
||||||
@@ -170,13 +170,13 @@ build-image-release:
|
|||||||
**Result:**
|
**Result:**
|
||||||
```
|
```
|
||||||
# Development push to master
|
# Development push to master
|
||||||
code.cannabrands.app/cannabrands/hub:dev
|
git.spdy.io/cannabrands/hub:dev
|
||||||
code.cannabrands.app/cannabrands/hub:sha-c658193
|
git.spdy.io/cannabrands/hub:sha-c658193
|
||||||
code.cannabrands.app/cannabrands/hub:master
|
git.spdy.io/cannabrands/hub:master
|
||||||
|
|
||||||
# Release (git tag 2025.10.1)
|
# Release (git tag 2025.10.1)
|
||||||
code.cannabrands.app/cannabrands/hub:2025.10.1 # Specific version
|
git.spdy.io/cannabrands/hub:2025.10.1 # Specific version
|
||||||
code.cannabrands.app/cannabrands/hub:latest # Latest stable
|
git.spdy.io/cannabrands/hub:latest # Latest stable
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -243,11 +243,11 @@ git checkout c658193
|
|||||||
```bash
|
```bash
|
||||||
# Option 1: Rollback to specific version (recommended)
|
# Option 1: Rollback to specific version (recommended)
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:v1.2.2
|
app=git.spdy.io/cannabrands/hub:v1.2.2
|
||||||
|
|
||||||
# Option 2: Rollback to last stable
|
# Option 2: Rollback to last stable
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:stable
|
app=git.spdy.io/cannabrands/hub:stable
|
||||||
|
|
||||||
# Option 3: Kubernetes rollback (uses previous deployment)
|
# Option 3: Kubernetes rollback (uses previous deployment)
|
||||||
kubectl rollout undo deployment/cannabrands
|
kubectl rollout undo deployment/cannabrands
|
||||||
@@ -281,7 +281,7 @@ cat CHANGELOG.md
|
|||||||
|
|
||||||
# 5. Deploy specific version
|
# 5. Deploy specific version
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:v1.2.1
|
app=git.spdy.io/cannabrands/hub:v1.2.1
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -357,7 +357,7 @@ audit-deployment:
|
|||||||
```
|
```
|
||||||
Developer → Commit to master → CI tests → Build dev image
|
Developer → Commit to master → CI tests → Build dev image
|
||||||
↓
|
↓
|
||||||
code.cannabrands.app/cannabrands/hub:dev-COMMIT
|
git.spdy.io/cannabrands/hub:dev-COMMIT
|
||||||
↓
|
↓
|
||||||
Deploy to dev/staging (optional)
|
Deploy to dev/staging (optional)
|
||||||
```
|
```
|
||||||
@@ -486,7 +486,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: code.cannabrands.app/cannabrands/hub:v1.2.3
|
image: git.spdy.io/cannabrands/hub:v1.2.3
|
||||||
imagePullPolicy: IfNotPresent # Don't pull if tag exists
|
imagePullPolicy: IfNotPresent # Don't pull if tag exists
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 80
|
- containerPort: 80
|
||||||
@@ -535,7 +535,7 @@ git push origin master
|
|||||||
|
|
||||||
# 5. Deploy to production (manual)
|
# 5. Deploy to production (manual)
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:v1.3.0
|
app=git.spdy.io/cannabrands/hub:v1.3.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Emergency Rollback
|
### Emergency Rollback
|
||||||
@@ -546,7 +546,7 @@ kubectl rollout undo deployment/cannabrands
|
|||||||
|
|
||||||
# Or specific version
|
# Or specific version
|
||||||
kubectl set image deployment/cannabrands \
|
kubectl set image deployment/cannabrands \
|
||||||
app=code.cannabrands.app/cannabrands/hub:v1.2.3
|
app=git.spdy.io/cannabrands/hub:v1.2.3
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
kubectl rollout status deployment/cannabrands
|
kubectl rollout status deployment/cannabrands
|
||||||
|
|||||||
67
CLAUDE.md
67
CLAUDE.md
@@ -65,15 +65,74 @@ ALL routes need auth + user type middleware except public pages
|
|||||||
**Creating PRs via Gitea API:**
|
**Creating PRs via Gitea API:**
|
||||||
```bash
|
```bash
|
||||||
# Requires GITEA_TOKEN environment variable
|
# Requires GITEA_TOKEN environment variable
|
||||||
curl -X POST "https://code.cannabrands.app/api/v1/repos/Cannabrands/hub/pulls" \
|
curl -X POST "https://git.spdy.io/api/v1/repos/Cannabrands/hub/pulls" \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "develop"}'
|
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "develop"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gitea Services:**
|
**Infrastructure Services:**
|
||||||
- **Gitea:** `https://code.cannabrands.app`
|
|
||||||
- **Woodpecker CI:** `https://ci.cannabrands.app`
|
| Service | Host | Notes |
|
||||||
|
|---------|------|-------|
|
||||||
|
| **Gitea** | `https://git.spdy.io` | Git repository |
|
||||||
|
| **Woodpecker CI** | `https://ci.spdy.io` | CI/CD pipelines |
|
||||||
|
| **Docker Registry** | `registry.spdy.io` | HTTPS registry with Let's Encrypt |
|
||||||
|
|
||||||
|
**PostgreSQL (Dev) - EXTERNAL DATABASE**
|
||||||
|
⚠️ **DO NOT create PostgreSQL databases on spdy.io infrastructure for cannabrands.**
|
||||||
|
Cannabrands uses an external managed PostgreSQL database.
|
||||||
|
```
|
||||||
|
Host: 10.100.6.50 (read replica)
|
||||||
|
Port: 5432
|
||||||
|
Database: cannabrands_dev
|
||||||
|
Username: cannabrands
|
||||||
|
Password: SpDyCannaBrands2024
|
||||||
|
URL: postgresql://cannabrands:SpDyCannaBrands2024@10.100.6.50:5432/cannabrands_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**PostgreSQL (CI)** - Ephemeral container for isolated tests
|
||||||
|
```
|
||||||
|
Host: postgres (service name)
|
||||||
|
Port: 5432
|
||||||
|
Database: testing
|
||||||
|
Username: testing
|
||||||
|
Password: testing
|
||||||
|
```
|
||||||
|
|
||||||
|
**Redis**
|
||||||
|
```
|
||||||
|
Host: 10.100.9.50
|
||||||
|
Port: 6379
|
||||||
|
Password: SpDyR3d1s2024!
|
||||||
|
URL: redis://:SpDyR3d1s2024!@10.100.9.50:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
**MinIO (S3-Compatible Storage)**
|
||||||
|
```
|
||||||
|
Endpoint: 10.100.9.80:9000
|
||||||
|
Console: 10.100.9.80:9001
|
||||||
|
Region: us-east-1
|
||||||
|
Path Style: true
|
||||||
|
Bucket: cannabrands
|
||||||
|
Access Key: cannabrands-app
|
||||||
|
Secret Key: cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gitea Container Registry** (for CI image pushes)
|
||||||
|
```
|
||||||
|
Registry: git.spdy.io
|
||||||
|
User: kelly@spdy.io
|
||||||
|
Token: c89fa0eeb417343b171f11de6b8e4292b2f50e2b
|
||||||
|
Scope: write:package
|
||||||
|
```
|
||||||
|
Woodpecker secrets: `registry_user`, `registry_password`
|
||||||
|
|
||||||
|
**CI/CD Notes:**
|
||||||
|
- Uses **Kaniko** for Docker builds (no Docker daemon, avoids DNS issues)
|
||||||
|
- Images pushed to `registry.spdy.io/cannabrands/hub`
|
||||||
|
- Base images pulled from `registry.spdy.io` (HTTPS with Let's Encrypt)
|
||||||
|
- Deploy: `develop` → dev.cannabrands.app, `master` → cannabrands.app
|
||||||
|
|
||||||
### 8. User-Business Relationship (Pivot Table)
|
### 8. User-Business Relationship (Pivot Table)
|
||||||
Users connect to businesses via `business_user` pivot table (many-to-many).
|
Users connect to businesses via `business_user` pivot table (many-to-many).
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Our workflow provides audit trails regulators love:
|
|||||||
|
|
||||||
1. **Clone the repository**
|
1. **Clone the repository**
|
||||||
```bash
|
```bash
|
||||||
git clone https://code.cannabrands.app/Cannabrands/hub.git
|
git clone https://git.spdy.io/Cannabrands/hub.git
|
||||||
cd hub
|
cd hub
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ git commit -m "feat: add new feature"
|
|||||||
git push origin feature/my-feature-name
|
git push origin feature/my-feature-name
|
||||||
|
|
||||||
# 4. Create Pull Request on Gitea
|
# 4. Create Pull Request on Gitea
|
||||||
# - Navigate to https://code.cannabrands.app
|
# - Navigate to https://git.spdy.io
|
||||||
# - Create PR to merge your branch into develop
|
# - Create PR to merge your branch into develop
|
||||||
# - CI will run automatically
|
# - CI will run automatically
|
||||||
# - Request review from team
|
# - Request review from team
|
||||||
@@ -630,7 +630,7 @@ git push origin chore/changelog-2025.11.1
|
|||||||
|
|
||||||
### Services
|
### Services
|
||||||
- **Woodpecker CI:** `https://ci.cannabrands.app`
|
- **Woodpecker CI:** `https://ci.cannabrands.app`
|
||||||
- **Gitea:** `https://code.cannabrands.app`
|
- **Gitea:** `https://git.spdy.io`
|
||||||
- **Production:** `https://app.cannabrands.com` (future)
|
- **Production:** `https://app.cannabrands.com` (future)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# ==================== Stage 1: Node Builder ====================
|
# ==================== Stage 1: Node Builder ====================
|
||||||
FROM node:22-alpine AS node-builder
|
FROM 10.100.9.70:5000/library/node:22-alpine AS node-builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -35,10 +35,10 @@ RUN npm run build
|
|||||||
|
|
||||||
# ==================== Stage 2: Composer Builder ====================
|
# ==================== Stage 2: Composer Builder ====================
|
||||||
# Pin to PHP 8.4 - composer:2 uses latest PHP which may not be supported by dependencies yet
|
# 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
|
FROM 10.100.9.70:5000/library/php:8.4-cli-alpine AS composer-builder
|
||||||
|
|
||||||
# Install Composer
|
# Install Composer
|
||||||
COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer
|
COPY --from=10.100.9.70:5000/library/composer:2.8 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ RUN composer install \
|
|||||||
--optimize-autoloader
|
--optimize-autoloader
|
||||||
|
|
||||||
# ==================== Stage 3: Production Runtime ====================
|
# ==================== Stage 3: Production Runtime ====================
|
||||||
FROM php:8.3-fpm-alpine
|
FROM 10.100.9.70:5000/library/php:8.3-fpm-alpine
|
||||||
|
|
||||||
LABEL maintainer="CannaBrands Team"
|
LABEL maintainer="CannaBrands Team"
|
||||||
|
|
||||||
|
|||||||
93
Dockerfile.fast
Normal file
93
Dockerfile.fast
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# ============================================
|
||||||
|
# Fast Production Dockerfile
|
||||||
|
# Single-stage build using CI pre-built assets
|
||||||
|
# Saves time by skipping multi-stage node/composer builders
|
||||||
|
# ============================================
|
||||||
|
#
|
||||||
|
# This Dockerfile expects:
|
||||||
|
# - vendor/ already populated (from CI composer-install step)
|
||||||
|
# - public/build/ already populated (from CI build-frontend step)
|
||||||
|
#
|
||||||
|
# Build time: ~5-7 min (vs 15-20 min with multi-stage Dockerfile)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
FROM 10.100.9.70:5000/library/php:8.3-fpm-alpine
|
||||||
|
|
||||||
|
LABEL maintainer="CannaBrands Team"
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
postgresql-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libjpeg-turbo-dev \
|
||||||
|
freetype-dev \
|
||||||
|
libzip-dev \
|
||||||
|
icu-dev \
|
||||||
|
icu-data-full \
|
||||||
|
zip \
|
||||||
|
unzip \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
bash
|
||||||
|
|
||||||
|
# Install build dependencies for PHP extensions
|
||||||
|
RUN apk add --no-cache --virtual .build-deps \
|
||||||
|
autoconf \
|
||||||
|
g++ \
|
||||||
|
make
|
||||||
|
|
||||||
|
# Install PHP extensions
|
||||||
|
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
|
&& docker-php-ext-install -j$(nproc) \
|
||||||
|
pdo_pgsql \
|
||||||
|
pgsql \
|
||||||
|
gd \
|
||||||
|
zip \
|
||||||
|
intl \
|
||||||
|
pcntl \
|
||||||
|
bcmath \
|
||||||
|
opcache
|
||||||
|
|
||||||
|
# Install Redis extension
|
||||||
|
RUN pecl install redis \
|
||||||
|
&& docker-php-ext-enable redis \
|
||||||
|
&& apk del .build-deps
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
ARG GIT_COMMIT_SHA=unknown
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY --chown=www-data:www-data . .
|
||||||
|
|
||||||
|
# Copy pre-built frontend assets (built in CI step)
|
||||||
|
# These are already in public/build from the build-frontend step
|
||||||
|
|
||||||
|
# Copy pre-installed vendor (from CI composer-install step)
|
||||||
|
# Already included in COPY . .
|
||||||
|
|
||||||
|
# Create version metadata file
|
||||||
|
RUN echo "VERSION=${APP_VERSION}" > /var/www/html/version.env && \
|
||||||
|
echo "COMMIT=${GIT_COMMIT_SHA}" >> /var/www/html/version.env && \
|
||||||
|
chown www-data:www-data /var/www/html/version.env
|
||||||
|
|
||||||
|
# Copy production configurations
|
||||||
|
COPY docker/production/nginx/default.conf /etc/nginx/http.d/default.conf
|
||||||
|
COPY docker/production/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||||
|
COPY docker/production/php/php.ini /usr/local/etc/php/conf.d/99-custom.ini
|
||||||
|
|
||||||
|
# Remove default PHP-FPM pool config and use our custom one
|
||||||
|
RUN rm -f /usr/local/etc/php-fpm.d/www.conf /usr/local/etc/php-fpm.d/www.conf.default
|
||||||
|
COPY docker/production/php/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
|
||||||
|
|
||||||
|
# Create supervisor log directory and fix permissions
|
||||||
|
RUN mkdir -p /var/log/supervisor \
|
||||||
|
&& chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache \
|
||||||
|
&& chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||||
144
app/Console/Commands/MigrateDbaData.php
Normal file
144
app/Console/Commands/MigrateDbaData.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\BusinessDba;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate existing business DBA data to the new business_dbas table.
|
||||||
|
*
|
||||||
|
* This command creates DBA records from existing business fields:
|
||||||
|
* - dba_name
|
||||||
|
* - invoice_payable_company_name, invoice_payable_address, etc.
|
||||||
|
* - ap_contact_* fields
|
||||||
|
* - primary_contact_* fields
|
||||||
|
*/
|
||||||
|
class MigrateDbaData extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'dba:migrate
|
||||||
|
{--dry-run : Show what would be created without actually creating records}
|
||||||
|
{--business= : Migrate only a specific business by ID or slug}
|
||||||
|
{--force : Skip confirmation prompt}';
|
||||||
|
|
||||||
|
protected $description = 'Migrate existing dba_name and invoice_payable_* fields to the business_dbas table';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->info('DBA Data Migration');
|
||||||
|
$this->line('==================');
|
||||||
|
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$specificBusiness = $this->option('business');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY RUN MODE - No records will be created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query
|
||||||
|
$query = Business::query()
|
||||||
|
->whereNotNull('dba_name')
|
||||||
|
->where('dba_name', '!=', '');
|
||||||
|
|
||||||
|
if ($specificBusiness) {
|
||||||
|
$query->where(function ($q) use ($specificBusiness) {
|
||||||
|
$q->where('id', $specificBusiness)
|
||||||
|
->orWhere('slug', $specificBusiness);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$businesses = $query->get();
|
||||||
|
$this->info("Found {$businesses->count()} businesses with dba_name set.");
|
||||||
|
|
||||||
|
if ($businesses->isEmpty()) {
|
||||||
|
$this->info('No businesses to migrate.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
$this->newLine();
|
||||||
|
$this->table(
|
||||||
|
['ID', 'Business Name', 'DBA Name', 'Has Invoice Address', 'Already Has DBAs'],
|
||||||
|
$businesses->map(fn ($b) => [
|
||||||
|
$b->id,
|
||||||
|
\Illuminate\Support\Str::limit($b->name, 30),
|
||||||
|
\Illuminate\Support\Str::limit($b->dba_name, 30),
|
||||||
|
$b->invoice_payable_address ? 'Yes' : 'No',
|
||||||
|
$b->dbas()->exists() ? 'Yes' : 'No',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $dryRun && ! $this->option('force')) {
|
||||||
|
if (! $this->confirm('Do you want to proceed with creating DBA records?')) {
|
||||||
|
$this->info('Aborted.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($businesses as $business) {
|
||||||
|
// Skip if business already has DBAs
|
||||||
|
if ($business->dbas()->exists()) {
|
||||||
|
$this->line(" Skipping {$business->name} - already has DBAs");
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(" Would create DBA for: {$business->name} -> {$business->dba_name}");
|
||||||
|
$created++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create DBA from existing business fields
|
||||||
|
$dba = BusinessDba::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'trade_name' => $business->dba_name,
|
||||||
|
|
||||||
|
// Address - prefer invoice_payable fields, fall back to physical
|
||||||
|
'address' => $business->invoice_payable_address ?: $business->physical_address,
|
||||||
|
'city' => $business->invoice_payable_city ?: $business->physical_city,
|
||||||
|
'state' => $business->invoice_payable_state ?: $business->physical_state,
|
||||||
|
'zip' => $business->invoice_payable_zipcode ?: $business->physical_zipcode,
|
||||||
|
|
||||||
|
// License
|
||||||
|
'license_number' => $business->license_number,
|
||||||
|
'license_type' => $business->license_type,
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
'primary_contact_name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')) ?: null,
|
||||||
|
'primary_contact_email' => $business->primary_contact_email,
|
||||||
|
'primary_contact_phone' => $business->primary_contact_phone,
|
||||||
|
'ap_contact_name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')) ?: null,
|
||||||
|
'ap_contact_email' => $business->ap_contact_email,
|
||||||
|
'ap_contact_phone' => $business->ap_contact_phone,
|
||||||
|
|
||||||
|
// Invoice Settings
|
||||||
|
'invoice_footer' => $business->order_invoice_footer,
|
||||||
|
|
||||||
|
// Status
|
||||||
|
'is_default' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->info(" Created DBA #{$dba->id} for {$business->name}: {$dba->trade_name}");
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Summary: {$created} created, {$skipped} skipped");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('Run without --dry-run to actually create records.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,20 @@ class Kernel extends ConsoleKernel
|
|||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->runInBackground();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// BANNER ADS
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Update banner ad statuses (activate scheduled, expire ended) - every minute
|
||||||
|
$schedule->job(new \App\Jobs\UpdateBannerAdStatuses)
|
||||||
|
->everyMinute()
|
||||||
|
->withoutOverlapping();
|
||||||
|
|
||||||
|
// Rollup daily banner ad stats - daily at 2 AM
|
||||||
|
$schedule->job(new \App\Jobs\RollupBannerAdStats)
|
||||||
|
->dailyAt('02:00')
|
||||||
|
->withoutOverlapping();
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// HOUSEKEEPING & MAINTENANCE
|
// HOUSEKEEPING & MAINTENANCE
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
41
app/Enums/BannerAdStatus.php
Normal file
41
app/Enums/BannerAdStatus.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum BannerAdStatus: string
|
||||||
|
{
|
||||||
|
case DRAFT = 'draft';
|
||||||
|
case ACTIVE = 'active';
|
||||||
|
case SCHEDULED = 'scheduled';
|
||||||
|
case PAUSED = 'paused';
|
||||||
|
case EXPIRED = 'expired';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DRAFT => 'Draft',
|
||||||
|
self::ACTIVE => 'Active',
|
||||||
|
self::SCHEDULED => 'Scheduled',
|
||||||
|
self::PAUSED => 'Paused',
|
||||||
|
self::EXPIRED => 'Expired',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function color(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DRAFT => 'gray',
|
||||||
|
self::ACTIVE => 'success',
|
||||||
|
self::SCHEDULED => 'info',
|
||||||
|
self::PAUSED => 'warning',
|
||||||
|
self::EXPIRED => 'danger',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function options(): array
|
||||||
|
{
|
||||||
|
return collect(self::cases())->mapWithKeys(fn (self $status) => [
|
||||||
|
$status->value => $status->label(),
|
||||||
|
])->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Enums/BannerAdZone.php
Normal file
51
app/Enums/BannerAdZone.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum BannerAdZone: string
|
||||||
|
{
|
||||||
|
case MARKETPLACE_HERO = 'marketplace_hero';
|
||||||
|
case MARKETPLACE_LEADERBOARD = 'marketplace_leaderboard';
|
||||||
|
case MARKETPLACE_SIDEBAR = 'marketplace_sidebar';
|
||||||
|
case MARKETPLACE_INLINE = 'marketplace_inline';
|
||||||
|
case BRAND_PAGE_BANNER = 'brand_page_banner';
|
||||||
|
case DEALS_PAGE_HERO = 'deals_page_hero';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::MARKETPLACE_HERO => 'Marketplace Hero (Full Width)',
|
||||||
|
self::MARKETPLACE_LEADERBOARD => 'Marketplace Leaderboard (728x90)',
|
||||||
|
self::MARKETPLACE_SIDEBAR => 'Marketplace Sidebar (300x250)',
|
||||||
|
self::MARKETPLACE_INLINE => 'Marketplace Inline (Between Products)',
|
||||||
|
self::BRAND_PAGE_BANNER => 'Brand Page Banner',
|
||||||
|
self::DEALS_PAGE_HERO => 'Deals Page Hero',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dimensions(): array
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::MARKETPLACE_HERO => ['width' => 1920, 'height' => 400, 'display' => '1920x400'],
|
||||||
|
self::MARKETPLACE_LEADERBOARD => ['width' => 728, 'height' => 90, 'display' => '728x90'],
|
||||||
|
self::MARKETPLACE_SIDEBAR => ['width' => 300, 'height' => 250, 'display' => '300x250'],
|
||||||
|
self::MARKETPLACE_INLINE => ['width' => 970, 'height' => 250, 'display' => '970x250'],
|
||||||
|
self::BRAND_PAGE_BANNER => ['width' => 1344, 'height' => 280, 'display' => '1344x280'],
|
||||||
|
self::DEALS_PAGE_HERO => ['width' => 1920, 'height' => 350, 'display' => '1920x350'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function options(): array
|
||||||
|
{
|
||||||
|
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
|
||||||
|
$zone->value => $zone->label().' - '.$zone->dimensions()['display'],
|
||||||
|
])->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function optionsSimple(): array
|
||||||
|
{
|
||||||
|
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
|
||||||
|
$zone->value => $zone->label(),
|
||||||
|
])->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Events/CrmAgentStatusChanged.php
Normal file
41
app/Events/CrmAgentStatusChanged.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\AgentStatus;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class CrmAgentStatusChanged implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public AgentStatus $agentStatus
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new PrivateChannel("crm-inbox.{$this->agentStatus->business_id}")];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'agent.status';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => $this->agentStatus->user_id,
|
||||||
|
'user_name' => $this->agentStatus->user?->name,
|
||||||
|
'status' => $this->agentStatus->status,
|
||||||
|
'status_label' => AgentStatus::statuses()[$this->agentStatus->status] ?? $this->agentStatus->status,
|
||||||
|
'status_message' => $this->agentStatus->status_message,
|
||||||
|
'last_seen_at' => $this->agentStatus->last_seen_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Events/CrmThreadMessageSent.php
Normal file
80
app/Events/CrmThreadMessageSent.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Crm\CrmChannelMessage;
|
||||||
|
use App\Models\Crm\CrmThread;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class CrmThreadMessageSent implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public CrmChannelMessage $message,
|
||||||
|
public CrmThread $thread
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
$channels = [
|
||||||
|
new PrivateChannel("crm-inbox.{$this->thread->business_id}"),
|
||||||
|
new PrivateChannel("crm-thread.{$this->thread->id}"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// For marketplace B2B threads, also broadcast to buyer/seller businesses
|
||||||
|
if ($this->thread->buyer_business_id) {
|
||||||
|
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->buyer_business_id}");
|
||||||
|
}
|
||||||
|
if ($this->thread->seller_business_id) {
|
||||||
|
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->seller_business_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'message.new';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'message' => [
|
||||||
|
'id' => $this->message->id,
|
||||||
|
'thread_id' => $this->message->thread_id,
|
||||||
|
'body' => $this->message->body,
|
||||||
|
'body_html' => $this->message->body_html,
|
||||||
|
'direction' => $this->message->direction,
|
||||||
|
'channel_type' => $this->message->channel_type,
|
||||||
|
'sender_id' => $this->message->user_id,
|
||||||
|
'sender_name' => $this->message->user?->name ?? ($this->message->direction === 'inbound' ? $this->thread->contact?->getFullName() : 'System'),
|
||||||
|
'status' => $this->message->status,
|
||||||
|
'created_at' => $this->message->created_at->toIso8601String(),
|
||||||
|
'attachments' => $this->message->attachments->map(fn ($a) => [
|
||||||
|
'id' => $a->id,
|
||||||
|
'filename' => $a->original_filename ?? $a->filename,
|
||||||
|
'mime_type' => $a->mime_type,
|
||||||
|
'size' => $a->size,
|
||||||
|
'url' => Storage::disk($a->disk ?? 'minio')->url($a->path),
|
||||||
|
])->toArray(),
|
||||||
|
],
|
||||||
|
'thread' => [
|
||||||
|
'id' => $this->thread->id,
|
||||||
|
'subject' => $this->thread->subject,
|
||||||
|
'status' => $this->thread->status,
|
||||||
|
'priority' => $this->thread->priority,
|
||||||
|
'last_message_at' => $this->thread->last_message_at?->toIso8601String(),
|
||||||
|
'last_message_preview' => $this->message->body ? \Str::limit(strip_tags($this->message->body), 100) : null,
|
||||||
|
'last_message_direction' => $this->message->direction,
|
||||||
|
'last_channel_type' => $this->message->channel_type,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Events/CrmThreadUpdated.php
Normal file
73
app/Events/CrmThreadUpdated.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Crm\CrmThread;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class CrmThreadUpdated implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public const UPDATE_ASSIGNED = 'assigned';
|
||||||
|
|
||||||
|
public const UPDATE_CLOSED = 'closed';
|
||||||
|
|
||||||
|
public const UPDATE_REOPENED = 'reopened';
|
||||||
|
|
||||||
|
public const UPDATE_SNOOZED = 'snoozed';
|
||||||
|
|
||||||
|
public const UPDATE_PRIORITY = 'priority';
|
||||||
|
|
||||||
|
public const UPDATE_STATUS = 'status';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public CrmThread $thread,
|
||||||
|
public string $updateType
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
$channels = [
|
||||||
|
new PrivateChannel("crm-inbox.{$this->thread->business_id}"),
|
||||||
|
new PrivateChannel("crm-thread.{$this->thread->id}"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// For marketplace B2B threads, also broadcast to buyer/seller businesses
|
||||||
|
if ($this->thread->buyer_business_id) {
|
||||||
|
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->buyer_business_id}");
|
||||||
|
}
|
||||||
|
if ($this->thread->seller_business_id) {
|
||||||
|
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->seller_business_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'thread.updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'thread' => [
|
||||||
|
'id' => $this->thread->id,
|
||||||
|
'subject' => $this->thread->subject,
|
||||||
|
'status' => $this->thread->status,
|
||||||
|
'priority' => $this->thread->priority,
|
||||||
|
'assigned_to' => $this->thread->assigned_to,
|
||||||
|
'assignee_name' => $this->thread->assignee?->name,
|
||||||
|
'snoozed_until' => $this->thread->snoozed_until?->toIso8601String(),
|
||||||
|
'last_message_at' => $this->thread->last_message_at?->toIso8601String(),
|
||||||
|
],
|
||||||
|
'update_type' => $this->updateType,
|
||||||
|
'updated_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Events/CrmTypingIndicator.php
Normal file
41
app/Events/CrmTypingIndicator.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class CrmTypingIndicator implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $threadId,
|
||||||
|
public int $userId,
|
||||||
|
public string $userName,
|
||||||
|
public bool $isTyping
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new PrivateChannel("crm-thread.{$this->threadId}")];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'typing';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'user_name' => $this->userName,
|
||||||
|
'is_typing' => $this->isTyping,
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Events/NewMarketplaceMessage.php
Normal file
79
app/Events/NewMarketplaceMessage.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Crm\CrmChannelMessage;
|
||||||
|
use App\Models\Crm\CrmThread;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class NewMarketplaceMessage implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public CrmChannelMessage $message,
|
||||||
|
public CrmThread $thread
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the channels the event should broadcast on.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||||
|
*/
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
$channels = [];
|
||||||
|
|
||||||
|
if ($this->thread->buyer_business_id) {
|
||||||
|
$channels[] = new PrivateChannel("marketplace-chat.{$this->thread->buyer_business_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->thread->seller_business_id) {
|
||||||
|
$channels[] = new PrivateChannel("marketplace-chat.{$this->thread->seller_business_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the data to broadcast.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'message' => [
|
||||||
|
'id' => $this->message->id,
|
||||||
|
'thread_id' => $this->message->thread_id,
|
||||||
|
'body' => $this->message->body,
|
||||||
|
'sender_id' => $this->message->sender_id,
|
||||||
|
'sender_name' => $this->message->sender
|
||||||
|
? trim($this->message->sender->first_name.' '.$this->message->sender->last_name)
|
||||||
|
: 'Unknown',
|
||||||
|
'direction' => $this->message->direction,
|
||||||
|
'created_at' => $this->message->created_at->toIso8601String(),
|
||||||
|
'attachments' => $this->message->attachments,
|
||||||
|
],
|
||||||
|
'thread' => [
|
||||||
|
'id' => $this->thread->id,
|
||||||
|
'subject' => $this->thread->subject,
|
||||||
|
'buyer_business_id' => $this->thread->buyer_business_id,
|
||||||
|
'seller_business_id' => $this->thread->seller_business_id,
|
||||||
|
'order_id' => $this->thread->order_id,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's broadcast name.
|
||||||
|
*/
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'message.new';
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Events/TeamMessageSent.php
Normal file
47
app/Events/TeamMessageSent.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\TeamMessage;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class TeamMessageSent implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public TeamMessage $message
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
// Broadcast to the team conversation channel
|
||||||
|
return [
|
||||||
|
new PrivateChannel('team-conversation.'.$this->message->conversation_id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'message.sent';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->message->id,
|
||||||
|
'conversation_id' => $this->message->conversation_id,
|
||||||
|
'sender_id' => $this->message->sender_id,
|
||||||
|
'sender_name' => $this->message->getSenderName(),
|
||||||
|
'sender_initials' => $this->message->getSenderInitials(),
|
||||||
|
'body' => $this->message->body,
|
||||||
|
'type' => $this->message->type,
|
||||||
|
'metadata' => $this->message->metadata,
|
||||||
|
'created_at' => $this->message->created_at->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -210,7 +210,7 @@ class AiContentRuleResource extends Resource
|
|||||||
])
|
])
|
||||||
->query(function ($query, array $data) {
|
->query(function ($query, array $data) {
|
||||||
if (! empty($data['value'])) {
|
if (! empty($data['value'])) {
|
||||||
$query->where('content_type_key', 'like', $data['value'].'.%');
|
$query->where('content_type_key', 'ilike', $data['value'].'.%');
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|||||||
318
app/Filament/Resources/BannerAdResource.php
Normal file
318
app/Filament/Resources/BannerAdResource.php
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Enums\BannerAdStatus;
|
||||||
|
use App\Enums\BannerAdZone;
|
||||||
|
use App\Filament\Resources\BannerAdResource\Pages;
|
||||||
|
use App\Models\BannerAd;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Actions\ForceDeleteBulkAction;
|
||||||
|
use Filament\Actions\RestoreBulkAction;
|
||||||
|
use Filament\Actions\ViewAction;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\FileUpload;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class BannerAdResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = BannerAd::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPhoto;
|
||||||
|
|
||||||
|
protected static UnitEnum|string|null $navigationGroup = 'Marketing';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 10;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Banner Ads';
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
// Hide this resource if the banner_ads table doesn't exist yet
|
||||||
|
if (! \Illuminate\Support\Facades\Schema::hasTable('banner_ads')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::canAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationBadge(): ?string
|
||||||
|
{
|
||||||
|
return cache()->remember('banner_ad_active_count', 60, function () {
|
||||||
|
// Handle case where migrations haven't been run yet
|
||||||
|
if (! \Illuminate\Support\Facades\Schema::hasTable('banner_ads')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$count = static::getModel()::where('status', BannerAdStatus::ACTIVE)->count();
|
||||||
|
|
||||||
|
return $count ?: null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Basic Information')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label('Internal Name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->helperText('Internal reference name (not shown to users)'),
|
||||||
|
|
||||||
|
Select::make('zone')
|
||||||
|
->label('Ad Zone')
|
||||||
|
->options(BannerAdZone::options())
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(fn ($state, $set) => $state
|
||||||
|
? $set('zone_info', BannerAdZone::from($state)->dimensions()['display'])
|
||||||
|
: $set('zone_info', null)),
|
||||||
|
|
||||||
|
Placeholder::make('zone_info')
|
||||||
|
->label('Recommended Dimensions')
|
||||||
|
->content(fn ($get) => $get('zone')
|
||||||
|
? BannerAdZone::from($get('zone'))->dimensions()['display']
|
||||||
|
: 'Select a zone'),
|
||||||
|
|
||||||
|
Select::make('status')
|
||||||
|
->options(BannerAdStatus::options())
|
||||||
|
->default('draft')
|
||||||
|
->required(),
|
||||||
|
|
||||||
|
Select::make('brand_id')
|
||||||
|
->label('Brand (Optional)')
|
||||||
|
->relationship('brand', 'name')
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->helperText('Leave empty for platform-wide ads'),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Creative Content')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
FileUpload::make('image_path')
|
||||||
|
->label('Banner Image')
|
||||||
|
->image()
|
||||||
|
->required()
|
||||||
|
->disk('minio')
|
||||||
|
->directory('banner-ads')
|
||||||
|
->visibility('public')
|
||||||
|
->maxSize(5120)
|
||||||
|
->helperText('Upload banner image at recommended dimensions')
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
TextInput::make('image_alt')
|
||||||
|
->label('Alt Text')
|
||||||
|
->maxLength(255)
|
||||||
|
->helperText('Accessibility description'),
|
||||||
|
|
||||||
|
TextInput::make('headline')
|
||||||
|
->maxLength(100)
|
||||||
|
->helperText('Optional overlay headline'),
|
||||||
|
|
||||||
|
Textarea::make('description')
|
||||||
|
->maxLength(200)
|
||||||
|
->helperText('Optional overlay description'),
|
||||||
|
|
||||||
|
TextInput::make('cta_text')
|
||||||
|
->label('Button Text')
|
||||||
|
->maxLength(50)
|
||||||
|
->placeholder('Shop Now')
|
||||||
|
->helperText('Call-to-action button text'),
|
||||||
|
|
||||||
|
TextInput::make('cta_url')
|
||||||
|
->label('Destination URL')
|
||||||
|
->required()
|
||||||
|
->url()
|
||||||
|
->maxLength(500)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Scheduling')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
DateTimePicker::make('starts_at')
|
||||||
|
->label('Start Date')
|
||||||
|
->helperText('Leave empty to start immediately'),
|
||||||
|
|
||||||
|
DateTimePicker::make('ends_at')
|
||||||
|
->label('End Date')
|
||||||
|
->helperText('Leave empty to run indefinitely'),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Targeting & Priority')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Toggle::make('is_platform_wide')
|
||||||
|
->label('Platform Wide')
|
||||||
|
->default(true)
|
||||||
|
->helperText('Show to all users'),
|
||||||
|
|
||||||
|
Select::make('target_business_types')
|
||||||
|
->label('Target Business Types')
|
||||||
|
->multiple()
|
||||||
|
->options([
|
||||||
|
'buyer' => 'Buyers (Dispensaries)',
|
||||||
|
'seller' => 'Sellers (Brands)',
|
||||||
|
])
|
||||||
|
->helperText('Leave empty for all types'),
|
||||||
|
|
||||||
|
TextInput::make('priority')
|
||||||
|
->numeric()
|
||||||
|
->default(0)
|
||||||
|
->helperText('Higher = shown first (0-100)'),
|
||||||
|
|
||||||
|
TextInput::make('weight')
|
||||||
|
->numeric()
|
||||||
|
->default(100)
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(1000)
|
||||||
|
->helperText('Weight for random rotation (1-1000)'),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Analytics')
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
Placeholder::make('impressions_display')
|
||||||
|
->label('Impressions')
|
||||||
|
->content(fn (?BannerAd $record) => number_format($record?->impressions ?? 0)),
|
||||||
|
|
||||||
|
Placeholder::make('clicks_display')
|
||||||
|
->label('Clicks')
|
||||||
|
->content(fn (?BannerAd $record) => number_format($record?->clicks ?? 0)),
|
||||||
|
|
||||||
|
Placeholder::make('ctr_display')
|
||||||
|
->label('CTR')
|
||||||
|
->content(fn (?BannerAd $record) => ($record?->click_through_rate ?? 0).'%'),
|
||||||
|
])
|
||||||
|
->hiddenOn('create'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\ImageColumn::make('image_path')
|
||||||
|
->label('Preview')
|
||||||
|
->disk('minio')
|
||||||
|
->width(120)
|
||||||
|
->height(60),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->weight('bold'),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('zone')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn ($state) => $state instanceof BannerAdZone
|
||||||
|
? $state->label()
|
||||||
|
: BannerAdZone::tryFrom($state)?->label() ?? $state),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->color(fn ($state) => $state instanceof BannerAdStatus
|
||||||
|
? $state->color()
|
||||||
|
: BannerAdStatus::tryFrom($state)?->color() ?? 'gray'),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('impressions')
|
||||||
|
->numeric()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('clicks')
|
||||||
|
->numeric()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('click_through_rate')
|
||||||
|
->label('CTR')
|
||||||
|
->suffix('%')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('starts_at')
|
||||||
|
->dateTime('M j, Y')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('ends_at')
|
||||||
|
->dateTime('M j, Y')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')
|
||||||
|
->options(BannerAdStatus::options()),
|
||||||
|
Tables\Filters\SelectFilter::make('zone')
|
||||||
|
->options(BannerAdZone::optionsSimple()),
|
||||||
|
Tables\Filters\TrashedFilter::make(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
ViewAction::make(),
|
||||||
|
EditAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
ForceDeleteBulkAction::make(),
|
||||||
|
RestoreBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListBannerAds::route('/'),
|
||||||
|
'create' => Pages\CreateBannerAd::route('/create'),
|
||||||
|
'view' => Pages\ViewBannerAd::route('/{record}'),
|
||||||
|
'edit' => Pages\EditBannerAd::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->withoutGlobalScopes([
|
||||||
|
SoftDeletingScope::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BannerAdResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateBannerAd extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BannerAdResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$data['created_by_user_id'] = auth()->id();
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('index');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BannerAdResource;
|
||||||
|
use App\Services\BannerAdService;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditBannerAd extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BannerAdResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
Actions\ForceDeleteAction::make(),
|
||||||
|
Actions\RestoreAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
// Clear caches when banner ad is updated
|
||||||
|
app(BannerAdService::class)->clearAllCaches();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BannerAdResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListBannerAds extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = BannerAdResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BannerAdResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewBannerAd extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BannerAdResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -699,6 +699,11 @@ class BusinessResource extends Resource
|
|||||||
'</div>'
|
'</div>'
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
Toggle::make('ping_pong_enabled')
|
||||||
|
->label('Ping Pong Order Flow')
|
||||||
|
->helperText('When enabled, buyers and sellers can send order details back and forth during the order process. Shows order progress stages and enables collaborative order editing.')
|
||||||
|
->default(false),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// ===== SUITE ASSIGNMENT SECTION =====
|
// ===== SUITE ASSIGNMENT SECTION =====
|
||||||
@@ -1789,8 +1794,8 @@ class BusinessResource extends Resource
|
|||||||
})
|
})
|
||||||
->description(fn ($record) => $record->parent ? 'Managed by '.$record->parent->name : null)
|
->description(fn ($record) => $record->parent ? 'Managed by '.$record->parent->name : null)
|
||||||
->searchable(query: function ($query, $search) {
|
->searchable(query: function ($query, $search) {
|
||||||
return $query->where('name', 'like', "%{$search}%")
|
return $query->where('name', 'ilike', "%{$search}%")
|
||||||
->orWhere('dba_name', 'like', "%{$search}%");
|
->orWhere('dba_name', 'ilike', "%{$search}%");
|
||||||
})
|
})
|
||||||
->sortable(query: fn ($query, $direction) => $query->orderBy('parent_id')->orderBy('name', $direction)),
|
->sortable(query: fn ($query, $direction) => $query->orderBy('parent_id')->orderBy('name', $direction)),
|
||||||
TextColumn::make('types.label')
|
TextColumn::make('types.label')
|
||||||
@@ -1910,9 +1915,9 @@ class BusinessResource extends Resource
|
|||||||
return $query->whereHas('users', function ($q) use ($search) {
|
return $query->whereHas('users', function ($q) use ($search) {
|
||||||
$q->wherePivot('is_primary', true)
|
$q->wherePivot('is_primary', true)
|
||||||
->where(function ($q2) use ($search) {
|
->where(function ($q2) use ($search) {
|
||||||
$q2->where('first_name', 'like', "%{$search}%")
|
$q2->where('first_name', 'ilike', "%{$search}%")
|
||||||
->orWhere('last_name', 'like', "%{$search}%")
|
->orWhere('last_name', 'ilike', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%");
|
->orWhere('email', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -1943,9 +1948,9 @@ class BusinessResource extends Resource
|
|||||||
})
|
})
|
||||||
->searchable(query: function ($query, $search) {
|
->searchable(query: function ($query, $search) {
|
||||||
return $query->whereHas('users', function ($q) use ($search) {
|
return $query->whereHas('users', function ($q) use ($search) {
|
||||||
$q->where('first_name', 'like', "%{$search}%")
|
$q->where('first_name', 'ilike', "%{$search}%")
|
||||||
->orWhere('last_name', 'like', "%{$search}%")
|
->orWhere('last_name', 'ilike', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%");
|
->orWhere('email', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
TextColumn::make('users_count')
|
TextColumn::make('users_count')
|
||||||
@@ -2082,6 +2087,7 @@ class BusinessResource extends Resource
|
|||||||
public static function getRelations(): array
|
public static function getRelations(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
BusinessResource\RelationManagers\DbasRelationManager::class,
|
||||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\BusinessResource\RelationManagers;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\DatePicker;
|
||||||
|
use Filament\Forms\Components\Grid;
|
||||||
|
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\Actions\CreateAction;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class DbasRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'dbas';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Trade Names (DBAs)';
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'trade_name';
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Basic Information')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('trade_name')
|
||||||
|
->label('Trade Name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('slug')
|
||||||
|
->label('Slug')
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false)
|
||||||
|
->helperText('Auto-generated from trade name'),
|
||||||
|
Toggle::make('is_default')
|
||||||
|
->label('Default DBA')
|
||||||
|
->helperText('Use for new invoices by default'),
|
||||||
|
Toggle::make('is_active')
|
||||||
|
->label('Active')
|
||||||
|
->default(true),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make('Address')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('address')
|
||||||
|
->label('Street Address')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('address_line_2')
|
||||||
|
->label('Address Line 2')
|
||||||
|
->maxLength(255),
|
||||||
|
Grid::make(3)
|
||||||
|
->schema([
|
||||||
|
TextInput::make('city')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('state')
|
||||||
|
->maxLength(2)
|
||||||
|
->extraAttributes(['class' => 'uppercase']),
|
||||||
|
TextInput::make('zip')
|
||||||
|
->label('ZIP Code')
|
||||||
|
->maxLength(10),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->collapsible(),
|
||||||
|
|
||||||
|
Section::make('License Information')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('license_number')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('license_type')
|
||||||
|
->maxLength(255),
|
||||||
|
DatePicker::make('license_expiration')
|
||||||
|
->label('Expiration Date'),
|
||||||
|
])
|
||||||
|
->columns(3)
|
||||||
|
->collapsible(),
|
||||||
|
|
||||||
|
Section::make('Banking Information')
|
||||||
|
->description('Sensitive data is encrypted at rest.')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('bank_name')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('bank_account_name')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('bank_routing_number')
|
||||||
|
->maxLength(50)
|
||||||
|
->password()
|
||||||
|
->revealable(),
|
||||||
|
TextInput::make('bank_account_number')
|
||||||
|
->maxLength(50)
|
||||||
|
->password()
|
||||||
|
->revealable(),
|
||||||
|
Select::make('bank_account_type')
|
||||||
|
->options([
|
||||||
|
'checking' => 'Checking',
|
||||||
|
'savings' => 'Savings',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->collapsible()
|
||||||
|
->collapsed(),
|
||||||
|
|
||||||
|
Section::make('Tax Information')
|
||||||
|
->description('Sensitive data is encrypted at rest.')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('tax_id')
|
||||||
|
->label('Tax ID')
|
||||||
|
->maxLength(50)
|
||||||
|
->password()
|
||||||
|
->revealable(),
|
||||||
|
Select::make('tax_id_type')
|
||||||
|
->label('Tax ID Type')
|
||||||
|
->options([
|
||||||
|
'ein' => 'EIN',
|
||||||
|
'ssn' => 'SSN',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->collapsible()
|
||||||
|
->collapsed(),
|
||||||
|
|
||||||
|
Section::make('Contacts')
|
||||||
|
->schema([
|
||||||
|
Grid::make(2)
|
||||||
|
->schema([
|
||||||
|
Section::make('Primary Contact')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('primary_contact_name')
|
||||||
|
->label('Name')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('primary_contact_email')
|
||||||
|
->label('Email')
|
||||||
|
->email()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('primary_contact_phone')
|
||||||
|
->label('Phone')
|
||||||
|
->tel()
|
||||||
|
->maxLength(50),
|
||||||
|
]),
|
||||||
|
Section::make('AP Contact')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('ap_contact_name')
|
||||||
|
->label('Name')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('ap_contact_email')
|
||||||
|
->label('Email')
|
||||||
|
->email()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('ap_contact_phone')
|
||||||
|
->label('Phone')
|
||||||
|
->tel()
|
||||||
|
->maxLength(50),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->collapsible()
|
||||||
|
->collapsed(),
|
||||||
|
|
||||||
|
Section::make('Invoice Settings')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('payment_terms')
|
||||||
|
->maxLength(50)
|
||||||
|
->placeholder('Net 30'),
|
||||||
|
TextInput::make('invoice_prefix')
|
||||||
|
->maxLength(10)
|
||||||
|
->placeholder('INV-'),
|
||||||
|
Textarea::make('payment_instructions')
|
||||||
|
->rows(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Textarea::make('invoice_footer')
|
||||||
|
->rows(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->collapsible()
|
||||||
|
->collapsed(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('trade_name')
|
||||||
|
->label('Trade Name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('city')
|
||||||
|
->label('Location')
|
||||||
|
->formatStateUsing(fn ($record) => $record->city && $record->state
|
||||||
|
? "{$record->city}, {$record->state}"
|
||||||
|
: ($record->city ?? $record->state ?? '-'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('license_number')
|
||||||
|
->label('License')
|
||||||
|
->limit(15)
|
||||||
|
->tooltip(fn ($state) => $state),
|
||||||
|
IconColumn::make('is_default')
|
||||||
|
->label('Default')
|
||||||
|
->boolean()
|
||||||
|
->trueIcon('heroicon-o-star')
|
||||||
|
->falseIcon('heroicon-o-minus')
|
||||||
|
->trueColor('warning'),
|
||||||
|
IconColumn::make('is_active')
|
||||||
|
->label('Active')
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label('Created')
|
||||||
|
->dateTime('M j, Y')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->defaultSort('is_default', 'desc')
|
||||||
|
->headerActions([
|
||||||
|
CreateAction::make(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->requiresConfirmation(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('No Trade Names')
|
||||||
|
->emptyStateDescription('Add a DBA to manage different trade names for invoices and licenses.')
|
||||||
|
->emptyStateIcon('heroicon-o-building-office-2');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,8 +116,8 @@ class DatabaseBackupResource extends Resource
|
|||||||
})
|
})
|
||||||
->searchable(query: function ($query, $search) {
|
->searchable(query: function ($query, $search) {
|
||||||
return $query->whereHas('creator', function ($q) use ($search) {
|
return $query->whereHas('creator', function ($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'ilike', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%");
|
->orWhere('email', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class ProductsTable
|
|||||||
ImageColumn::make('image_path')
|
ImageColumn::make('image_path')
|
||||||
->label('Image')
|
->label('Image')
|
||||||
->circular()
|
->circular()
|
||||||
->defaultImageUrl(url('/images/placeholder-product.png'))
|
->defaultImageUrl(\Storage::disk('minio')->url('defaults/placeholder-product.svg'))
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
|
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ class UserResource extends Resource
|
|||||||
})
|
})
|
||||||
->searchable(query: function ($query, $search) {
|
->searchable(query: function ($query, $search) {
|
||||||
return $query->whereHas('businesses', function ($q) use ($search) {
|
return $query->whereHas('businesses', function ($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%");
|
$q->where('name', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
TextColumn::make('status')
|
TextColumn::make('status')
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ class ApVendorController extends Controller
|
|||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'ilike', "%{$search}%")
|
||||||
->orWhere('code', 'like', "%{$search}%");
|
->orWhere('code', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ class ApVendorController extends Controller
|
|||||||
$prefix = substr($prefix, 0, 6);
|
$prefix = substr($prefix, 0, 6);
|
||||||
|
|
||||||
$count = ApVendor::where('business_id', $businessId)
|
$count = ApVendor::where('business_id', $businessId)
|
||||||
->where('code', 'like', "{$prefix}%")
|
->where('code', 'ilike', "{$prefix}%")
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
|
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
|
||||||
|
|||||||
103
app/Http/Controllers/Api/AgentStatusController.php
Normal file
103
app/Http/Controllers/Api/AgentStatusController.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Events\CrmAgentStatusChanged;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AgentStatus;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class AgentStatusController extends Controller
|
||||||
|
{
|
||||||
|
public function update(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'business_id' => 'required|integer|exists:businesses,id',
|
||||||
|
'status' => ['required', Rule::in(array_keys(AgentStatus::statuses()))],
|
||||||
|
'status_message' => 'nullable|string|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Verify user belongs to the business
|
||||||
|
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$agentStatus = AgentStatus::getOrCreate($user->id, $validated['business_id']);
|
||||||
|
$oldStatus = $agentStatus->status;
|
||||||
|
$agentStatus->setStatus($validated['status'], $validated['status_message'] ?? null);
|
||||||
|
|
||||||
|
// Broadcast status change if it changed
|
||||||
|
if ($oldStatus !== $validated['status']) {
|
||||||
|
broadcast(new CrmAgentStatusChanged($agentStatus->fresh()))->toOthers();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'status' => $agentStatus->status,
|
||||||
|
'status_label' => AgentStatus::statuses()[$agentStatus->status],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heartbeat to maintain online status
|
||||||
|
*/
|
||||||
|
public function heartbeat(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'business_id' => 'required|integer|exists:businesses,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Verify user belongs to the business
|
||||||
|
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$agentStatus = AgentStatus::where('user_id', $user->id)
|
||||||
|
->where('business_id', $validated['business_id'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($agentStatus) {
|
||||||
|
$agentStatus->updateLastSeen();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get team members' statuses for a business
|
||||||
|
*/
|
||||||
|
public function team(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'business_id' => 'required|integer|exists:businesses,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Verify user belongs to the business
|
||||||
|
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$statuses = AgentStatus::where('business_id', $validated['business_id'])
|
||||||
|
->where('status', '!=', AgentStatus::STATUS_OFFLINE)
|
||||||
|
->where('last_seen_at', '>=', now()->subMinutes(5))
|
||||||
|
->with('user:id,name')
|
||||||
|
->get()
|
||||||
|
->map(fn ($s) => [
|
||||||
|
'user_id' => $s->user_id,
|
||||||
|
'user_name' => $s->user?->name,
|
||||||
|
'status' => $s->status,
|
||||||
|
'status_message' => $s->status_message,
|
||||||
|
'last_seen_at' => $s->last_seen_at?->toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['team' => $statuses]);
|
||||||
|
}
|
||||||
|
}
|
||||||
247
app/Http/Controllers/Api/MarketplaceChatController.php
Normal file
247
app/Http/Controllers/Api/MarketplaceChatController.php
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Crm\CrmThread;
|
||||||
|
use App\Services\MarketplaceChatService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class MarketplaceChatController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected MarketplaceChatService $chatService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List threads for the current business
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$businessId = $request->input('business_id');
|
||||||
|
|
||||||
|
if (! $businessId) {
|
||||||
|
return response()->json(['error' => 'business_id is required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$business = Business::find($businessId);
|
||||||
|
|
||||||
|
if (! $business || ! $user->businesses->contains('id', $businessId)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$threads = $this->chatService->getThreadsForUser($user, $business);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'threads' => $threads->map(fn ($thread) => $this->formatThread($thread, $business)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single thread with messages
|
||||||
|
*/
|
||||||
|
public function show(Request $request, CrmThread $thread): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $this->chatService->canAccessThread($thread, $user)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$beforeId = $request->input('before_id');
|
||||||
|
$limit = min($request->input('limit', 50), 100);
|
||||||
|
|
||||||
|
$messages = $this->chatService->getMessages($thread, $limit, $beforeId);
|
||||||
|
|
||||||
|
// Mark as read
|
||||||
|
$this->chatService->markAsRead($thread, $user);
|
||||||
|
|
||||||
|
$business = $user->primaryBusiness();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'thread' => $this->formatThread($thread, $business),
|
||||||
|
'messages' => $messages->map(fn ($msg) => $this->formatMessage($msg)),
|
||||||
|
'has_more' => $messages->count() === $limit,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new thread or get existing one
|
||||||
|
*/
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'buyer_business_id' => 'required|integer|exists:businesses,id',
|
||||||
|
'seller_business_id' => 'required|integer|exists:businesses,id',
|
||||||
|
'order_id' => 'nullable|integer|exists:orders,id',
|
||||||
|
'initial_message' => 'nullable|string|max:5000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||||
|
|
||||||
|
// Verify user belongs to one of the businesses
|
||||||
|
if (! in_array($validated['buyer_business_id'], $userBusinessIds)
|
||||||
|
&& ! in_array($validated['seller_business_id'], $userBusinessIds)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$buyerBusiness = Business::findOrFail($validated['buyer_business_id']);
|
||||||
|
$sellerBusiness = Business::findOrFail($validated['seller_business_id']);
|
||||||
|
$order = isset($validated['order_id'])
|
||||||
|
? \App\Models\Order::find($validated['order_id'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$thread = $this->chatService->getOrCreateThread($buyerBusiness, $sellerBusiness, $order);
|
||||||
|
|
||||||
|
// Send initial message if provided
|
||||||
|
if (! empty($validated['initial_message'])) {
|
||||||
|
$this->chatService->sendMessage($thread, $user, $validated['initial_message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$business = $user->primaryBusiness();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'thread' => $this->formatThread($thread->fresh(), $business),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message in a thread
|
||||||
|
*/
|
||||||
|
public function sendMessage(Request $request, CrmThread $thread): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $this->chatService->canAccessThread($thread, $user)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'body' => 'required|string|max:5000',
|
||||||
|
'attachments' => 'nullable|array',
|
||||||
|
'attachments.*.url' => 'required_with:attachments|string',
|
||||||
|
'attachments.*.name' => 'required_with:attachments|string',
|
||||||
|
'attachments.*.type' => 'nullable|string',
|
||||||
|
'attachments.*.size' => 'nullable|integer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$message = $this->chatService->sendMessage(
|
||||||
|
$thread,
|
||||||
|
$user,
|
||||||
|
$validated['body'],
|
||||||
|
$validated['attachments'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $this->formatMessage($message),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark thread as read
|
||||||
|
*/
|
||||||
|
public function markAsRead(Request $request, CrmThread $thread): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $this->chatService->canAccessThread($thread, $user)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->chatService->markAsRead($thread, $user);
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread count for user
|
||||||
|
*/
|
||||||
|
public function unreadCount(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$businessId = $request->input('business_id');
|
||||||
|
|
||||||
|
if (! $businessId) {
|
||||||
|
return response()->json(['error' => 'business_id is required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$business = Business::find($businessId);
|
||||||
|
|
||||||
|
if (! $business || ! $user->businesses->contains('id', $businessId)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = $this->chatService->getUnreadCount($user, $business);
|
||||||
|
|
||||||
|
return response()->json(['unread_count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format thread for JSON response
|
||||||
|
*/
|
||||||
|
protected function formatThread(CrmThread $thread, ?Business $currentBusiness): array
|
||||||
|
{
|
||||||
|
$otherBusiness = $currentBusiness
|
||||||
|
? $this->chatService->getOtherBusiness($thread, $currentBusiness)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$lastMessage = $thread->messages->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $thread->id,
|
||||||
|
'subject' => $thread->subject,
|
||||||
|
'status' => $thread->status,
|
||||||
|
'buyer_business' => $thread->buyerBusiness ? [
|
||||||
|
'id' => $thread->buyerBusiness->id,
|
||||||
|
'name' => $thread->buyerBusiness->name,
|
||||||
|
'slug' => $thread->buyerBusiness->slug,
|
||||||
|
] : null,
|
||||||
|
'seller_business' => $thread->sellerBusiness ? [
|
||||||
|
'id' => $thread->sellerBusiness->id,
|
||||||
|
'name' => $thread->sellerBusiness->name,
|
||||||
|
'slug' => $thread->sellerBusiness->slug,
|
||||||
|
] : null,
|
||||||
|
'other_business' => $otherBusiness ? [
|
||||||
|
'id' => $otherBusiness->id,
|
||||||
|
'name' => $otherBusiness->name,
|
||||||
|
'slug' => $otherBusiness->slug,
|
||||||
|
] : null,
|
||||||
|
'order' => $thread->order ? [
|
||||||
|
'id' => $thread->order->id,
|
||||||
|
'order_number' => $thread->order->order_number,
|
||||||
|
] : null,
|
||||||
|
'last_message' => $lastMessage ? [
|
||||||
|
'body' => \Str::limit($lastMessage->body, 100),
|
||||||
|
'sender_name' => $lastMessage->sender
|
||||||
|
? trim($lastMessage->sender->first_name.' '.$lastMessage->sender->last_name)
|
||||||
|
: 'Unknown',
|
||||||
|
'created_at' => $lastMessage->created_at->toIso8601String(),
|
||||||
|
] : null,
|
||||||
|
'last_message_at' => $thread->last_message_at?->toIso8601String(),
|
||||||
|
'created_at' => $thread->created_at->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format message for JSON response
|
||||||
|
*/
|
||||||
|
protected function formatMessage(mixed $message): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $message->id,
|
||||||
|
'thread_id' => $message->thread_id,
|
||||||
|
'body' => $message->body,
|
||||||
|
'sender_id' => $message->sender_id,
|
||||||
|
'sender_name' => $message->sender
|
||||||
|
? trim($message->sender->first_name.' '.$message->sender->last_name)
|
||||||
|
: 'Unknown',
|
||||||
|
'direction' => $message->direction,
|
||||||
|
'attachments' => $message->attachments,
|
||||||
|
'created_at' => $message->created_at->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Http/Controllers/Api/PushSubscriptionController.php
Normal file
58
app/Http/Controllers/Api/PushSubscriptionController.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use NotificationChannels\WebPush\PushSubscription;
|
||||||
|
|
||||||
|
class PushSubscriptionController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Store a new push subscription
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'endpoint' => 'required|url',
|
||||||
|
'keys.p256dh' => 'required|string',
|
||||||
|
'keys.auth' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Delete existing subscription for this endpoint
|
||||||
|
PushSubscription::where('endpoint', $validated['endpoint'])->delete();
|
||||||
|
|
||||||
|
// Create new subscription
|
||||||
|
$subscription = $user->updatePushSubscription(
|
||||||
|
$validated['endpoint'],
|
||||||
|
$validated['keys']['p256dh'],
|
||||||
|
$validated['keys']['auth']
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Push subscription saved',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a push subscription
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'endpoint' => 'required|url',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PushSubscription::where('endpoint', $validated['endpoint'])
|
||||||
|
->where('subscribable_id', $request->user()->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Push subscription removed',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
app/Http/Controllers/Api/TeamChatController.php
Normal file
237
app/Http/Controllers/Api/TeamChatController.php
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\TeamConversation;
|
||||||
|
use App\Models\TeamMessage;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TeamChatController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get all team conversations for current user
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'business_id' => 'required|integer|exists:businesses,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Verify user belongs to business
|
||||||
|
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$conversations = TeamConversation::forBusiness($validated['business_id'])
|
||||||
|
->forUser($user->id)
|
||||||
|
->with(['participants:id,first_name,last_name', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||||
|
->orderByDesc('last_message_at')
|
||||||
|
->get()
|
||||||
|
->map(fn ($conv) => $this->formatConversation($conv, $user->id));
|
||||||
|
|
||||||
|
return response()->json(['conversations' => $conversations]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a direct conversation with another user
|
||||||
|
*/
|
||||||
|
public function getOrCreateDirect(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'business_id' => 'required|integer|exists:businesses,id',
|
||||||
|
'user_id' => 'required|integer|exists:users,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Verify current user belongs to business
|
||||||
|
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify target user belongs to same business
|
||||||
|
$targetUser = User::find($validated['user_id']);
|
||||||
|
if (! $targetUser->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||||
|
return response()->json(['error' => 'User not in business'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't chat with yourself
|
||||||
|
if ($validated['user_id'] === $user->id) {
|
||||||
|
return response()->json(['error' => 'Cannot chat with yourself'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$conversation = TeamConversation::getOrCreateDirect(
|
||||||
|
$validated['business_id'],
|
||||||
|
$user->id,
|
||||||
|
$validated['user_id']
|
||||||
|
);
|
||||||
|
|
||||||
|
$conversation->load('participants:id,first_name,last_name');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'conversation' => $this->formatConversation($conversation, $user->id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get messages for a conversation
|
||||||
|
*/
|
||||||
|
public function messages(Request $request, int $conversationId): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$conversation = TeamConversation::with('participants')
|
||||||
|
->findOrFail($conversationId);
|
||||||
|
|
||||||
|
// Verify user is participant
|
||||||
|
if (! $conversation->participants->contains('id', $user->id)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = $conversation->messages()
|
||||||
|
->with('sender:id,first_name,last_name')
|
||||||
|
->orderBy('created_at')
|
||||||
|
->limit(100)
|
||||||
|
->get()
|
||||||
|
->map(fn ($msg) => $this->formatMessage($msg));
|
||||||
|
|
||||||
|
// Mark conversation as read
|
||||||
|
$conversation->markReadFor($user->id);
|
||||||
|
|
||||||
|
return response()->json(['messages' => $messages]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a conversation
|
||||||
|
*/
|
||||||
|
public function send(Request $request, int $conversationId): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'body' => 'required|string|max:10000',
|
||||||
|
'type' => 'sometimes|string|in:text,file,image',
|
||||||
|
'metadata' => 'sometimes|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$conversation = TeamConversation::with('participants')
|
||||||
|
->findOrFail($conversationId);
|
||||||
|
|
||||||
|
// Verify user is participant
|
||||||
|
if (! $conversation->participants->contains('id', $user->id)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = TeamMessage::create([
|
||||||
|
'conversation_id' => $conversationId,
|
||||||
|
'sender_id' => $user->id,
|
||||||
|
'body' => $validated['body'],
|
||||||
|
'type' => $validated['type'] ?? TeamMessage::TYPE_TEXT,
|
||||||
|
'metadata' => $validated['metadata'] ?? null,
|
||||||
|
'read_by' => [$user->id], // Sender has read it
|
||||||
|
]);
|
||||||
|
|
||||||
|
$message->load('sender:id,first_name,last_name');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $this->formatMessage($message),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark conversation as read
|
||||||
|
*/
|
||||||
|
public function markRead(Request $request, int $conversationId): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$conversation = TeamConversation::with('participants')
|
||||||
|
->findOrFail($conversationId);
|
||||||
|
|
||||||
|
// Verify user is participant
|
||||||
|
if (! $conversation->participants->contains('id', $user->id)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$conversation->markReadFor($user->id);
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get team members available for chat
|
||||||
|
*/
|
||||||
|
public function teamMembers(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'business_id' => 'required|integer|exists:businesses,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Verify user belongs to business
|
||||||
|
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all users in the business except current user
|
||||||
|
$members = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $validated['business_id']))
|
||||||
|
->where('id', '!=', $user->id)
|
||||||
|
->select('id', 'first_name', 'last_name')
|
||||||
|
->orderBy('first_name')
|
||||||
|
->get()
|
||||||
|
->map(fn ($u) => [
|
||||||
|
'id' => $u->id,
|
||||||
|
'name' => $u->name,
|
||||||
|
'initials' => strtoupper(substr($u->first_name ?? '', 0, 1).substr($u->last_name ?? '', 0, 1)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['members' => $members]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format conversation for API response
|
||||||
|
*/
|
||||||
|
private function formatConversation(TeamConversation $conversation, int $currentUserId): array
|
||||||
|
{
|
||||||
|
$other = $conversation->getOtherParticipant($currentUserId);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $conversation->id,
|
||||||
|
'type' => $conversation->type,
|
||||||
|
'name' => $conversation->getDisplayName($currentUserId),
|
||||||
|
'other_user' => $other ? [
|
||||||
|
'id' => $other->id,
|
||||||
|
'name' => $other->name,
|
||||||
|
'initials' => strtoupper(substr($other->first_name ?? '', 0, 1).substr($other->last_name ?? '', 0, 1)),
|
||||||
|
] : null,
|
||||||
|
'last_message_preview' => $conversation->last_message_preview,
|
||||||
|
'last_message_at' => $conversation->last_message_at?->toIso8601String(),
|
||||||
|
'unread_count' => $conversation->getUnreadCountFor($currentUserId),
|
||||||
|
'is_pinned' => $conversation->participants->firstWhere('id', $currentUserId)?->pivot?->is_pinned ?? false,
|
||||||
|
'is_muted' => $conversation->participants->firstWhere('id', $currentUserId)?->pivot?->is_muted ?? false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format message for API response
|
||||||
|
*/
|
||||||
|
private function formatMessage(TeamMessage $message): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $message->id,
|
||||||
|
'sender_id' => $message->sender_id,
|
||||||
|
'sender_name' => $message->getSenderName(),
|
||||||
|
'sender_initials' => $message->getSenderInitials(),
|
||||||
|
'body' => $message->body,
|
||||||
|
'type' => $message->type,
|
||||||
|
'metadata' => $message->metadata,
|
||||||
|
'created_at' => $message->created_at->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/Http/Controllers/BannerAdController.php
Normal file
96
app/Http/Controllers/BannerAdController.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\BannerAd;
|
||||||
|
use App\Services\BannerAdService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Intervention\Image\Drivers\Gd\Driver;
|
||||||
|
use Intervention\Image\ImageManager;
|
||||||
|
|
||||||
|
class BannerAdController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected BannerAdService $bannerAdService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle click tracking and redirect
|
||||||
|
* URL: /ads/click/{bannerAd}
|
||||||
|
*/
|
||||||
|
public function click(Request $request, BannerAd $bannerAd)
|
||||||
|
{
|
||||||
|
$this->bannerAdService->recordClick($bannerAd, [
|
||||||
|
'business_id' => auth()->user()?->businesses->first()?->id,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'session_id' => session()->getId(),
|
||||||
|
'page_url' => $request->header('referer'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->away($bannerAd->cta_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track impression via AJAX (for lazy-loaded ads)
|
||||||
|
* URL: POST /ads/impression/{bannerAd}
|
||||||
|
*/
|
||||||
|
public function impression(Request $request, BannerAd $bannerAd)
|
||||||
|
{
|
||||||
|
$this->bannerAdService->recordImpression($bannerAd, [
|
||||||
|
'business_id' => auth()->user()?->businesses->first()?->id,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'session_id' => session()->getId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve banner ad image at specific width
|
||||||
|
* URL: /images/banner-ad/{bannerAd}/{width?}
|
||||||
|
*/
|
||||||
|
public function image(BannerAd $bannerAd, ?int $width = null)
|
||||||
|
{
|
||||||
|
if (! $bannerAd->image_path || ! Storage::exists($bannerAd->image_path)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return original if no width specified
|
||||||
|
if (! $width) {
|
||||||
|
$contents = Storage::get($bannerAd->image_path);
|
||||||
|
$mimeType = Storage::mimeType($bannerAd->image_path);
|
||||||
|
|
||||||
|
return response($contents)
|
||||||
|
->header('Content-Type', $mimeType)
|
||||||
|
->header('Cache-Control', 'public, max-age=86400');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate and cache resized version
|
||||||
|
$ext = pathinfo($bannerAd->image_path, PATHINFO_EXTENSION);
|
||||||
|
$thumbnailName = "banner-ad-{$bannerAd->id}-{$width}w.{$ext}";
|
||||||
|
$thumbnailPath = "banner-ads/cache/{$thumbnailName}";
|
||||||
|
|
||||||
|
if (! Storage::disk('local')->exists($thumbnailPath)) {
|
||||||
|
$originalContents = Storage::get($bannerAd->image_path);
|
||||||
|
|
||||||
|
$manager = new ImageManager(new Driver);
|
||||||
|
$image = $manager->read($originalContents);
|
||||||
|
$image->scale(width: $width);
|
||||||
|
|
||||||
|
if (! Storage::disk('local')->exists('banner-ads/cache')) {
|
||||||
|
Storage::disk('local')->makeDirectory('banner-ads/cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
|
||||||
|
Storage::disk('local')->put($thumbnailPath, $encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mimeType = $ext === 'png' ? 'image/png' : 'image/jpeg';
|
||||||
|
|
||||||
|
return response()->file(
|
||||||
|
storage_path("app/private/{$thumbnailPath}"),
|
||||||
|
['Content-Type' => $mimeType, 'Cache-Control' => 'public, max-age=86400']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/Http/Controllers/Buyer/BuyAgainController.php
Normal file
101
app/Http/Controllers/Buyer/BuyAgainController.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Buyer;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Buyer\BuyerBrandFollow;
|
||||||
|
use App\Models\OrderItem;
|
||||||
|
use App\Services\Cannaiq\MarketingIntelligenceService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class BuyAgainController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$tab = $request->get('tab', 'favorites'); // 'favorites' or 'history'
|
||||||
|
|
||||||
|
if ($tab === 'favorites') {
|
||||||
|
$brands = $this->getFavoriteBrands($business);
|
||||||
|
} else {
|
||||||
|
$brands = $this->getPurchaseHistory($business);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Enrich with CannaIQ inventory data if business has it
|
||||||
|
$storeMetrics = null;
|
||||||
|
if ($business->cannaiq_store_id) {
|
||||||
|
$storeMetrics = $this->getStoreInventory($business, $brands);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('buyer.buy-again.index', compact('business', 'brands', 'tab', 'storeMetrics'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFavoriteBrands(Business $business)
|
||||||
|
{
|
||||||
|
// Get brands the buyer follows
|
||||||
|
$followedBrandIds = BuyerBrandFollow::where('business_id', $business->id)
|
||||||
|
->pluck('brand_id');
|
||||||
|
|
||||||
|
if ($followedBrandIds->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get products from those brands that user has ordered
|
||||||
|
return Brand::whereIn('id', $followedBrandIds)
|
||||||
|
->with(['products' => function ($query) use ($business) {
|
||||||
|
$query->whereHas('orderItems.order', function ($q) use ($business) {
|
||||||
|
$q->where('business_id', $business->id);
|
||||||
|
})
|
||||||
|
->with(['orderItems' => function ($q) use ($business) {
|
||||||
|
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
|
||||||
|
->latest()
|
||||||
|
->limit(1);
|
||||||
|
}])
|
||||||
|
->where('is_active', true);
|
||||||
|
}])
|
||||||
|
->get()
|
||||||
|
->filter(fn ($brand) => $brand->products->isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPurchaseHistory(Business $business)
|
||||||
|
{
|
||||||
|
// Get all products ever ordered, grouped by brand
|
||||||
|
$orderedProductIds = OrderItem::whereHas('order', function ($q) use ($business) {
|
||||||
|
$q->where('business_id', $business->id);
|
||||||
|
})->distinct()->pluck('product_id');
|
||||||
|
|
||||||
|
if ($orderedProductIds->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Brand::whereHas('products', fn ($q) => $q->whereIn('id', $orderedProductIds))
|
||||||
|
->with(['products' => function ($query) use ($orderedProductIds, $business) {
|
||||||
|
$query->whereIn('id', $orderedProductIds)
|
||||||
|
->with(['orderItems' => function ($q) use ($business) {
|
||||||
|
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
|
||||||
|
->latest()
|
||||||
|
->limit(1);
|
||||||
|
}]);
|
||||||
|
}])
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getStoreInventory(Business $business, $brands)
|
||||||
|
{
|
||||||
|
if ($brands->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$productIds = $brands->flatMap(fn ($b) => $b->products->pluck('id'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cannaiq = app(MarketingIntelligenceService::class);
|
||||||
|
|
||||||
|
return $cannaiq->getStoreMetrics($business->cannaiq_store_id, $productIds->toArray());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Silently fail if CannaIQ unavailable
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/Http/Controllers/Buyer/CompareController.php
Normal file
90
app/Http/Controllers/Buyer/CompareController.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Buyer;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Services\ProductComparisonService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class CompareController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected ProductComparisonService $comparison
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the comparison page.
|
||||||
|
*/
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$products = $this->comparison->getProducts();
|
||||||
|
$business = auth()->user()->businesses->first();
|
||||||
|
|
||||||
|
return view('buyer.compare.index', compact('products', 'business'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current comparison state (AJAX).
|
||||||
|
*/
|
||||||
|
public function state(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ids' => $this->comparison->getProductIds(),
|
||||||
|
'count' => $this->comparison->count(),
|
||||||
|
'is_full' => $this->comparison->isFull(),
|
||||||
|
'max' => $this->comparison->maxItems(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a product in the comparison list (AJAX).
|
||||||
|
*/
|
||||||
|
public function toggle(Product $product): JsonResponse
|
||||||
|
{
|
||||||
|
if (! $product->is_active) {
|
||||||
|
return response()->json(['error' => 'Product not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->comparison->toggle($product->id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'added' => $result['added'],
|
||||||
|
'count' => $result['count'],
|
||||||
|
'is_full' => $this->comparison->isFull(),
|
||||||
|
'message' => $result['added']
|
||||||
|
? 'Added to comparison'
|
||||||
|
: 'Removed from comparison',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a product from comparison list (AJAX).
|
||||||
|
*/
|
||||||
|
public function remove(Product $product): JsonResponse
|
||||||
|
{
|
||||||
|
$this->comparison->remove($product->id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'count' => $this->comparison->count(),
|
||||||
|
'is_full' => $this->comparison->isFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the comparison list.
|
||||||
|
*/
|
||||||
|
public function clear(): JsonResponse
|
||||||
|
{
|
||||||
|
$this->comparison->clear();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'count' => 0,
|
||||||
|
'is_full' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Buyer\Crm;
|
namespace App\Http\Controllers\Buyer\Crm;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
use App\Models\Buyer\BuyerMessageSettings;
|
use App\Models\Buyer\BuyerMessageSettings;
|
||||||
use App\Models\Crm\CrmThread;
|
use App\Models\Crm\CrmThread;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -10,9 +11,8 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
|
|
||||||
class InboxController extends Controller
|
class InboxController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request)
|
public function index(Request $request, Business $business)
|
||||||
{
|
{
|
||||||
$business = Auth::user()->business;
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
$filter = $request->get('filter', 'all');
|
$filter = $request->get('filter', 'all');
|
||||||
@@ -20,7 +20,7 @@ class InboxController extends Controller
|
|||||||
|
|
||||||
$query = CrmThread::forBuyerBusiness($business->id)
|
$query = CrmThread::forBuyerBusiness($business->id)
|
||||||
->with(['brand', 'latestMessage', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
->with(['brand', 'latestMessage', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||||
->withCount(['messages', 'unreadMessages as unread_count' => fn ($q) => $q->unreadForBuyer()]);
|
->withCount('messages');
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
$query = match ($filter) {
|
$query = match ($filter) {
|
||||||
@@ -54,6 +54,7 @@ class InboxController extends Controller
|
|||||||
];
|
];
|
||||||
|
|
||||||
return view('buyer.crm.inbox.index', compact(
|
return view('buyer.crm.inbox.index', compact(
|
||||||
|
'business',
|
||||||
'threads',
|
'threads',
|
||||||
'filter',
|
'filter',
|
||||||
'search',
|
'search',
|
||||||
@@ -62,9 +63,8 @@ class InboxController extends Controller
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(CrmThread $thread)
|
public function show(Business $business, CrmThread $thread)
|
||||||
{
|
{
|
||||||
$business = Auth::user()->business;
|
|
||||||
|
|
||||||
// Verify thread belongs to this buyer
|
// Verify thread belongs to this buyer
|
||||||
if ($thread->buyer_business_id !== $business->id) {
|
if ($thread->buyer_business_id !== $business->id) {
|
||||||
@@ -84,9 +84,8 @@ class InboxController extends Controller
|
|||||||
return view('buyer.crm.inbox.show', compact('thread'));
|
return view('buyer.crm.inbox.show', compact('thread'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function compose(Request $request)
|
public function compose(Request $request, Business $business)
|
||||||
{
|
{
|
||||||
$business = Auth::user()->business;
|
|
||||||
|
|
||||||
// Get brands the buyer has ordered from or can message
|
// Get brands the buyer has ordered from or can message
|
||||||
$brands = \App\Models\Brand::whereHas('products.orderItems.order', function ($q) use ($business) {
|
$brands = \App\Models\Brand::whereHas('products.orderItems.order', function ($q) use ($business) {
|
||||||
@@ -107,7 +106,7 @@ class InboxController extends Controller
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request, Business $business)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'brand_id' => 'required|exists:brands,id',
|
'brand_id' => 'required|exists:brands,id',
|
||||||
@@ -117,7 +116,6 @@ class InboxController extends Controller
|
|||||||
'quote_id' => 'nullable|exists:crm_quotes,id',
|
'quote_id' => 'nullable|exists:crm_quotes,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$business = Auth::user()->business;
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// Create thread
|
// Create thread
|
||||||
@@ -143,9 +141,8 @@ class InboxController extends Controller
|
|||||||
->with('success', 'Message sent successfully.');
|
->with('success', 'Message sent successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function star(CrmThread $thread)
|
public function star(Business $business, CrmThread $thread)
|
||||||
{
|
{
|
||||||
$business = Auth::user()->business;
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
if ($thread->buyer_business_id !== $business->id) {
|
if ($thread->buyer_business_id !== $business->id) {
|
||||||
@@ -157,9 +154,8 @@ class InboxController extends Controller
|
|||||||
return back()->with('success', $thread->isStarredByBuyer($user->id) ? 'Conversation starred.' : 'Star removed.');
|
return back()->with('success', $thread->isStarredByBuyer($user->id) ? 'Conversation starred.' : 'Star removed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function archive(CrmThread $thread)
|
public function archive(Business $business, CrmThread $thread)
|
||||||
{
|
{
|
||||||
$business = Auth::user()->business;
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
if ($thread->buyer_business_id !== $business->id) {
|
if ($thread->buyer_business_id !== $business->id) {
|
||||||
@@ -172,9 +168,8 @@ class InboxController extends Controller
|
|||||||
->with('success', 'Conversation archived.');
|
->with('success', 'Conversation archived.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function unarchive(CrmThread $thread)
|
public function unarchive(Business $business, CrmThread $thread)
|
||||||
{
|
{
|
||||||
$business = Auth::user()->business;
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
if ($thread->buyer_business_id !== $business->id) {
|
if ($thread->buyer_business_id !== $business->id) {
|
||||||
@@ -186,9 +181,8 @@ class InboxController extends Controller
|
|||||||
return back()->with('success', 'Conversation restored.');
|
return back()->with('success', 'Conversation restored.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function markAllRead()
|
public function markAllRead(Business $business)
|
||||||
{
|
{
|
||||||
$business = Auth::user()->business;
|
|
||||||
|
|
||||||
CrmThread::forBuyerBusiness($business->id)
|
CrmThread::forBuyerBusiness($business->id)
|
||||||
->hasUnreadForBuyer()
|
->hasUnreadForBuyer()
|
||||||
|
|||||||
45
app/Http/Controllers/Buyer/ProductController.php
Normal file
45
app/Http/Controllers/Buyer/ProductController.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Buyer;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ProductController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get quick view data for a product (AJAX endpoint).
|
||||||
|
*/
|
||||||
|
public function quickView(Product $product): JsonResponse
|
||||||
|
{
|
||||||
|
// Only return active products
|
||||||
|
if (! $product->is_active) {
|
||||||
|
return response()->json(['error' => 'Product not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the product's brand
|
||||||
|
$product->load('brand:id,name,slug');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $product->id,
|
||||||
|
'hashid' => $product->hashid,
|
||||||
|
'name' => $product->name,
|
||||||
|
'sku' => $product->sku,
|
||||||
|
'description' => $product->short_description ?? $product->description,
|
||||||
|
'price' => $product->wholesale_price ?? 0,
|
||||||
|
'price_unit' => $product->price_unit,
|
||||||
|
'thc_percentage' => $product->thc_percentage,
|
||||||
|
'cbd_percentage' => $product->cbd_percentage,
|
||||||
|
'in_stock' => $product->isInStock(),
|
||||||
|
'available_quantity' => $product->quantity_on_hand,
|
||||||
|
'image_url' => $product->getImageUrl('medium'),
|
||||||
|
'brand_name' => $product->brand?->name,
|
||||||
|
'brand_slug' => $product->brand?->slug,
|
||||||
|
'brand_url' => $product->brand ? route('buyer.brands.show', $product->brand->slug) : null,
|
||||||
|
'url' => $product->brand ? route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Http/Controllers/Buyer/SearchController.php
Normal file
129
app/Http/Controllers/Buyer/SearchController.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Buyer;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buyer Search Controller
|
||||||
|
*
|
||||||
|
* Provides search autocomplete endpoints for the marketplace header.
|
||||||
|
*/
|
||||||
|
class SearchController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Search autocomplete for products and brands.
|
||||||
|
*
|
||||||
|
* GET /b/search/autocomplete?q=...
|
||||||
|
*
|
||||||
|
* Returns products and brands matching the query for dropdown suggestions.
|
||||||
|
*/
|
||||||
|
public function autocomplete(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = trim($request->input('q', ''));
|
||||||
|
|
||||||
|
if (strlen($query) < 2) {
|
||||||
|
return response()->json(['products' => [], 'brands' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search products (limit 8)
|
||||||
|
$products = Product::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereHas('brand', fn ($q) => $q->where('is_active', true))
|
||||||
|
->with('brand:id,name,slug')
|
||||||
|
->where(function ($q) use ($query) {
|
||||||
|
$q->where('name', 'ILIKE', "%{$query}%")
|
||||||
|
->orWhere('sku', 'ILIKE', "%{$query}%")
|
||||||
|
->orWhereHas('brand', fn ($b) => $b->where('name', 'ILIKE', "%{$query}%"));
|
||||||
|
})
|
||||||
|
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(8)
|
||||||
|
->get(['id', 'brand_id', 'name', 'sku', 'wholesale_price', 'image_path']);
|
||||||
|
|
||||||
|
// Search brands (limit 4)
|
||||||
|
$brands = Brand::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->where(function ($q) use ($query) {
|
||||||
|
$q->where('name', 'ILIKE', "%{$query}%")
|
||||||
|
->orWhere('description', 'ILIKE', "%{$query}%");
|
||||||
|
})
|
||||||
|
->withCount('products')
|
||||||
|
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(4)
|
||||||
|
->get(['id', 'name', 'slug', 'logo_path']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'products' => $products->map(fn ($p) => [
|
||||||
|
'id' => $p->id,
|
||||||
|
'hashid' => $p->hashid,
|
||||||
|
'name' => $p->name,
|
||||||
|
'sku' => $p->sku,
|
||||||
|
'price' => $p->wholesale_price ?? 0,
|
||||||
|
'image_url' => $p->getImageUrl('thumb'),
|
||||||
|
'brand_name' => $p->brand?->name,
|
||||||
|
'brand_slug' => $p->brand?->slug,
|
||||||
|
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
|
||||||
|
]),
|
||||||
|
'brands' => $brands->map(fn ($b) => [
|
||||||
|
'id' => $b->id,
|
||||||
|
'name' => $b->name,
|
||||||
|
'slug' => $b->slug,
|
||||||
|
'logo_url' => $b->getLogoUrl('thumb'),
|
||||||
|
'products_count' => $b->products_count,
|
||||||
|
'url' => route('buyer.brands.show', $b->slug),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search suggestions based on popular searches.
|
||||||
|
*
|
||||||
|
* GET /b/search/suggestions
|
||||||
|
*
|
||||||
|
* Returns popular search terms and trending products.
|
||||||
|
*/
|
||||||
|
public function suggestions(): JsonResponse
|
||||||
|
{
|
||||||
|
// Popular search terms (could be tracked and stored, for now use static list)
|
||||||
|
$popularTerms = [
|
||||||
|
'gummies',
|
||||||
|
'vape',
|
||||||
|
'flower',
|
||||||
|
'indica',
|
||||||
|
'sativa',
|
||||||
|
'edibles',
|
||||||
|
'pre-roll',
|
||||||
|
'concentrate',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Trending products (recently added or best sellers)
|
||||||
|
$trending = Product::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereHas('brand', fn ($q) => $q->where('is_active', true))
|
||||||
|
->with('brand:id,name,slug')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->limit(4)
|
||||||
|
->get(['id', 'brand_id', 'name', 'image_path']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'terms' => $popularTerms,
|
||||||
|
'trending' => $trending->map(fn ($p) => [
|
||||||
|
'id' => $p->id,
|
||||||
|
'hashid' => $p->hashid,
|
||||||
|
'name' => $p->name,
|
||||||
|
'image_url' => $p->getImageUrl('thumb'),
|
||||||
|
'brand_name' => $p->brand?->name,
|
||||||
|
'brand_slug' => $p->brand?->slug,
|
||||||
|
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Models\Crm\CrmMeetingBooking;
|
|||||||
use App\Models\Crm\CrmTask;
|
use App\Models\Crm\CrmTask;
|
||||||
use App\Models\Crm\CrmThread;
|
use App\Models\Crm\CrmThread;
|
||||||
use App\Services\Crm\CrmSlaService;
|
use App\Services\Crm\CrmSlaService;
|
||||||
|
use App\Services\Dashboard\CommandCenterService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ class DashboardController extends Controller
|
|||||||
*/
|
*/
|
||||||
private const DASHBOARD_CACHE_TTL = 300;
|
private const DASHBOARD_CACHE_TTL = 300;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected CommandCenterService $commandCenterService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main dashboard redirect - automatically routes to business context
|
* Main dashboard redirect - automatically routes to business context
|
||||||
* Redirects to /s/{business}/dashboard based on user's primary business
|
* Redirects to /s/{business}/dashboard based on user's primary business
|
||||||
@@ -40,104 +45,25 @@ class DashboardController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard Overview - Main overview page
|
* Dashboard Overview - Revenue Command Center
|
||||||
*
|
*
|
||||||
* NOTE: All metrics are pre-calculated by CalculateDashboardMetrics job
|
* Single source of truth for all seller dashboard metrics.
|
||||||
* and stored in Redis. This method only reads from Redis for instant response.
|
* Uses CommandCenterService which provides:
|
||||||
|
* - DB/service as source of truth
|
||||||
|
* - Redis as cache layer
|
||||||
|
* - Explicit scoping (business|brand|user) per metric
|
||||||
*/
|
*/
|
||||||
public function overview(Request $request, Business $business)
|
public function overview(Request $request, Business $business)
|
||||||
{
|
{
|
||||||
// Read pre-calculated metrics from Redis
|
$user = $request->user();
|
||||||
$redisKey = "dashboard:{$business->id}:overview";
|
|
||||||
$cachedMetrics = \Illuminate\Support\Facades\Redis::get($redisKey);
|
|
||||||
|
|
||||||
if ($cachedMetrics) {
|
// Get all Command Center data via the single service
|
||||||
$data = json_decode($cachedMetrics, true);
|
$commandCenterData = $this->commandCenterService->getData($business, $user);
|
||||||
|
|
||||||
// Map cached data to view variables
|
return view('seller.dashboard.overview', [
|
||||||
$revenueLast30 = $data['kpis']['revenue_last_30'] ?? 0;
|
'business' => $business,
|
||||||
$ordersLast30 = $data['kpis']['orders_last_30'] ?? 0;
|
'commandCenter' => $commandCenterData,
|
||||||
$unitsSoldLast30 = $data['kpis']['units_sold_last_30'] ?? 0;
|
]);
|
||||||
$averageOrderValueLast30 = $data['kpis']['average_order_value_last_30'] ?? 0;
|
|
||||||
$revenueGrowth = $data['kpis']['revenue_growth'] ?? 0;
|
|
||||||
$ordersGrowth = $data['kpis']['orders_growth'] ?? 0;
|
|
||||||
$unitsGrowth = $data['kpis']['units_growth'] ?? 0;
|
|
||||||
$aovGrowth = $data['kpis']['aov_growth'] ?? 0;
|
|
||||||
$activeBrandCount = $data['kpis']['active_brand_count'] ?? 0;
|
|
||||||
$activeBuyerCount = $data['kpis']['active_buyer_count'] ?? 0;
|
|
||||||
$activeInventoryAlertsCount = $data['kpis']['active_inventory_alerts_count'] ?? 0;
|
|
||||||
$activePromotionCount = $data['kpis']['active_promotion_count'] ?? 0;
|
|
||||||
|
|
||||||
// Convert arrays to objects and parse timestamps back to Carbon
|
|
||||||
$topProducts = collect($data['top_products'] ?? [])->map(fn ($item) => (object) $item);
|
|
||||||
$topBrands = collect($data['top_brands'] ?? [])->map(fn ($item) => (object) $item);
|
|
||||||
$needsAttention = collect($data['needs_attention'] ?? [])->map(function ($item) {
|
|
||||||
if (isset($item['timestamp']) && is_string($item['timestamp'])) {
|
|
||||||
$item['timestamp'] = \Carbon\Carbon::parse($item['timestamp']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $item; // Keep as array since view uses array syntax
|
|
||||||
});
|
|
||||||
$recentActivity = collect($data['recent_activity'] ?? [])->map(function ($item) {
|
|
||||||
if (isset($item['timestamp']) && is_string($item['timestamp'])) {
|
|
||||||
$item['timestamp'] = \Carbon\Carbon::parse($item['timestamp']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $item; // Keep as array since view uses array syntax
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// No cached data - dispatch job and return empty state
|
|
||||||
\App\Jobs\CalculateDashboardMetrics::dispatch($business->id);
|
|
||||||
|
|
||||||
$revenueLast30 = 0;
|
|
||||||
$ordersLast30 = 0;
|
|
||||||
$unitsSoldLast30 = 0;
|
|
||||||
$averageOrderValueLast30 = 0;
|
|
||||||
$revenueGrowth = 0;
|
|
||||||
$ordersGrowth = 0;
|
|
||||||
$unitsGrowth = 0;
|
|
||||||
$aovGrowth = 0;
|
|
||||||
$activeBrandCount = 0;
|
|
||||||
$activeBuyerCount = 0;
|
|
||||||
$activeInventoryAlertsCount = 0;
|
|
||||||
$activePromotionCount = 0;
|
|
||||||
$topProducts = collect([]);
|
|
||||||
$topBrands = collect([]);
|
|
||||||
$needsAttention = collect([]);
|
|
||||||
$recentActivity = collect([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orchestrator Widget Data (if enabled)
|
|
||||||
$orchestratorWidget = (new \App\Http\Controllers\Seller\OrchestratorController)->getWidgetData($business);
|
|
||||||
|
|
||||||
// Hub Tiles Data (CRM, Tasks, Calendar, etc.)
|
|
||||||
$hubTiles = $this->getHubTilesData($business, $request->user());
|
|
||||||
|
|
||||||
// Sales Inbox - unified view of items needing attention
|
|
||||||
$salesInbox = $this->getSalesInboxData($business, $request->user());
|
|
||||||
|
|
||||||
return view('seller.dashboard.overview', compact(
|
|
||||||
'business',
|
|
||||||
'revenueLast30',
|
|
||||||
'ordersLast30',
|
|
||||||
'unitsSoldLast30',
|
|
||||||
'averageOrderValueLast30',
|
|
||||||
'revenueGrowth',
|
|
||||||
'ordersGrowth',
|
|
||||||
'unitsGrowth',
|
|
||||||
'aovGrowth',
|
|
||||||
'activeBrandCount',
|
|
||||||
'activeBuyerCount',
|
|
||||||
'activeInventoryAlertsCount',
|
|
||||||
'activePromotionCount',
|
|
||||||
'topProducts',
|
|
||||||
'topBrands',
|
|
||||||
'needsAttention',
|
|
||||||
'recentActivity',
|
|
||||||
'orchestratorWidget',
|
|
||||||
'hubTiles',
|
|
||||||
'salesInbox'
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1248,7 +1174,7 @@ class DashboardController extends Controller
|
|||||||
$overdueTasks = CrmTask::where('seller_business_id', $business->id)
|
$overdueTasks = CrmTask::where('seller_business_id', $business->id)
|
||||||
->whereNull('completed_at')
|
->whereNull('completed_at')
|
||||||
->where('due_at', '<', now())
|
->where('due_at', '<', now())
|
||||||
->with(['contact:id,first_name,last_name,company_name'])
|
->with(['contact:id,first_name,last_name'])
|
||||||
->orderBy('due_at', 'asc')
|
->orderBy('due_at', 'asc')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get();
|
->get();
|
||||||
@@ -1256,7 +1182,7 @@ class DashboardController extends Controller
|
|||||||
foreach ($overdueTasks as $task) {
|
foreach ($overdueTasks as $task) {
|
||||||
$daysOverdue = now()->diffInDays($task->due_at, false);
|
$daysOverdue = now()->diffInDays($task->due_at, false);
|
||||||
$contactName = $task->contact
|
$contactName = $task->contact
|
||||||
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
|
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: 'Contact'
|
||||||
: 'Unknown';
|
: 'Unknown';
|
||||||
$overdue[] = [
|
$overdue[] = [
|
||||||
'type' => 'task',
|
'type' => 'task',
|
||||||
@@ -1295,7 +1221,7 @@ class DashboardController extends Controller
|
|||||||
->whereNull('completed_at')
|
->whereNull('completed_at')
|
||||||
->where('due_at', '>=', now())
|
->where('due_at', '>=', now())
|
||||||
->where('due_at', '<=', now()->addDays(7))
|
->where('due_at', '<=', now()->addDays(7))
|
||||||
->with(['contact:id,first_name,last_name,company_name'])
|
->with(['contact:id,first_name,last_name'])
|
||||||
->orderBy('due_at', 'asc')
|
->orderBy('due_at', 'asc')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get();
|
->get();
|
||||||
@@ -1303,7 +1229,7 @@ class DashboardController extends Controller
|
|||||||
foreach ($upcomingTasks as $task) {
|
foreach ($upcomingTasks as $task) {
|
||||||
$daysUntilDue = now()->diffInDays($task->due_at, false);
|
$daysUntilDue = now()->diffInDays($task->due_at, false);
|
||||||
$contactName = $task->contact
|
$contactName = $task->contact
|
||||||
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
|
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: 'Contact'
|
||||||
: 'Unknown';
|
: 'Unknown';
|
||||||
$upcoming[] = [
|
$upcoming[] = [
|
||||||
'type' => 'task',
|
'type' => 'task',
|
||||||
@@ -1318,7 +1244,7 @@ class DashboardController extends Controller
|
|||||||
->where('status', 'scheduled')
|
->where('status', 'scheduled')
|
||||||
->where('start_at', '>=', now())
|
->where('start_at', '>=', now())
|
||||||
->where('start_at', '<=', now()->addDays(7))
|
->where('start_at', '<=', now()->addDays(7))
|
||||||
->with(['contact:id,first_name,last_name,company_name'])
|
->with(['contact:id,first_name,last_name'])
|
||||||
->orderBy('start_at', 'asc')
|
->orderBy('start_at', 'asc')
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get();
|
->get();
|
||||||
@@ -1326,7 +1252,7 @@ class DashboardController extends Controller
|
|||||||
foreach ($upcomingMeetings as $meeting) {
|
foreach ($upcomingMeetings as $meeting) {
|
||||||
$daysUntil = now()->diffInDays($meeting->start_at, false);
|
$daysUntil = now()->diffInDays($meeting->start_at, false);
|
||||||
$contactName = $meeting->contact
|
$contactName = $meeting->contact
|
||||||
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: $meeting->contact->company_name
|
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: 'Contact'
|
||||||
: 'Unknown';
|
: 'Unknown';
|
||||||
$upcoming[] = [
|
$upcoming[] = [
|
||||||
'type' => 'meeting',
|
'type' => 'meeting',
|
||||||
|
|||||||
@@ -53,6 +53,39 @@ use Intervention\Image\ImageManager;
|
|||||||
*/
|
*/
|
||||||
class ImageController extends Controller
|
class ImageController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Cache duration for images (1 year in seconds)
|
||||||
|
*/
|
||||||
|
private const CACHE_TTL = 31536000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a cached response for an image
|
||||||
|
*/
|
||||||
|
private function cachedResponse(string $contents, string $mimeType, ?string $etag = null): \Illuminate\Http\Response
|
||||||
|
{
|
||||||
|
$response = response($contents)
|
||||||
|
->header('Content-Type', $mimeType)
|
||||||
|
->header('Cache-Control', 'public, max-age='.self::CACHE_TTL.', immutable')
|
||||||
|
->header('Expires', gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT');
|
||||||
|
|
||||||
|
if ($etag) {
|
||||||
|
$response->header('ETag', '"'.$etag.'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a cached file response
|
||||||
|
*/
|
||||||
|
private function cachedFileResponse(string $path): \Symfony\Component\HttpFoundation\BinaryFileResponse
|
||||||
|
{
|
||||||
|
return response()->file($path, [
|
||||||
|
'Cache-Control' => 'public, max-age='.self::CACHE_TTL.', immutable',
|
||||||
|
'Expires' => gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serve a brand logo at a specific size
|
* Serve a brand logo at a specific size
|
||||||
* URL: /images/brand-logo/{brand}/{width?}
|
* URL: /images/brand-logo/{brand}/{width?}
|
||||||
@@ -67,8 +100,9 @@ class ImageController extends Controller
|
|||||||
if (! $width) {
|
if (! $width) {
|
||||||
$contents = Storage::get($brand->logo_path);
|
$contents = Storage::get($brand->logo_path);
|
||||||
$mimeType = Storage::mimeType($brand->logo_path);
|
$mimeType = Storage::mimeType($brand->logo_path);
|
||||||
|
$etag = md5($brand->logo_path.$brand->updated_at);
|
||||||
|
|
||||||
return response($contents)->header('Content-Type', $mimeType);
|
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map common widths to pre-generated sizes (retina-optimized)
|
// Map common widths to pre-generated sizes (retina-optimized)
|
||||||
@@ -104,7 +138,7 @@ class ImageController extends Controller
|
|||||||
|
|
||||||
$path = storage_path('app/private/'.$thumbnailPath);
|
$path = storage_path('app/private/'.$thumbnailPath);
|
||||||
|
|
||||||
return response()->file($path);
|
return $this->cachedFileResponse($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,8 +155,9 @@ class ImageController extends Controller
|
|||||||
if (! $width) {
|
if (! $width) {
|
||||||
$contents = Storage::get($brand->banner_path);
|
$contents = Storage::get($brand->banner_path);
|
||||||
$mimeType = Storage::mimeType($brand->banner_path);
|
$mimeType = Storage::mimeType($brand->banner_path);
|
||||||
|
$etag = md5($brand->banner_path.$brand->updated_at);
|
||||||
|
|
||||||
return response($contents)->header('Content-Type', $mimeType);
|
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map common widths to pre-generated sizes (retina-optimized)
|
// Map common widths to pre-generated sizes (retina-optimized)
|
||||||
@@ -155,7 +190,7 @@ class ImageController extends Controller
|
|||||||
|
|
||||||
$path = storage_path('app/private/'.$thumbnailPath);
|
$path = storage_path('app/private/'.$thumbnailPath);
|
||||||
|
|
||||||
return response()->file($path);
|
return $this->cachedFileResponse($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,8 +207,9 @@ class ImageController extends Controller
|
|||||||
if (! $width) {
|
if (! $width) {
|
||||||
$contents = Storage::get($product->image_path);
|
$contents = Storage::get($product->image_path);
|
||||||
$mimeType = Storage::mimeType($product->image_path);
|
$mimeType = Storage::mimeType($product->image_path);
|
||||||
|
$etag = md5($product->image_path.$product->updated_at);
|
||||||
|
|
||||||
return response($contents)->header('Content-Type', $mimeType);
|
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if cached dynamic thumbnail exists in local storage
|
// Check if cached dynamic thumbnail exists in local storage
|
||||||
@@ -202,6 +238,6 @@ class ImageController extends Controller
|
|||||||
|
|
||||||
$path = storage_path('app/private/'.$thumbnailPath);
|
$path = storage_path('app/private/'.$thumbnailPath);
|
||||||
|
|
||||||
return response()->file($path);
|
return $this->cachedFileResponse($path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,39 +4,58 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Brand;
|
use App\Models\Brand;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductCategory;
|
||||||
use App\Models\Strain;
|
use App\Models\Strain;
|
||||||
|
use App\Services\RecentlyViewedService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class MarketplaceController extends Controller
|
class MarketplaceController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected RecentlyViewedService $recentlyViewed
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display marketplace browse page
|
* Display marketplace browse page (Amazon/Shopify style)
|
||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
|
$business = auth()->user()->businesses->first();
|
||||||
|
$hasFilters = $request->hasAny(['search', 'brand_id', 'strain_type', 'price_min', 'price_max', 'in_stock', 'category_id']);
|
||||||
|
|
||||||
// Start with active products only
|
// Start with active products only
|
||||||
$query = Product::query()
|
$query = Product::query()
|
||||||
->with(['brand', 'strain'])
|
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type', 'category:id,name,slug'])
|
||||||
->active();
|
->active();
|
||||||
|
|
||||||
// Search filter (name, SKU, description)
|
// Search filter (name, SKU, description)
|
||||||
if ($search = $request->input('search')) {
|
if ($search = $request->input('search')) {
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'ilike', "%{$search}%")
|
||||||
->orWhere('sku', 'like', "%{$search}%")
|
->orWhere('sku', 'ilike', "%{$search}%")
|
||||||
->orWhere('description', 'like', "%{$search}%");
|
->orWhere('description', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brand filter
|
// Brand filter (supports multiple)
|
||||||
if ($brandId = $request->input('brand_id')) {
|
if ($brandIds = $request->input('brand_id')) {
|
||||||
$query->where('brand_id', $brandId);
|
$brandIds = is_array($brandIds) ? $brandIds : [$brandIds];
|
||||||
|
$query->whereIn('brand_id', $brandIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strain type filter
|
// Category filter (uses category_id foreign key)
|
||||||
|
if ($categoryId = $request->input('category_id')) {
|
||||||
|
$query->where('category_id', $categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strain type filter - use join instead of whereHas for performance
|
||||||
if ($strainType = $request->input('strain_type')) {
|
if ($strainType = $request->input('strain_type')) {
|
||||||
$query->whereHas('strain', function ($q) use ($strainType) {
|
$query->whereExists(function ($q) use ($strainType) {
|
||||||
$q->where('type', $strainType);
|
$q->select(DB::raw(1))
|
||||||
|
->from('strains')
|
||||||
|
->whereColumn('strains.id', 'products.strain_id')
|
||||||
|
->where('strains.type', $strainType);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,23 +83,121 @@ class MarketplaceController extends Controller
|
|||||||
default => $query->latest(),
|
default => $query->latest(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// View mode (grid/list)
|
||||||
|
$viewMode = $request->input('view', 'grid');
|
||||||
|
|
||||||
// Paginate results
|
// Paginate results
|
||||||
$products = $query->paginate(12)->withQueryString();
|
$perPage = $viewMode === 'list' ? 10 : 12;
|
||||||
|
$products = $query->paginate($perPage)->withQueryString();
|
||||||
|
|
||||||
// Get all active brands for filters
|
// Cache brands and categories for 5 minutes (used frequently, rarely change)
|
||||||
$brands = Brand::active()->orderBy('name')->get();
|
$brands = cache()->remember('marketplace:brands', 300, function () {
|
||||||
|
return Brand::query()
|
||||||
|
->active()
|
||||||
|
->whereExists(function ($q) {
|
||||||
|
$q->select(DB::raw(1))
|
||||||
|
->from('products')
|
||||||
|
->whereColumn('products.brand_id', 'brands.id')
|
||||||
|
->where('products.is_active', true);
|
||||||
|
})
|
||||||
|
->withCount(['products' => fn ($q) => $q->active()])
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
});
|
||||||
|
|
||||||
// Get featured products for carousel (exclude from main results if in first page)
|
// Cache categories for 5 minutes
|
||||||
$featuredProducts = Product::query()
|
$categories = cache()->remember('marketplace:categories', 300, function () {
|
||||||
->with(['brand', 'strain'])
|
return ProductCategory::query()
|
||||||
->featured()
|
->whereNull('parent_id')
|
||||||
->inStock()
|
->where('is_active', true)
|
||||||
->limit(3)
|
->whereExists(function ($q) {
|
||||||
->get();
|
$q->select(DB::raw(1))
|
||||||
|
->from('products')
|
||||||
|
->whereColumn('products.category_id', 'product_categories.id')
|
||||||
|
->where('products.is_active', true);
|
||||||
|
})
|
||||||
|
->withCount(['products' => fn ($q) => $q->active()])
|
||||||
|
->orderByDesc('products_count')
|
||||||
|
->get();
|
||||||
|
});
|
||||||
|
|
||||||
$business = auth()->user()->businesses->first();
|
// Only load extra sections if not filtering (homepage view)
|
||||||
|
$featuredProducts = collect();
|
||||||
|
$topBrands = collect();
|
||||||
|
$newArrivals = collect();
|
||||||
|
$trending = collect();
|
||||||
|
$recentlyViewed = collect();
|
||||||
|
|
||||||
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts', 'business'));
|
if (! $hasFilters) {
|
||||||
|
// Featured products for hero carousel
|
||||||
|
$featuredProducts = Product::query()
|
||||||
|
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||||
|
->featured()
|
||||||
|
->inStock()
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Top brands - reuse cached brands
|
||||||
|
$topBrands = $brands->sortByDesc('products_count')->take(6);
|
||||||
|
|
||||||
|
// New arrivals (products created in last 14 days)
|
||||||
|
$newArrivals = Product::query()
|
||||||
|
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||||
|
->active()
|
||||||
|
->inStock()
|
||||||
|
->where('created_at', '>=', now()->subDays(14))
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(8)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Trending products - cache for 10 minutes
|
||||||
|
$trending = cache()->remember('marketplace:trending', 600, function () {
|
||||||
|
$trendingIds = DB::table('order_items')
|
||||||
|
->select('product_id', DB::raw('SUM(quantity) as total_sold'))
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
->groupBy('product_id')
|
||||||
|
->orderByDesc('total_sold')
|
||||||
|
->limit(8)
|
||||||
|
->pluck('product_id');
|
||||||
|
|
||||||
|
if ($trendingIds->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Product::with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||||
|
->whereIn('id', $trendingIds)
|
||||||
|
->active()
|
||||||
|
->get()
|
||||||
|
->sortBy(fn ($p) => array_search($p->id, $trendingIds->toArray()));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recently viewed products
|
||||||
|
$recentlyViewed = $this->recentlyViewed->getProducts(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active filters for pills display
|
||||||
|
$activeFilters = collect([
|
||||||
|
'search' => $request->input('search'),
|
||||||
|
'brand_id' => $request->input('brand_id'),
|
||||||
|
'category_id' => $request->input('category_id'),
|
||||||
|
'strain_type' => $request->input('strain_type'),
|
||||||
|
'in_stock' => $request->input('in_stock'),
|
||||||
|
])->filter();
|
||||||
|
|
||||||
|
return view('buyer.marketplace.index', compact(
|
||||||
|
'products',
|
||||||
|
'brands',
|
||||||
|
'categories',
|
||||||
|
'featuredProducts',
|
||||||
|
'topBrands',
|
||||||
|
'newArrivals',
|
||||||
|
'trending',
|
||||||
|
'recentlyViewed',
|
||||||
|
'business',
|
||||||
|
'viewMode',
|
||||||
|
'activeFilters',
|
||||||
|
'hasFilters'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,19 +211,64 @@ class MarketplaceController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display all brands directory
|
* Display all brands directory
|
||||||
*/
|
*/
|
||||||
public function brands()
|
public function brands(Request $request)
|
||||||
{
|
{
|
||||||
$brands = Brand::query()
|
$search = $request->input('search');
|
||||||
->active()
|
$sort = $request->input('sort', 'name');
|
||||||
->withCount(['products' => function ($query) {
|
|
||||||
$query->active();
|
// Only cache if no search (search results shouldn't be cached)
|
||||||
}])
|
$cacheKey = $search ? null : "marketplace:brands_directory:{$sort}";
|
||||||
->orderBy('name')
|
|
||||||
->get();
|
$brands = $cacheKey
|
||||||
|
? cache()->remember($cacheKey, 300, fn () => $this->getBrandsQuery($search, $sort))
|
||||||
|
: $this->getBrandsQuery($search, $sort);
|
||||||
|
|
||||||
|
// Group brands alphabetically for index navigation
|
||||||
|
$alphabetGroups = $brands->groupBy(fn ($b) => strtoupper(substr($b->name, 0, 1)));
|
||||||
|
|
||||||
|
// Featured brands (first 4 with most products)
|
||||||
|
$featuredBrands = $brands->sortByDesc('products_count')->take(4);
|
||||||
|
|
||||||
$business = auth()->user()->businesses->first();
|
$business = auth()->user()->businesses->first();
|
||||||
|
|
||||||
return view('buyer.marketplace.brands', compact('brands', 'business'));
|
return view('buyer.marketplace.brands', compact('brands', 'alphabetGroups', 'featuredBrands', 'business'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to build brands query for directory
|
||||||
|
*/
|
||||||
|
private function getBrandsQuery(?string $search, string $sort)
|
||||||
|
{
|
||||||
|
$query = Brand::query()
|
||||||
|
->select(['id', 'name', 'slug', 'hashid', 'tagline', 'logo_path', 'updated_at'])
|
||||||
|
->active()
|
||||||
|
// Filter to only brands with active products using EXISTS (faster than having())
|
||||||
|
->whereExists(function ($q) {
|
||||||
|
$q->select(DB::raw(1))
|
||||||
|
->from('products')
|
||||||
|
->whereColumn('products.brand_id', 'brands.id')
|
||||||
|
->where('products.is_active', true);
|
||||||
|
})
|
||||||
|
->withCount(['products' => fn ($q) => $q->active()]);
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if ($search) {
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('name', 'ILIKE', "%{$search}%")
|
||||||
|
->orWhere('tagline', 'ILIKE', "%{$search}%")
|
||||||
|
->orWhere('description', 'ILIKE', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
match ($sort) {
|
||||||
|
'name_desc' => $query->orderByDesc('name'),
|
||||||
|
'products' => $query->orderByDesc('products_count'),
|
||||||
|
'newest' => $query->orderByDesc('created_at'),
|
||||||
|
default => $query->orderBy('name'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,27 +286,30 @@ class MarketplaceController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function showProduct($brandSlug, $productSlug)
|
public function showProduct($brandSlug, $productSlug)
|
||||||
{
|
{
|
||||||
// Find brand by slug
|
// Find brand by slug - minimal columns
|
||||||
$brand = Brand::query()
|
$brand = Brand::query()
|
||||||
|
->select(['id', 'name', 'slug', 'hashid', 'logo_path', 'banner_path', 'tagline', 'description', 'updated_at'])
|
||||||
->where('slug', $brandSlug)
|
->where('slug', $brandSlug)
|
||||||
->active()
|
->active()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
// Find product by slug within this brand
|
// Find product by hashid, slug, or numeric ID within this brand
|
||||||
$product = Product::query()
|
$product = Product::query()
|
||||||
->with([
|
->with([
|
||||||
'brand',
|
'brand:id,name,slug,hashid,logo_path,updated_at',
|
||||||
'strain',
|
'strain:id,name,type',
|
||||||
|
// Only load batches if needed - limit to recent ones
|
||||||
'availableBatches' => function ($query) {
|
'availableBatches' => function ($query) {
|
||||||
$query->with(['coaFiles'])
|
$query->select(['id', 'product_id', 'batch_number', 'production_date', 'quantity_available'])
|
||||||
->orderBy('production_date', 'desc')
|
->with(['coaFiles:id,batch_id,file_path,file_name'])
|
||||||
->orderBy('created_at', 'desc');
|
->orderByDesc('production_date')
|
||||||
|
->limit(5);
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->where('brand_id', $brand->id)
|
->where('brand_id', $brand->id)
|
||||||
->where(function ($query) use ($productSlug) {
|
->where(function ($query) use ($productSlug) {
|
||||||
$query->where('slug', $productSlug);
|
$query->where('hashid', $productSlug)
|
||||||
// Only try ID lookup if the value is numeric
|
->orWhere('slug', $productSlug);
|
||||||
if (is_numeric($productSlug)) {
|
if (is_numeric($productSlug)) {
|
||||||
$query->orWhere('id', $productSlug);
|
$query->orWhere('id', $productSlug);
|
||||||
}
|
}
|
||||||
@@ -152,9 +317,12 @@ class MarketplaceController extends Controller
|
|||||||
->active()
|
->active()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
// Get related products from same brand
|
// Record this view for recently viewed products (async-friendly)
|
||||||
|
$this->recentlyViewed->recordView($product->id);
|
||||||
|
|
||||||
|
// Get related products from same brand - minimal eager loading
|
||||||
$relatedProducts = Product::query()
|
$relatedProducts = Product::query()
|
||||||
->with(['brand', 'strain'])
|
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||||
->where('brand_id', $product->brand_id)
|
->where('brand_id', $product->brand_id)
|
||||||
->where('id', '!=', $product->id)
|
->where('id', '!=', $product->id)
|
||||||
->active()
|
->active()
|
||||||
@@ -162,9 +330,69 @@ class MarketplaceController extends Controller
|
|||||||
->limit(4)
|
->limit(4)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
// Get recently viewed products (excluding current product)
|
||||||
|
$recentlyViewed = $this->recentlyViewed->getProducts(6, $product->id);
|
||||||
|
|
||||||
$business = auth()->user()->businesses->first();
|
$business = auth()->user()->businesses->first();
|
||||||
|
|
||||||
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'brand', 'business'));
|
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'recentlyViewed', 'brand', 'business'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display deals/promotions page for buyers
|
||||||
|
*/
|
||||||
|
public function deals()
|
||||||
|
{
|
||||||
|
// Get all active promotions with their brands and products
|
||||||
|
$activePromos = \App\Models\Promotion::query()
|
||||||
|
->with([
|
||||||
|
'brand:id,name,slug,hashid,logo_path,updated_at',
|
||||||
|
'products' => fn ($q) => $q->with(['brand:id,name,slug,hashid,logo_path,updated_at'])->active()->inStock(),
|
||||||
|
])
|
||||||
|
->active()
|
||||||
|
->orderByDesc('discount_value')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Group by type for display sections
|
||||||
|
$percentageDeals = $activePromos->where('type', 'percentage');
|
||||||
|
$bogoDeals = $activePromos->where('type', 'bogo');
|
||||||
|
$fixedDeals = $activePromos->where('type', 'bundle');
|
||||||
|
$priceOverrides = $activePromos->where('type', 'price_override');
|
||||||
|
|
||||||
|
// Get all products that are on any active promotion
|
||||||
|
$dealProducts = Product::query()
|
||||||
|
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||||
|
->whereHas('promotions', fn ($q) => $q->active())
|
||||||
|
->active()
|
||||||
|
->inStock()
|
||||||
|
->limit(16)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get brands with active deals
|
||||||
|
$brandsWithDeals = Brand::query()
|
||||||
|
->select(['id', 'name', 'slug', 'hashid', 'logo_path', 'updated_at'])
|
||||||
|
->whereHas('promotions', fn ($q) => $q->active())
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Stats for the header
|
||||||
|
$stats = [
|
||||||
|
'total_deals' => $activePromos->count(),
|
||||||
|
'percentage_deals' => $percentageDeals->count(),
|
||||||
|
'bogo_deals' => $bogoDeals->count(),
|
||||||
|
'bundle_deals' => $fixedDeals->count() + $priceOverrides->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('buyer.marketplace.deals', compact(
|
||||||
|
'activePromos',
|
||||||
|
'dealProducts',
|
||||||
|
'percentageDeals',
|
||||||
|
'bogoDeals',
|
||||||
|
'fixedDeals',
|
||||||
|
'priceOverrides',
|
||||||
|
'brandsWithDeals',
|
||||||
|
'stats'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,27 +400,30 @@ class MarketplaceController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function showBrand($brandSlug)
|
public function showBrand($brandSlug)
|
||||||
{
|
{
|
||||||
// Find brand by slug
|
// Find brand by slug with minimal columns
|
||||||
$brand = Brand::query()
|
$brand = Brand::query()
|
||||||
->where('slug', $brandSlug)
|
->where('slug', $brandSlug)
|
||||||
->active()
|
->active()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
// Get featured products from this brand
|
// Optimized: Use simple inStock scope instead of expensive whereHas on batches
|
||||||
|
// The inStock scope should check inventory_mode or quantity_on_hand
|
||||||
$featuredProducts = Product::query()
|
$featuredProducts = Product::query()
|
||||||
->with(['strain'])
|
->with(['strain:id,name,type', 'brand:id,name,slug,hashid,logo_path,updated_at'])
|
||||||
->where('brand_id', $brand->id)
|
->where('brand_id', $brand->id)
|
||||||
|
->active()
|
||||||
->featured()
|
->featured()
|
||||||
->inStock()
|
->inStock()
|
||||||
->limit(3)
|
->limit(3)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// Get all products from this brand
|
// Get products - use simpler inStock check
|
||||||
$products = Product::query()
|
$products = Product::query()
|
||||||
->with(['strain'])
|
->with(['strain:id,name,type', 'brand:id,name,slug,hashid,logo_path,updated_at'])
|
||||||
->where('brand_id', $brand->id)
|
->where('brand_id', $brand->id)
|
||||||
->active()
|
->active()
|
||||||
->orderBy('is_featured', 'desc')
|
->inStock()
|
||||||
|
->orderByDesc('is_featured')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->paginate(20);
|
->paginate(20);
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,135 @@ class OrderController extends Controller
|
|||||||
return view('seller.orders.index', compact('orders', 'business'));
|
return view('seller.orders.index', compact('orders', 'business'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new order (seller-initiated).
|
||||||
|
*/
|
||||||
|
public function create(\App\Models\Business $business): View
|
||||||
|
{
|
||||||
|
// Get all buyer businesses for the customer dropdown
|
||||||
|
$buyers = \App\Models\Business::where('is_active', true)
|
||||||
|
->whereIn('business_type', ['buyer', 'both'])
|
||||||
|
->with(['locations' => function ($query) {
|
||||||
|
$query->where('is_active', true)->orderBy('is_primary', 'desc')->orderBy('name');
|
||||||
|
}])
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get recently ordered products (last 30 days, top 10 most common)
|
||||||
|
$recentProducts = \App\Models\Product::forBusiness($business)
|
||||||
|
->whereHas('orderItems', function ($query) {
|
||||||
|
$query->where('created_at', '>=', now()->subDays(30));
|
||||||
|
})
|
||||||
|
->with(['brand', 'images'])
|
||||||
|
->withCount(['orderItems' => function ($query) {
|
||||||
|
$query->where('created_at', '>=', now()->subDays(30));
|
||||||
|
}])
|
||||||
|
->orderByDesc('order_items_count')
|
||||||
|
->take(10)
|
||||||
|
->get()
|
||||||
|
->map(function ($product) use ($business) {
|
||||||
|
// Calculate inventory from InventoryItem model
|
||||||
|
$totalOnHand = $product->inventoryItems()
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->sum('quantity_on_hand');
|
||||||
|
$totalAllocated = $product->inventoryItems()
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->sum('quantity_allocated');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $product->id,
|
||||||
|
'name' => $product->name,
|
||||||
|
'sku' => $product->sku,
|
||||||
|
'brand_name' => $product->brand?->name,
|
||||||
|
'wholesale_price' => $product->wholesale_price,
|
||||||
|
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
|
||||||
|
'type' => $product->type,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return view('seller.orders.create', compact('business', 'buyers', 'recentProducts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created order (seller-initiated).
|
||||||
|
*/
|
||||||
|
public function store(\App\Models\Business $business, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'buyer_business_id' => 'required|exists:businesses,id',
|
||||||
|
'location_id' => 'nullable|exists:locations,id',
|
||||||
|
'contact_id' => 'nullable|exists:contacts,id',
|
||||||
|
'payment_terms' => 'required|in:cod,net_15,net_30,net_60,net_90',
|
||||||
|
'notes' => 'nullable|string|max:1000',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.product_id' => 'required|exists:products,id',
|
||||||
|
'items.*.quantity' => 'required|integer|min:1',
|
||||||
|
'items.*.unit_price' => 'required|numeric|min:0',
|
||||||
|
'items.*.discount_amount' => 'nullable|numeric|min:0',
|
||||||
|
'items.*.discount_type' => 'nullable|in:fixed,percent',
|
||||||
|
'items.*.notes' => 'nullable|string|max:500',
|
||||||
|
'items.*.batch_id' => 'nullable|exists:batches,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the order
|
||||||
|
$order = Order::create([
|
||||||
|
'business_id' => $validated['buyer_business_id'],
|
||||||
|
'location_id' => $validated['location_id'] ?? null,
|
||||||
|
'contact_id' => $validated['contact_id'] ?? null,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'status' => 'new',
|
||||||
|
'created_by' => 'seller',
|
||||||
|
'payment_terms' => $validated['payment_terms'],
|
||||||
|
'notes' => $validated['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add line items
|
||||||
|
$subtotal = 0;
|
||||||
|
foreach ($validated['items'] as $item) {
|
||||||
|
$product = \App\Models\Product::findOrFail($item['product_id']);
|
||||||
|
|
||||||
|
$lineSubtotal = $item['quantity'] * $item['unit_price'];
|
||||||
|
$discountAmount = 0;
|
||||||
|
|
||||||
|
if (! empty($item['discount_amount']) && $item['discount_amount'] > 0) {
|
||||||
|
if (($item['discount_type'] ?? 'percent') === 'percent') {
|
||||||
|
$discountAmount = $lineSubtotal * ($item['discount_amount'] / 100);
|
||||||
|
} else {
|
||||||
|
$discountAmount = $item['discount_amount'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lineTotal = $lineSubtotal - $discountAmount;
|
||||||
|
$subtotal += $lineTotal;
|
||||||
|
|
||||||
|
$order->items()->create([
|
||||||
|
'product_id' => $item['product_id'],
|
||||||
|
'batch_id' => $item['batch_id'] ?? null,
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'price' => $item['unit_price'],
|
||||||
|
'discount_amount' => $discountAmount,
|
||||||
|
'total' => $lineTotal,
|
||||||
|
'notes' => $item['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order totals
|
||||||
|
$order->update([
|
||||||
|
'subtotal' => $subtotal,
|
||||||
|
'total' => $subtotal, // Tax can be added later
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.orders.show', [$business->slug, $order])
|
||||||
|
->with('success', 'Order created successfully!');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return back()
|
||||||
|
->withInput()
|
||||||
|
->with('error', 'Failed to create order: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display order detail with workorder/picking ticket functionality.
|
* Display order detail with workorder/picking ticket functionality.
|
||||||
*/
|
*/
|
||||||
@@ -213,6 +342,41 @@ class OrderController extends Controller
|
|||||||
return back()->with('success', "Order {$order->order_number} has been cancelled.");
|
return back()->with('success', "Order {$order->order_number} has been cancelled.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update item comment for an order line item.
|
||||||
|
*/
|
||||||
|
public function updateItemComment(\App\Models\Business $business, Order $order, \App\Models\OrderItem $orderItem, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
// Verify the item belongs to this order
|
||||||
|
if ($orderItem->order_id !== $order->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'item_comment' => 'nullable|string|max:2000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$orderItem->update([
|
||||||
|
'item_comment' => $validated['item_comment'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Item comment updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle ping pong mode for an order.
|
||||||
|
*/
|
||||||
|
public function togglePingPong(\App\Models\Business $business, Order $order): RedirectResponse
|
||||||
|
{
|
||||||
|
$order->update([
|
||||||
|
'is_ping_pong' => ! $order->is_ping_pong,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = $order->is_ping_pong ? 'enabled' : 'disabled';
|
||||||
|
|
||||||
|
return back()->with('success', "Ping Pong flow {$status} for this order.");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approve order for delivery (after buyer selects delivery method).
|
* Approve order for delivery (after buyer selects delivery method).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ class DivisionAccountingController extends Controller
|
|||||||
// Search filter
|
// Search filter
|
||||||
if ($search = $request->get('search')) {
|
if ($search = $request->get('search')) {
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'ilike', "%{$search}%")
|
||||||
->orWhere('code', 'like', "%{$search}%")
|
->orWhere('code', 'ilike', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%");
|
->orWhere('email', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,13 +55,17 @@ class BrandController extends Controller
|
|||||||
'is_active' => $brand->is_active,
|
'is_active' => $brand->is_active,
|
||||||
'is_public' => $brand->is_public,
|
'is_public' => $brand->is_public,
|
||||||
'is_featured' => $brand->is_featured,
|
'is_featured' => $brand->is_featured,
|
||||||
|
'is_cannaiq_connected' => $brand->isCannaiqConnected(),
|
||||||
'products_count' => $brand->products_count ?? 0,
|
'products_count' => $brand->products_count ?? 0,
|
||||||
'updated_at' => $brand->updated_at?->diffForHumans(),
|
'updated_at' => $brand->updated_at?->diffForHumans(),
|
||||||
'website_url' => $brand->website_url,
|
'website_url' => $brand->website_url,
|
||||||
'preview_url' => route('seller.business.brands.preview', [$business->slug, $brand]),
|
'preview_url' => route('seller.business.brands.preview', [$business->slug, $brand]),
|
||||||
'dashboard_url' => route('seller.business.brands.dashboard', [$business->slug, $brand]),
|
'dashboard_url' => route('seller.business.brands.dashboard', [$business->slug, $brand]),
|
||||||
|
'profile_url' => route('seller.business.brands.profile', [$business->slug, $brand]),
|
||||||
'stats_url' => route('seller.business.brands.stats', [$business->slug, $brand]),
|
'stats_url' => route('seller.business.brands.stats', [$business->slug, $brand]),
|
||||||
'edit_url' => route('seller.business.brands.edit', [$business->slug, $brand]),
|
'edit_url' => route('seller.business.brands.edit', [$business->slug, $brand]),
|
||||||
|
'stores_url' => route('seller.business.brands.stores.index', [$business->slug, $brand]),
|
||||||
|
'orders_url' => route('seller.business.brands.orders', [$business->slug, $brand]),
|
||||||
'isNewBrand' => $brand->created_at && $brand->created_at->diffInDays(now()) <= 30,
|
'isNewBrand' => $brand->created_at && $brand->created_at->diffInDays(now()) <= 30,
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
@@ -767,6 +771,11 @@ class BrandController extends Controller
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
$salesStats = $this->calculateBrandStats($brand, $ninetyDaysAgo, now());
|
$salesStats = $this->calculateBrandStats($brand, $ninetyDaysAgo, now());
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// STORE INTELLIGENCE (90 days)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
$storeStats = $this->calculateStoreStats($brand, 90);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// PRODUCT VELOCITY DATA
|
// PRODUCT VELOCITY DATA
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -880,6 +889,7 @@ class BrandController extends Controller
|
|||||||
'isBrandManager' => $isBrandManager,
|
'isBrandManager' => $isBrandManager,
|
||||||
// Core stats
|
// Core stats
|
||||||
'salesStats' => $salesStats,
|
'salesStats' => $salesStats,
|
||||||
|
'storeStats' => $storeStats,
|
||||||
'productCategories' => $productCategories,
|
'productCategories' => $productCategories,
|
||||||
'productVelocity' => $productVelocity,
|
'productVelocity' => $productVelocity,
|
||||||
// Product states
|
// Product states
|
||||||
@@ -2089,4 +2099,185 @@ class BrandController extends Controller
|
|||||||
'store_id' => $storeId,
|
'store_id' => $storeId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect brand to CannaiQ API.
|
||||||
|
*
|
||||||
|
* Normalizes the brand name and stores as cannaiq_brand_key.
|
||||||
|
*/
|
||||||
|
public function cannaiqConnect(Request $request, Business $business, Brand $brand)
|
||||||
|
{
|
||||||
|
$this->authorize('update', [$brand, $business]);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'brand_name' => 'required|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$brand->connectToCannaiq($validated['brand_name']);
|
||||||
|
|
||||||
|
if ($request->wantsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Brand connected to CannaiQ',
|
||||||
|
'cannaiq_brand_key' => $brand->cannaiq_brand_key,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
|
||||||
|
->with('success', 'Brand connected to CannaiQ successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect brand from CannaiQ API.
|
||||||
|
*/
|
||||||
|
public function cannaiqDisconnect(Request $request, Business $business, Brand $brand)
|
||||||
|
{
|
||||||
|
$this->authorize('update', [$brand, $business]);
|
||||||
|
|
||||||
|
$brand->disconnectFromCannaiq();
|
||||||
|
|
||||||
|
if ($request->wantsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Brand disconnected from CannaiQ',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
|
||||||
|
->with('success', 'Brand disconnected from CannaiQ.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CannaiQ product mapping page.
|
||||||
|
*
|
||||||
|
* Shows Hub products for this brand and allows mapping to CannaiQ products.
|
||||||
|
*/
|
||||||
|
public function cannaiqMapping(Business $business, Brand $brand)
|
||||||
|
{
|
||||||
|
$this->authorize('view', [$brand, $business]);
|
||||||
|
|
||||||
|
if (! $brand->isCannaiqConnected()) {
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
|
||||||
|
->with('error', 'Please connect this brand to CannaiQ first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = $brand->products()
|
||||||
|
->with('cannaiqMappings')
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('seller.brands.cannaiq-mapping', [
|
||||||
|
'business' => $business,
|
||||||
|
'brand' => $brand,
|
||||||
|
'products' => $products,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a Hub product to a CannaiQ product.
|
||||||
|
*/
|
||||||
|
public function cannaiqMapProduct(Request $request, Business $business, Brand $brand)
|
||||||
|
{
|
||||||
|
$this->authorize('update', [$brand, $business]);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'product_id' => 'required|exists:products,id',
|
||||||
|
'cannaiq_product_id' => 'required|integer',
|
||||||
|
'cannaiq_product_name' => 'required|string|max:255',
|
||||||
|
'cannaiq_store_id' => 'nullable|string|max:255',
|
||||||
|
'cannaiq_store_name' => 'nullable|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify product belongs to this brand
|
||||||
|
$product = $brand->products()->findOrFail($validated['product_id']);
|
||||||
|
|
||||||
|
// Create mapping (ignore if already exists)
|
||||||
|
$mapping = $product->cannaiqMappings()->firstOrCreate(
|
||||||
|
['cannaiq_product_id' => $validated['cannaiq_product_id']],
|
||||||
|
[
|
||||||
|
'cannaiq_product_name' => $validated['cannaiq_product_name'],
|
||||||
|
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
|
||||||
|
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($request->wantsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'mapping' => $mapping,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
|
||||||
|
->with('success', 'Product mapped successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a product mapping.
|
||||||
|
*/
|
||||||
|
public function cannaiqUnmapProduct(Request $request, Business $business, Brand $brand, \App\Models\ProductCannaiqMapping $mapping)
|
||||||
|
{
|
||||||
|
$this->authorize('update', [$brand, $business]);
|
||||||
|
|
||||||
|
// Verify mapping belongs to a product of this brand
|
||||||
|
if ($mapping->product->brand_id !== $brand->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapping->delete();
|
||||||
|
|
||||||
|
if ($request->wantsJson()) {
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
|
||||||
|
->with('success', 'Mapping removed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate store/distribution metrics for the brand.
|
||||||
|
*
|
||||||
|
* Returns metrics about store penetration, SKU stock rate, and average SKUs per store.
|
||||||
|
*/
|
||||||
|
private function calculateStoreStats(Brand $brand, int $days = 90): array
|
||||||
|
{
|
||||||
|
// Count unique buyer businesses (stores) that ordered this brand in current period
|
||||||
|
$currentStores = \App\Models\Order::whereHas('items.product', fn ($q) => $q->where('brand_id', $brand->id))
|
||||||
|
->where('created_at', '>=', now()->subDays($days))
|
||||||
|
->distinct('business_id')
|
||||||
|
->count('business_id');
|
||||||
|
|
||||||
|
// Previous period for comparison
|
||||||
|
$previousStores = \App\Models\Order::whereHas('items.product', fn ($q) => $q->where('brand_id', $brand->id))
|
||||||
|
->whereBetween('created_at', [now()->subDays($days * 2), now()->subDays($days)])
|
||||||
|
->distinct('business_id')
|
||||||
|
->count('business_id');
|
||||||
|
|
||||||
|
// SKU stock rate: % of brand's active SKUs that have been ordered
|
||||||
|
$activeSkus = $brand->products()->where('is_active', true)->count();
|
||||||
|
$orderedSkus = \App\Models\OrderItem::whereHas('product', fn ($q) => $q->where('brand_id', $brand->id))
|
||||||
|
->whereHas('order', fn ($q) => $q->where('created_at', '>=', now()->subDays($days)))
|
||||||
|
->distinct('product_id')
|
||||||
|
->count('product_id');
|
||||||
|
|
||||||
|
$stockRate = $activeSkus > 0 ? round(($orderedSkus / $activeSkus) * 100, 1) : 0;
|
||||||
|
|
||||||
|
// Avg SKUs per store
|
||||||
|
$avgSkusPerStore = $currentStores > 0 ? round($orderedSkus / $currentStores, 1) : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'currentStores' => $currentStores,
|
||||||
|
'storeChange' => $currentStores - $previousStores,
|
||||||
|
'stockRate' => $stockRate,
|
||||||
|
'avgSkusPerStore' => $avgSkusPerStore,
|
||||||
|
'orderedSkus' => $orderedSkus,
|
||||||
|
'activeSkus' => $activeSkus,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ class BrandManagerSettingsController extends Controller
|
|||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('first_name', 'like', "%{$search}%")
|
$q->where('first_name', 'ilike', "%{$search}%")
|
||||||
->orWhere('last_name', 'like', "%{$search}%")
|
->orWhere('last_name', 'ilike', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%");
|
->orWhere('email', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Brand;
|
use App\Models\Brand;
|
||||||
use App\Models\Business;
|
use App\Models\Business;
|
||||||
|
use App\Models\Crm\CrmThread;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\Promotion;
|
use App\Models\Promotion;
|
||||||
@@ -268,19 +269,26 @@ class BrandPortalController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Inbox - Conversations (messaging).
|
* Inbox - Conversations (messaging).
|
||||||
*
|
*
|
||||||
* Shows conversations related to the business.
|
* Shows CRM threads related to the user's linked brands only.
|
||||||
* Uses existing messaging infrastructure but scoped to Brand Portal context.
|
* Uses CrmThread scoped by brand_id for filtering.
|
||||||
*/
|
*/
|
||||||
public function inbox(Request $request, Business $business)
|
public function inbox(Request $request, Business $business)
|
||||||
{
|
{
|
||||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||||
|
|
||||||
// For inbox, we show conversations but in a limited Brand Portal context
|
// Get threads filtered to only those related to linked brands
|
||||||
// This integrates with existing messaging system
|
$threads = CrmThread::forBusiness($business->id)
|
||||||
|
->forBrandPortal($brandIds)
|
||||||
|
->with(['contact', 'assignee', 'brand'])
|
||||||
|
->withCount('messages')
|
||||||
|
->orderByDesc('last_message_at')
|
||||||
|
->paginate(25);
|
||||||
|
|
||||||
return view('seller.brand-portal.inbox', compact(
|
return view('seller.brand-portal.inbox', compact(
|
||||||
'business',
|
'business',
|
||||||
'brands'
|
'brands',
|
||||||
|
'threads'
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
556
app/Http/Controllers/Seller/BrandStoresController.php
Normal file
556
app/Http/Controllers/Seller/BrandStoresController.php
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Brand;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\OrderItem;
|
||||||
|
use App\Services\Cannaiq\CannaiqClient;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class BrandStoresController extends Controller
|
||||||
|
{
|
||||||
|
protected CannaiqClient $cannaiq;
|
||||||
|
|
||||||
|
public function __construct(CannaiqClient $cannaiq)
|
||||||
|
{
|
||||||
|
$this->cannaiq = $cannaiq;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page 1: Stores Dashboard - List of stores (buyer businesses) for a brand
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Business $business, Brand $brand)
|
||||||
|
{
|
||||||
|
$this->authorize('view', [$brand, $business]);
|
||||||
|
|
||||||
|
// Cache dashboard data for 15 minutes
|
||||||
|
$cacheKey = "brand:{$brand->id}:stores:dashboard";
|
||||||
|
$dashboardData = Cache::remember($cacheKey, 900, fn () => $this->calculateStoresDashboardData($brand, $business));
|
||||||
|
|
||||||
|
// Fetch and merge CannaiQ data (cached separately for 10 minutes)
|
||||||
|
$cannaiqCacheKey = "brand:{$brand->id}:cannaiq:stores";
|
||||||
|
$cannaiqData = Cache::remember($cannaiqCacheKey, 600, fn () => $this->fetchCannaiqStoreMetrics($brand));
|
||||||
|
|
||||||
|
// Merge CannaiQ data into store rows
|
||||||
|
$stores = $this->mergeCannaiqData($dashboardData['stores'], $cannaiqData);
|
||||||
|
|
||||||
|
return view('seller.brands.stores.index', [
|
||||||
|
'business' => $business,
|
||||||
|
'brand' => $brand,
|
||||||
|
'stores' => $stores,
|
||||||
|
'kpis' => $dashboardData['kpis'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page 2: Order Management - SKU-level view for one store
|
||||||
|
*/
|
||||||
|
public function show(Request $request, Business $business, Brand $brand, Business $retailStore)
|
||||||
|
{
|
||||||
|
$this->authorize('view', [$brand, $business]);
|
||||||
|
|
||||||
|
// Get all stores for the dropdown switcher
|
||||||
|
$dashboardCacheKey = "brand:{$brand->id}:stores:dashboard";
|
||||||
|
$dashboardData = Cache::remember($dashboardCacheKey, 900, fn () => $this->calculateStoresDashboardData($brand, $business));
|
||||||
|
|
||||||
|
// Cache store detail data for 10 minutes
|
||||||
|
$cacheKey = "brand:{$brand->id}:store:{$retailStore->id}:detail";
|
||||||
|
$storeData = Cache::remember($cacheKey, 600, fn () => $this->calculateStoreDetailData($brand, $business, $retailStore));
|
||||||
|
|
||||||
|
return view('seller.brands.stores.show', [
|
||||||
|
'business' => $business,
|
||||||
|
'brand' => $brand,
|
||||||
|
'store' => $retailStore,
|
||||||
|
'stores' => $dashboardData['stores'],
|
||||||
|
'products' => $storeData['products'],
|
||||||
|
'kpis' => $storeData['kpis'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page 3: Order Management - Store-level summary with enhanced columns
|
||||||
|
* Shows all stores for a brand with CannaiQ metrics when available
|
||||||
|
*/
|
||||||
|
public function orders(Request $request, Business $business, Brand $brand)
|
||||||
|
{
|
||||||
|
$this->authorize('view', [$brand, $business]);
|
||||||
|
|
||||||
|
// Cache dashboard data for 15 minutes
|
||||||
|
$cacheKey = "brand:{$brand->id}:orders:dashboard";
|
||||||
|
$dashboardData = Cache::remember($cacheKey, 900, fn () => $this->calculateOrdersDashboardData($brand, $business));
|
||||||
|
|
||||||
|
// Fetch and merge CannaiQ data (cached separately for 10 minutes)
|
||||||
|
$cannaiqCacheKey = "brand:{$brand->id}:cannaiq:stores";
|
||||||
|
$cannaiqData = Cache::remember($cannaiqCacheKey, 600, fn () => $this->fetchCannaiqStoreMetrics($brand));
|
||||||
|
|
||||||
|
// Merge CannaiQ data into store rows
|
||||||
|
$stores = $this->mergeCannaiqData($dashboardData['stores'], $cannaiqData);
|
||||||
|
|
||||||
|
// Get all brands for the brand switcher dropdown
|
||||||
|
$brands = Brand::where(function ($query) use ($business) {
|
||||||
|
$query->where('business_id', $business->id);
|
||||||
|
if ($business->parent_id) {
|
||||||
|
$query->orWhere('business_id', $business->parent_id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(fn ($b) => [
|
||||||
|
'hashid' => $b->hashid,
|
||||||
|
'name' => $b->name,
|
||||||
|
'orders_url' => route('seller.business.brands.orders', [$business->slug, $b->hashid]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return view('seller.brands.stores.orders', [
|
||||||
|
'business' => $business,
|
||||||
|
'brand' => $brand,
|
||||||
|
'brands' => $brands,
|
||||||
|
'stores' => $stores,
|
||||||
|
'kpis' => $dashboardData['kpis'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate order management dashboard data (store-level with enhanced metrics)
|
||||||
|
*/
|
||||||
|
private function calculateOrdersDashboardData(Brand $brand, Business $business): array
|
||||||
|
{
|
||||||
|
$fourWeeksAgo = now()->subWeeks(4);
|
||||||
|
|
||||||
|
// Get all product IDs for this brand
|
||||||
|
$brandProductIds = $brand->products()->pluck('id');
|
||||||
|
|
||||||
|
if ($brandProductIds->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'stores' => collect(),
|
||||||
|
'kpis' => [
|
||||||
|
'total_sales_4wk' => 0,
|
||||||
|
'total_oos' => 0,
|
||||||
|
'potential_sales' => 0,
|
||||||
|
'store_count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single aggregation query for store-level sales (4 weeks)
|
||||||
|
$storesSales = OrderItem::whereIn('product_id', $brandProductIds)
|
||||||
|
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||||
|
->where('orders.created_at', '>=', $fourWeeksAgo)
|
||||||
|
->whereNotIn('orders.status', ['cancelled', 'rejected'])
|
||||||
|
->select([
|
||||||
|
'orders.business_id as store_id',
|
||||||
|
DB::raw('SUM(order_items.line_total) as total_sales'),
|
||||||
|
DB::raw('SUM(order_items.quantity) as total_units'),
|
||||||
|
DB::raw('COUNT(DISTINCT orders.id) as order_count'),
|
||||||
|
DB::raw('COUNT(DISTINCT order_items.product_id) as active_skus'),
|
||||||
|
])
|
||||||
|
->groupBy('orders.business_id')
|
||||||
|
->get()
|
||||||
|
->keyBy('store_id');
|
||||||
|
|
||||||
|
if ($storesSales->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'stores' => collect(),
|
||||||
|
'kpis' => [
|
||||||
|
'total_sales_4wk' => 0,
|
||||||
|
'total_oos' => 0,
|
||||||
|
'potential_sales' => 0,
|
||||||
|
'store_count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load store businesses
|
||||||
|
$storeIds = $storesSales->keys();
|
||||||
|
$stores = Business::whereIn('id', $storeIds)
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
$daysPeriod = 28; // 4 weeks
|
||||||
|
$totalSkusAvailable = $brand->products()->where('is_active', true)->count();
|
||||||
|
$avgPrice = $brand->products()->avg('wholesale_price') ?? 0; // Calculate once outside loop
|
||||||
|
|
||||||
|
// Build store rows with enhanced columns
|
||||||
|
$storeRows = $storesSales->map(function ($sales, $storeId) use ($stores, $daysPeriod, $totalSkusAvailable, $avgPrice) {
|
||||||
|
$store = $stores->get($storeId);
|
||||||
|
if (! $store) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeSkus = $sales->active_skus;
|
||||||
|
$oosSkus = max(0, $totalSkusAvailable - $activeSkus);
|
||||||
|
$oosPercent = $totalSkusAvailable > 0 ? round(($oosSkus / $totalSkusAvailable) * 100, 0) : 0;
|
||||||
|
$avgDailyUnits = $daysPeriod > 0 ? round($sales->total_units / $daysPeriod, 1) : 0;
|
||||||
|
|
||||||
|
// Calculate lost opportunity (simplified)
|
||||||
|
$lostOpportunity = $oosSkus * $avgPrice * 7;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $store->id,
|
||||||
|
'slug' => $store->slug,
|
||||||
|
'name' => $store->name,
|
||||||
|
'address' => $this->formatStoreAddress($store),
|
||||||
|
'business_type' => $store->business_type,
|
||||||
|
'tags' => [], // CannaiQ: will provide "must_win" etc.
|
||||||
|
'active_skus' => $activeSkus,
|
||||||
|
'oos_skus' => $oosSkus,
|
||||||
|
'oos_percent' => $oosPercent,
|
||||||
|
'avg_daily_units' => $avgDailyUnits,
|
||||||
|
'avg_days_on_hand' => null, // CannaiQ
|
||||||
|
'total_sales' => round($sales->total_sales, 2),
|
||||||
|
'avg_margin_3mo' => null, // CannaiQ
|
||||||
|
'lost_opportunity' => round($lostOpportunity, 2),
|
||||||
|
'categories' => null, // CannaiQ: category breakdown for mini charts
|
||||||
|
'order_count' => $sales->order_count,
|
||||||
|
];
|
||||||
|
})->filter()->sortByDesc('total_sales')->values();
|
||||||
|
|
||||||
|
// Calculate summary KPIs
|
||||||
|
$kpis = [
|
||||||
|
'total_sales_4wk' => $storeRows->sum('total_sales'),
|
||||||
|
'total_oos' => $storeRows->sum('oos_skus'),
|
||||||
|
'potential_sales' => $storeRows->sum('lost_opportunity'),
|
||||||
|
'store_count' => $storeRows->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'stores' => $storeRows,
|
||||||
|
'kpis' => $kpis,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate aggregated stores dashboard data
|
||||||
|
*/
|
||||||
|
private function calculateStoresDashboardData(Brand $brand, Business $business): array
|
||||||
|
{
|
||||||
|
$fourWeeksAgo = now()->subWeeks(4);
|
||||||
|
|
||||||
|
// Get all product IDs for this brand
|
||||||
|
$brandProductIds = $brand->products()->pluck('id');
|
||||||
|
|
||||||
|
if ($brandProductIds->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'stores' => collect(),
|
||||||
|
'kpis' => [
|
||||||
|
'total_sales_4wk' => 0,
|
||||||
|
'total_oos' => 0,
|
||||||
|
'potential_sales' => 0,
|
||||||
|
'store_count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single aggregation query for store-level sales (4 weeks)
|
||||||
|
$storesSales = OrderItem::whereIn('product_id', $brandProductIds)
|
||||||
|
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||||
|
->where('orders.created_at', '>=', $fourWeeksAgo)
|
||||||
|
->whereNotIn('orders.status', ['cancelled', 'rejected'])
|
||||||
|
->select([
|
||||||
|
'orders.business_id as store_id',
|
||||||
|
DB::raw('SUM(order_items.line_total) as total_sales'),
|
||||||
|
DB::raw('SUM(order_items.quantity) as total_units'),
|
||||||
|
DB::raw('COUNT(DISTINCT orders.id) as order_count'),
|
||||||
|
DB::raw('COUNT(DISTINCT order_items.product_id) as active_skus'),
|
||||||
|
])
|
||||||
|
->groupBy('orders.business_id')
|
||||||
|
->get()
|
||||||
|
->keyBy('store_id');
|
||||||
|
|
||||||
|
if ($storesSales->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'stores' => collect(),
|
||||||
|
'kpis' => [
|
||||||
|
'total_sales_4wk' => 0,
|
||||||
|
'total_oos' => 0,
|
||||||
|
'potential_sales' => 0,
|
||||||
|
'store_count' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load store businesses
|
||||||
|
$storeIds = $storesSales->keys();
|
||||||
|
$stores = Business::whereIn('id', $storeIds)
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
$daysPeriod = 28; // 4 weeks
|
||||||
|
$totalSkusAvailable = $brand->products()->where('is_active', true)->count();
|
||||||
|
$avgPrice = $brand->products()->avg('wholesale_price') ?? 0; // Calculate once outside loop
|
||||||
|
|
||||||
|
// Build store rows
|
||||||
|
$storeRows = $storesSales->map(function ($sales, $storeId) use ($stores, $daysPeriod, $totalSkusAvailable, $avgPrice) {
|
||||||
|
$store = $stores->get($storeId);
|
||||||
|
if (! $store) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeSkus = $sales->active_skus;
|
||||||
|
$oosSkus = max(0, $totalSkusAvailable - $activeSkus); // Products not ordered = potentially OOS
|
||||||
|
$oosPercent = $totalSkusAvailable > 0 ? round(($oosSkus / $totalSkusAvailable) * 100, 1) : 0;
|
||||||
|
$avgDailyUnits = $daysPeriod > 0 ? round($sales->total_units / $daysPeriod, 1) : 0;
|
||||||
|
|
||||||
|
// Calculate lost opportunity (simplified: OOS SKUs * avg price * estimated days)
|
||||||
|
$lostOpportunity = $oosSkus * $avgPrice * 7; // 7 days estimated
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $store->id,
|
||||||
|
'slug' => $store->slug,
|
||||||
|
'name' => $store->name,
|
||||||
|
'address' => $this->formatStoreAddress($store),
|
||||||
|
'business_type' => $store->business_type,
|
||||||
|
'active_skus' => $activeSkus,
|
||||||
|
'oos_skus' => $oosSkus,
|
||||||
|
'oos_percent' => $oosPercent,
|
||||||
|
'avg_daily_units' => $avgDailyUnits,
|
||||||
|
'avg_days_on_hand' => null, // Requires CannaiQ data
|
||||||
|
'total_sales' => round($sales->total_sales, 2),
|
||||||
|
'avg_margin' => null, // Requires CannaiQ data
|
||||||
|
'lost_opportunity' => round($lostOpportunity, 2),
|
||||||
|
'order_count' => $sales->order_count,
|
||||||
|
];
|
||||||
|
})->filter()->sortByDesc('total_sales')->values();
|
||||||
|
|
||||||
|
// Calculate summary KPIs
|
||||||
|
$kpis = [
|
||||||
|
'total_sales_4wk' => $storeRows->sum('total_sales'),
|
||||||
|
'total_oos' => $storeRows->sum('oos_skus'),
|
||||||
|
'potential_sales' => $storeRows->sum('lost_opportunity'),
|
||||||
|
'store_count' => $storeRows->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'stores' => $storeRows,
|
||||||
|
'kpis' => $kpis,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate store detail data (SKU-level)
|
||||||
|
*/
|
||||||
|
private function calculateStoreDetailData(Brand $brand, Business $business, Business $store): array
|
||||||
|
{
|
||||||
|
$fourWeeksAgo = now()->subWeeks(4);
|
||||||
|
|
||||||
|
// Get all active products for this brand
|
||||||
|
$brandProducts = $brand->products()
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($brandProducts->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'products' => collect(),
|
||||||
|
'kpis' => [
|
||||||
|
'total_sales' => 0,
|
||||||
|
'total_units' => 0,
|
||||||
|
'oos_count' => 0,
|
||||||
|
'low_stock_count' => 0,
|
||||||
|
'total_lost_opportunity' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sales per product for this store
|
||||||
|
$productSales = OrderItem::whereIn('product_id', $brandProducts->pluck('id'))
|
||||||
|
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||||
|
->where('orders.business_id', $store->id)
|
||||||
|
->where('orders.created_at', '>=', $fourWeeksAgo)
|
||||||
|
->whereNotIn('orders.status', ['cancelled', 'rejected'])
|
||||||
|
->select([
|
||||||
|
'order_items.product_id',
|
||||||
|
DB::raw('SUM(order_items.line_total) as total_sales'),
|
||||||
|
DB::raw('SUM(order_items.quantity) as total_units'),
|
||||||
|
])
|
||||||
|
->groupBy('order_items.product_id')
|
||||||
|
->get()
|
||||||
|
->keyBy('product_id');
|
||||||
|
|
||||||
|
$daysPeriod = 28;
|
||||||
|
|
||||||
|
// Build product rows
|
||||||
|
$productRows = $brandProducts->map(function ($product) use ($productSales, $daysPeriod, $store, $brand) {
|
||||||
|
$sales = $productSales->get($product->id);
|
||||||
|
|
||||||
|
$totalUnits = $sales->total_units ?? 0;
|
||||||
|
$totalSales = $sales->total_sales ?? 0;
|
||||||
|
$avgDailyUnits = $daysPeriod > 0 ? round($totalUnits / $daysPeriod, 2) : 0;
|
||||||
|
|
||||||
|
// Determine stock status based on recent orders
|
||||||
|
// No orders in 4 weeks = likely OOS
|
||||||
|
$stockStatus = 'in_stock';
|
||||||
|
$daysSinceOos = null;
|
||||||
|
|
||||||
|
if (! $sales || $totalUnits === 0) {
|
||||||
|
$stockStatus = 'oos';
|
||||||
|
$daysSinceOos = 28; // Assume OOS for full period if no orders
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate lost opportunity
|
||||||
|
$lostOpportunity = 0;
|
||||||
|
if ($stockStatus === 'oos' && $avgDailyUnits > 0) {
|
||||||
|
$unitPrice = $product->wholesale_price ?? 0;
|
||||||
|
$lostOpportunity = $avgDailyUnits * ($daysSinceOos ?? 7) * $unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate units to order (target 14 days of stock)
|
||||||
|
$unitsToOrder = null;
|
||||||
|
if ($avgDailyUnits > 0) {
|
||||||
|
$unitsToOrder = (int) ceil($avgDailyUnits * 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $product->id,
|
||||||
|
'hashid' => $product->hashid,
|
||||||
|
'name' => $product->name,
|
||||||
|
'sku' => $product->sku,
|
||||||
|
'brand_name' => $brand->name,
|
||||||
|
'dispensary_name' => $store->name,
|
||||||
|
'vendor' => $brand->business?->name ?? '-',
|
||||||
|
'total_sales' => round($totalSales, 2),
|
||||||
|
'total_units' => $totalUnits,
|
||||||
|
'avg_daily_units' => $avgDailyUnits,
|
||||||
|
'margin_dollars' => null, // Requires CannaiQ data
|
||||||
|
'margin_percent' => null, // Requires CannaiQ data
|
||||||
|
'stock_level' => null, // Requires CannaiQ data
|
||||||
|
'days_of_stock' => null, // Requires CannaiQ data
|
||||||
|
'days_since_oos' => $daysSinceOos,
|
||||||
|
'lost_opportunity' => round($lostOpportunity, 2),
|
||||||
|
'units_to_order' => $unitsToOrder,
|
||||||
|
'price' => $product->wholesale_price,
|
||||||
|
'discount' => null, // Requires CannaiQ data
|
||||||
|
'measure' => $product->weight_display ?? $product->weight_unit ?? 'unit',
|
||||||
|
'stock_status' => $stockStatus,
|
||||||
|
'image_url' => $product->getImageUrl('thumb'),
|
||||||
|
];
|
||||||
|
})->sortByDesc('total_sales')->values();
|
||||||
|
|
||||||
|
// Calculate KPIs
|
||||||
|
$kpis = [
|
||||||
|
'total_sales' => $productRows->sum('total_sales'),
|
||||||
|
'total_units' => $productRows->sum('total_units'),
|
||||||
|
'oos_count' => $productRows->where('stock_status', 'oos')->count(),
|
||||||
|
'low_stock_count' => $productRows->where('stock_status', 'low')->count(),
|
||||||
|
'total_lost_opportunity' => $productRows->sum('lost_opportunity'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'products' => $productRows,
|
||||||
|
'kpis' => $kpis,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format store address for display
|
||||||
|
*/
|
||||||
|
private function formatStoreAddress(Business $store): string
|
||||||
|
{
|
||||||
|
$parts = array_filter([
|
||||||
|
$store->address,
|
||||||
|
$store->city,
|
||||||
|
$store->state,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return implode(', ', $parts) ?: 'No address';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch CannaiQ store metrics for a brand
|
||||||
|
*/
|
||||||
|
private function fetchCannaiqStoreMetrics(Brand $brand): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Use brand slug or name for CannaiQ lookup
|
||||||
|
$brandSlug = $brand->slug ?? $brand->name;
|
||||||
|
|
||||||
|
// Fetch aggregated store metrics from CannaiQ
|
||||||
|
$response = $this->cannaiq->getBrandStoreMetrics($brandSlug);
|
||||||
|
|
||||||
|
if (isset($response['error'])) {
|
||||||
|
Log::warning('CannaiQ: Failed to fetch store metrics for brand', [
|
||||||
|
'brand' => $brandSlug,
|
||||||
|
'error' => $response['message'] ?? 'Unknown error',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['stores'] ?? [];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('CannaiQ: Exception fetching store metrics', [
|
||||||
|
'brand' => $brand->slug,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge CannaiQ data into internal store rows
|
||||||
|
* Matches stores by name (fuzzy) or cannaiq_store_id if available
|
||||||
|
*/
|
||||||
|
private function mergeCannaiqData($stores, array $cannaiqData): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
if (empty($cannaiqData)) {
|
||||||
|
return $stores;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index CannaiQ data by normalized store name for fuzzy matching
|
||||||
|
$cannaiqByName = [];
|
||||||
|
foreach ($cannaiqData as $storeId => $data) {
|
||||||
|
$normalizedName = $this->normalizeStoreName($data['name'] ?? '');
|
||||||
|
if ($normalizedName) {
|
||||||
|
$cannaiqByName[$normalizedName] = $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stores->map(function ($store) use ($cannaiqByName) {
|
||||||
|
// Try to match by normalized name
|
||||||
|
$normalizedName = $this->normalizeStoreName($store['name']);
|
||||||
|
$cannaiq = $cannaiqByName[$normalizedName] ?? null;
|
||||||
|
|
||||||
|
if ($cannaiq) {
|
||||||
|
// Merge CannaiQ data into store row
|
||||||
|
$store['tags'] = $cannaiq['tags'] ?? $store['tags'];
|
||||||
|
$store['avg_days_on_hand'] = $cannaiq['avg_days_on_hand'] ?? $store['avg_days_on_hand'];
|
||||||
|
$store['avg_margin_3mo'] = $cannaiq['avg_margin_3mo'] ?? $store['avg_margin_3mo'];
|
||||||
|
$store['categories'] = $cannaiq['categories'] ?? $store['categories'];
|
||||||
|
|
||||||
|
// Override OOS if CannaiQ has more accurate data
|
||||||
|
if (isset($cannaiq['oos_skus'])) {
|
||||||
|
$store['oos_skus'] = $cannaiq['oos_skus'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override lost opportunity if CannaiQ has it
|
||||||
|
if (isset($cannaiq['lost_opportunity'])) {
|
||||||
|
$store['lost_opportunity'] = $cannaiq['lost_opportunity'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $store;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize store name for fuzzy matching
|
||||||
|
*/
|
||||||
|
private function normalizeStoreName(string $name): string
|
||||||
|
{
|
||||||
|
// Lowercase, remove common suffixes, trim whitespace
|
||||||
|
$name = strtolower(trim($name));
|
||||||
|
$name = preg_replace('/\s+(inc|llc|dispensary|cannabis|co|company)\.?$/i', '', $name);
|
||||||
|
$name = preg_replace('/[^a-z0-9]/', '', $name);
|
||||||
|
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
415
app/Http/Controllers/Seller/ChatController.php
Normal file
415
app/Http/Controllers/Seller/ChatController.php
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Crm\CrmActiveView;
|
||||||
|
use App\Models\Crm\CrmChannel;
|
||||||
|
use App\Models\Crm\CrmInternalNote;
|
||||||
|
use App\Models\Crm\CrmThread;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Crm\CrmAiService;
|
||||||
|
use App\Services\Crm\CrmChannelService;
|
||||||
|
use App\Services\Crm\CrmSlaService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ChatController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected CrmChannelService $channelService,
|
||||||
|
protected CrmSlaService $slaService,
|
||||||
|
protected CrmAiService $aiService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified chat inbox view (Chatwoot-style)
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$query = CrmThread::forBusiness($business->id)
|
||||||
|
->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||||
|
->withCount('messages');
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('assigned_to')) {
|
||||||
|
if ($request->assigned_to === 'unassigned') {
|
||||||
|
$query->unassigned();
|
||||||
|
} else {
|
||||||
|
$query->assignedTo($request->assigned_to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('department')) {
|
||||||
|
$query->forDepartment($request->department);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('brand_id')) {
|
||||||
|
$query->forBrand($request->brand_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$query->where(function ($q) use ($request) {
|
||||||
|
$q->where('subject', 'ilike', "%{$request->search}%")
|
||||||
|
->orWhere('last_message_preview', 'ilike', "%{$request->search}%")
|
||||||
|
->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$threads = $query->orderByDesc('last_message_at')->paginate(50);
|
||||||
|
|
||||||
|
// Get team members for assignment dropdown
|
||||||
|
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||||
|
|
||||||
|
// Get available channels
|
||||||
|
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||||
|
|
||||||
|
// Get brands for filter dropdown
|
||||||
|
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get departments for filter dropdown
|
||||||
|
$departments = CrmChannel::DEPARTMENTS;
|
||||||
|
|
||||||
|
// Get contacts for new conversation modal
|
||||||
|
// Include: 1) Customer contacts (from businesses that ordered), 2) Own business contacts (coworkers)
|
||||||
|
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||||
|
->pluck('business_id')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
// Add the seller's own business ID to include coworkers
|
||||||
|
$allBusinessIds = $customerBusinessIds->push($business->id)->unique();
|
||||||
|
|
||||||
|
$contacts = \App\Models\Contact::whereIn('business_id', $allBusinessIds)
|
||||||
|
->with('business:id,name')
|
||||||
|
->orderBy('first_name')
|
||||||
|
->limit(200)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('seller.chat.index', compact(
|
||||||
|
'business',
|
||||||
|
'threads',
|
||||||
|
'teamMembers',
|
||||||
|
'channels',
|
||||||
|
'brands',
|
||||||
|
'departments',
|
||||||
|
'contacts'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Get thread data for inline loading
|
||||||
|
*/
|
||||||
|
public function getThread(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thread->load([
|
||||||
|
'contact',
|
||||||
|
'account',
|
||||||
|
'assignee',
|
||||||
|
'brand',
|
||||||
|
'channel',
|
||||||
|
'messages.attachments',
|
||||||
|
'messages.user',
|
||||||
|
'deals',
|
||||||
|
'internalNotes.user',
|
||||||
|
'tags.tag',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mark as read
|
||||||
|
$thread->markAsRead($request->user());
|
||||||
|
|
||||||
|
// Start viewing (collision detection)
|
||||||
|
CrmActiveView::startViewing($thread, $request->user());
|
||||||
|
|
||||||
|
// Get other viewers
|
||||||
|
$otherViewers = CrmActiveView::getActiveViewers($thread, $request->user()->id);
|
||||||
|
|
||||||
|
// Get SLA status
|
||||||
|
$slaStatus = $this->slaService->getThreadSlaStatus($thread);
|
||||||
|
|
||||||
|
// Get AI suggestions
|
||||||
|
$suggestions = $thread->aiSuggestions()->pending()->notExpired()->get();
|
||||||
|
|
||||||
|
// Get available channels for reply
|
||||||
|
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||||
|
|
||||||
|
// Get team members for assignment dropdown
|
||||||
|
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'thread' => $thread,
|
||||||
|
'otherViewers' => $otherViewers->map(fn ($v) => [
|
||||||
|
'id' => $v->user->id,
|
||||||
|
'name' => $v->user->name,
|
||||||
|
'type' => $v->view_type,
|
||||||
|
]),
|
||||||
|
'slaStatus' => $slaStatus,
|
||||||
|
'suggestions' => $suggestions,
|
||||||
|
'channels' => $channels,
|
||||||
|
'teamMembers' => $teamMembers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Send reply in thread
|
||||||
|
*/
|
||||||
|
public function reply(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'body' => 'required|string|max:10000',
|
||||||
|
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$contact = $thread->contact;
|
||||||
|
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
||||||
|
? $contact->email
|
||||||
|
: $contact->phone;
|
||||||
|
|
||||||
|
if (! $to) {
|
||||||
|
return response()->json(['error' => 'Contact does not have required contact info for this channel.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$success = $this->channelService->sendMessage(
|
||||||
|
businessId: $business->id,
|
||||||
|
channelType: $validated['channel_type'],
|
||||||
|
to: $to,
|
||||||
|
body: $validated['body'],
|
||||||
|
subject: null,
|
||||||
|
threadId: $thread->id,
|
||||||
|
contactId: $contact->id,
|
||||||
|
userId: $request->user()->id,
|
||||||
|
attachments: []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $success) {
|
||||||
|
return response()->json(['error' => 'Failed to send message.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-assign thread to sender if unassigned
|
||||||
|
if ($thread->assigned_to === null) {
|
||||||
|
$thread->assigned_to = $request->user()->id;
|
||||||
|
$thread->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SLA
|
||||||
|
$this->slaService->handleOutboundMessage($thread);
|
||||||
|
|
||||||
|
// Reload messages
|
||||||
|
$thread->load(['messages.attachments', 'messages.user']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'messages' => $thread->messages,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Create new thread
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'contact_id' => 'required|exists:contacts,id',
|
||||||
|
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
|
||||||
|
'body' => 'required|string|max:10000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get allowed business IDs (customers + own business for coworkers)
|
||||||
|
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||||
|
->pluck('business_id')
|
||||||
|
->unique();
|
||||||
|
$allBusinessIds = $customerBusinessIds->push($business->id)->unique();
|
||||||
|
|
||||||
|
// SECURITY: Verify contact belongs to a customer business or own business (coworker)
|
||||||
|
$contact = \App\Models\Contact::whereIn('business_id', $allBusinessIds)
|
||||||
|
->findOrFail($validated['contact_id']);
|
||||||
|
|
||||||
|
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
||||||
|
? $contact->email
|
||||||
|
: $contact->phone;
|
||||||
|
|
||||||
|
if (! $to) {
|
||||||
|
return response()->json(['error' => 'Contact does not have the required contact info for this channel.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create thread
|
||||||
|
$thread = CrmThread::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'contact_id' => $contact->id,
|
||||||
|
'account_id' => $contact->account_id,
|
||||||
|
'status' => 'open',
|
||||||
|
'priority' => 'normal',
|
||||||
|
'last_channel_type' => $validated['channel_type'],
|
||||||
|
'assigned_to' => $request->user()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send the message
|
||||||
|
$success = $this->channelService->sendMessage(
|
||||||
|
businessId: $business->id,
|
||||||
|
channelType: $validated['channel_type'],
|
||||||
|
to: $to,
|
||||||
|
body: $validated['body'],
|
||||||
|
subject: null,
|
||||||
|
threadId: $thread->id,
|
||||||
|
contactId: $contact->id,
|
||||||
|
userId: $request->user()->id,
|
||||||
|
attachments: []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $success) {
|
||||||
|
$thread->delete();
|
||||||
|
|
||||||
|
return response()->json(['error' => 'Failed to send message.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thread->load(['contact', 'messages']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'thread' => $thread,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Assign thread
|
||||||
|
*/
|
||||||
|
public function assign(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'assigned_to' => 'nullable|exists:users,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validated['assigned_to']) {
|
||||||
|
$assignee = User::where('id', $validated['assigned_to'])
|
||||||
|
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $assignee) {
|
||||||
|
return response()->json(['error' => 'Invalid user.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thread->assignTo($assignee, $request->user());
|
||||||
|
} else {
|
||||||
|
$thread->assigned_to = null;
|
||||||
|
$thread->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Close thread
|
||||||
|
*/
|
||||||
|
public function close(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thread->close($request->user());
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'status' => 'closed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Reopen thread
|
||||||
|
*/
|
||||||
|
public function reopen(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thread->reopen($request->user());
|
||||||
|
$this->slaService->resumeTimers($thread);
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'status' => 'open']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Add internal note
|
||||||
|
*/
|
||||||
|
public function addNote(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'content' => 'required|string|max:5000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$note = CrmInternalNote::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'notable_type' => CrmThread::class,
|
||||||
|
'notable_id' => $thread->id,
|
||||||
|
'content' => $validated['content'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$note->load('user');
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'note' => $note]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Generate AI reply
|
||||||
|
*/
|
||||||
|
public function generateAiReply(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$suggestion = $this->aiService->generateReplyDraft($thread, $request->input('tone', 'professional'));
|
||||||
|
|
||||||
|
if (! $suggestion) {
|
||||||
|
return response()->json(['error' => 'Failed to generate reply.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'content' => $suggestion->content,
|
||||||
|
'suggestion_id' => $suggestion->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Heartbeat for active viewing
|
||||||
|
*/
|
||||||
|
public function heartbeat(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
CrmActiveView::startViewing($thread, $request->user(), $request->input('view_type', 'viewing'));
|
||||||
|
|
||||||
|
$otherViewers = CrmActiveView::getActiveViewers($thread, $request->user()->id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'other_viewers' => $otherViewers->map(fn ($v) => [
|
||||||
|
'id' => $v->user->id,
|
||||||
|
'name' => $v->user->name,
|
||||||
|
'type' => $v->view_type,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,12 +25,12 @@ class ConversationController extends Controller
|
|||||||
if ($search) {
|
if ($search) {
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->whereHas('contact', function ($c) use ($search) {
|
$q->whereHas('contact', function ($c) use ($search) {
|
||||||
$c->where('name', 'like', "%{$search}%")
|
$c->where('name', 'ilike', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%")
|
->orWhere('email', 'ilike', "%{$search}%")
|
||||||
->orWhere('phone', 'like', "%{$search}%");
|
->orWhere('phone', 'ilike', "%{$search}%");
|
||||||
})
|
})
|
||||||
->orWhereHas('messages', function ($m) use ($search) {
|
->orWhereHas('messages', function ($m) use ($search) {
|
||||||
$m->where('message_body', 'like', "%{$search}%");
|
$m->where('message_body', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ use Illuminate\Http\Request;
|
|||||||
class AccountController extends Controller
|
class AccountController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display accounts listing
|
* Display accounts listing - only buyers who have ordered from this seller
|
||||||
*/
|
*/
|
||||||
public function index(Request $request, Business $business)
|
public function index(Request $request, Business $business)
|
||||||
{
|
{
|
||||||
$query = Business::where('type', 'buyer')
|
$query = Business::where('type', 'buyer')
|
||||||
|
->whereHas('orders', function ($q) use ($business) {
|
||||||
|
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||||
|
})
|
||||||
->with(['contacts']);
|
->with(['contacts']);
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
@@ -36,11 +39,16 @@ class AccountController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status filter - default to approved, but allow viewing all
|
// Only show approved accounts (approved buyers)
|
||||||
if ($request->filled('status') && $request->status !== 'all') {
|
$query->where('status', 'approved');
|
||||||
$query->where('status', $request->status);
|
|
||||||
} else {
|
// Active/Inactive filter
|
||||||
$query->where('status', 'approved');
|
if ($request->filled('status')) {
|
||||||
|
if ($request->status === 'active') {
|
||||||
|
$query->where('is_active', true);
|
||||||
|
} elseif ($request->status === 'inactive') {
|
||||||
|
$query->where('is_active', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$accounts = $query->orderBy('name')->paginate(25);
|
$accounts = $query->orderBy('name')->paginate(25);
|
||||||
@@ -322,7 +330,7 @@ class AccountController extends Controller
|
|||||||
'past_due_amount' => $financialStats->past_due_amount ?? 0,
|
'past_due_amount' => $financialStats->past_due_amount ?? 0,
|
||||||
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
|
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
|
||||||
'oldest_past_due_days' => $financialStats->oldest_past_due_date
|
'oldest_past_due_days' => $financialStats->oldest_past_due_date
|
||||||
? now()->diffInDays($financialStats->oldest_past_due_date)
|
? (int) ceil(abs(now()->diffInDays($financialStats->oldest_past_due_date)))
|
||||||
: null,
|
: null,
|
||||||
'last_payment_amount' => $lastPayment->amount ?? null,
|
'last_payment_amount' => $lastPayment->amount ?? null,
|
||||||
'last_payment_date' => $lastPayment->payment_date ?? null,
|
'last_payment_date' => $lastPayment->payment_date ?? null,
|
||||||
|
|||||||
@@ -172,8 +172,9 @@ class CrmCalendarController extends Controller
|
|||||||
]);
|
]);
|
||||||
$allEvents = $allEvents->merge($bookings);
|
$allEvents = $allEvents->merge($bookings);
|
||||||
|
|
||||||
// 4. CRM Tasks with due dates (shown as all-day markers)
|
// 4. CRM Tasks with due dates (shown as all-day markers) - only show user's assigned tasks
|
||||||
$tasks = CrmTask::forSellerBusiness($business->id)
|
$tasks = CrmTask::forSellerBusiness($business->id)
|
||||||
|
->where('assigned_to', $user->id)
|
||||||
->incomplete()
|
->incomplete()
|
||||||
->whereNotNull('due_at')
|
->whereNotNull('due_at')
|
||||||
->whereBetween('due_at', [$startDate, $endDate])
|
->whereBetween('due_at', [$startDate, $endDate])
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Seller\Crm;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Business;
|
use App\Models\Business;
|
||||||
|
use App\Models\ChatQuickReply;
|
||||||
use App\Models\Crm\CrmChannel;
|
use App\Models\Crm\CrmChannel;
|
||||||
use App\Models\Crm\CrmMessageTemplate;
|
use App\Models\Crm\CrmMessageTemplate;
|
||||||
use App\Models\Crm\CrmPipeline;
|
use App\Models\Crm\CrmPipeline;
|
||||||
@@ -649,4 +650,81 @@ class CrmSettingsController extends Controller
|
|||||||
|
|
||||||
return back()->with('success', 'Role deleted.');
|
return back()->with('success', 'Role deleted.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick replies list
|
||||||
|
*/
|
||||||
|
public function quickReplies(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$quickReplies = ChatQuickReply::where('business_id', $business->id)
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('label')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$categories = $quickReplies->pluck('category')->filter()->unique()->values();
|
||||||
|
|
||||||
|
return view('seller.crm.settings.quick-replies.index', [
|
||||||
|
'business' => $business,
|
||||||
|
'quickReplies' => $quickReplies,
|
||||||
|
'categories' => $categories,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store new quick reply
|
||||||
|
*/
|
||||||
|
public function storeQuickReply(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'label' => 'required|string|max:100',
|
||||||
|
'message' => 'required|string|max:2000',
|
||||||
|
'category' => 'nullable|string|max:50',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['business_id'] = $business->id;
|
||||||
|
$validated['is_active'] = $request->boolean('is_active', true);
|
||||||
|
$validated['sort_order'] = ChatQuickReply::where('business_id', $business->id)->max('sort_order') + 1;
|
||||||
|
|
||||||
|
ChatQuickReply::create($validated);
|
||||||
|
|
||||||
|
return back()->with('success', 'Quick reply created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update quick reply
|
||||||
|
*/
|
||||||
|
public function updateQuickReply(Request $request, Business $business, ChatQuickReply $quickReply)
|
||||||
|
{
|
||||||
|
if ($quickReply->business_id !== $business->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'label' => 'required|string|max:100',
|
||||||
|
'message' => 'required|string|max:2000',
|
||||||
|
'category' => 'nullable|string|max:50',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_active'] = $request->boolean('is_active', true);
|
||||||
|
|
||||||
|
$quickReply->update($validated);
|
||||||
|
|
||||||
|
return back()->with('success', 'Quick reply updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete quick reply
|
||||||
|
*/
|
||||||
|
public function destroyQuickReply(Request $request, Business $business, ChatQuickReply $quickReply)
|
||||||
|
{
|
||||||
|
if ($quickReply->business_id !== $business->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$quickReply->delete();
|
||||||
|
|
||||||
|
return back()->with('success', 'Quick reply deleted.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ class DealController extends Controller
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
// Limit accounts for dropdown - most recent 100
|
// Limit accounts for dropdown - most recent 100
|
||||||
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
|
// Get businesses that have placed orders containing this seller's products
|
||||||
|
$accounts = Business::whereHas('orders', function ($q) use ($business) {
|
||||||
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||||
})
|
})
|
||||||
->select('id', 'name')
|
->select('id', 'name')
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
namespace App\Http\Controllers\Seller\Crm;
|
namespace App\Http\Controllers\Seller\Crm;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Mail\InvoiceMail;
|
||||||
use App\Models\Business;
|
use App\Models\Business;
|
||||||
|
use App\Models\Crm\CrmDeal;
|
||||||
use App\Models\Crm\CrmInvoice;
|
use App\Models\Crm\CrmInvoice;
|
||||||
use App\Models\Crm\CrmInvoiceItem;
|
use App\Models\Crm\CrmInvoiceItem;
|
||||||
use App\Models\Crm\CrmInvoicePayment;
|
use App\Models\Crm\CrmInvoicePayment;
|
||||||
use App\Models\Crm\CrmQuote;
|
use App\Models\Crm\CrmQuote;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
class InvoiceController extends Controller
|
class InvoiceController extends Controller
|
||||||
{
|
{
|
||||||
@@ -42,8 +46,8 @@ class InvoiceController extends Controller
|
|||||||
// Stats - single efficient query with conditional aggregation
|
// Stats - single efficient query with conditional aggregation
|
||||||
$invoiceStats = CrmInvoice::forBusiness($business->id)
|
$invoiceStats = CrmInvoice::forBusiness($business->id)
|
||||||
->selectRaw("
|
->selectRaw("
|
||||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN amount_due ELSE 0 END) as outstanding,
|
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN balance_due ELSE 0 END) as outstanding,
|
||||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN amount_due ELSE 0 END) as overdue
|
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN balance_due ELSE 0 END) as overdue
|
||||||
")
|
")
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ class InvoiceController extends Controller
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$invoice->load(['contact', 'account', 'quote', 'creator', 'items.product', 'payments']);
|
$invoice->load(['contact', 'account', 'quote', 'creator', 'items.product', 'payments.recordedBy']);
|
||||||
|
|
||||||
return view('seller.crm.invoices.show', compact('invoice', 'business'));
|
return view('seller.crm.invoices.show', compact('invoice', 'business'));
|
||||||
}
|
}
|
||||||
@@ -80,23 +84,76 @@ class InvoiceController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function create(Request $request, Business $business)
|
public function create(Request $request, Business $business)
|
||||||
{
|
{
|
||||||
// Limit contacts for dropdown - most recent 100
|
// Get all approved buyer businesses as potential customers (matching quotes)
|
||||||
$contacts = \App\Models\Contact::where('business_id', $business->id)
|
$accounts = Business::where('type', 'buyer')
|
||||||
->select('id', 'first_name', 'last_name', 'email', 'company_name')
|
->where('status', 'approved')
|
||||||
->orderByDesc('updated_at')
|
->with('locations:id,business_id,name,is_primary')
|
||||||
->limit(100)
|
->orderBy('name')
|
||||||
|
->select(['id', 'name', 'slug'])
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
// Get open deals for linking
|
||||||
|
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
||||||
|
|
||||||
// Limit quotes to accepted without invoices
|
// Limit quotes to accepted without invoices
|
||||||
$quotes = CrmQuote::forBusiness($business->id)
|
$quotes = CrmQuote::forBusiness($business->id)
|
||||||
->where('status', CrmQuote::STATUS_ACCEPTED)
|
->where('status', CrmQuote::STATUS_ACCEPTED)
|
||||||
->whereDoesntHave('invoice')
|
->whereDoesntHave('invoice')
|
||||||
->select('id', 'quote_number', 'title', 'total', 'contact_id')
|
->select('id', 'quote_number', 'title', 'total', 'contact_id', 'account_id', 'location_id')
|
||||||
->with('contact:id,first_name,last_name')
|
->with(['contact:id,first_name,last_name', 'items.product'])
|
||||||
->limit(50)
|
->limit(50)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return view('seller.crm.invoices.create', compact('contacts', 'quotes', 'business'));
|
// Transform quotes for Alpine.js (avoid complex closures in Blade @json)
|
||||||
|
$quotesForJs = $quotes->map(fn ($q) => [
|
||||||
|
'id' => $q->id,
|
||||||
|
'account_id' => $q->account_id,
|
||||||
|
'contact_id' => $q->contact_id,
|
||||||
|
'location_id' => $q->location_id,
|
||||||
|
'items' => $q->items->map(fn ($i) => [
|
||||||
|
'product_id' => $i->product_id,
|
||||||
|
'description' => $i->description,
|
||||||
|
'quantity' => $i->quantity,
|
||||||
|
'unit_price' => $i->unit_price,
|
||||||
|
'discount_percent' => $i->discount_percent ?? 0,
|
||||||
|
])->values(),
|
||||||
|
])->values();
|
||||||
|
|
||||||
|
// Pre-fill from URL parameters
|
||||||
|
$selectedAccount = null;
|
||||||
|
$selectedLocation = null;
|
||||||
|
$selectedContact = null;
|
||||||
|
$locationContacts = collect();
|
||||||
|
|
||||||
|
if ($request->filled('account_id')) {
|
||||||
|
$selectedAccount = $accounts->firstWhere('id', $request->account_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('location_id') && $selectedAccount) {
|
||||||
|
$selectedLocation = $selectedAccount->locations->firstWhere('id', $request->location_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill from quote if provided
|
||||||
|
$quote = null;
|
||||||
|
if ($request->filled('quote_id')) {
|
||||||
|
$quote = $quotes->firstWhere('id', $request->quote_id);
|
||||||
|
if ($quote) {
|
||||||
|
$selectedAccount = $accounts->firstWhere('id', $quote->account_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('seller.crm.invoices.create', compact(
|
||||||
|
'accounts',
|
||||||
|
'deals',
|
||||||
|
'quotes',
|
||||||
|
'quotesForJs',
|
||||||
|
'business',
|
||||||
|
'selectedAccount',
|
||||||
|
'selectedLocation',
|
||||||
|
'selectedContact',
|
||||||
|
'locationContacts',
|
||||||
|
'quote'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,21 +165,28 @@ class InvoiceController extends Controller
|
|||||||
'title' => 'required|string|max:255',
|
'title' => 'required|string|max:255',
|
||||||
'contact_id' => 'required|exists:contacts,id',
|
'contact_id' => 'required|exists:contacts,id',
|
||||||
'account_id' => 'nullable|exists:businesses,id',
|
'account_id' => 'nullable|exists:businesses,id',
|
||||||
|
'location_id' => 'nullable|exists:locations,id',
|
||||||
'quote_id' => 'nullable|exists:crm_quotes,id',
|
'quote_id' => 'nullable|exists:crm_quotes,id',
|
||||||
|
'deal_id' => 'nullable|exists:crm_deals,id',
|
||||||
'due_date' => 'required|date|after_or_equal:today',
|
'due_date' => 'required|date|after_or_equal:today',
|
||||||
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
||||||
|
'discount_type' => 'nullable|in:fixed,percentage',
|
||||||
|
'discount_value' => 'nullable|numeric|min:0',
|
||||||
'notes' => 'nullable|string|max:2000',
|
'notes' => 'nullable|string|max:2000',
|
||||||
'payment_terms' => 'nullable|string|max:1000',
|
'payment_terms' => 'nullable|string|max:1000',
|
||||||
'items' => 'required|array|min:1',
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.product_id' => 'nullable|exists:products,id',
|
||||||
'items.*.description' => 'required|string|max:500',
|
'items.*.description' => 'required|string|max:500',
|
||||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||||
'items.*.unit_price' => 'required|numeric|min:0',
|
'items.*.unit_price' => 'required|numeric|min:0',
|
||||||
|
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// SECURITY: Verify contact belongs to business
|
// SECURITY: Verify contact belongs to the account if account is provided
|
||||||
\App\Models\Contact::where('id', $validated['contact_id'])
|
$contact = \App\Models\Contact::findOrFail($validated['contact_id']);
|
||||||
->where('business_id', $business->id)
|
if (! empty($validated['account_id']) && $contact->business_id !== (int) $validated['account_id']) {
|
||||||
->firstOrFail();
|
return back()->withErrors(['contact_id' => 'Contact must belong to the selected account.']);
|
||||||
|
}
|
||||||
|
|
||||||
// SECURITY: Verify quote belongs to business if provided
|
// SECURITY: Verify quote belongs to business if provided
|
||||||
if (! empty($validated['quote_id'])) {
|
if (! empty($validated['quote_id'])) {
|
||||||
@@ -131,22 +195,33 @@ class InvoiceController extends Controller
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SECURITY: Verify deal belongs to business if provided
|
||||||
|
if (! empty($validated['deal_id'])) {
|
||||||
|
CrmDeal::where('id', $validated['deal_id'])
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
$invoiceNumber = CrmInvoice::generateInvoiceNumber($business->id);
|
$invoiceNumber = CrmInvoice::generateInvoiceNumber($business->id);
|
||||||
|
|
||||||
$invoice = CrmInvoice::create([
|
$invoice = CrmInvoice::create([
|
||||||
'business_id' => $business->id,
|
'business_id' => $business->id,
|
||||||
'contact_id' => $validated['contact_id'],
|
'contact_id' => $validated['contact_id'],
|
||||||
'account_id' => $validated['account_id'],
|
'account_id' => $validated['account_id'],
|
||||||
'quote_id' => $validated['quote_id'],
|
'location_id' => $validated['location_id'] ?? null,
|
||||||
|
'quote_id' => $validated['quote_id'] ?? null,
|
||||||
|
'deal_id' => $validated['deal_id'] ?? null,
|
||||||
'created_by' => $request->user()->id,
|
'created_by' => $request->user()->id,
|
||||||
'invoice_number' => $invoiceNumber,
|
'invoice_number' => $invoiceNumber,
|
||||||
'title' => $validated['title'],
|
'title' => $validated['title'],
|
||||||
'status' => CrmInvoice::STATUS_DRAFT,
|
'status' => CrmInvoice::STATUS_DRAFT,
|
||||||
'issue_date' => now(),
|
'invoice_date' => now(),
|
||||||
'due_date' => $validated['due_date'],
|
'due_date' => $validated['due_date'],
|
||||||
'tax_rate' => $validated['tax_rate'] ?? 0,
|
'tax_rate' => $validated['tax_rate'] ?? 0,
|
||||||
|
'discount_type' => $validated['discount_type'],
|
||||||
|
'discount_value' => $validated['discount_value'] ?? 0,
|
||||||
'notes' => $validated['notes'],
|
'notes' => $validated['notes'],
|
||||||
'payment_terms' => $validated['payment_terms'],
|
'terms' => $validated['payment_terms'],
|
||||||
'currency' => 'USD',
|
'currency' => 'USD',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -154,10 +229,12 @@ class InvoiceController extends Controller
|
|||||||
foreach ($validated['items'] as $index => $item) {
|
foreach ($validated['items'] as $index => $item) {
|
||||||
CrmInvoiceItem::create([
|
CrmInvoiceItem::create([
|
||||||
'invoice_id' => $invoice->id,
|
'invoice_id' => $invoice->id,
|
||||||
|
'product_id' => $item['product_id'] ?? null,
|
||||||
'description' => $item['description'],
|
'description' => $item['description'],
|
||||||
'quantity' => $item['quantity'],
|
'quantity' => $item['quantity'],
|
||||||
'unit_price' => $item['unit_price'],
|
'unit_price' => $item['unit_price'],
|
||||||
'sort_order' => $index,
|
'discount_percent' => $item['discount_percent'] ?? 0,
|
||||||
|
'position' => $index,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +244,135 @@ class InvoiceController extends Controller
|
|||||||
->with('success', 'Invoice created successfully.');
|
->with('success', 'Invoice created successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit invoice form
|
||||||
|
*/
|
||||||
|
public function edit(Request $request, Business $business, CrmInvoice $invoice)
|
||||||
|
{
|
||||||
|
if ($invoice->business_id !== $business->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $invoice->canBeEdited()) {
|
||||||
|
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||||
|
->withErrors(['error' => 'This invoice cannot be edited.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice->load(['contact', 'account', 'items.product']);
|
||||||
|
|
||||||
|
// Get all approved buyer businesses
|
||||||
|
$accounts = Business::where('type', 'buyer')
|
||||||
|
->where('status', 'approved')
|
||||||
|
->with('locations:id,business_id,name,is_primary')
|
||||||
|
->orderBy('name')
|
||||||
|
->select(['id', 'name', 'slug'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get open deals for linking
|
||||||
|
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
||||||
|
|
||||||
|
// No quotes dropdown in edit - already linked
|
||||||
|
$quotes = collect();
|
||||||
|
|
||||||
|
$selectedAccount = $invoice->account;
|
||||||
|
$selectedLocation = $invoice->location ?? null;
|
||||||
|
$selectedContact = $invoice->contact;
|
||||||
|
$locationContacts = collect();
|
||||||
|
|
||||||
|
return view('seller.crm.invoices.edit', compact(
|
||||||
|
'invoice',
|
||||||
|
'accounts',
|
||||||
|
'deals',
|
||||||
|
'quotes',
|
||||||
|
'business',
|
||||||
|
'selectedAccount',
|
||||||
|
'selectedLocation',
|
||||||
|
'selectedContact',
|
||||||
|
'locationContacts'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update invoice
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Business $business, CrmInvoice $invoice)
|
||||||
|
{
|
||||||
|
if ($invoice->business_id !== $business->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $invoice->canBeEdited()) {
|
||||||
|
return back()->withErrors(['error' => 'This invoice cannot be edited.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'contact_id' => 'required|exists:contacts,id',
|
||||||
|
'account_id' => 'nullable|exists:businesses,id',
|
||||||
|
'location_id' => 'nullable|exists:locations,id',
|
||||||
|
'deal_id' => 'nullable|exists:crm_deals,id',
|
||||||
|
'due_date' => 'required|date',
|
||||||
|
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
||||||
|
'discount_type' => 'nullable|in:fixed,percentage',
|
||||||
|
'discount_value' => 'nullable|numeric|min:0',
|
||||||
|
'notes' => 'nullable|string|max:2000',
|
||||||
|
'payment_terms' => 'nullable|string|max:1000',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.product_id' => 'nullable|exists:products,id',
|
||||||
|
'items.*.description' => 'required|string|max:500',
|
||||||
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||||
|
'items.*.unit_price' => 'required|numeric|min:0',
|
||||||
|
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// SECURITY: Verify contact belongs to the account if account is provided
|
||||||
|
$contact = \App\Models\Contact::findOrFail($validated['contact_id']);
|
||||||
|
if (! empty($validated['account_id']) && $contact->business_id !== (int) $validated['account_id']) {
|
||||||
|
return back()->withErrors(['contact_id' => 'Contact must belong to the selected account.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: Verify deal belongs to business if provided
|
||||||
|
if (! empty($validated['deal_id'])) {
|
||||||
|
CrmDeal::where('id', $validated['deal_id'])
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice->update([
|
||||||
|
'contact_id' => $validated['contact_id'],
|
||||||
|
'account_id' => $validated['account_id'],
|
||||||
|
'location_id' => $validated['location_id'] ?? null,
|
||||||
|
'deal_id' => $validated['deal_id'] ?? null,
|
||||||
|
'title' => $validated['title'],
|
||||||
|
'due_date' => $validated['due_date'],
|
||||||
|
'tax_rate' => $validated['tax_rate'] ?? 0,
|
||||||
|
'discount_type' => $validated['discount_type'],
|
||||||
|
'discount_value' => $validated['discount_value'] ?? 0,
|
||||||
|
'notes' => $validated['notes'],
|
||||||
|
'terms' => $validated['payment_terms'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delete existing items and recreate
|
||||||
|
$invoice->items()->delete();
|
||||||
|
|
||||||
|
foreach ($validated['items'] as $index => $item) {
|
||||||
|
CrmInvoiceItem::create([
|
||||||
|
'invoice_id' => $invoice->id,
|
||||||
|
'product_id' => $item['product_id'] ?? null,
|
||||||
|
'description' => $item['description'],
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'unit_price' => $item['unit_price'],
|
||||||
|
'discount_percent' => $item['discount_percent'] ?? 0,
|
||||||
|
'position' => $index,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice->calculateTotals();
|
||||||
|
|
||||||
|
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||||
|
->with('success', 'Invoice updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send invoice to contact
|
* Send invoice to contact
|
||||||
*/
|
*/
|
||||||
@@ -180,9 +386,31 @@ class InvoiceController extends Controller
|
|||||||
return back()->withErrors(['error' => 'This invoice cannot be sent.']);
|
return back()->withErrors(['error' => 'This invoice cannot be sent.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$invoice->send($request->user());
|
$validated = $request->validate([
|
||||||
|
'to' => 'required|email',
|
||||||
|
'cc' => 'nullable|string',
|
||||||
|
'message' => 'nullable|string|max:2000',
|
||||||
|
]);
|
||||||
|
|
||||||
// TODO: Send email notification to contact
|
// Generate PDF
|
||||||
|
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
|
||||||
|
$pdf = Pdf::loadView('pdfs.crm-invoice', [
|
||||||
|
'invoice' => $invoice,
|
||||||
|
'business' => $business,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
$ccEmails = [];
|
||||||
|
if (! empty($validated['cc'])) {
|
||||||
|
$ccEmails = array_filter(array_map('trim', explode(',', $validated['cc'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
Mail::to($validated['to'])
|
||||||
|
->cc($ccEmails)
|
||||||
|
->send(new InvoiceMail($invoice, $business, $validated['message'] ?? null, $pdf->output()));
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
$invoice->send($request->user());
|
||||||
|
|
||||||
return back()->with('success', 'Invoice sent successfully.');
|
return back()->with('success', 'Invoice sent successfully.');
|
||||||
}
|
}
|
||||||
@@ -259,8 +487,33 @@ class InvoiceController extends Controller
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Generate PDF
|
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
|
||||||
return back()->with('info', 'PDF generation coming soon.');
|
|
||||||
|
$pdf = Pdf::loadView('pdfs.crm-invoice', [
|
||||||
|
'invoice' => $invoice,
|
||||||
|
'business' => $business,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $pdf->download($invoice->invoice_number.'.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View invoice PDF inline
|
||||||
|
*/
|
||||||
|
public function pdf(Request $request, Business $business, CrmInvoice $invoice)
|
||||||
|
{
|
||||||
|
if ($invoice->business_id !== $business->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
|
||||||
|
|
||||||
|
$pdf = Pdf::loadView('pdfs.crm-invoice', [
|
||||||
|
'invoice' => $invoice,
|
||||||
|
'business' => $business,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $pdf->stream($invoice->invoice_number.'.pdf');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use App\Models\Crm\CrmQuoteItem;
|
|||||||
use App\Models\Location;
|
use App\Models\Location;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\OrderItem;
|
use App\Models\OrderItem;
|
||||||
use App\Models\Product;
|
|
||||||
use App\Services\Accounting\ArService;
|
use App\Services\Accounting\ArService;
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -38,8 +37,8 @@ class QuoteController extends Controller
|
|||||||
|
|
||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$query->where(function ($q) use ($request) {
|
$query->where(function ($q) use ($request) {
|
||||||
$q->where('quote_number', 'like', "%{$request->search}%")
|
$q->where('quote_number', 'ilike', "%{$request->search}%")
|
||||||
->orWhere('title', 'like', "%{$request->search}%");
|
->orWhere('title', 'ilike', "%{$request->search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +173,6 @@ class QuoteController extends Controller
|
|||||||
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
||||||
'terms' => 'nullable|string|max:5000',
|
'terms' => 'nullable|string|max:5000',
|
||||||
'notes' => 'nullable|string|max:2000',
|
'notes' => 'nullable|string|max:2000',
|
||||||
'signature_requested' => 'boolean',
|
|
||||||
'items' => 'required|array|min:1',
|
'items' => 'required|array|min:1',
|
||||||
'items.*.product_id' => 'nullable|exists:products,id',
|
'items.*.product_id' => 'nullable|exists:products,id',
|
||||||
'items.*.description' => 'required|string|max:500',
|
'items.*.description' => 'required|string|max:500',
|
||||||
@@ -210,13 +208,13 @@ class QuoteController extends Controller
|
|||||||
'quote_number' => $quoteNumber,
|
'quote_number' => $quoteNumber,
|
||||||
'title' => $validated['title'],
|
'title' => $validated['title'],
|
||||||
'status' => CrmQuote::STATUS_DRAFT,
|
'status' => CrmQuote::STATUS_DRAFT,
|
||||||
|
'quote_date' => now(),
|
||||||
'valid_until' => $validated['valid_until'] ?? now()->addDays($business->crm_quote_validity_days ?? 30),
|
'valid_until' => $validated['valid_until'] ?? now()->addDays($business->crm_quote_validity_days ?? 30),
|
||||||
'discount_type' => $validated['discount_type'],
|
'discount_type' => $validated['discount_type'],
|
||||||
'discount_value' => $validated['discount_value'],
|
'discount_value' => $validated['discount_value'],
|
||||||
'tax_rate' => $validated['tax_rate'] ?? 0,
|
'tax_rate' => $validated['tax_rate'] ?? 0,
|
||||||
'terms' => $validated['terms'] ?? $business->crm_default_terms,
|
'terms' => $validated['terms'] ?? $business->crm_default_terms,
|
||||||
'notes' => $validated['notes'],
|
'notes' => $validated['notes'],
|
||||||
'signature_requested' => $validated['signature_requested'] ?? false,
|
|
||||||
'currency' => 'USD',
|
'currency' => 'USD',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -248,7 +246,7 @@ class QuoteController extends Controller
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product', 'invoice', 'files']);
|
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product.brand', 'invoice', 'files']);
|
||||||
|
|
||||||
return view('seller.crm.quotes.show', compact('quote', 'business'));
|
return view('seller.crm.quotes.show', compact('quote', 'business'));
|
||||||
}
|
}
|
||||||
@@ -266,16 +264,9 @@ class QuoteController extends Controller
|
|||||||
return back()->withErrors(['error' => 'This quote cannot be edited.']);
|
return back()->withErrors(['error' => 'This quote cannot be edited.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$quote->load('items');
|
$quote->load(['items.product', 'contact', 'account', 'deal']);
|
||||||
|
|
||||||
$contacts = Contact::where('business_id', $business->id)->get();
|
return view('seller.crm.quotes.edit', compact('quote', 'business'));
|
||||||
$accounts = Business::whereHas('ordersAsCustomer')->get();
|
|
||||||
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
|
||||||
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
|
||||||
->where('is_active', true)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return view('seller.crm.quotes.edit', compact('quote', 'contacts', 'accounts', 'deals', 'products', 'business'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -590,7 +581,7 @@ class QuoteController extends Controller
|
|||||||
'sellerBusiness' => $business,
|
'sellerBusiness' => $business,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $pdf->inline("{$quote->quote_number}.pdf");
|
return $pdf->stream("{$quote->quote_number}.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -97,7 +97,19 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function create(Request $request, Business $business)
|
public function create(Request $request, Business $business)
|
||||||
{
|
{
|
||||||
return view('seller.crm.tasks.create', compact('business'));
|
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||||
|
|
||||||
|
// Prefill from query params (when creating task from contact/account/etc)
|
||||||
|
$prefill = [
|
||||||
|
'title' => $request->get('title'),
|
||||||
|
'business_id' => $request->get('business_id'),
|
||||||
|
'contact_id' => $request->get('contact_id'),
|
||||||
|
'opportunity_id' => $request->get('opportunity_id'),
|
||||||
|
'conversation_id' => $request->get('conversation_id'),
|
||||||
|
'order_id' => $request->get('order_id'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('seller.crm.tasks.create', compact('business', 'teamMembers', 'prefill'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Seller\Crm;
|
namespace App\Http\Controllers\Seller\Crm;
|
||||||
|
|
||||||
|
use App\Events\CrmTypingIndicator;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AgentStatus;
|
||||||
use App\Models\Business;
|
use App\Models\Business;
|
||||||
|
use App\Models\ChatQuickReply;
|
||||||
|
use App\Models\Contact;
|
||||||
use App\Models\Crm\CrmActiveView;
|
use App\Models\Crm\CrmActiveView;
|
||||||
use App\Models\Crm\CrmChannel;
|
use App\Models\Crm\CrmChannel;
|
||||||
use App\Models\Crm\CrmInternalNote;
|
use App\Models\Crm\CrmInternalNote;
|
||||||
use App\Models\Crm\CrmThread;
|
use App\Models\Crm\CrmThread;
|
||||||
|
use App\Models\SalesRepAssignment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Crm\CrmAiService;
|
use App\Services\Crm\CrmAiService;
|
||||||
use App\Services\Crm\CrmChannelService;
|
use App\Services\Crm\CrmChannelService;
|
||||||
use App\Services\Crm\CrmSlaService;
|
use App\Services\Crm\CrmSlaService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class ThreadController extends Controller
|
class ThreadController extends Controller
|
||||||
{
|
{
|
||||||
@@ -164,9 +171,9 @@ class ThreadController extends Controller
|
|||||||
|
|
||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$query->where(function ($q) use ($request) {
|
$query->where(function ($q) use ($request) {
|
||||||
$q->where('subject', 'like', "%{$request->search}%")
|
$q->where('subject', 'ilike', "%{$request->search}%")
|
||||||
->orWhere('last_message_preview', 'like', "%{$request->search}%")
|
->orWhere('last_message_preview', 'ilike', "%{$request->search}%")
|
||||||
->orWhereHas('contact', fn ($c) => $c->where('name', 'like', "%{$request->search}%"));
|
->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,4 +453,363 @@ class ThreadController extends Controller
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// API Endpoints for Real-Time Inbox
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Get threads list for real-time updates
|
||||||
|
*/
|
||||||
|
public function apiIndex(Request $request, Business $business): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$query = CrmThread::forBusiness($business->id)
|
||||||
|
->with(['contact:id,first_name,last_name,email,phone', 'assignee:id,name', 'channel:id,type,name', 'account:id,name'])
|
||||||
|
->withCount('messages');
|
||||||
|
|
||||||
|
// Apply "my accounts" filter for sales reps
|
||||||
|
if ($request->boolean('my_accounts')) {
|
||||||
|
$query->forSalesRep($business->id, $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if ($request->filled('status') && $request->status !== 'all') {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel filter
|
||||||
|
if ($request->filled('channel') && $request->channel !== 'all') {
|
||||||
|
$query->where('last_channel_type', $request->channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigned filter
|
||||||
|
if ($request->filled('assigned')) {
|
||||||
|
if ($request->assigned === 'me') {
|
||||||
|
$query->where('assigned_to', $user->id);
|
||||||
|
} elseif ($request->assigned === 'unassigned') {
|
||||||
|
$query->whereNull('assigned_to');
|
||||||
|
} elseif (is_numeric($request->assigned)) {
|
||||||
|
$query->where('assigned_to', $request->assigned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('subject', 'ilike', "%{$search}%")
|
||||||
|
->orWhere('last_message_preview', 'ilike', "%{$search}%")
|
||||||
|
->orWhereHas('contact', fn ($c) => $c->whereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", ["%{$search}%"]))
|
||||||
|
->orWhereHas('account', fn ($a) => $a->where('name', 'ilike', "%{$search}%"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$threads = $query->orderByDesc('last_message_at')
|
||||||
|
->limit($request->input('limit', 50))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'threads' => $threads->map(fn ($t) => [
|
||||||
|
'id' => $t->id,
|
||||||
|
'subject' => $t->subject,
|
||||||
|
'status' => $t->status,
|
||||||
|
'priority' => $t->priority,
|
||||||
|
'is_read' => $t->is_read,
|
||||||
|
'last_message_at' => $t->last_message_at?->toIso8601String(),
|
||||||
|
'last_message_preview' => $t->last_message_preview,
|
||||||
|
'last_message_direction' => $t->last_message_direction,
|
||||||
|
'last_channel_type' => $t->last_channel_type,
|
||||||
|
'contact' => $t->contact ? [
|
||||||
|
'id' => $t->contact->id,
|
||||||
|
'name' => $t->contact->getFullName(),
|
||||||
|
'email' => $t->contact->email,
|
||||||
|
'phone' => $t->contact->phone,
|
||||||
|
] : null,
|
||||||
|
'account' => $t->account ? [
|
||||||
|
'id' => $t->account->id,
|
||||||
|
'name' => $t->account->name,
|
||||||
|
] : null,
|
||||||
|
'assignee' => $t->assignee ? [
|
||||||
|
'id' => $t->assignee->id,
|
||||||
|
'name' => $t->assignee->name,
|
||||||
|
] : null,
|
||||||
|
'messages_count' => $t->messages_count,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Get messages for a thread
|
||||||
|
*/
|
||||||
|
public function apiMessages(Request $request, Business $business, CrmThread $thread): JsonResponse
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $thread->messages()
|
||||||
|
->with(['user:id,name', 'attachments'])
|
||||||
|
->orderBy('created_at', 'asc');
|
||||||
|
|
||||||
|
// Pagination for infinite scroll
|
||||||
|
if ($request->filled('before_id')) {
|
||||||
|
$query->where('id', '<', $request->before_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = $query->limit($request->input('limit', 50))->get();
|
||||||
|
|
||||||
|
// Mark thread as read
|
||||||
|
if ($messages->isNotEmpty()) {
|
||||||
|
$thread->markAsRead($request->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'messages' => $messages->map(fn ($m) => [
|
||||||
|
'id' => $m->id,
|
||||||
|
'body' => $m->body,
|
||||||
|
'body_html' => $m->body_html,
|
||||||
|
'direction' => $m->direction,
|
||||||
|
'channel_type' => $m->channel_type,
|
||||||
|
'sender_id' => $m->user_id,
|
||||||
|
'sender_name' => $m->user?->name ?? ($m->direction === 'inbound' ? $thread->contact?->getFullName() : 'System'),
|
||||||
|
'status' => $m->status,
|
||||||
|
'created_at' => $m->created_at->toIso8601String(),
|
||||||
|
'attachments' => $m->attachments->map(fn ($a) => [
|
||||||
|
'id' => $a->id,
|
||||||
|
'filename' => $a->original_filename ?? $a->filename,
|
||||||
|
'mime_type' => $a->mime_type,
|
||||||
|
'size' => $a->size,
|
||||||
|
'url' => Storage::disk($a->disk ?? 'minio')->url($a->path),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
'has_more' => $messages->count() === $request->input('limit', 50),
|
||||||
|
'thread' => [
|
||||||
|
'id' => $thread->id,
|
||||||
|
'subject' => $thread->subject,
|
||||||
|
'status' => $thread->status,
|
||||||
|
'contact' => $thread->contact ? [
|
||||||
|
'id' => $thread->contact->id,
|
||||||
|
'name' => $thread->contact->getFullName(),
|
||||||
|
'email' => $thread->contact->email,
|
||||||
|
'phone' => $thread->contact->phone,
|
||||||
|
] : null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Send typing indicator
|
||||||
|
*/
|
||||||
|
public function typing(Request $request, Business $business, CrmThread $thread): JsonResponse
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'is_typing' => 'required|boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
broadcast(new CrmTypingIndicator(
|
||||||
|
threadId: $thread->id,
|
||||||
|
userId: $request->user()->id,
|
||||||
|
userName: $request->user()->name,
|
||||||
|
isTyping: $validated['is_typing']
|
||||||
|
))->toOthers();
|
||||||
|
|
||||||
|
// Update active view type
|
||||||
|
CrmActiveView::startViewing(
|
||||||
|
$thread,
|
||||||
|
$request->user(),
|
||||||
|
$validated['is_typing'] ? CrmActiveView::VIEW_TYPE_TYPING : CrmActiveView::VIEW_TYPE_VIEWING
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Get quick replies
|
||||||
|
*/
|
||||||
|
public function quickReplies(Request $request, Business $business): JsonResponse
|
||||||
|
{
|
||||||
|
$quickReplies = ChatQuickReply::where('business_id', $business->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get()
|
||||||
|
->groupBy('category');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'quick_replies' => $quickReplies,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Use a quick reply (increment usage count)
|
||||||
|
*/
|
||||||
|
public function useQuickReply(Request $request, Business $business, ChatQuickReply $quickReply): JsonResponse
|
||||||
|
{
|
||||||
|
if ($quickReply->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment usage count
|
||||||
|
$quickReply->increment('usage_count');
|
||||||
|
|
||||||
|
// Process template variables
|
||||||
|
$message = $quickReply->message;
|
||||||
|
|
||||||
|
if ($request->filled('contact_id')) {
|
||||||
|
$contact = Contact::find($request->contact_id);
|
||||||
|
if ($contact) {
|
||||||
|
$message = str_replace(
|
||||||
|
['{{name}}', '{{first_name}}', '{{last_name}}', '{{company}}'],
|
||||||
|
[$contact->getFullName(), $contact->first_name, $contact->last_name, $contact->business?->name ?? ''],
|
||||||
|
$message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $message,
|
||||||
|
'label' => $quickReply->label,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Get contact details with email engagement
|
||||||
|
*/
|
||||||
|
public function apiContact(Request $request, Business $business, CrmThread $thread): JsonResponse
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = $thread->contact;
|
||||||
|
if (! $contact) {
|
||||||
|
return response()->json(['contact' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent email engagement
|
||||||
|
$emailEngagement = [];
|
||||||
|
if (class_exists(\App\Models\Analytics\EmailInteraction::class)) {
|
||||||
|
$emailEngagement = \App\Models\Analytics\EmailInteraction::where(function ($q) use ($contact) {
|
||||||
|
$q->where('recipient_email', $contact->email);
|
||||||
|
if ($contact->user_id) {
|
||||||
|
$q->orWhere('recipient_user_id', $contact->user_id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->whereNotNull('first_opened_at')
|
||||||
|
->with('emailCampaign:id,subject')
|
||||||
|
->orderByDesc('first_opened_at')
|
||||||
|
->limit(10)
|
||||||
|
->get()
|
||||||
|
->map(fn ($i) => [
|
||||||
|
'id' => $i->id,
|
||||||
|
'campaign_subject' => $i->emailCampaign?->subject ?? 'Unknown Campaign',
|
||||||
|
'opened_at' => $i->first_opened_at?->toIso8601String(),
|
||||||
|
'open_count' => $i->open_count,
|
||||||
|
'clicked_at' => $i->first_clicked_at?->toIso8601String(),
|
||||||
|
'click_count' => $i->click_count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent orders from this contact's account
|
||||||
|
$recentOrders = [];
|
||||||
|
if ($thread->account_id) {
|
||||||
|
$recentOrders = \App\Models\Order::where('business_id', $thread->account_id)
|
||||||
|
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(fn ($o) => [
|
||||||
|
'id' => $o->id,
|
||||||
|
'hashid' => $o->hashid,
|
||||||
|
'total' => $o->total,
|
||||||
|
'status' => $o->status,
|
||||||
|
'created_at' => $o->created_at->toIso8601String(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'contact' => [
|
||||||
|
'id' => $contact->id,
|
||||||
|
'name' => $contact->getFullName(),
|
||||||
|
'email' => $contact->email,
|
||||||
|
'phone' => $contact->phone,
|
||||||
|
'title' => $contact->title,
|
||||||
|
'contact_type' => $contact->contact_type,
|
||||||
|
],
|
||||||
|
'account' => $thread->account ? [
|
||||||
|
'id' => $thread->account->id,
|
||||||
|
'name' => $thread->account->name,
|
||||||
|
'address' => $thread->account->full_address ?? null,
|
||||||
|
] : null,
|
||||||
|
'email_engagement' => $emailEngagement,
|
||||||
|
'recent_orders' => $recentOrders,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified inbox view (Chatwoot-style)
|
||||||
|
*/
|
||||||
|
public function unified(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Get initial threads
|
||||||
|
$query = CrmThread::forBusiness($business->id)
|
||||||
|
->with(['contact:id,first_name,last_name,email,phone', 'assignee:id,name', 'account:id,name'])
|
||||||
|
->withCount('messages')
|
||||||
|
->orderByDesc('last_message_at')
|
||||||
|
->limit(50);
|
||||||
|
|
||||||
|
$threads = $query->get();
|
||||||
|
|
||||||
|
// Get team members with their status
|
||||||
|
$teamMemberStatuses = AgentStatus::where('business_id', $business->id)
|
||||||
|
->where('last_seen_at', '>=', now()->subMinutes(5))
|
||||||
|
->pluck('status', 'user_id');
|
||||||
|
|
||||||
|
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||||
|
->select('id', 'first_name', 'last_name')
|
||||||
|
->get()
|
||||||
|
->map(fn ($member) => [
|
||||||
|
'id' => $member->id,
|
||||||
|
'name' => trim($member->first_name.' '.$member->last_name),
|
||||||
|
'status' => $teamMemberStatuses[$member->id] ?? 'offline',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get agent status
|
||||||
|
$agentStatus = AgentStatus::where('business_id', $business->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// Get quick replies
|
||||||
|
$quickReplies = ChatQuickReply::where('business_id', $business->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->get()
|
||||||
|
->groupBy('category');
|
||||||
|
|
||||||
|
// Get channels
|
||||||
|
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||||
|
|
||||||
|
// Check if user has sales rep assignments (for "My Accounts" filter)
|
||||||
|
$hasSalesRepAssignments = SalesRepAssignment::where('business_id', $business->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
return view('seller.crm.inbox.unified', compact(
|
||||||
|
'business',
|
||||||
|
'threads',
|
||||||
|
'teamMembers',
|
||||||
|
'agentStatus',
|
||||||
|
'quickReplies',
|
||||||
|
'channels',
|
||||||
|
'hasSalesRepAssignments'
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ class ApVendorsController extends Controller
|
|||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'ilike', "%{$search}%")
|
||||||
->orWhere('code', 'like', "%{$search}%")
|
->orWhere('code', 'ilike', "%{$search}%")
|
||||||
->orWhere('contact_email', 'like', "%{$search}%");
|
->orWhere('contact_email', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ class ApVendorsController extends Controller
|
|||||||
|
|
||||||
// Check for uniqueness
|
// Check for uniqueness
|
||||||
$count = ApVendor::where('business_id', $businessId)
|
$count = ApVendor::where('business_id', $businessId)
|
||||||
->where('code', 'like', "{$prefix}%")
|
->where('code', 'ilike', "{$prefix}%")
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
|
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ class ChartOfAccountsController extends Controller
|
|||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('account_number', 'like', "%{$search}%")
|
$q->where('account_number', 'ilike', "%{$search}%")
|
||||||
->orWhere('name', 'like', "%{$search}%");
|
->orWhere('name', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ class RequisitionsApprovalController extends Controller
|
|||||||
// Search
|
// Search
|
||||||
if ($search = $request->get('search')) {
|
if ($search = $request->get('search')) {
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('requisition_number', 'like', "%{$search}%")
|
$q->where('requisition_number', 'ilike', "%{$search}%")
|
||||||
->orWhere('notes', 'like', "%{$search}%");
|
->orWhere('notes', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class BiomassController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$query->where('lot_number', 'like', '%'.$request->search.'%');
|
$query->where('lot_number', 'ilike', '%'.$request->search.'%');
|
||||||
}
|
}
|
||||||
|
|
||||||
$biomassLots = $query->paginate(25);
|
$biomassLots = $query->paginate(25);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class MaterialLotController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$query->where('lot_number', 'like', '%'.$request->search.'%');
|
$query->where('lot_number', 'ilike', '%'.$request->search.'%');
|
||||||
}
|
}
|
||||||
|
|
||||||
$materialLots = $query->paginate(25);
|
$materialLots = $query->paginate(25);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ProcessingSalesOrderController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$query->where('order_number', 'like', '%'.$request->search.'%');
|
$query->where('order_number', 'ilike', '%'.$request->search.'%');
|
||||||
}
|
}
|
||||||
|
|
||||||
$salesOrders = $query->paginate(25);
|
$salesOrders = $query->paginate(25);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class ProcessingShipmentController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$query->where('shipment_number', 'like', '%'.$request->search.'%');
|
$query->where('shipment_number', 'ilike', '%'.$request->search.'%');
|
||||||
}
|
}
|
||||||
|
|
||||||
$shipments = $query->paginate(25);
|
$shipments = $query->paginate(25);
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ class ProductController extends Controller
|
|||||||
// Get brand IDs to filter by (respects brand context switcher)
|
// Get brand IDs to filter by (respects brand context switcher)
|
||||||
$brandIds = BrandSwitcherController::getFilteredBrandIds();
|
$brandIds = BrandSwitcherController::getFilteredBrandIds();
|
||||||
|
|
||||||
// Get all brands for the business for the filter dropdown
|
// Get all brands for the business for the filter dropdown and new product button
|
||||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'name']);
|
->get(['id', 'name', 'hashid', 'logo_path', 'slug', 'updated_at']);
|
||||||
|
|
||||||
// Calculate missing BOM count for health alert
|
// Calculate missing BOM count for health alert
|
||||||
$missingBomCount = Product::whereIn('brand_id', $brandIds)
|
$missingBomCount = Product::whereIn('brand_id', $brandIds)
|
||||||
@@ -881,9 +881,9 @@ class ProductController extends Controller
|
|||||||
'content' => [
|
'content' => [
|
||||||
'description' => ['nullable', 'string', 'max:255'],
|
'description' => ['nullable', 'string', 'max:255'],
|
||||||
'tagline' => ['nullable', 'string', 'max:100'],
|
'tagline' => ['nullable', 'string', 'max:100'],
|
||||||
'long_description' => ['nullable', 'string', 'max:500'],
|
'long_description' => ['nullable', 'string'],
|
||||||
'consumer_long_description' => ['nullable', 'string', 'max:500'],
|
'consumer_long_description' => ['nullable', 'string'],
|
||||||
'buyer_long_description' => ['nullable', 'string', 'max:500'],
|
'buyer_long_description' => ['nullable', 'string'],
|
||||||
'product_link' => 'nullable|url|max:255',
|
'product_link' => 'nullable|url|max:255',
|
||||||
'creatives_json' => 'nullable|json',
|
'creatives_json' => 'nullable|json',
|
||||||
'seo_title' => ['nullable', 'string', 'max:70'],
|
'seo_title' => ['nullable', 'string', 'max:70'],
|
||||||
@@ -923,10 +923,10 @@ class ProductController extends Controller
|
|||||||
|
|
||||||
// Define checkbox fields per tab
|
// Define checkbox fields per tab
|
||||||
$checkboxesByTab = [
|
$checkboxesByTab = [
|
||||||
'overview' => ['is_active', 'is_featured', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
|
'overview' => ['is_active', 'is_featured', 'is_sellable', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
|
||||||
'pricing' => ['is_case', 'is_box'],
|
'pricing' => ['is_case', 'is_box'],
|
||||||
'inventory' => ['sync_bamboo', 'low_stock_alert_enabled', 'is_assembly', 'show_inventory_to_buyers', 'has_varieties'],
|
'inventory' => ['sync_bamboo', 'low_stock_alert_enabled', 'is_assembly', 'show_inventory_to_buyers', 'has_varieties'],
|
||||||
'advanced' => ['is_sellable', 'is_fpr', 'is_raw_material'],
|
'advanced' => ['is_fpr', 'is_raw_material'],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Convert checkboxes to boolean - only for fields in current validation scope
|
// Convert checkboxes to boolean - only for fields in current validation scope
|
||||||
@@ -938,7 +938,7 @@ class ProductController extends Controller
|
|||||||
if (array_key_exists($checkbox, $rules)) {
|
if (array_key_exists($checkbox, $rules)) {
|
||||||
// Use boolean() for fields that send actual values (hidden inputs with 0/1)
|
// Use boolean() for fields that send actual values (hidden inputs with 0/1)
|
||||||
// Use has() for traditional checkboxes that are absent when unchecked
|
// Use has() for traditional checkboxes that are absent when unchecked
|
||||||
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'low_stock_alert_enabled', 'has_varieties']);
|
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'is_sellable', 'low_stock_alert_enabled', 'has_varieties']);
|
||||||
$validated[$checkbox] = $useBoolean
|
$validated[$checkbox] = $useBoolean
|
||||||
? $request->boolean($checkbox)
|
? $request->boolean($checkbox)
|
||||||
: $request->has($checkbox);
|
: $request->has($checkbox);
|
||||||
|
|||||||
@@ -29,16 +29,25 @@ class ProductImageController extends Controller
|
|||||||
'image' => 'required|image|mimes:jpeg,jpg,png|max:2048|dimensions:min_width=750,min_height=384', // 2MB max, 750x384 min
|
'image' => 'required|image|mimes:jpeg,jpg,png|max:2048|dimensions:min_width=750,min_height=384', // 2MB max, 750x384 min
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check if product already has 8 images
|
// Check if product already has 6 images
|
||||||
if ($product->images()->count() >= 8) {
|
if ($product->images()->count() >= 6) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Maximum of 8 images allowed per product',
|
'message' => 'Maximum of 6 images allowed per product',
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the image using trait method
|
// Build proper storage path: businesses/{business_slug}/brands/{brand_slug}/products/{sku}/images/
|
||||||
$path = $this->storeFile($request->file('image'), 'products');
|
$brand = $product->brand;
|
||||||
|
$storagePath = sprintf(
|
||||||
|
'businesses/%s/brands/%s/products/%s/images',
|
||||||
|
$business->slug,
|
||||||
|
$brand->slug,
|
||||||
|
$product->sku
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the image with proper path
|
||||||
|
$path = $this->storeFile($request->file('image'), $storagePath);
|
||||||
|
|
||||||
// Determine if this should be the primary image (first one)
|
// Determine if this should be the primary image (first one)
|
||||||
$isPrimary = $product->images()->count() === 0;
|
$isPrimary = $product->images()->count() === 0;
|
||||||
@@ -61,6 +70,8 @@ class ProductImageController extends Controller
|
|||||||
'id' => $image->id,
|
'id' => $image->id,
|
||||||
'path' => $image->path,
|
'path' => $image->path,
|
||||||
'is_primary' => $image->is_primary,
|
'is_primary' => $image->is_primary,
|
||||||
|
'url' => route('image.product', ['product' => $product->hashid, 'width' => 400]),
|
||||||
|
'thumb_url' => route('image.product', ['product' => $product->hashid, 'width' => 80]),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,17 +16,32 @@ class PromotionController extends Controller
|
|||||||
protected PromoCalculator $promoCalculator
|
protected PromoCalculator $promoCalculator
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Business $business)
|
public function index(Request $request, Business $business)
|
||||||
{
|
{
|
||||||
// TODO: Future deprecation - This global promotions page will be replaced by brand-scoped promotions
|
// TODO: Future deprecation - This global promotions page will be replaced by brand-scoped promotions
|
||||||
// Once brand-scoped promotions are stable and rolled out, this route should redirect to:
|
// Once brand-scoped promotions are stable and rolled out, this route should redirect to:
|
||||||
// return redirect()->route('seller.business.brands.promotions.index', [$business, $defaultBrand]);
|
// return redirect()->route('seller.business.brands.promotions.index', [$business, $defaultBrand]);
|
||||||
// Where $defaultBrand is determined by business context or user preference
|
// Where $defaultBrand is determined by business context or user preference
|
||||||
|
|
||||||
$promotions = Promotion::where('business_id', $business->id)
|
// Get brands for filter dropdown
|
||||||
->withCount('products')
|
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('name')
|
||||||
->get();
|
->get(['id', 'name', 'hashid']);
|
||||||
|
|
||||||
|
$query = Promotion::where('business_id', $business->id)
|
||||||
|
->withCount('products');
|
||||||
|
|
||||||
|
// Filter by brand
|
||||||
|
if ($request->filled('brand')) {
|
||||||
|
$query->where('brand_id', $request->brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$promotions = $query->orderBy('created_at', 'desc')->get();
|
||||||
|
|
||||||
// Load pending recommendations with product data
|
// Load pending recommendations with product data
|
||||||
// Gracefully handle if promo_recommendations table doesn't exist yet
|
// Gracefully handle if promo_recommendations table doesn't exist yet
|
||||||
@@ -41,7 +56,7 @@ class PromotionController extends Controller
|
|||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('seller.promotions.index', compact('business', 'promotions', 'recommendations'));
|
return view('seller.promotions.index', compact('business', 'promotions', 'recommendations', 'brands'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(Business $business)
|
public function create(Business $business)
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ class RequisitionsController extends Controller
|
|||||||
|
|
||||||
if ($search = $request->get('search')) {
|
if ($search = $request->get('search')) {
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('requisition_number', 'like', "%{$search}%")
|
$q->where('requisition_number', 'ilike', "%{$search}%")
|
||||||
->orWhere('notes', 'like', "%{$search}%");
|
->orWhere('notes', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
274
app/Http/Controllers/Seller/Sales/AccountsController.php
Normal file
274
app/Http/Controllers/Seller/Sales/AccountsController.php
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller\Sales;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AccountNote;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\SalesRepAssignment;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AccountsController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* My Accounts - list of accounts assigned to this sales rep
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Get account assignments with eager loading
|
||||||
|
$assignments = SalesRepAssignment::forBusiness($business->id)
|
||||||
|
->forUser($user->id)
|
||||||
|
->accounts()
|
||||||
|
->with(['assignable', 'assignable.locations', 'assignable.contacts'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get account metrics in batch
|
||||||
|
$accountIds = $assignments->pluck('assignable_id');
|
||||||
|
$metrics = $this->getAccountMetrics($accountIds);
|
||||||
|
|
||||||
|
// Build account list with metrics
|
||||||
|
$accounts = $assignments->map(function ($assignment) use ($metrics) {
|
||||||
|
$accountId = $assignment->assignable_id;
|
||||||
|
$accountMetrics = $metrics[$accountId] ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'assignment' => $assignment,
|
||||||
|
'account' => $assignment->assignable,
|
||||||
|
'metrics' => $accountMetrics,
|
||||||
|
'health' => $this->calculateHealth($accountMetrics),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
$statusFilter = $request->get('status');
|
||||||
|
if ($statusFilter) {
|
||||||
|
$accounts = $accounts->filter(fn ($a) => $a['health']['status'] === $statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by health priority (at_risk first)
|
||||||
|
$accounts = $accounts->sortBy(fn ($a) => $a['health']['priority'])->values();
|
||||||
|
|
||||||
|
return view('seller.sales.accounts.index', compact('business', 'accounts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show account detail with full history
|
||||||
|
*/
|
||||||
|
public function show(Request $request, Business $business, Business $account)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Verify user is assigned to this account
|
||||||
|
$assignment = SalesRepAssignment::forBusiness($business->id)
|
||||||
|
->forUser($user->id)
|
||||||
|
->where('assignable_type', Business::class)
|
||||||
|
->where('assignable_id', $account->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $assignment) {
|
||||||
|
abort(403, 'You are not assigned to this account.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get account details with relationships
|
||||||
|
$account->load(['locations', 'contacts']);
|
||||||
|
|
||||||
|
// Get order history
|
||||||
|
$orders = Order::where('business_id', $account->id)
|
||||||
|
->with(['items.product.brand'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(20)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get account notes
|
||||||
|
$notes = AccountNote::forBusiness($business->id)
|
||||||
|
->forAccount($account->id)
|
||||||
|
->with('author')
|
||||||
|
->orderByDesc('is_pinned')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get account metrics
|
||||||
|
$metrics = $this->getAccountDetailMetrics($account);
|
||||||
|
|
||||||
|
return view('seller.sales.accounts.show', compact(
|
||||||
|
'business',
|
||||||
|
'account',
|
||||||
|
'assignment',
|
||||||
|
'orders',
|
||||||
|
'notes',
|
||||||
|
'metrics'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new account note
|
||||||
|
*/
|
||||||
|
public function storeNote(Request $request, Business $business, Business $account)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'note_type' => 'required|in:general,competitor,pain_point,opportunity,objection',
|
||||||
|
'content' => 'required|string|max:5000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$note = AccountNote::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'note_type' => $validated['note_type'],
|
||||||
|
'content' => $validated['content'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Note added successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle note pinned status
|
||||||
|
*/
|
||||||
|
public function toggleNotePin(Request $request, Business $business, AccountNote $note)
|
||||||
|
{
|
||||||
|
// Verify note belongs to this business
|
||||||
|
if ($note->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$note->is_pinned = ! $note->is_pinned;
|
||||||
|
$note->save();
|
||||||
|
|
||||||
|
return back()->with('success', $note->is_pinned ? 'Note pinned.' : 'Note unpinned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an account note
|
||||||
|
*/
|
||||||
|
public function destroyNote(Request $request, Business $business, AccountNote $note)
|
||||||
|
{
|
||||||
|
// Verify note belongs to this business
|
||||||
|
if ($note->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow deletion by author or admin
|
||||||
|
if ($note->user_id !== $request->user()->id && ! $request->user()->isAdmin()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$note->delete();
|
||||||
|
|
||||||
|
return back()->with('success', 'Note deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get batch metrics for multiple accounts
|
||||||
|
*/
|
||||||
|
protected function getAccountMetrics($accountIds): array
|
||||||
|
{
|
||||||
|
if ($accountIds->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$metrics = Order::whereIn('business_id', $accountIds)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->groupBy('business_id')
|
||||||
|
->selectRaw('
|
||||||
|
business_id,
|
||||||
|
COUNT(*) as order_count,
|
||||||
|
SUM(total) as total_revenue,
|
||||||
|
MAX(created_at) as last_order_date
|
||||||
|
')
|
||||||
|
->get()
|
||||||
|
->keyBy('business_id');
|
||||||
|
|
||||||
|
// Get 4-week rolling revenue
|
||||||
|
$recentRevenue = Order::whereIn('business_id', $accountIds)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->where('created_at', '>=', now()->subWeeks(4))
|
||||||
|
->groupBy('business_id')
|
||||||
|
->selectRaw('business_id, SUM(total) as four_week_revenue')
|
||||||
|
->pluck('four_week_revenue', 'business_id');
|
||||||
|
|
||||||
|
return $accountIds->mapWithKeys(function ($id) use ($metrics, $recentRevenue) {
|
||||||
|
$m = $metrics[$id] ?? null;
|
||||||
|
|
||||||
|
return [$id => [
|
||||||
|
'order_count' => $m?->order_count ?? 0,
|
||||||
|
'total_revenue' => ($m?->total_revenue ?? 0) / 100,
|
||||||
|
'four_week_revenue' => ($recentRevenue[$id] ?? 0) / 100,
|
||||||
|
'last_order_date' => $m?->last_order_date,
|
||||||
|
'days_since_order' => $m?->last_order_date
|
||||||
|
? now()->diffInDays($m->last_order_date)
|
||||||
|
: null,
|
||||||
|
]];
|
||||||
|
})->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed metrics for a single account
|
||||||
|
*/
|
||||||
|
protected function getAccountDetailMetrics(Business $account): array
|
||||||
|
{
|
||||||
|
$orders = Order::where('business_id', $account->id)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($orders->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'lifetime_revenue' => 0,
|
||||||
|
'lifetime_orders' => 0,
|
||||||
|
'avg_order_value' => 0,
|
||||||
|
'four_week_revenue' => 0,
|
||||||
|
'last_order_date' => null,
|
||||||
|
'days_since_order' => null,
|
||||||
|
'avg_order_interval' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$lifetime = $orders->sum('total') / 100;
|
||||||
|
$recentOrders = $orders->where('created_at', '>=', now()->subWeeks(4));
|
||||||
|
$fourWeekRevenue = $recentOrders->sum('total') / 100;
|
||||||
|
|
||||||
|
// Calculate average order interval
|
||||||
|
$sortedDates = $orders->pluck('created_at')->sort()->values();
|
||||||
|
$intervals = [];
|
||||||
|
for ($i = 1; $i < $sortedDates->count(); $i++) {
|
||||||
|
$intervals[] = $sortedDates[$i]->diffInDays($sortedDates[$i - 1]);
|
||||||
|
}
|
||||||
|
$avgInterval = count($intervals) > 0 ? array_sum($intervals) / count($intervals) : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lifetime_revenue' => $lifetime,
|
||||||
|
'lifetime_orders' => $orders->count(),
|
||||||
|
'avg_order_value' => $orders->count() > 0 ? $lifetime / $orders->count() : 0,
|
||||||
|
'four_week_revenue' => $fourWeekRevenue,
|
||||||
|
'last_order_date' => $orders->max('created_at'),
|
||||||
|
'days_since_order' => $orders->max('created_at')
|
||||||
|
? now()->diffInDays($orders->max('created_at'))
|
||||||
|
: null,
|
||||||
|
'avg_order_interval' => $avgInterval ? round($avgInterval) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate health status
|
||||||
|
*/
|
||||||
|
protected function calculateHealth(array $metrics): array
|
||||||
|
{
|
||||||
|
$days = $metrics['days_since_order'] ?? null;
|
||||||
|
|
||||||
|
if ($days === null) {
|
||||||
|
return ['status' => 'new', 'label' => 'New', 'color' => 'info', 'priority' => 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($days >= 60) {
|
||||||
|
return ['status' => 'at_risk', 'label' => 'At Risk', 'color' => 'error', 'priority' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($days >= 30) {
|
||||||
|
return ['status' => 'needs_attention', 'label' => 'Needs Attention', 'color' => 'warning', 'priority' => 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 'healthy', 'label' => 'Healthy', 'color' => 'success', 'priority' => 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
261
app/Http/Controllers/Seller/Sales/CommissionController.php
Normal file
261
app/Http/Controllers/Seller/Sales/CommissionController.php
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller\Sales;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\SalesCommission;
|
||||||
|
use App\Models\SalesCommissionRate;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CommissionController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Commission dashboard for sales rep - see their earnings
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Get commission summary
|
||||||
|
$summary = $this->getCommissionSummary($business, $user);
|
||||||
|
|
||||||
|
// Get recent commissions
|
||||||
|
$commissions = SalesCommission::forBusiness($business->id)
|
||||||
|
->forUser($user->id)
|
||||||
|
->with(['order', 'order.business'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
// Get commission rates for this user
|
||||||
|
$rates = SalesCommissionRate::forBusiness($business->id)
|
||||||
|
->where(function ($q) use ($user) {
|
||||||
|
$q->whereNull('user_id')
|
||||||
|
->orWhere('user_id', $user->id);
|
||||||
|
})
|
||||||
|
->active()
|
||||||
|
->effective()
|
||||||
|
->orderBy('rate_type')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('seller.sales.commissions.index', compact(
|
||||||
|
'business',
|
||||||
|
'summary',
|
||||||
|
'commissions',
|
||||||
|
'rates'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin view - manage all commission rates and approve commissions
|
||||||
|
*/
|
||||||
|
public function manage(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
// Get pending commissions
|
||||||
|
$pendingCommissions = SalesCommission::forBusiness($business->id)
|
||||||
|
->pending()
|
||||||
|
->with(['salesRep', 'order', 'order.business'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get approved commissions ready for payment
|
||||||
|
$approvedCommissions = SalesCommission::forBusiness($business->id)
|
||||||
|
->approved()
|
||||||
|
->with(['salesRep', 'order'])
|
||||||
|
->orderByDesc('approved_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get commission rates
|
||||||
|
$rates = SalesCommissionRate::forBusiness($business->id)
|
||||||
|
->with('user')
|
||||||
|
->orderBy('rate_type')
|
||||||
|
->orderBy('user_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get sales reps for rate assignment
|
||||||
|
$salesReps = $this->getAvailableSalesReps($business);
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
$stats = [
|
||||||
|
'pending_count' => $pendingCommissions->count(),
|
||||||
|
'pending_total' => $pendingCommissions->sum('commission_amount') / 100,
|
||||||
|
'approved_count' => $approvedCommissions->count(),
|
||||||
|
'approved_total' => $approvedCommissions->sum('commission_amount') / 100,
|
||||||
|
'paid_this_month' => SalesCommission::forBusiness($business->id)
|
||||||
|
->paid()
|
||||||
|
->whereMonth('paid_at', now()->month)
|
||||||
|
->whereYear('paid_at', now()->year)
|
||||||
|
->sum('commission_amount') / 100,
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('seller.sales.commissions.manage', compact(
|
||||||
|
'business',
|
||||||
|
'pendingCommissions',
|
||||||
|
'approvedCommissions',
|
||||||
|
'rates',
|
||||||
|
'salesReps',
|
||||||
|
'stats'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a commission
|
||||||
|
*/
|
||||||
|
public function approve(Request $request, Business $business, SalesCommission $commission)
|
||||||
|
{
|
||||||
|
if ($commission->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$commission->approve($request->user());
|
||||||
|
|
||||||
|
return back()->with('success', 'Commission approved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk approve commissions
|
||||||
|
*/
|
||||||
|
public function bulkApprove(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'commission_ids' => 'required|array',
|
||||||
|
'commission_ids.*' => 'exists:sales_commissions,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count = SalesCommission::forBusiness($business->id)
|
||||||
|
->whereIn('id', $validated['commission_ids'])
|
||||||
|
->pending()
|
||||||
|
->update([
|
||||||
|
'status' => SalesCommission::STATUS_APPROVED,
|
||||||
|
'approved_at' => now(),
|
||||||
|
'approved_by' => $request->user()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', "{$count} commissions approved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark commissions as paid
|
||||||
|
*/
|
||||||
|
public function markPaid(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'commission_ids' => 'required|array',
|
||||||
|
'commission_ids.*' => 'exists:sales_commissions,id',
|
||||||
|
'payment_reference' => 'nullable|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count = SalesCommission::forBusiness($business->id)
|
||||||
|
->whereIn('id', $validated['commission_ids'])
|
||||||
|
->approved()
|
||||||
|
->update([
|
||||||
|
'status' => SalesCommission::STATUS_PAID,
|
||||||
|
'paid_at' => now(),
|
||||||
|
'payment_reference' => $validated['payment_reference'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', "{$count} commissions marked as paid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new commission rate
|
||||||
|
*/
|
||||||
|
public function storeRate(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'user_id' => 'nullable|exists:users,id',
|
||||||
|
'rate_type' => 'required|in:default,account,product,brand',
|
||||||
|
'commission_percent' => 'required|numeric|min:0|max:100',
|
||||||
|
'effective_from' => 'required|date',
|
||||||
|
'effective_to' => 'nullable|date|after:effective_from',
|
||||||
|
]);
|
||||||
|
|
||||||
|
SalesCommissionRate::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'user_id' => $validated['user_id'],
|
||||||
|
'rate_type' => $validated['rate_type'],
|
||||||
|
'commission_percent' => $validated['commission_percent'],
|
||||||
|
'effective_from' => $validated['effective_from'],
|
||||||
|
'effective_to' => $validated['effective_to'] ?? null,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Commission rate created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a commission rate
|
||||||
|
*/
|
||||||
|
public function updateRate(Request $request, Business $business, SalesCommissionRate $rate)
|
||||||
|
{
|
||||||
|
if ($rate->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'commission_percent' => 'required|numeric|min:0|max:100',
|
||||||
|
'effective_to' => 'nullable|date',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rate->update([
|
||||||
|
'commission_percent' => $validated['commission_percent'],
|
||||||
|
'effective_to' => $validated['effective_to'] ?? null,
|
||||||
|
'is_active' => $validated['is_active'] ?? true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Commission rate updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a commission rate
|
||||||
|
*/
|
||||||
|
public function destroyRate(Request $request, Business $business, SalesCommissionRate $rate)
|
||||||
|
{
|
||||||
|
if ($rate->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't delete if commissions reference this rate
|
||||||
|
if ($rate->commissions()->exists()) {
|
||||||
|
$rate->update(['is_active' => false]);
|
||||||
|
|
||||||
|
return back()->with('warning', 'Rate deactivated (has existing commissions).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rate->delete();
|
||||||
|
|
||||||
|
return back()->with('success', 'Commission rate deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commission summary for a user
|
||||||
|
*/
|
||||||
|
protected function getCommissionSummary(Business $business, User $user): array
|
||||||
|
{
|
||||||
|
$baseQuery = SalesCommission::forBusiness($business->id)->forUser($user->id);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pending' => (clone $baseQuery)->pending()->sum('commission_amount') / 100,
|
||||||
|
'approved' => (clone $baseQuery)->approved()->sum('commission_amount') / 100,
|
||||||
|
'paid_this_month' => (clone $baseQuery)
|
||||||
|
->paid()
|
||||||
|
->whereMonth('paid_at', now()->month)
|
||||||
|
->whereYear('paid_at', now()->year)
|
||||||
|
->sum('commission_amount') / 100,
|
||||||
|
'paid_total' => (clone $baseQuery)->paid()->sum('commission_amount') / 100,
|
||||||
|
'total_orders' => (clone $baseQuery)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available sales reps for this business
|
||||||
|
*/
|
||||||
|
protected function getAvailableSalesReps(Business $business)
|
||||||
|
{
|
||||||
|
return User::whereHas('businesses', function ($q) use ($business) {
|
||||||
|
$q->where('businesses.id', $business->id);
|
||||||
|
})->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/Http/Controllers/Seller/Sales/CompetitorController.php
Normal file
153
app/Http/Controllers/Seller/Sales/CompetitorController.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller\Sales;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\CompetitorReplacement;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CompetitorController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List competitor replacements - when you see competitor X, pitch product Y
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$replacements = CompetitorReplacement::forBusiness($business->id)
|
||||||
|
->with(['product', 'product.brand', 'creator'])
|
||||||
|
->orderBy('competitor_name')
|
||||||
|
->get()
|
||||||
|
->groupBy('competitor_name');
|
||||||
|
|
||||||
|
// Get products for the add form
|
||||||
|
$products = Product::whereHas('brand', function ($q) use ($business) {
|
||||||
|
$q->where('business_id', $business->id);
|
||||||
|
})
|
||||||
|
->with('brand')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get unique competitor names for filtering
|
||||||
|
$competitors = CompetitorReplacement::forBusiness($business->id)
|
||||||
|
->distinct()
|
||||||
|
->pluck('competitor_name')
|
||||||
|
->sort();
|
||||||
|
|
||||||
|
return view('seller.sales.competitors.index', compact(
|
||||||
|
'business',
|
||||||
|
'replacements',
|
||||||
|
'products',
|
||||||
|
'competitors'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new competitor replacement mapping
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'competitor_name' => 'required|string|max:255',
|
||||||
|
'competitor_product_name' => 'nullable|string|max:255',
|
||||||
|
'cannaiq_product_id' => 'nullable|string|max:255',
|
||||||
|
'product_id' => 'required|exists:products,id',
|
||||||
|
'advantage_notes' => 'nullable|string|max:2000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify product belongs to this business
|
||||||
|
$product = Product::whereHas('brand', function ($q) use ($business) {
|
||||||
|
$q->where('business_id', $business->id);
|
||||||
|
})->findOrFail($validated['product_id']);
|
||||||
|
|
||||||
|
CompetitorReplacement::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'cannaiq_product_id' => $validated['cannaiq_product_id'] ?? uniqid('manual_'),
|
||||||
|
'competitor_name' => $validated['competitor_name'],
|
||||||
|
'competitor_product_name' => $validated['competitor_product_name'],
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'advantage_notes' => $validated['advantage_notes'],
|
||||||
|
'created_by' => $request->user()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Competitor replacement added.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a competitor replacement
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Business $business, CompetitorReplacement $replacement)
|
||||||
|
{
|
||||||
|
if ($replacement->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'competitor_product_name' => 'nullable|string|max:255',
|
||||||
|
'product_id' => 'required|exists:products,id',
|
||||||
|
'advantage_notes' => 'nullable|string|max:2000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify product belongs to this business
|
||||||
|
$product = Product::whereHas('brand', function ($q) use ($business) {
|
||||||
|
$q->where('business_id', $business->id);
|
||||||
|
})->findOrFail($validated['product_id']);
|
||||||
|
|
||||||
|
$replacement->update([
|
||||||
|
'competitor_product_name' => $validated['competitor_product_name'],
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'advantage_notes' => $validated['advantage_notes'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Replacement updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a competitor replacement
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, Business $business, CompetitorReplacement $replacement)
|
||||||
|
{
|
||||||
|
if ($replacement->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$replacement->delete();
|
||||||
|
|
||||||
|
return back()->with('success', 'Replacement deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick lookup - get our replacement for a competitor product
|
||||||
|
*/
|
||||||
|
public function lookup(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$competitorName = $request->get('competitor');
|
||||||
|
$productName = $request->get('product');
|
||||||
|
|
||||||
|
$query = CompetitorReplacement::forBusiness($business->id)
|
||||||
|
->with(['product', 'product.brand']);
|
||||||
|
|
||||||
|
if ($competitorName) {
|
||||||
|
$query->where('competitor_name', 'ILIKE', "%{$competitorName}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($productName) {
|
||||||
|
$query->where('competitor_product_name', 'ILIKE', "%{$productName}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
$replacements = $query->limit(10)->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'replacements' => $replacements->map(fn ($r) => [
|
||||||
|
'id' => $r->id,
|
||||||
|
'competitor' => $r->competitor_name,
|
||||||
|
'competitor_product' => $r->competitor_product_name,
|
||||||
|
'our_product' => $r->product->name,
|
||||||
|
'our_sku' => $r->product->sku,
|
||||||
|
'advantage' => $r->advantage_notes,
|
||||||
|
'pitch' => $r->getPitchSummary(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
285
app/Http/Controllers/Seller/Sales/DashboardController.php
Normal file
285
app/Http/Controllers/Seller/Sales/DashboardController.php
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller\Sales;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\SalesRepAssignment;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Sales Rep Dashboard - My Accounts with health status
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Cache dashboard data for 5 minutes
|
||||||
|
$cacheKey = "sales_dashboard_{$business->id}_{$user->id}";
|
||||||
|
|
||||||
|
$data = Cache::remember($cacheKey, 300, function () use ($business, $user) {
|
||||||
|
return $this->getDashboardData($business, $user);
|
||||||
|
});
|
||||||
|
|
||||||
|
$data['business'] = $business;
|
||||||
|
|
||||||
|
return view('seller.sales.dashboard.index', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* My Accounts view - accounts assigned to this sales rep
|
||||||
|
*/
|
||||||
|
public function myAccounts(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Get account assignments for this user
|
||||||
|
$assignments = SalesRepAssignment::forBusiness($business->id)
|
||||||
|
->forUser($user->id)
|
||||||
|
->accounts()
|
||||||
|
->with(['assignable', 'assignable.locations'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get account IDs
|
||||||
|
$accountIds = $assignments->pluck('assignable_id');
|
||||||
|
|
||||||
|
// Calculate account health metrics in a single efficient query
|
||||||
|
$accountMetrics = $this->getAccountMetrics($accountIds);
|
||||||
|
|
||||||
|
// Combine assignments with metrics
|
||||||
|
$accounts = $assignments->map(function ($assignment) use ($accountMetrics) {
|
||||||
|
$accountId = $assignment->assignable_id;
|
||||||
|
$metrics = $accountMetrics[$accountId] ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'assignment' => $assignment,
|
||||||
|
'account' => $assignment->assignable,
|
||||||
|
'metrics' => $metrics,
|
||||||
|
'health_status' => $this->calculateHealthStatus($metrics),
|
||||||
|
];
|
||||||
|
})->sortBy(fn ($a) => $a['health_status']['priority']);
|
||||||
|
|
||||||
|
return view('seller.sales.accounts.index', compact('business', 'accounts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dashboard data
|
||||||
|
*/
|
||||||
|
protected function getDashboardData(Business $business, $user): array
|
||||||
|
{
|
||||||
|
// Get assigned accounts count
|
||||||
|
$accountAssignments = SalesRepAssignment::forBusiness($business->id)
|
||||||
|
->forUser($user->id)
|
||||||
|
->accounts()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// Get assigned locations count
|
||||||
|
$locationAssignments = SalesRepAssignment::forBusiness($business->id)
|
||||||
|
->forUser($user->id)
|
||||||
|
->locations()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// Get accounts needing attention (no order in 30+ days)
|
||||||
|
$needsAttention = $this->getAccountsNeedingAttention($business, $user);
|
||||||
|
|
||||||
|
// Get accounts at risk (no order in 60+ days)
|
||||||
|
$atRisk = $this->getAccountsAtRisk($business, $user);
|
||||||
|
|
||||||
|
// Get recent orders for assigned accounts
|
||||||
|
$recentOrders = $this->getRecentOrders($business, $user, 10);
|
||||||
|
|
||||||
|
// Get commission summary
|
||||||
|
$commissionSummary = $this->getCommissionSummary($business, $user);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'stats' => [
|
||||||
|
'assigned_accounts' => $accountAssignments,
|
||||||
|
'assigned_locations' => $locationAssignments,
|
||||||
|
'needs_attention' => $needsAttention->count(),
|
||||||
|
'at_risk' => $atRisk->count(),
|
||||||
|
],
|
||||||
|
'needs_attention' => $needsAttention,
|
||||||
|
'at_risk' => $atRisk,
|
||||||
|
'recent_orders' => $recentOrders,
|
||||||
|
'commission_summary' => $commissionSummary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account metrics for multiple accounts efficiently
|
||||||
|
*/
|
||||||
|
protected function getAccountMetrics($accountIds): array
|
||||||
|
{
|
||||||
|
if ($accountIds->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get order metrics per account
|
||||||
|
$orderMetrics = Order::whereIn('business_id', $accountIds)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->groupBy('business_id')
|
||||||
|
->selectRaw('business_id,
|
||||||
|
COUNT(*) as order_count,
|
||||||
|
SUM(total) as total_revenue,
|
||||||
|
MAX(created_at) as last_order_date,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (created_at - LAG(created_at) OVER (PARTITION BY business_id ORDER BY created_at)))) as avg_order_interval_seconds
|
||||||
|
')
|
||||||
|
->get()
|
||||||
|
->keyBy('business_id');
|
||||||
|
|
||||||
|
return $orderMetrics->mapWithKeys(function ($metrics, $accountId) {
|
||||||
|
return [$accountId => [
|
||||||
|
'order_count' => $metrics->order_count,
|
||||||
|
'total_revenue' => $metrics->total_revenue ?? 0,
|
||||||
|
'last_order_date' => $metrics->last_order_date,
|
||||||
|
'days_since_last_order' => $metrics->last_order_date
|
||||||
|
? now()->diffInDays($metrics->last_order_date)
|
||||||
|
: null,
|
||||||
|
'avg_order_interval_days' => $metrics->avg_order_interval_seconds
|
||||||
|
? round($metrics->avg_order_interval_seconds / 86400)
|
||||||
|
: null,
|
||||||
|
]];
|
||||||
|
})->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate health status based on metrics
|
||||||
|
*/
|
||||||
|
protected function calculateHealthStatus(array $metrics): array
|
||||||
|
{
|
||||||
|
$daysSinceOrder = $metrics['days_since_last_order'] ?? null;
|
||||||
|
$avgInterval = $metrics['avg_order_interval_days'] ?? 30;
|
||||||
|
|
||||||
|
if ($daysSinceOrder === null) {
|
||||||
|
return [
|
||||||
|
'status' => 'new',
|
||||||
|
'label' => 'New Account',
|
||||||
|
'color' => 'info',
|
||||||
|
'priority' => 2,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// At risk: More than 2x their average order interval, or 60+ days
|
||||||
|
if ($daysSinceOrder >= max($avgInterval * 2, 60)) {
|
||||||
|
return [
|
||||||
|
'status' => 'at_risk',
|
||||||
|
'label' => 'At Risk',
|
||||||
|
'color' => 'error',
|
||||||
|
'priority' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needs attention: More than 1.5x their average order interval, or 30+ days
|
||||||
|
if ($daysSinceOrder >= max($avgInterval * 1.5, 30)) {
|
||||||
|
return [
|
||||||
|
'status' => 'needs_attention',
|
||||||
|
'label' => 'Needs Attention',
|
||||||
|
'color' => 'warning',
|
||||||
|
'priority' => 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'healthy',
|
||||||
|
'label' => 'Healthy',
|
||||||
|
'color' => 'success',
|
||||||
|
'priority' => 3,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get accounts needing attention (no order in 30-59 days)
|
||||||
|
*/
|
||||||
|
protected function getAccountsNeedingAttention(Business $business, $user)
|
||||||
|
{
|
||||||
|
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
|
||||||
|
->forUser($user->id)
|
||||||
|
->accounts()
|
||||||
|
->pluck('assignable_id');
|
||||||
|
|
||||||
|
return Business::whereIn('id', $assignedAccountIds)
|
||||||
|
->whereHas('orders', function ($q) {
|
||||||
|
$q->where('status', 'completed')
|
||||||
|
->where('created_at', '<', now()->subDays(30))
|
||||||
|
->where('created_at', '>=', now()->subDays(60));
|
||||||
|
})
|
||||||
|
->orWhereDoesntHave('orders')
|
||||||
|
->with('locations')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get accounts at risk (no order in 60+ days)
|
||||||
|
*/
|
||||||
|
protected function getAccountsAtRisk(Business $business, $user)
|
||||||
|
{
|
||||||
|
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
|
||||||
|
->forUser($user->id)
|
||||||
|
->accounts()
|
||||||
|
->pluck('assignable_id');
|
||||||
|
|
||||||
|
return Business::whereIn('id', $assignedAccountIds)
|
||||||
|
->whereHas('orders', function ($q) {
|
||||||
|
$q->where('status', 'completed')
|
||||||
|
->where('created_at', '<', now()->subDays(60));
|
||||||
|
})
|
||||||
|
->with('locations')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent orders for assigned accounts
|
||||||
|
*/
|
||||||
|
protected function getRecentOrders(Business $business, $user, int $limit)
|
||||||
|
{
|
||||||
|
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
|
||||||
|
->forUser($user->id)
|
||||||
|
->accounts()
|
||||||
|
->pluck('assignable_id');
|
||||||
|
|
||||||
|
return Order::whereIn('business_id', $assignedAccountIds)
|
||||||
|
->with(['business', 'items.product'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commission summary for the current user
|
||||||
|
*/
|
||||||
|
protected function getCommissionSummary(Business $business, $user): array
|
||||||
|
{
|
||||||
|
$pendingCommission = DB::table('sales_commissions')
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->sum('commission_amount');
|
||||||
|
|
||||||
|
$approvedCommission = DB::table('sales_commissions')
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('status', 'approved')
|
||||||
|
->sum('commission_amount');
|
||||||
|
|
||||||
|
$paidThisMonth = DB::table('sales_commissions')
|
||||||
|
->where('business_id', $business->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('status', 'paid')
|
||||||
|
->whereMonth('paid_at', now()->month)
|
||||||
|
->whereYear('paid_at', now()->year)
|
||||||
|
->sum('commission_amount');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pending' => $pendingCommission / 100,
|
||||||
|
'approved' => $approvedCommission / 100,
|
||||||
|
'paid_this_month' => $paidThisMonth / 100,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
334
app/Http/Controllers/Seller/Sales/ExportController.php
Normal file
334
app/Http/Controllers/Seller/Sales/ExportController.php
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller\Sales;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Crm\CrmLead;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\SalesRepAssignment;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class ExportController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Export accounts assigned to the current sales rep as CSV.
|
||||||
|
*/
|
||||||
|
public function accounts(Business $business): StreamedResponse
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
// Get assigned account IDs
|
||||||
|
$assignedAccountIds = SalesRepAssignment::where('business_id', $business->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('assignable_type', Business::class)
|
||||||
|
->pluck('assignable_id');
|
||||||
|
|
||||||
|
$accounts = Business::whereIn('id', $assignedAccountIds)
|
||||||
|
->with(['locations', 'contacts'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$filename = 'my-accounts-'.now()->format('Y-m-d').'.csv';
|
||||||
|
|
||||||
|
return $this->streamCsv($filename, function () use ($accounts, $business) {
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
fputcsv($handle, [
|
||||||
|
'Account Name',
|
||||||
|
'Status',
|
||||||
|
'Primary Location',
|
||||||
|
'City',
|
||||||
|
'State',
|
||||||
|
'ZIP',
|
||||||
|
'Primary Contact',
|
||||||
|
'Email',
|
||||||
|
'Phone',
|
||||||
|
'Last Order Date',
|
||||||
|
'Last Order Total',
|
||||||
|
'Days Since Order',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($accounts as $account) {
|
||||||
|
$location = $account->locations->first();
|
||||||
|
$contact = $account->contacts->first();
|
||||||
|
|
||||||
|
// Get last order
|
||||||
|
$lastOrder = Order::where('business_id', $account->id)
|
||||||
|
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
fputcsv($handle, [
|
||||||
|
$account->name,
|
||||||
|
$account->status ?? 'active',
|
||||||
|
$location?->name ?? '',
|
||||||
|
$location?->city ?? '',
|
||||||
|
$location?->state ?? '',
|
||||||
|
$location?->zipcode ?? '',
|
||||||
|
$contact?->name ?? '',
|
||||||
|
$contact?->email ?? '',
|
||||||
|
$contact?->phone ?? '',
|
||||||
|
$lastOrder?->created_at?->format('Y-m-d') ?? '',
|
||||||
|
$lastOrder ? '$'.number_format($lastOrder->total / 100, 2) : '',
|
||||||
|
$lastOrder ? now()->diffInDays($lastOrder->created_at) : '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a single account's order history for meeting prep.
|
||||||
|
*/
|
||||||
|
public function accountHistory(Business $business, Business $account): StreamedResponse
|
||||||
|
{
|
||||||
|
// Verify assignment
|
||||||
|
$isAssigned = SalesRepAssignment::where('business_id', $business->id)
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->where('assignable_type', Business::class)
|
||||||
|
->where('assignable_id', $account->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $isAssigned) {
|
||||||
|
abort(403, 'Account not assigned to you');
|
||||||
|
}
|
||||||
|
|
||||||
|
$orders = Order::where('business_id', $account->id)
|
||||||
|
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||||
|
->with(['items.product'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$filename = Str::slug($account->name).'-history-'.now()->format('Y-m-d').'.csv';
|
||||||
|
|
||||||
|
return $this->streamCsv($filename, function () use ($orders, $account) {
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
// Account summary header
|
||||||
|
fputcsv($handle, ['Account Summary: '.$account->name]);
|
||||||
|
fputcsv($handle, ['Generated: '.now()->format('F j, Y')]);
|
||||||
|
fputcsv($handle, ['Total Orders: '.$orders->count()]);
|
||||||
|
fputcsv($handle, ['']);
|
||||||
|
|
||||||
|
// Order details header
|
||||||
|
fputcsv($handle, [
|
||||||
|
'Order Number',
|
||||||
|
'Date',
|
||||||
|
'Status',
|
||||||
|
'Product',
|
||||||
|
'SKU',
|
||||||
|
'Quantity',
|
||||||
|
'Unit Price',
|
||||||
|
'Line Total',
|
||||||
|
'Order Total',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($orders as $order) {
|
||||||
|
$firstItem = true;
|
||||||
|
foreach ($order->items as $item) {
|
||||||
|
fputcsv($handle, [
|
||||||
|
$firstItem ? $order->order_number : '',
|
||||||
|
$firstItem ? $order->created_at->format('Y-m-d') : '',
|
||||||
|
$firstItem ? ucfirst($order->status) : '',
|
||||||
|
$item->product?->name ?? 'Unknown',
|
||||||
|
$item->product?->sku ?? '',
|
||||||
|
$item->quantity,
|
||||||
|
'$'.number_format($item->price / 100, 2),
|
||||||
|
'$'.number_format(($item->price * $item->quantity) / 100, 2),
|
||||||
|
$firstItem ? '$'.number_format($order->total / 100, 2) : '',
|
||||||
|
]);
|
||||||
|
$firstItem = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export prospect data with insights for pitch preparation.
|
||||||
|
*/
|
||||||
|
public function prospects(Business $business): StreamedResponse
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$leads = CrmLead::where('business_id', $business->id)
|
||||||
|
->where('assigned_to', $user->id)
|
||||||
|
->with(['insights'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$filename = 'prospects-'.now()->format('Y-m-d').'.csv';
|
||||||
|
|
||||||
|
return $this->streamCsv($filename, function () use ($leads) {
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
fputcsv($handle, [
|
||||||
|
'Company Name',
|
||||||
|
'Contact Name',
|
||||||
|
'Email',
|
||||||
|
'Phone',
|
||||||
|
'City',
|
||||||
|
'State',
|
||||||
|
'Status',
|
||||||
|
'Source',
|
||||||
|
'License Number',
|
||||||
|
'Gaps',
|
||||||
|
'Pain Points',
|
||||||
|
'Opportunities',
|
||||||
|
'Notes',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($leads as $lead) {
|
||||||
|
$gaps = $lead->insights->where('insight_type', 'gap')->pluck('description')->implode('; ');
|
||||||
|
$painPoints = $lead->insights->where('insight_type', 'pain_point')->pluck('description')->implode('; ');
|
||||||
|
$opportunities = $lead->insights->where('insight_type', 'opportunity')->pluck('description')->implode('; ');
|
||||||
|
|
||||||
|
fputcsv($handle, [
|
||||||
|
$lead->company_name,
|
||||||
|
$lead->contact_name ?? '',
|
||||||
|
$lead->email ?? '',
|
||||||
|
$lead->phone ?? '',
|
||||||
|
$lead->city ?? '',
|
||||||
|
$lead->state ?? '',
|
||||||
|
ucfirst($lead->status),
|
||||||
|
$lead->source ?? '',
|
||||||
|
$lead->license_number ?? '',
|
||||||
|
$gaps,
|
||||||
|
$painPoints,
|
||||||
|
$opportunities,
|
||||||
|
$lead->notes ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export competitor replacement data for sales training.
|
||||||
|
*/
|
||||||
|
public function competitors(Business $business): StreamedResponse
|
||||||
|
{
|
||||||
|
$replacements = \App\Models\CompetitorReplacement::where('business_id', $business->id)
|
||||||
|
->with(['product.brand'])
|
||||||
|
->orderBy('competitor_name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$filename = 'competitor-replacements-'.now()->format('Y-m-d').'.csv';
|
||||||
|
|
||||||
|
return $this->streamCsv($filename, function () use ($replacements) {
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
fputcsv($handle, [
|
||||||
|
'Competitor Brand',
|
||||||
|
'Competitor Product',
|
||||||
|
'Our Product',
|
||||||
|
'Our SKU',
|
||||||
|
'Our Brand',
|
||||||
|
'Why Ours is Better',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($replacements as $replacement) {
|
||||||
|
fputcsv($handle, [
|
||||||
|
$replacement->competitor_name,
|
||||||
|
$replacement->competitor_product_name ?? 'Any product',
|
||||||
|
$replacement->product->name,
|
||||||
|
$replacement->product->sku,
|
||||||
|
$replacement->product->brand?->name ?? '',
|
||||||
|
$replacement->advantage_notes ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a pitch builder export for a specific prospect.
|
||||||
|
*/
|
||||||
|
public function pitchBuilder(Business $business, CrmLead $lead): StreamedResponse
|
||||||
|
{
|
||||||
|
// Verify assignment
|
||||||
|
if ($lead->assigned_to !== Auth::id()) {
|
||||||
|
abort(403, 'Lead not assigned to you');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get similar successful accounts for reference
|
||||||
|
$successStories = Business::where('type', 'buyer')
|
||||||
|
->whereHas('orders', function ($query) use ($business) {
|
||||||
|
$query->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||||
|
->where('created_at', '>=', now()->subMonths(3));
|
||||||
|
})
|
||||||
|
->when($lead->city, fn ($q) => $q->whereHas('locations', fn ($l) => $l->where('city', $lead->city)))
|
||||||
|
->with(['locations'])
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$filename = 'pitch-'.Str::slug($lead->company_name).'-'.now()->format('Y-m-d').'.csv';
|
||||||
|
|
||||||
|
return $this->streamCsv($filename, function () use ($lead, $successStories) {
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
// Prospect info
|
||||||
|
fputcsv($handle, ['PITCH PREPARATION: '.$lead->company_name]);
|
||||||
|
fputcsv($handle, ['Generated: '.now()->format('F j, Y')]);
|
||||||
|
fputcsv($handle, ['']);
|
||||||
|
|
||||||
|
// Contact info
|
||||||
|
fputcsv($handle, ['CONTACT INFORMATION']);
|
||||||
|
fputcsv($handle, ['Contact Name', $lead->contact_name ?? 'N/A']);
|
||||||
|
fputcsv($handle, ['Email', $lead->email ?? 'N/A']);
|
||||||
|
fputcsv($handle, ['Phone', $lead->phone ?? 'N/A']);
|
||||||
|
fputcsv($handle, ['Location', ($lead->city ?? '').($lead->city && $lead->state ? ', ' : '').($lead->state ?? '')]);
|
||||||
|
fputcsv($handle, ['License', $lead->license_number ?? 'N/A']);
|
||||||
|
fputcsv($handle, ['']);
|
||||||
|
|
||||||
|
// Insights
|
||||||
|
fputcsv($handle, ['IDENTIFIED GAPS & OPPORTUNITIES']);
|
||||||
|
foreach ($lead->insights as $insight) {
|
||||||
|
fputcsv($handle, [
|
||||||
|
ucfirst(str_replace('_', ' ', $insight->insight_type)),
|
||||||
|
$insight->description,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
fputcsv($handle, ['']);
|
||||||
|
|
||||||
|
// Success stories
|
||||||
|
fputcsv($handle, ['SIMILAR SUCCESSFUL ACCOUNTS (Reference for pitch)']);
|
||||||
|
fputcsv($handle, ['Account Name', 'Location']);
|
||||||
|
foreach ($successStories as $account) {
|
||||||
|
$location = $account->locations->first();
|
||||||
|
fputcsv($handle, [
|
||||||
|
$account->name,
|
||||||
|
($location?->city ?? '').($location?->city && $location?->state ? ', ' : '').($location?->state ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($handle, ['']);
|
||||||
|
fputcsv($handle, ['NOTES']);
|
||||||
|
fputcsv($handle, [$lead->notes ?? 'No additional notes']);
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to stream a CSV response.
|
||||||
|
*/
|
||||||
|
private function streamCsv(string $filename, callable $callback): StreamedResponse
|
||||||
|
{
|
||||||
|
return response()->stream($callback, 200, [
|
||||||
|
'Content-Type' => 'text/csv',
|
||||||
|
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||||
|
'Pragma' => 'no-cache',
|
||||||
|
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
|
||||||
|
'Expires' => '0',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
314
app/Http/Controllers/Seller/Sales/ProspectController.php
Normal file
314
app/Http/Controllers/Seller/Sales/ProspectController.php
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller\Sales;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Crm\CrmLead;
|
||||||
|
use App\Models\ProspectImport;
|
||||||
|
use App\Models\ProspectInsight;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ProspectController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List prospects (leads) assigned to this sales rep
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Get leads assigned to this user
|
||||||
|
$leads = CrmLead::where('seller_business_id', $business->id)
|
||||||
|
->where('assigned_to', $user->id)
|
||||||
|
->with('insights')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
// Get insight counts by type
|
||||||
|
$insightCounts = ProspectInsight::forBusiness($business->id)
|
||||||
|
->whereNotNull('lead_id')
|
||||||
|
->selectRaw('insight_type, COUNT(*) as count')
|
||||||
|
->groupBy('insight_type')
|
||||||
|
->pluck('count', 'insight_type');
|
||||||
|
|
||||||
|
return view('seller.sales.prospects.index', compact(
|
||||||
|
'business',
|
||||||
|
'leads',
|
||||||
|
'insightCounts'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show prospect detail with insights
|
||||||
|
*/
|
||||||
|
public function show(Request $request, Business $business, CrmLead $lead)
|
||||||
|
{
|
||||||
|
if ($lead->seller_business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lead->load('insights.creator');
|
||||||
|
|
||||||
|
// Get similar successful accounts for reference
|
||||||
|
$successStories = $this->findSimilarSuccessStories($business, $lead);
|
||||||
|
|
||||||
|
return view('seller.sales.prospects.show', compact(
|
||||||
|
'business',
|
||||||
|
'lead',
|
||||||
|
'successStories'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add insight to a prospect
|
||||||
|
*/
|
||||||
|
public function storeInsight(Request $request, Business $business, CrmLead $lead)
|
||||||
|
{
|
||||||
|
if ($lead->seller_business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'insight_type' => 'required|in:gap,pain_point,opportunity,objection,competitor_weakness',
|
||||||
|
'category' => 'nullable|in:price_point,quality,consistency,service,margin,reliability,selection',
|
||||||
|
'description' => 'required|string|max:2000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProspectInsight::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'lead_id' => $lead->id,
|
||||||
|
'insight_type' => $validated['insight_type'],
|
||||||
|
'category' => $validated['category'],
|
||||||
|
'description' => $validated['description'],
|
||||||
|
'created_by' => $request->user()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Insight added.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an insight
|
||||||
|
*/
|
||||||
|
public function destroyInsight(Request $request, Business $business, ProspectInsight $insight)
|
||||||
|
{
|
||||||
|
if ($insight->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$insight->delete();
|
||||||
|
|
||||||
|
return back()->with('success', 'Insight deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show import history and upload form
|
||||||
|
*/
|
||||||
|
public function imports(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$imports = ProspectImport::forBusiness($business->id)
|
||||||
|
->with('importer')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate(10);
|
||||||
|
|
||||||
|
return view('seller.sales.prospects.imports', compact('business', 'imports'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload and process import file
|
||||||
|
*/
|
||||||
|
public function upload(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'file' => 'required|file|mimes:csv,txt|max:5120', // 5MB max
|
||||||
|
]);
|
||||||
|
|
||||||
|
$file = $request->file('file');
|
||||||
|
$filename = $file->getClientOriginalName();
|
||||||
|
$path = $file->store("imports/{$business->id}", 'local');
|
||||||
|
|
||||||
|
// Count rows
|
||||||
|
$content = file_get_contents($file->getRealPath());
|
||||||
|
$lines = explode("\n", trim($content));
|
||||||
|
$totalRows = count($lines) - 1; // Exclude header
|
||||||
|
|
||||||
|
// Create import record
|
||||||
|
$import = ProspectImport::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'filename' => $filename,
|
||||||
|
'status' => ProspectImport::STATUS_PENDING,
|
||||||
|
'total_rows' => max(0, $totalRows),
|
||||||
|
'processed_rows' => 0,
|
||||||
|
'created_count' => 0,
|
||||||
|
'updated_count' => 0,
|
||||||
|
'skipped_count' => 0,
|
||||||
|
'error_count' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get headers for mapping
|
||||||
|
$headers = str_getcsv($lines[0]);
|
||||||
|
|
||||||
|
return view('seller.sales.prospects.map-columns', compact(
|
||||||
|
'business',
|
||||||
|
'import',
|
||||||
|
'headers',
|
||||||
|
'path'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process import with column mapping
|
||||||
|
*/
|
||||||
|
public function processImport(Request $request, Business $business, ProspectImport $import)
|
||||||
|
{
|
||||||
|
if ($import->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'mapping' => 'required|array',
|
||||||
|
'mapping.company_name' => 'required|string',
|
||||||
|
'path' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import->update([
|
||||||
|
'column_mapping' => $validated['mapping'],
|
||||||
|
'status' => ProspectImport::STATUS_PROCESSING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Process synchronously for now (could dispatch to queue for large files)
|
||||||
|
$this->processImportFile($import, $validated['path'], $validated['mapping']);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.sales.prospects.imports', $business)
|
||||||
|
->with('success', "Import completed. {$import->created_count} created, {$import->updated_count} updated, {$import->error_count} errors.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the import file
|
||||||
|
*/
|
||||||
|
protected function processImportFile(ProspectImport $import, string $path, array $mapping): void
|
||||||
|
{
|
||||||
|
$content = Storage::disk('local')->get($path);
|
||||||
|
$lines = explode("\n", trim($content));
|
||||||
|
$headers = str_getcsv(array_shift($lines));
|
||||||
|
|
||||||
|
// Create column index map
|
||||||
|
$columnMap = [];
|
||||||
|
foreach ($mapping as $field => $column) {
|
||||||
|
$index = array_search($column, $headers);
|
||||||
|
if ($index !== false) {
|
||||||
|
$columnMap[$field] = $index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($lines as $lineNum => $line) {
|
||||||
|
if (empty(trim($line))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = str_getcsv($line);
|
||||||
|
$import->incrementProcessed();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$companyName = $row[$columnMap['company_name']] ?? null;
|
||||||
|
|
||||||
|
if (! $companyName) {
|
||||||
|
$import->addError($lineNum + 2, 'Missing company name');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
$existing = CrmLead::where('seller_business_id', $import->business_id)
|
||||||
|
->where('company_name', $companyName)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// Update existing
|
||||||
|
$this->updateLeadFromRow($existing, $row, $columnMap);
|
||||||
|
$import->incrementUpdated();
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
$this->createLeadFromRow($import, $row, $columnMap);
|
||||||
|
$import->incrementCreated();
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$import->addError($lineNum + 2, $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$import->markCompleted();
|
||||||
|
|
||||||
|
// Clean up file
|
||||||
|
Storage::disk('local')->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new lead from import row
|
||||||
|
*/
|
||||||
|
protected function createLeadFromRow(ProspectImport $import, array $row, array $columnMap): CrmLead
|
||||||
|
{
|
||||||
|
return CrmLead::create([
|
||||||
|
'seller_business_id' => $import->business_id,
|
||||||
|
'company_name' => $row[$columnMap['company_name']] ?? null,
|
||||||
|
'contact_name' => $row[$columnMap['contact_name'] ?? -1] ?? null,
|
||||||
|
'email' => $row[$columnMap['email'] ?? -1] ?? null,
|
||||||
|
'phone' => $row[$columnMap['phone'] ?? -1] ?? null,
|
||||||
|
'address' => $row[$columnMap['address'] ?? -1] ?? null,
|
||||||
|
'city' => $row[$columnMap['city'] ?? -1] ?? null,
|
||||||
|
'state' => $row[$columnMap['state'] ?? -1] ?? null,
|
||||||
|
'zipcode' => $row[$columnMap['zipcode'] ?? -1] ?? null,
|
||||||
|
'license_number' => $row[$columnMap['license_number'] ?? -1] ?? null,
|
||||||
|
'notes' => $row[$columnMap['notes'] ?? -1] ?? null,
|
||||||
|
'source' => 'import',
|
||||||
|
'status' => 'new',
|
||||||
|
'assigned_to' => $import->user_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing lead from import row
|
||||||
|
*/
|
||||||
|
protected function updateLeadFromRow(CrmLead $lead, array $row, array $columnMap): void
|
||||||
|
{
|
||||||
|
$updates = [];
|
||||||
|
|
||||||
|
foreach (['contact_name', 'email', 'phone', 'address', 'city', 'state', 'zipcode', 'license_number'] as $field) {
|
||||||
|
if (isset($columnMap[$field]) && ! empty($row[$columnMap[$field]])) {
|
||||||
|
$updates[$field] = $row[$columnMap[$field]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($updates)) {
|
||||||
|
$lead->update($updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find similar successful accounts for a prospect
|
||||||
|
*/
|
||||||
|
protected function findSimilarSuccessStories(Business $business, CrmLead $lead): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
// Get successful accounts in same city/state
|
||||||
|
$query = Business::query()
|
||||||
|
->whereHas('orders', function ($q) {
|
||||||
|
$q->where('status', 'completed');
|
||||||
|
})
|
||||||
|
->with('locations');
|
||||||
|
|
||||||
|
if ($lead->city) {
|
||||||
|
$query->whereHas('locations', function ($q) use ($lead) {
|
||||||
|
$q->where('city', 'ILIKE', "%{$lead->city}%");
|
||||||
|
});
|
||||||
|
} elseif ($lead->state) {
|
||||||
|
$query->whereHas('locations', function ($q) use ($lead) {
|
||||||
|
$q->where('state', $lead->state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->limit(5)->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Http/Controllers/Seller/Sales/ReorderController.php
Normal file
39
app/Http/Controllers/Seller/Sales/ReorderController.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller\Sales;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Services\Sales\ReorderPredictionService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ReorderController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected ReorderPredictionService $reorderService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder Alerts - accounts approaching their reorder window
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Get accounts approaching reorder with predictions
|
||||||
|
$accounts = $this->reorderService->getReorderAlerts($business->id, $user->id);
|
||||||
|
|
||||||
|
// Separate into categories
|
||||||
|
$overdue = $accounts->filter(fn ($a) => ($a['days_until_predicted_order'] ?? 0) < 0);
|
||||||
|
$dueSoon = $accounts->filter(fn ($a) => ($a['days_until_predicted_order'] ?? 0) >= 0 && ($a['days_until_predicted_order'] ?? 999) <= 7);
|
||||||
|
$upcoming = $accounts->filter(fn ($a) => ($a['days_until_predicted_order'] ?? 999) > 7 && ($a['days_until_predicted_order'] ?? 999) <= 14);
|
||||||
|
|
||||||
|
return view('seller.sales.reorders.index', compact(
|
||||||
|
'business',
|
||||||
|
'accounts',
|
||||||
|
'overdue',
|
||||||
|
'dueSoon',
|
||||||
|
'upcoming'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
193
app/Http/Controllers/Seller/Sales/TerritoryController.php
Normal file
193
app/Http/Controllers/Seller/Sales/TerritoryController.php
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller\Sales;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\SalesTerritory;
|
||||||
|
use App\Models\SalesTerritoryArea;
|
||||||
|
use App\Models\SalesTerritoryAssignment;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TerritoryController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List all territories for this business
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$territories = SalesTerritory::forBusiness($business->id)
|
||||||
|
->with(['areas', 'salesReps'])
|
||||||
|
->withCount('assignments')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get available sales reps for assignment
|
||||||
|
$salesReps = $this->getAvailableSalesReps($business);
|
||||||
|
|
||||||
|
return view('seller.sales.territories.index', compact('business', 'territories', 'salesReps'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show create territory form
|
||||||
|
*/
|
||||||
|
public function create(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$salesReps = $this->getAvailableSalesReps($business);
|
||||||
|
|
||||||
|
return view('seller.sales.territories.create', compact('business', 'salesReps'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new territory
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string|max:1000',
|
||||||
|
'color' => 'required|string|max:7',
|
||||||
|
'areas' => 'nullable|array',
|
||||||
|
'areas.*.type' => 'required_with:areas|in:zip,city,state,county',
|
||||||
|
'areas.*.value' => 'required_with:areas|string|max:255',
|
||||||
|
'primary_rep_id' => 'nullable|exists:users,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$territory = SalesTerritory::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'description' => $validated['description'] ?? null,
|
||||||
|
'color' => $validated['color'],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add areas
|
||||||
|
if (! empty($validated['areas'])) {
|
||||||
|
foreach ($validated['areas'] as $area) {
|
||||||
|
SalesTerritoryArea::create([
|
||||||
|
'territory_id' => $territory->id,
|
||||||
|
'area_type' => $area['type'],
|
||||||
|
'area_value' => $area['value'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign primary rep
|
||||||
|
if (! empty($validated['primary_rep_id'])) {
|
||||||
|
SalesTerritoryAssignment::create([
|
||||||
|
'territory_id' => $territory->id,
|
||||||
|
'user_id' => $validated['primary_rep_id'],
|
||||||
|
'assignment_type' => 'primary',
|
||||||
|
'assigned_at' => now(),
|
||||||
|
'assigned_by' => $request->user()->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.sales.territories', $business)
|
||||||
|
->with('success', 'Territory created successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show edit territory form
|
||||||
|
*/
|
||||||
|
public function edit(Request $request, Business $business, SalesTerritory $territory)
|
||||||
|
{
|
||||||
|
// Verify territory belongs to this business
|
||||||
|
if ($territory->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$territory->load(['areas', 'salesReps']);
|
||||||
|
$salesReps = $this->getAvailableSalesReps($business);
|
||||||
|
|
||||||
|
return view('seller.sales.territories.edit', compact('business', 'territory', 'salesReps'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a territory
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Business $business, SalesTerritory $territory)
|
||||||
|
{
|
||||||
|
// Verify territory belongs to this business
|
||||||
|
if ($territory->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string|max:1000',
|
||||||
|
'color' => 'required|string|max:7',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'areas' => 'nullable|array',
|
||||||
|
'areas.*.type' => 'required_with:areas|in:zip,city,state,county',
|
||||||
|
'areas.*.value' => 'required_with:areas|string|max:255',
|
||||||
|
'primary_rep_id' => 'nullable|exists:users,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$territory->update([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'description' => $validated['description'] ?? null,
|
||||||
|
'color' => $validated['color'],
|
||||||
|
'is_active' => $validated['is_active'] ?? true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Replace areas
|
||||||
|
$territory->areas()->delete();
|
||||||
|
if (! empty($validated['areas'])) {
|
||||||
|
foreach ($validated['areas'] as $area) {
|
||||||
|
SalesTerritoryArea::create([
|
||||||
|
'territory_id' => $territory->id,
|
||||||
|
'area_type' => $area['type'],
|
||||||
|
'area_value' => $area['value'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update primary rep
|
||||||
|
$territory->assignments()->where('assignment_type', 'primary')->delete();
|
||||||
|
if (! empty($validated['primary_rep_id'])) {
|
||||||
|
SalesTerritoryAssignment::create([
|
||||||
|
'territory_id' => $territory->id,
|
||||||
|
'user_id' => $validated['primary_rep_id'],
|
||||||
|
'assignment_type' => 'primary',
|
||||||
|
'assigned_at' => now(),
|
||||||
|
'assigned_by' => $request->user()->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.sales.territories', $business)
|
||||||
|
->with('success', 'Territory updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a territory
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, Business $business, SalesTerritory $territory)
|
||||||
|
{
|
||||||
|
// Verify territory belongs to this business
|
||||||
|
if ($territory->business_id !== $business->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$territory->areas()->delete();
|
||||||
|
$territory->assignments()->delete();
|
||||||
|
$territory->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.sales.territories', $business)
|
||||||
|
->with('success', 'Territory deleted successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available sales reps for this business
|
||||||
|
*/
|
||||||
|
protected function getAvailableSalesReps(Business $business)
|
||||||
|
{
|
||||||
|
return User::whereHas('businesses', function ($q) use ($business) {
|
||||||
|
$q->where('businesses.id', $business->id);
|
||||||
|
})->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
263
app/Http/Controllers/Seller/Settings/DbaController.php
Normal file
263
app/Http/Controllers/Seller/Settings/DbaController.php
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\BusinessDba;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class DbaController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of all DBAs for the business.
|
||||||
|
*/
|
||||||
|
public function index(Business $business): View
|
||||||
|
{
|
||||||
|
$dbas = $business->dbas()
|
||||||
|
->orderByDesc('is_default')
|
||||||
|
->orderBy('trade_name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('seller.settings.dbas.index', compact('business', 'dbas'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new DBA.
|
||||||
|
*/
|
||||||
|
public function create(Business $business): View
|
||||||
|
{
|
||||||
|
return view('seller.settings.dbas.create', compact('business'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created DBA in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Business $business): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
// Identity
|
||||||
|
'trade_name' => 'required|string|max:255',
|
||||||
|
|
||||||
|
// Address
|
||||||
|
'address' => 'nullable|string|max:255',
|
||||||
|
'address_line_2' => 'nullable|string|max:255',
|
||||||
|
'city' => 'nullable|string|max:255',
|
||||||
|
'state' => 'nullable|string|max:2',
|
||||||
|
'zip' => 'nullable|string|max:10',
|
||||||
|
|
||||||
|
// License
|
||||||
|
'license_number' => 'nullable|string|max:255',
|
||||||
|
'license_type' => 'nullable|string|max:255',
|
||||||
|
'license_expiration' => 'nullable|date',
|
||||||
|
|
||||||
|
// Bank Info
|
||||||
|
'bank_name' => 'nullable|string|max:255',
|
||||||
|
'bank_account_name' => 'nullable|string|max:255',
|
||||||
|
'bank_routing_number' => 'nullable|string|max:50',
|
||||||
|
'bank_account_number' => 'nullable|string|max:50',
|
||||||
|
'bank_account_type' => 'nullable|string|in:checking,savings',
|
||||||
|
|
||||||
|
// Tax
|
||||||
|
'tax_id' => 'nullable|string|max:50',
|
||||||
|
'tax_id_type' => 'nullable|string|in:ein,ssn',
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
'primary_contact_name' => 'nullable|string|max:255',
|
||||||
|
'primary_contact_email' => 'nullable|email|max:255',
|
||||||
|
'primary_contact_phone' => 'nullable|string|max:50',
|
||||||
|
'ap_contact_name' => 'nullable|string|max:255',
|
||||||
|
'ap_contact_email' => 'nullable|email|max:255',
|
||||||
|
'ap_contact_phone' => 'nullable|string|max:50',
|
||||||
|
|
||||||
|
// Invoice Settings
|
||||||
|
'payment_terms' => 'nullable|string|max:50',
|
||||||
|
'payment_instructions' => 'nullable|string|max:2000',
|
||||||
|
'invoice_footer' => 'nullable|string|max:2000',
|
||||||
|
'invoice_prefix' => 'nullable|string|max:10',
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
'logo_path' => 'nullable|string|max:255',
|
||||||
|
'brand_colors' => 'nullable|array',
|
||||||
|
|
||||||
|
// Status
|
||||||
|
'is_default' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['business_id'] = $business->id;
|
||||||
|
$validated['is_default'] = $request->boolean('is_default');
|
||||||
|
$validated['is_active'] = $request->boolean('is_active', true);
|
||||||
|
|
||||||
|
$dba = BusinessDba::create($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.settings.dbas.index', $business)
|
||||||
|
->with('success', "DBA \"{$dba->trade_name}\" created successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified DBA.
|
||||||
|
*/
|
||||||
|
public function edit(Business $business, BusinessDba $dba): View
|
||||||
|
{
|
||||||
|
// Verify DBA belongs to this business
|
||||||
|
if ($dba->business_id !== $business->id) {
|
||||||
|
abort(403, 'This DBA does not belong to your business.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('seller.settings.dbas.edit', compact('business', 'dba'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified DBA in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Business $business, BusinessDba $dba): RedirectResponse
|
||||||
|
{
|
||||||
|
// Verify DBA belongs to this business
|
||||||
|
if ($dba->business_id !== $business->id) {
|
||||||
|
abort(403, 'This DBA does not belong to your business.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
// Identity
|
||||||
|
'trade_name' => 'required|string|max:255',
|
||||||
|
|
||||||
|
// Address
|
||||||
|
'address' => 'nullable|string|max:255',
|
||||||
|
'address_line_2' => 'nullable|string|max:255',
|
||||||
|
'city' => 'nullable|string|max:255',
|
||||||
|
'state' => 'nullable|string|max:2',
|
||||||
|
'zip' => 'nullable|string|max:10',
|
||||||
|
|
||||||
|
// License
|
||||||
|
'license_number' => 'nullable|string|max:255',
|
||||||
|
'license_type' => 'nullable|string|max:255',
|
||||||
|
'license_expiration' => 'nullable|date',
|
||||||
|
|
||||||
|
// Bank Info
|
||||||
|
'bank_name' => 'nullable|string|max:255',
|
||||||
|
'bank_account_name' => 'nullable|string|max:255',
|
||||||
|
'bank_routing_number' => 'nullable|string|max:50',
|
||||||
|
'bank_account_number' => 'nullable|string|max:50',
|
||||||
|
'bank_account_type' => 'nullable|string|in:checking,savings',
|
||||||
|
|
||||||
|
// Tax
|
||||||
|
'tax_id' => 'nullable|string|max:50',
|
||||||
|
'tax_id_type' => 'nullable|string|in:ein,ssn',
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
'primary_contact_name' => 'nullable|string|max:255',
|
||||||
|
'primary_contact_email' => 'nullable|email|max:255',
|
||||||
|
'primary_contact_phone' => 'nullable|string|max:50',
|
||||||
|
'ap_contact_name' => 'nullable|string|max:255',
|
||||||
|
'ap_contact_email' => 'nullable|email|max:255',
|
||||||
|
'ap_contact_phone' => 'nullable|string|max:50',
|
||||||
|
|
||||||
|
// Invoice Settings
|
||||||
|
'payment_terms' => 'nullable|string|max:50',
|
||||||
|
'payment_instructions' => 'nullable|string|max:2000',
|
||||||
|
'invoice_footer' => 'nullable|string|max:2000',
|
||||||
|
'invoice_prefix' => 'nullable|string|max:10',
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
'logo_path' => 'nullable|string|max:255',
|
||||||
|
'brand_colors' => 'nullable|array',
|
||||||
|
|
||||||
|
// Status
|
||||||
|
'is_default' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_default'] = $request->boolean('is_default');
|
||||||
|
$validated['is_active'] = $request->boolean('is_active', true);
|
||||||
|
|
||||||
|
// Don't overwrite encrypted fields if left blank (preserve existing values)
|
||||||
|
$encryptedFields = ['bank_routing_number', 'bank_account_number', 'tax_id'];
|
||||||
|
foreach ($encryptedFields as $field) {
|
||||||
|
if (empty($validated[$field])) {
|
||||||
|
unset($validated[$field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dba->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.settings.dbas.index', $business)
|
||||||
|
->with('success', "DBA \"{$dba->trade_name}\" updated successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified DBA from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Business $business, BusinessDba $dba): RedirectResponse
|
||||||
|
{
|
||||||
|
// Verify DBA belongs to this business
|
||||||
|
if ($dba->business_id !== $business->id) {
|
||||||
|
abort(403, 'This DBA does not belong to your business.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the only active DBA
|
||||||
|
$activeCount = $business->dbas()->where('is_active', true)->count();
|
||||||
|
if ($activeCount <= 1 && $dba->is_active) {
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.settings.dbas.index', $business)
|
||||||
|
->with('error', 'You cannot delete the only active DBA. Create another DBA first or deactivate this one.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tradeName = $dba->trade_name;
|
||||||
|
$dba->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.settings.dbas.index', $business)
|
||||||
|
->with('success', "DBA \"{$tradeName}\" deleted successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the specified DBA as the default for the business.
|
||||||
|
*/
|
||||||
|
public function setDefault(Business $business, BusinessDba $dba): RedirectResponse
|
||||||
|
{
|
||||||
|
// Verify DBA belongs to this business
|
||||||
|
if ($dba->business_id !== $business->id) {
|
||||||
|
abort(403, 'This DBA does not belong to your business.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dba->markAsDefault();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.settings.dbas.index', $business)
|
||||||
|
->with('success', "\"{$dba->trade_name}\" is now your default DBA for invoices.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the active status of a DBA.
|
||||||
|
*/
|
||||||
|
public function toggleActive(Business $business, BusinessDba $dba): RedirectResponse
|
||||||
|
{
|
||||||
|
// Verify DBA belongs to this business
|
||||||
|
if ($dba->business_id !== $business->id) {
|
||||||
|
abort(403, 'This DBA does not belong to your business.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deactivating if it's the only active DBA
|
||||||
|
if ($dba->is_active) {
|
||||||
|
$activeCount = $business->dbas()->where('is_active', true)->count();
|
||||||
|
if ($activeCount <= 1) {
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.settings.dbas.index', $business)
|
||||||
|
->with('error', 'You cannot deactivate the only active DBA.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dba->update(['is_active' => ! $dba->is_active]);
|
||||||
|
|
||||||
|
$status = $dba->is_active ? 'activated' : 'deactivated';
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('seller.business.settings.dbas.index', $business)
|
||||||
|
->with('success', "DBA \"{$dba->trade_name}\" has been {$status}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,8 +147,8 @@ class SettingsController extends Controller
|
|||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'ilike', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%");
|
->orWhere('email', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ class SettingsController extends Controller
|
|||||||
'email' => $validated['email'],
|
'email' => $validated['email'],
|
||||||
'phone' => $validated['phone'] ?? null,
|
'phone' => $validated['phone'] ?? null,
|
||||||
'position' => $validated['position'] ?? null,
|
'position' => $validated['position'] ?? null,
|
||||||
'user_type' => $business->business_type, // Match business type
|
'user_type' => 'seller', // Users in seller area are sellers
|
||||||
'password' => bcrypt(str()->random(32)), // Temporary password
|
'password' => bcrypt(str()->random(32)), // Temporary password
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -917,11 +917,11 @@ class SettingsController extends Controller
|
|||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('description', 'like', "%{$search}%")
|
$q->where('description', 'ilike', "%{$search}%")
|
||||||
->orWhere('event', 'like', "%{$search}%")
|
->orWhere('event', 'ilike', "%{$search}%")
|
||||||
->orWhereHas('user', function ($userQuery) use ($search) {
|
->orWhereHas('user', function ($userQuery) use ($search) {
|
||||||
$userQuery->where('name', 'like', "%{$search}%")
|
$userQuery->where('name', 'ilike', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%");
|
->orWhere('email', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1123,11 +1123,11 @@ class SettingsController extends Controller
|
|||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('description', 'like', "%{$search}%")
|
$q->where('description', 'ilike', "%{$search}%")
|
||||||
->orWhere('event', 'like', "%{$search}%")
|
->orWhere('event', 'ilike', "%{$search}%")
|
||||||
->orWhereHas('user', function ($userQuery) use ($search) {
|
->orWhereHas('user', function ($userQuery) use ($search) {
|
||||||
$userQuery->where('name', 'like', "%{$search}%")
|
$userQuery->where('name', 'ilike', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%");
|
->orWhere('email', 'ilike', "%{$search}%");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/Jobs/RollupBannerAdStats.php
Normal file
66
app/Jobs/RollupBannerAdStats.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BannerAdDailyStat;
|
||||||
|
use App\Models\BannerAdEvent;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class RollupBannerAdStats implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected ?string $date = null
|
||||||
|
) {
|
||||||
|
$this->date = $date ?? now()->subDay()->toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$stats = BannerAdEvent::query()
|
||||||
|
->whereDate('created_at', $this->date)
|
||||||
|
->select([
|
||||||
|
'banner_ad_id',
|
||||||
|
DB::raw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) as impressions"),
|
||||||
|
DB::raw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) as clicks"),
|
||||||
|
DB::raw("COUNT(DISTINCT CASE WHEN event_type = 'impression' THEN session_id END) as unique_impressions"),
|
||||||
|
DB::raw("COUNT(DISTINCT CASE WHEN event_type = 'click' THEN session_id END) as unique_clicks"),
|
||||||
|
])
|
||||||
|
->groupBy('banner_ad_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
foreach ($stats as $stat) {
|
||||||
|
BannerAdDailyStat::updateOrCreate(
|
||||||
|
[
|
||||||
|
'banner_ad_id' => $stat->banner_ad_id,
|
||||||
|
'date' => $this->date,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'impressions' => $stat->impressions,
|
||||||
|
'clicks' => $stat->clicks,
|
||||||
|
'unique_impressions' => $stat->unique_impressions,
|
||||||
|
'unique_clicks' => $stat->unique_clicks,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($created > 0) {
|
||||||
|
Log::info("Banner ad daily stats rolled up: {$created} records for {$this->date}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally clean up old events (older than 30 days)
|
||||||
|
$deleted = BannerAdEvent::where('created_at', '<', now()->subDays(30))->delete();
|
||||||
|
if ($deleted > 0) {
|
||||||
|
Log::info("Cleaned up {$deleted} old banner ad events");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Jobs/UpdateBannerAdStatuses.php
Normal file
25
app/Jobs/UpdateBannerAdStatuses.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\BannerAdService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class UpdateBannerAdStatuses implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function handle(BannerAdService $service): void
|
||||||
|
{
|
||||||
|
$updated = $service->updateScheduledStatuses();
|
||||||
|
|
||||||
|
if ($updated > 0) {
|
||||||
|
Log::info("Banner ad statuses updated: {$updated} ads changed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Mail/Concerns/HasBusinessReplyTo.php
Normal file
56
app/Mail/Concerns/HasBusinessReplyTo.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Mail\Mailables\Address;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trait for adding Reply-To header using business's primary email identity.
|
||||||
|
*
|
||||||
|
* This ensures replies to transactional emails (quotes, invoices, orders)
|
||||||
|
* are routed back to the CRM inbox.
|
||||||
|
*
|
||||||
|
* Supports plus addressing: inbox+user123@domain.com routes replies
|
||||||
|
* to the specific user who sent the original message.
|
||||||
|
*/
|
||||||
|
trait HasBusinessReplyTo
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the Reply-To addresses for the business.
|
||||||
|
*
|
||||||
|
* @param User|int|null $user Optional user for plus addressing
|
||||||
|
* @return array<Address>
|
||||||
|
*/
|
||||||
|
protected function getBusinessReplyTo(Business $business, User|int|null $user = null): array
|
||||||
|
{
|
||||||
|
$inboundEmail = $business->primaryEmailIdentity?->email;
|
||||||
|
|
||||||
|
if (! $inboundEmail) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add plus addressing for user routing
|
||||||
|
if ($user) {
|
||||||
|
$userId = $user instanceof User ? $user->id : $user;
|
||||||
|
$inboundEmail = $this->addPlusAddress($inboundEmail, "u{$userId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [new Address($inboundEmail, $business->name)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add plus addressing to an email.
|
||||||
|
* inbox@domain.com + "u123" => inbox+u123@domain.com
|
||||||
|
*/
|
||||||
|
protected function addPlusAddress(string $email, string $tag): string
|
||||||
|
{
|
||||||
|
$parts = explode('@', $email);
|
||||||
|
if (count($parts) !== 2) {
|
||||||
|
return $email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts[0].'+'.$tag.'@'.$parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Mail\Invoices;
|
namespace App\Mail\Invoices;
|
||||||
|
|
||||||
|
use App\Mail\Concerns\HasBusinessReplyTo;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Attachment;
|
use Illuminate\Mail\Attachment;
|
||||||
@@ -12,7 +13,7 @@ use Illuminate\Queue\SerializesModels;
|
|||||||
|
|
||||||
class InvoiceSentMail extends Mailable
|
class InvoiceSentMail extends Mailable
|
||||||
{
|
{
|
||||||
use Queueable, SerializesModels;
|
use HasBusinessReplyTo, Queueable, SerializesModels;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public Invoice $invoice,
|
public Invoice $invoice,
|
||||||
@@ -22,9 +23,19 @@ class InvoiceSentMail extends Mailable
|
|||||||
|
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
$business = $this->invoice->sellerBusiness;
|
||||||
subject: "Invoice {$this->invoice->invoice_number} from ".config('app.name'),
|
$envelope = new Envelope(
|
||||||
|
subject: "Invoice {$this->invoice->invoice_number} from ".($business?->name ?? config('app.name')),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($business) {
|
||||||
|
$replyTo = $this->getBusinessReplyTo($business);
|
||||||
|
if ($replyTo) {
|
||||||
|
$envelope->replyTo($replyTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function content(): Content
|
public function content(): Content
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Mail\Orders;
|
namespace App\Mail\Orders;
|
||||||
|
|
||||||
|
use App\Mail\Concerns\HasBusinessReplyTo;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
|
|||||||
|
|
||||||
class OrderAcceptedMail extends Mailable
|
class OrderAcceptedMail extends Mailable
|
||||||
{
|
{
|
||||||
use Queueable, SerializesModels;
|
use HasBusinessReplyTo, Queueable, SerializesModels;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new message instance.
|
* Create a new message instance.
|
||||||
@@ -25,9 +26,19 @@ class OrderAcceptedMail extends Mailable
|
|||||||
*/
|
*/
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
$business = $this->order->sellerBusiness;
|
||||||
|
$envelope = new Envelope(
|
||||||
subject: "Order {$this->order->order_number} Accepted",
|
subject: "Order {$this->order->order_number} Accepted",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($business) {
|
||||||
|
$replyTo = $this->getBusinessReplyTo($business);
|
||||||
|
if ($replyTo) {
|
||||||
|
$envelope->replyTo($replyTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Mail\Orders;
|
namespace App\Mail\Orders;
|
||||||
|
|
||||||
|
use App\Mail\Concerns\HasBusinessReplyTo;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
|
|||||||
|
|
||||||
class OrderDeliveredMail extends Mailable
|
class OrderDeliveredMail extends Mailable
|
||||||
{
|
{
|
||||||
use Queueable, SerializesModels;
|
use HasBusinessReplyTo, Queueable, SerializesModels;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public Order $order
|
public Order $order
|
||||||
@@ -19,9 +20,19 @@ class OrderDeliveredMail extends Mailable
|
|||||||
|
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
$business = $this->order->sellerBusiness;
|
||||||
|
$envelope = new Envelope(
|
||||||
subject: "Order {$this->order->order_number} Delivered",
|
subject: "Order {$this->order->order_number} Delivered",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($business) {
|
||||||
|
$replyTo = $this->getBusinessReplyTo($business);
|
||||||
|
if ($replyTo) {
|
||||||
|
$envelope->replyTo($replyTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function content(): Content
|
public function content(): Content
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Mail\Orders;
|
namespace App\Mail\Orders;
|
||||||
|
|
||||||
|
use App\Mail\Concerns\HasBusinessReplyTo;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
|
|||||||
|
|
||||||
class OrderReadyForDeliveryMail extends Mailable
|
class OrderReadyForDeliveryMail extends Mailable
|
||||||
{
|
{
|
||||||
use Queueable, SerializesModels;
|
use HasBusinessReplyTo, Queueable, SerializesModels;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public Order $order
|
public Order $order
|
||||||
@@ -19,9 +20,19 @@ class OrderReadyForDeliveryMail extends Mailable
|
|||||||
|
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
$business = $this->order->sellerBusiness;
|
||||||
|
$envelope = new Envelope(
|
||||||
subject: "Order {$this->order->order_number} Ready for Delivery",
|
subject: "Order {$this->order->order_number} Ready for Delivery",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($business) {
|
||||||
|
$replyTo = $this->getBusinessReplyTo($business);
|
||||||
|
if ($replyTo) {
|
||||||
|
$envelope->replyTo($replyTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function content(): Content
|
public function content(): Content
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Mail;
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Mail\Concerns\HasBusinessReplyTo;
|
||||||
use App\Models\Business;
|
use App\Models\Business;
|
||||||
use App\Models\Crm\CrmQuote;
|
use App\Models\Crm\CrmQuote;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
|
|
||||||
class QuoteMail extends Mailable
|
class QuoteMail extends Mailable
|
||||||
{
|
{
|
||||||
use Queueable, SerializesModels;
|
use HasBusinessReplyTo, Queueable, SerializesModels;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public CrmQuote $quote,
|
public CrmQuote $quote,
|
||||||
@@ -25,9 +26,16 @@ class QuoteMail extends Mailable
|
|||||||
|
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
$envelope = new Envelope(
|
||||||
subject: "Quote {$this->quote->quote_number} from {$this->business->name}",
|
subject: "Quote {$this->quote->quote_number} from {$this->business->name}",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$replyTo = $this->getBusinessReplyTo($this->business);
|
||||||
|
if ($replyTo) {
|
||||||
|
$envelope->replyTo($replyTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function content(): Content
|
public function content(): Content
|
||||||
|
|||||||
127
app/Models/AccountNote.php
Normal file
127
app/Models/AccountNote.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account Note - Sales rep notes on buyer accounts
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int $business_id
|
||||||
|
* @property int $account_id
|
||||||
|
* @property int $user_id
|
||||||
|
* @property string $note_type
|
||||||
|
* @property string $content
|
||||||
|
* @property bool $is_pinned
|
||||||
|
*/
|
||||||
|
class AccountNote extends Model
|
||||||
|
{
|
||||||
|
public const TYPE_GENERAL = 'general';
|
||||||
|
|
||||||
|
public const TYPE_COMPETITOR = 'competitor';
|
||||||
|
|
||||||
|
public const TYPE_PAIN_POINT = 'pain_point';
|
||||||
|
|
||||||
|
public const TYPE_OPPORTUNITY = 'opportunity';
|
||||||
|
|
||||||
|
public const TYPE_OBJECTION = 'objection';
|
||||||
|
|
||||||
|
public const TYPES = [
|
||||||
|
self::TYPE_GENERAL => 'General',
|
||||||
|
self::TYPE_COMPETITOR => 'Competitor Intel',
|
||||||
|
self::TYPE_PAIN_POINT => 'Pain Point',
|
||||||
|
self::TYPE_OPPORTUNITY => 'Opportunity',
|
||||||
|
self::TYPE_OBJECTION => 'Objection',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'business_id',
|
||||||
|
'account_id',
|
||||||
|
'user_id',
|
||||||
|
'note_type',
|
||||||
|
'content',
|
||||||
|
'is_pinned',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_pinned' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ==================== Relationships ====================
|
||||||
|
|
||||||
|
public function business(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Business::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function account(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Business::class, 'account_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Scopes ====================
|
||||||
|
|
||||||
|
public function scopeForBusiness($query, int $businessId)
|
||||||
|
{
|
||||||
|
return $query->where('business_id', $businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForAccount($query, int $accountId)
|
||||||
|
{
|
||||||
|
return $query->where('account_id', $accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePinned($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_pinned', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOfType($query, string $type)
|
||||||
|
{
|
||||||
|
return $query->where('note_type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeCompetitor($query)
|
||||||
|
{
|
||||||
|
return $query->where('note_type', self::TYPE_COMPETITOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePainPoints($query)
|
||||||
|
{
|
||||||
|
return $query->where('note_type', self::TYPE_PAIN_POINT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOpportunities($query)
|
||||||
|
{
|
||||||
|
return $query->where('note_type', self::TYPE_OPPORTUNITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
|
public function getTypeLabel(): string
|
||||||
|
{
|
||||||
|
return self::TYPES[$this->note_type] ?? ucfirst($this->note_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pin(): void
|
||||||
|
{
|
||||||
|
$this->update(['is_pinned' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unpin(): void
|
||||||
|
{
|
||||||
|
$this->update(['is_pinned' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ class InterBusinessSettlement extends Model
|
|||||||
|
|
||||||
return DB::transaction(function () use ($parentBusinessId, $prefix) {
|
return DB::transaction(function () use ($parentBusinessId, $prefix) {
|
||||||
$lastSettlement = static::where('parent_business_id', $parentBusinessId)
|
$lastSettlement = static::where('parent_business_id', $parentBusinessId)
|
||||||
->where('settlement_number', 'like', "{$prefix}%")
|
->where('settlement_number', 'ilike', "{$prefix}%")
|
||||||
->orderByDesc('settlement_number')
|
->orderByDesc('settlement_number')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ class JournalEntry extends Model implements AuditableContract
|
|||||||
// Get the last entry for this business+day, ordered by entry_number descending
|
// Get the last entry for this business+day, ordered by entry_number descending
|
||||||
// Lock the row to serialize concurrent access (PostgreSQL-safe)
|
// Lock the row to serialize concurrent access (PostgreSQL-safe)
|
||||||
$lastEntry = static::where('business_id', $businessId)
|
$lastEntry = static::where('business_id', $businessId)
|
||||||
->where('entry_number', 'like', "{$prefix}%")
|
->where('entry_number', 'ilike', "{$prefix}%")
|
||||||
->orderByDesc('entry_number')
|
->orderByDesc('entry_number')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ class Activity extends Model
|
|||||||
'task.reminder_sent' => ['icon' => 'heroicons--bell', 'color' => 'text-warning', 'label' => 'Reminder Sent'],
|
'task.reminder_sent' => ['icon' => 'heroicons--bell', 'color' => 'text-warning', 'label' => 'Reminder Sent'],
|
||||||
'event.reminder_sent' => ['icon' => 'heroicons--bell', 'color' => 'text-warning', 'label' => 'Reminder Sent'],
|
'event.reminder_sent' => ['icon' => 'heroicons--bell', 'color' => 'text-warning', 'label' => 'Reminder Sent'],
|
||||||
|
|
||||||
|
// Email engagement activities
|
||||||
|
'email.opened' => ['icon' => 'heroicons--envelope-open', 'color' => 'text-success', 'label' => 'Email Opened'],
|
||||||
|
'email.clicked' => ['icon' => 'heroicons--cursor-arrow-rays', 'color' => 'text-info', 'label' => 'Email Link Clicked'],
|
||||||
|
'email.bounced' => ['icon' => 'heroicons--exclamation-circle', 'color' => 'text-error', 'label' => 'Email Bounced'],
|
||||||
|
|
||||||
// Generic
|
// Generic
|
||||||
'note.added' => ['icon' => 'heroicons--document-text', 'color' => 'text-base-content', 'label' => 'Note Added'],
|
'note.added' => ['icon' => 'heroicons--document-text', 'color' => 'text-base-content', 'label' => 'Note Added'],
|
||||||
];
|
];
|
||||||
@@ -158,7 +163,7 @@ class Activity extends Model
|
|||||||
*/
|
*/
|
||||||
public function scopeOfTypeGroup($query, string $prefix)
|
public function scopeOfTypeGroup($query, string $prefix)
|
||||||
{
|
{
|
||||||
return $query->where('type', 'like', $prefix.'%');
|
return $query->where('type', 'ilike', $prefix.'%');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user