Compare commits
460 Commits
feat/admin
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
0b54c251bc | ||
|
|
8995c60d88 | ||
|
|
c4e178a900 | ||
|
|
6688bbf8a1 | ||
|
|
bb5f2c8aaa | ||
|
|
a9d0f328a8 | ||
|
|
3b769905b7 | ||
|
|
f7727d8c17 | ||
|
|
6d7eb4f151 | ||
|
|
0c260f69b0 | ||
|
|
63b9372372 | ||
|
|
aaff332937 | ||
|
|
964548ba38 | ||
|
|
cf05d8cad1 | ||
|
|
05dca8f847 | ||
|
|
27328c9106 | ||
|
|
b3dd9a8e23 | ||
|
|
1cd6c15cb3 | ||
|
|
3554578554 | ||
|
|
3962807fc6 | ||
|
|
32054ddcce | ||
|
|
5905699ca1 | ||
|
|
eb8e2a89c4 | ||
|
|
8286aebf4e | ||
|
|
4cff4af841 | ||
|
|
8abcd3291e | ||
|
|
a7c3eb4183 | ||
|
|
1ed62fe0de | ||
|
|
160b312ca5 | ||
|
|
6d22a99259 | ||
|
|
febfd75016 | ||
|
|
fbb72f902b | ||
|
|
fd11ae0fe0 | ||
|
|
16c5c455fa | ||
|
|
df587fdda3 | ||
|
|
3fb5747aa2 | ||
|
|
33c9420b00 | ||
|
|
37204edfd7 | ||
|
|
8d9725b501 | ||
|
|
6cf8ad1854 | ||
|
|
58f787feb0 | ||
|
|
970ce05846 | ||
|
|
672b0d5f6b | ||
|
|
4415194b28 | ||
|
|
213b0ef8f2 | ||
|
|
13dbe046e1 | ||
|
|
592df4de44 | ||
|
|
ae581b4d5c | ||
|
|
8a8f83cc0c | ||
|
|
722904d487 | ||
|
|
ddc84f6730 | ||
|
|
2c510844f0 | ||
|
|
105a1e8ce0 | ||
|
|
7e06ff3488 | ||
|
|
aed1e62c65 | ||
|
|
f9f1b8dc46 | ||
|
|
89d3a54988 | ||
|
|
0c60e5c519 | ||
|
|
1ecc4a916b | ||
|
|
d4ec8c16f3 | ||
|
|
f9d7573cb4 | ||
|
|
e48e9c9b82 | ||
|
|
afbb1ba79c | ||
|
|
5f0042e483 | ||
|
|
08f5a3adac | ||
|
|
e62ea5c809 | ||
|
|
8d43953cad | ||
|
|
a628f2b207 | ||
|
|
367daadfe9 | ||
|
|
329c01523a | ||
|
|
5fb26f901d | ||
|
|
6baadf5744 | ||
|
|
a3508c57a2 | ||
|
|
38cba2cd72 | ||
|
|
735e09ab90 | ||
|
|
05ef21cd71 | ||
|
|
65c65bf9cc | ||
|
|
e33f0d0182 | ||
|
|
c8faf2f2d6 | ||
|
|
50bb3fce77 | ||
|
|
c7fdc67060 | ||
|
|
c7e2b0e4ac | ||
|
|
0cf83744db | ||
|
|
defeeffa07 | ||
|
|
0fbf99c005 | ||
|
|
67eb679c7e | ||
|
|
3b7f3acaa6 | ||
|
|
3d1f3b1057 | ||
|
|
7a2748e904 | ||
|
|
4f2061cd00 | ||
|
|
8bb9044f2d | ||
|
|
7da52677d5 | ||
|
|
a049db38a9 | ||
|
|
bb60a772f9 | ||
|
|
95d92f27d3 | ||
|
|
f08910bbf4 | ||
|
|
e043137269 | ||
|
|
de988d9abd | ||
|
|
72df0cfe88 | ||
|
|
65a752f4d8 | ||
|
|
7d0230be5f | ||
|
|
75305a01b0 | ||
|
|
f2ce0dfee3 | ||
|
|
1222610080 | ||
|
|
c1d0cdf477 | ||
|
|
a55ea906ac | ||
|
|
70e274415d | ||
|
|
fca89475cc | ||
|
|
b33ebac9bf | ||
|
|
a88eeb7981 | ||
|
|
eed4df0c4a | ||
|
|
915b0407cf | ||
|
|
f173254700 | ||
|
|
539cd0e4e1 | ||
|
|
050a446ba0 | ||
|
|
8fe4213178 | ||
|
|
d7413784ea | ||
|
|
b6b049e321 | ||
|
|
11509c4af0 | ||
|
|
8651e5a9e6 | ||
|
|
e0d931d72c | ||
|
|
6c7a0d2a35 | ||
|
|
95684ffae0 | ||
|
|
b30f5db061 | ||
|
|
266bb3ff9c | ||
|
|
f227a53ac1 | ||
|
|
6d0adb0b02 | ||
|
|
61b2a2beb6 | ||
|
|
fdfe132545 | ||
|
|
c9e191ee7e | ||
|
|
d42c964c30 | ||
|
|
b8e7ebc3ac | ||
|
|
e156716002 | ||
|
|
b5c1d92397 | ||
|
|
72e96b7e0e | ||
|
|
4489377762 | ||
|
|
eedd4c9cef | ||
|
|
2370f31a18 | ||
|
|
27c8395d5a | ||
|
|
dbee401f61 | ||
|
|
b17bc590bb | ||
|
|
6ce5ca14e2 | ||
|
|
454b85ffb1 | ||
|
|
e13d7cd7ad | ||
|
|
f3436d35ec | ||
|
|
a46b44055e | ||
|
|
a3dda1520e | ||
|
|
4068bfc0b2 | ||
|
|
497523ee0c | ||
|
|
94d68f80e4 | ||
|
|
c091c3c168 | ||
|
|
7c54ece253 | ||
|
|
f7294fcf83 | ||
|
|
6d64d9527a | ||
|
|
08df003b20 | ||
|
|
59cd09eb5b | ||
|
|
3a6ab1c207 | ||
|
|
404a731bd9 | ||
|
|
2b30deed11 | ||
|
|
109d9cd39d | ||
|
|
aadd7a500a | ||
|
|
111ef20684 | ||
|
|
85fdb71f92 | ||
|
|
08e2eb3ac6 | ||
|
|
87e8384aca | ||
|
|
e56ad20568 | ||
|
|
fafb05e29b | ||
|
|
a322d7609b | ||
|
|
2aefba3619 | ||
|
|
b47fc35857 | ||
|
|
e5e1dea055 | ||
|
|
e5e485d636 | ||
|
|
3d383e0490 | ||
|
|
df188e21ce | ||
|
|
55016f7009 | ||
|
|
9cf89c7b1a | ||
|
|
0d810dff27 | ||
|
|
624a36d2c5 | ||
|
|
92e3e171e1 | ||
|
|
58ca83c8c2 | ||
|
|
7f175709a5 | ||
|
|
26a903bdd9 | ||
|
|
e871426817 | ||
|
|
c99511d696 | ||
|
|
963f00cd39 | ||
|
|
0db70220c7 | ||
|
|
4bcd0cca8a | ||
|
|
6c7d7016c9 | ||
|
|
6d92f37ea7 | ||
|
|
318d6b4fe8 | ||
|
|
9ea69447ec | ||
|
|
a24fbaac9a | ||
|
|
412a3beeed | ||
|
|
4e7f344941 | ||
|
|
d0e9369795 | ||
|
|
8f56f32e62 | ||
|
|
b8d307200b | ||
|
|
4e979c3158 | ||
|
|
085ca6c415 | ||
|
|
1d363d7157 | ||
|
|
71effd6f4c | ||
|
|
2198008b4c | ||
|
|
2320511cd3 | ||
|
|
6124e8fa07 | ||
|
|
23195d1887 | ||
|
|
d9e99b3091 | ||
|
|
e774093e94 | ||
|
|
697ba5f0f4 | ||
|
|
ef043bda0c | ||
|
|
0f419075cd | ||
|
|
9b3bb1d93b | ||
|
|
8b4f6a48ad | ||
|
|
f5d537cb67 | ||
|
|
fad91c5d7d | ||
|
|
7e2b3d4ce6 | ||
|
|
918d2a3a95 | ||
|
|
bff2199cb6 | ||
|
|
8b32be2c19 | ||
|
|
9ee02b6115 | ||
|
|
7c1ff57eb1 | ||
|
|
67c663faf4 | ||
|
|
691aeda2c2 | ||
|
|
0e4e7784d3 | ||
|
|
315a206542 | ||
|
|
d1ff2e8221 | ||
|
|
a2184e2de2 | ||
|
|
cf4a77c72a | ||
|
|
4adc611e83 | ||
|
|
3c88bbfb4d | ||
|
|
3496421264 | ||
|
|
91f1ae217a | ||
|
|
5b7a2dd7bf | ||
|
|
c991d3f141 |
@@ -8,8 +8,8 @@ node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Composer
|
||||
/vendor
|
||||
# Composer (NOT excluded - Dockerfile.fast needs pre-built vendor)
|
||||
# /vendor
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -58,7 +58,7 @@ docker-compose.*.yml
|
||||
# Build artifacts
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/build
|
||||
# /public/build - NOT excluded, Dockerfile.fast needs pre-built assets
|
||||
|
||||
# Misc
|
||||
.env.backup
|
||||
|
||||
53
.env.example
53
.env.example
@@ -24,12 +24,13 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# PostgreSQL: 10.100.6.50:5432
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=pgsql
|
||||
DB_HOST=10.100.6.50
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=cannabrands_app
|
||||
DB_USERNAME=sail
|
||||
DB_PASSWORD=password
|
||||
DB_DATABASE=cannabrands_dev
|
||||
DB_USERNAME=cannabrands
|
||||
DB_PASSWORD=SpDyCannaBrands2024
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
@@ -66,9 +67,10 @@ CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
# Redis: 10.100.9.50:6379
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_HOST=10.100.9.50
|
||||
REDIS_PASSWORD=SpDyR3d1s2024!
|
||||
REDIS_PORT=6379
|
||||
|
||||
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)
|
||||
# Access MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
|
||||
# Server: 10.100.9.80:9000 | Console: 10.100.9.80:9001
|
||||
FILESYSTEM_DISK=minio
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
AWS_ACCESS_KEY_ID=cannabrands-app
|
||||
AWS_SECRET_ACCESS_KEY=cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=media
|
||||
AWS_ENDPOINT=http://minio:9000
|
||||
AWS_URL=http://localhost:9000/media
|
||||
AWS_BUCKET=cannabrands
|
||||
AWS_ENDPOINT=http://10.100.9.80:9000
|
||||
AWS_URL=http://10.100.9.80:9000/cannabrands
|
||||
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}"
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -82,3 +82,4 @@ SESSION_*
|
||||
# AI workflow personal context files
|
||||
CLAUDE.local.md
|
||||
claude.*.md
|
||||
cannabrands_dev_backup.dump
|
||||
|
||||
@@ -1,399 +1,301 @@
|
||||
# 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):
|
||||
# - develop branch → dev.cannabrands.app (integration/testing)
|
||||
# - master branch → cannabrands.app (production)
|
||||
# - tags (2025.X) → cannabrands.app (versioned production releases)
|
||||
# Optimizations:
|
||||
# - Parallel composer + frontend builds
|
||||
# - Split tests (unit + feature run in parallel)
|
||||
# - Dependency caching (npm + composer)
|
||||
# - Single-stage Dockerfile.fast
|
||||
# - Kaniko layer caching
|
||||
#
|
||||
# Pipeline Strategy:
|
||||
# - PRs: Run tests (lint, style, phpunit)
|
||||
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
|
||||
# - Tags: Build versioned release
|
||||
# External Services:
|
||||
# - PostgreSQL: 10.100.6.50:5432 (cannabrands_dev)
|
||||
# - Redis: 10.100.9.50:6379
|
||||
# - MinIO: 10.100.9.80:9000
|
||||
# - Docker Registry: git.spdy.io (for k8s pulls)
|
||||
|
||||
when:
|
||||
- branch: [develop, master]
|
||||
event: push
|
||||
- event: [pull_request, tag]
|
||||
|
||||
# Install dependencies first (needed for php-lint to resolve traits/classes)
|
||||
steps:
|
||||
# Restore Composer cache
|
||||
restore-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
backend: "filesystem"
|
||||
restore: true
|
||||
cache_key: "composer-{{ checksum \"composer.lock\" }}"
|
||||
archive_format: "gzip"
|
||||
mount:
|
||||
- "vendor"
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
depth: 50
|
||||
lfs: false
|
||||
partial: false
|
||||
|
||||
steps:
|
||||
# ============================================
|
||||
# PARALLEL: Composer + Frontend (with caching)
|
||||
# ============================================
|
||||
|
||||
# Install dependencies (uses pre-built Laravel image with all extensions)
|
||||
composer-install:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
commands:
|
||||
- echo "Creating minimal .env for package discovery..."
|
||||
- |
|
||||
cat > .env << 'EOF'
|
||||
APP_NAME="Cannabrands Hub"
|
||||
APP_ENV=testing
|
||||
APP_ENV=development
|
||||
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
|
||||
- echo "Checking for cached dependencies..."
|
||||
- |
|
||||
if [ -d "vendor" ] && [ -f "vendor/autoload.php" ]; then
|
||||
echo "✅ Restored vendor from cache"
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
else
|
||||
echo "📦 Installing fresh dependencies (cache miss)"
|
||||
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
fi
|
||||
- echo "✅ Composer dependencies ready!"
|
||||
# Restore composer cache if available
|
||||
- mkdir -p /root/.composer/cache
|
||||
- if [ -d .composer-cache ]; then cp -r .composer-cache/* /root/.composer/cache/ 2>/dev/null || true; fi
|
||||
# Clean vendor and bootstrap cache to force fresh install
|
||||
- rm -rf vendor bootstrap/cache/*.php
|
||||
- composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
# Verify test command is available
|
||||
- php artisan list test | head -5
|
||||
# Save cache for next build
|
||||
- mkdir -p .composer-cache && cp -r /root/.composer/cache/* .composer-cache/ 2>/dev/null || true
|
||||
- echo "✅ Composer done"
|
||||
|
||||
# Rebuild Composer cache
|
||||
rebuild-composer-cache:
|
||||
image: meltwater/drone-cache:dev
|
||||
settings:
|
||||
backend: "filesystem"
|
||||
rebuild: true
|
||||
cache_key: "composer-{{ checksum \"composer.lock\" }}"
|
||||
archive_format: "gzip"
|
||||
mount:
|
||||
- "vendor"
|
||||
volumes:
|
||||
- /tmp/woodpecker-cache:/tmp/cache
|
||||
build-frontend:
|
||||
image: 10.100.9.70:5000/library/node:22-alpine
|
||||
environment:
|
||||
VITE_REVERB_APP_KEY: 6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU=
|
||||
VITE_REVERB_HOST: dev.cannabrands.app
|
||||
VITE_REVERB_PORT: "443"
|
||||
VITE_REVERB_SCHEME: https
|
||||
npm_config_cache: .npm-cache
|
||||
commands:
|
||||
# Use cached node_modules if available
|
||||
- npm ci --prefer-offline
|
||||
- npm run build
|
||||
- echo "✅ Frontend built"
|
||||
|
||||
# ============================================
|
||||
# PR CHECKS (Parallel: lint, style, tests)
|
||||
# ============================================
|
||||
|
||||
# PHP Syntax Check (PRs only - skipped on merge since tests already passed)
|
||||
php-lint:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- echo "Checking PHP syntax..."
|
||||
- find app -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
|
||||
- find routes -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
|
||||
- find database -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
|
||||
- echo "✅ PHP syntax check complete!"
|
||||
- ./vendor/bin/parallel-lint app routes database config --colors --blame
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run Laravel Pint (PRs only - skipped on merge since tests already passed)
|
||||
code-style:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
commands:
|
||||
- echo "Checking code style with Laravel Pint..."
|
||||
- ./vendor/bin/pint --test
|
||||
- echo "✅ Code style check complete!"
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Run PHPUnit Tests (PRs only - skipped on merge since tests already passed)
|
||||
# Note: Uses array cache/session for speed and isolation (Laravel convention)
|
||||
# Redis + Reverb services used for real-time broadcasting tests
|
||||
tests:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
# Split tests: Unit tests (with DB - some unit tests use factories)
|
||||
tests-unit:
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
APP_ENV: testing
|
||||
BROADCAST_CONNECTION: reverb
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_HOST: 10.100.6.50
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: testing
|
||||
DB_PASSWORD: testing
|
||||
REDIS_HOST: redis
|
||||
REVERB_APP_ID: test-app-id
|
||||
REVERB_APP_KEY: test-key
|
||||
REVERB_APP_SECRET: test-secret
|
||||
REVERB_HOST: localhost
|
||||
REVERB_PORT: 8080
|
||||
REVERB_SCHEME: http
|
||||
DB_DATABASE: cannabrands_test
|
||||
DB_USERNAME: cannabrands
|
||||
DB_PASSWORD: SpDyCannaBrands2024
|
||||
commands:
|
||||
- echo "Setting up Laravel environment..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Starting Reverb server in background..."
|
||||
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
|
||||
- sleep 2
|
||||
- echo "Running tests..."
|
||||
- php artisan test --parallel
|
||||
- echo "Tests complete!"
|
||||
- php artisan test --testsuite=Unit
|
||||
- echo "✅ Unit tests passed"
|
||||
|
||||
# Validate seeders that run in dev/staging environments
|
||||
# This prevents deployment failures caused by seeder errors (e.g., fake() crashes)
|
||||
# Uses APP_ENV=development to match K8s init container behavior
|
||||
validate-seeders:
|
||||
image: kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
# Split tests: Feature tests (with DB)
|
||||
tests-feature:
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
APP_ENV: development
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: testing
|
||||
DB_PASSWORD: testing
|
||||
APP_ENV: testing
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
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:
|
||||
- echo "Validating seeders (matches K8s init container)..."
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- echo "Running migrate:fresh --seed with APP_ENV=development..."
|
||||
- php artisan migrate:fresh --seed --force
|
||||
- echo "✅ Seeder validation complete!"
|
||||
- php artisan test --testsuite=Feature
|
||||
- echo "✅ Feature tests passed"
|
||||
|
||||
# ============================================
|
||||
# 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:
|
||||
branch: [develop, master]
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for DEV environment (develop branch)
|
||||
build-image-dev:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- dev # Latest dev build → dev.cannabrands.app
|
||||
- dev-${CI_COMMIT_SHA:0:7} # Unique dev tag with SHA
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
|
||||
- ${CI_COMMIT_BRANCH} # Branch name (develop)
|
||||
build_args:
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "dev"
|
||||
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
|
||||
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||
depends_on:
|
||||
- setup-registry-auth
|
||||
commands:
|
||||
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||
--destination=registry.spdy.io/cannabrands/hub:dev \
|
||||
--destination=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=APP_VERSION=dev \
|
||||
--registry-mirror=10.100.9.70:5000 \
|
||||
--insecure-registry=10.100.9.70:5000 \
|
||||
--cache=true \
|
||||
--cache-ttl=168h \
|
||||
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Auto-deploy to dev.cannabrands.app (develop branch only)
|
||||
deploy-dev:
|
||||
image: bitnami/kubectl:latest
|
||||
image: 10.100.9.70:5000/bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-dev
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_dev
|
||||
commands:
|
||||
- echo "🚀 Auto-deploying to dev.cannabrands.app..."
|
||||
- echo "Commit SHA${CI_COMMIT_SHA:0:7}"
|
||||
- echo ""
|
||||
# Setup kubeconfig
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
||||
- chmod 600 ~/.kube/config
|
||||
# Update deployment to use new SHA-tagged image (both app and init containers)
|
||||
- |
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
app=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
|
||||
-n cannabrands-dev
|
||||
# Wait for rollout to complete (timeout 5 minutes)
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
|
||||
# Verify deployment health
|
||||
- |
|
||||
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 ""
|
||||
- echo "✅ Deployed to dev.cannabrands.app"
|
||||
when:
|
||||
branch: develop
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for PRODUCTION (master branch)
|
||||
build-image-production:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- latest # Latest production build
|
||||
- prod-${CI_COMMIT_SHA:0:7} # Unique prod tag with SHA
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
|
||||
- ${CI_COMMIT_BRANCH} # Branch name (master)
|
||||
build_args:
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "production"
|
||||
cache_from:
|
||||
- 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
|
||||
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||
depends_on:
|
||||
- setup-registry-auth
|
||||
commands:
|
||||
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||
--destination=git.spdy.io/cannabrands/hub:latest \
|
||||
--destination=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=APP_VERSION=production \
|
||||
--cache=true \
|
||||
--cache-ttl=168h \
|
||||
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
|
||||
--insecure \
|
||||
--insecure-pull \
|
||||
--skip-tls-verify
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Deploy to production (master branch)
|
||||
deploy-production:
|
||||
image: bitnami/kubectl:latest
|
||||
image: 10.100.9.70:5000/bitnami/kubectl:latest
|
||||
depends_on:
|
||||
- build-image-production
|
||||
environment:
|
||||
KUBECONFIG_CONTENT:
|
||||
from_secret: kubeconfig_prod
|
||||
commands:
|
||||
- echo "🚀 Deploying to PRODUCTION (cannabrands.app)..."
|
||||
- echo "Commit SHA ${CI_COMMIT_SHA:0:7}"
|
||||
- mkdir -p ~/.kube
|
||||
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
|
||||
- chmod 600 ~/.kube/config
|
||||
- |
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
app=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
migrate=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
|
||||
-n cannabrands-prod
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
|
||||
- |
|
||||
echo ""
|
||||
echo "✅ PRODUCTION deployment successful!"
|
||||
echo "Pod status:"
|
||||
kubectl get pods -n cannabrands-prod -l app=cannabrands-hub
|
||||
- echo "✅ Deployed to cannabrands.app"
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
status: success
|
||||
|
||||
# Build and push Docker image for tagged releases (optional versioned releases)
|
||||
build-image-release:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_token
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG} # CalVer tag (e.g., 2025.10.1)
|
||||
- latest # Latest stable release
|
||||
build_args:
|
||||
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
|
||||
APP_VERSION: "${CI_COMMIT_TAG}"
|
||||
cache_images:
|
||||
- code.cannabrands.app/cannabrands/hub:buildcache-prod
|
||||
platforms: linux/amd64
|
||||
# Disable provenance attestations - can cause Gitea registry 500 errors
|
||||
provenance: false
|
||||
# For tags, setup auth first
|
||||
setup-registry-auth-release:
|
||||
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":{"git.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
|
||||
EOF
|
||||
when:
|
||||
event: tag
|
||||
status: success
|
||||
|
||||
# Success notification
|
||||
success:
|
||||
image: alpine:latest
|
||||
when:
|
||||
- evaluate: 'CI_PIPELINE_STATUS == "success"'
|
||||
build-image-release:
|
||||
image: 10.100.9.70:5000/kaniko-project/executor:debug
|
||||
depends_on:
|
||||
- setup-registry-auth-release
|
||||
commands:
|
||||
- echo "✅ Pipeline completed successfully!"
|
||||
- echo "All checks passed for commit ${CI_COMMIT_SHA:0:7}"
|
||||
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
|
||||
- |
|
||||
if [ "${CI_PIPELINE_EVENT}" = "tag" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🎉 PRODUCTION RELEASE BUILD COMPLETE"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Version: ${CI_COMMIT_TAG}"
|
||||
echo "Registry: code.cannabrands.app/cannabrands/hub"
|
||||
echo ""
|
||||
echo "Available as:"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
|
||||
echo " - code.cannabrands.app/cannabrands/hub:latest"
|
||||
echo ""
|
||||
echo "🚀 Deploy to PRODUCTION (cannabrands.app):"
|
||||
echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
|
||||
echo " docker-compose -f docker-compose.production.yml up -d"
|
||||
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
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: testing
|
||||
POSTGRES_PASSWORD: testing
|
||||
POSTGRES_DB: testing
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
commands:
|
||||
- redis-server --bind 0.0.0.0
|
||||
/kaniko/executor \
|
||||
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
|
||||
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
|
||||
--destination=git.spdy.io/cannabrands/hub:${CI_COMMIT_TAG} \
|
||||
--destination=git.spdy.io/cannabrands/hub:latest \
|
||||
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
|
||||
--build-arg=APP_VERSION=${CI_COMMIT_TAG} \
|
||||
--cache=true \
|
||||
--cache-ttl=168h \
|
||||
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
|
||||
--insecure \
|
||||
--insecure-pull \
|
||||
--skip-tls-verify
|
||||
when:
|
||||
event: tag
|
||||
|
||||
@@ -69,14 +69,14 @@ git push origin develop
|
||||
|
||||
**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):**
|
||||
```
|
||||
code.cannabrands.app/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
|
||||
code.cannabrands.app/cannabrands/hub:dev # Latest dev (convenience)
|
||||
code.cannabrands.app/cannabrands/hub:sha-a28d5b5 # Generic SHA
|
||||
git.spdy.io/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
|
||||
git.spdy.io/cannabrands/hub:dev # Latest dev (convenience)
|
||||
git.spdy.io/cannabrands/hub:sha-a28d5b5 # Generic SHA
|
||||
```
|
||||
|
||||
### 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 \
|
||||
-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
|
||||
git log --oneline develop | head -5
|
||||
|
||||
# 3. Rollback to previous SHA
|
||||
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
|
||||
|
||||
# 4. Verify rollback
|
||||
@@ -156,7 +156,7 @@ deploy-staging:
|
||||
- chmod 600 ~/.kube/config
|
||||
- |
|
||||
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
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-staging --timeout=300s
|
||||
when:
|
||||
@@ -207,7 +207,7 @@ kubectl logs -n cannabrands-dev deployment/cannabrands-hub --tail=100
|
||||
cannabrands-hub-7d85986845-gnkbv 1/1 Running 0 45s
|
||||
|
||||
Image deployed:
|
||||
code.cannabrands.app/cannabrands/hub:dev-a28d5b5
|
||||
git.spdy.io/cannabrands/hub:dev-a28d5b5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -47,8 +47,8 @@ steps:
|
||||
build-image:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags: [latest, ${CI_COMMIT_SHA:0:8}]
|
||||
when:
|
||||
branch: master
|
||||
@@ -68,7 +68,7 @@ steps:
|
||||
```bash
|
||||
# On production server
|
||||
ssh cannabrands-prod
|
||||
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
|
||||
docker pull git.spdy.io/cannabrands/hub:bef77df8
|
||||
docker-compose up -d
|
||||
# Or use deployment tool like Ansible, Deployer, etc.
|
||||
```
|
||||
@@ -108,7 +108,7 @@ steps:
|
||||
from_secret: ssh_private_key
|
||||
script:
|
||||
- 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 exec cannabrands php artisan migrate --force
|
||||
- docker exec cannabrands php artisan config:cache
|
||||
@@ -160,7 +160,7 @@ steps:
|
||||
from_secret: ssh_private_key
|
||||
script:
|
||||
- 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
|
||||
when:
|
||||
branch: develop
|
||||
@@ -176,7 +176,7 @@ steps:
|
||||
from_secret: ssh_private_key
|
||||
script:
|
||||
- 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
|
||||
when:
|
||||
branch: master
|
||||
@@ -367,7 +367,7 @@ Production:
|
||||
```bash
|
||||
# Quick rollback (under 2 minutes)
|
||||
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
|
||||
|
||||
# Database rollback (if migrations ran)
|
||||
@@ -536,8 +536,8 @@ steps:
|
||||
build-image:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- ${CI_COMMIT_BRANCH}
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
@@ -559,7 +559,7 @@ steps:
|
||||
from_secret: staging_ssh_key
|
||||
script:
|
||||
- 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 exec cannabrands php artisan migrate --force
|
||||
- docker exec cannabrands php artisan config:cache
|
||||
@@ -582,7 +582,7 @@ steps:
|
||||
- echo "To deploy to production:"
|
||||
- echo " ssh cannabrands-prod"
|
||||
- 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 ""
|
||||
- echo "⚠️ Remember: Check deployment checklist first!"
|
||||
|
||||
@@ -102,7 +102,7 @@ Push to master → Woodpecker runs:
|
||||
→ Build Docker image
|
||||
→ Tag: cannabrands-hub:c165bf9 (commit SHA)
|
||||
→ Tag: cannabrands-hub:latest
|
||||
→ Push to code.cannabrands.app/cannabrands/hub
|
||||
→ Push to git.spdy.io/cannabrands/hub
|
||||
→ Image ready, no deployment yet
|
||||
```
|
||||
|
||||
@@ -177,7 +177,7 @@ CMD ["php-fpm"]
|
||||
### Staging Deployment:
|
||||
```bash
|
||||
# Pull the same image
|
||||
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
docker pull git.spdy.io/cannabrands/hub:c165bf9
|
||||
|
||||
# Run with staging environment
|
||||
docker run \
|
||||
@@ -186,13 +186,13 @@ docker run \
|
||||
-e DB_DATABASE=cannabrands_staging \
|
||||
-e APP_DEBUG=true \
|
||||
-e MAIL_MAILER=log \
|
||||
code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
git.spdy.io/cannabrands/hub:c165bf9
|
||||
```
|
||||
|
||||
### Production Deployment:
|
||||
```bash
|
||||
# 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
|
||||
docker run \
|
||||
@@ -201,7 +201,7 @@ docker run \
|
||||
-e DB_DATABASE=cannabrands_production \
|
||||
-e APP_DEBUG=false \
|
||||
-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.
|
||||
@@ -218,7 +218,7 @@ docker run \
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: code.cannabrands.app/cannabrands/hub:latest
|
||||
image: git.spdy.io/cannabrands/hub:latest
|
||||
env_file:
|
||||
- .env.staging # Staging-specific vars
|
||||
ports:
|
||||
@@ -253,7 +253,7 @@ secrets:
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: code.cannabrands.app/cannabrands/hub:c165bf9 # Specific SHA
|
||||
image: git.spdy.io/cannabrands/hub:c165bf9 # Specific SHA
|
||||
env_file:
|
||||
- .env.production # Production-specific vars
|
||||
ports:
|
||||
@@ -301,7 +301,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
image: git.spdy.io/cannabrands/hub:c165bf9
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: app-config-staging # Different per namespace
|
||||
@@ -350,8 +350,8 @@ steps:
|
||||
build-and-publish:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- latest # Always overwrite
|
||||
- ${CI_COMMIT_SHA:0:8} # Immutable SHA
|
||||
@@ -384,7 +384,7 @@ Date: 2025-01-15 14:30:00 PST
|
||||
Image: cannabrands-hub:c165bf9
|
||||
Deployed by: jon@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
|
||||
Tests passed: ✅ 28/28
|
||||
Staging tested: ✅ 2 hours
|
||||
@@ -424,7 +424,7 @@ Rollback image: cannabrands-hub:a1b2c3d
|
||||
```bash
|
||||
# On production server
|
||||
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
|
||||
```
|
||||
|
||||
@@ -487,14 +487,14 @@ steps:
|
||||
security-scan:
|
||||
image: aquasec/trivy
|
||||
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)
|
||||
|
||||
Use Cosign to cryptographically sign images:
|
||||
```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.
|
||||
@@ -507,10 +507,10 @@ Compliance benefit: Prove image hasn't been tampered with.
|
||||
|
||||
```bash
|
||||
# List recent deployments
|
||||
docker images code.cannabrands.app/cannabrands/hub
|
||||
docker images git.spdy.io/cannabrands/hub
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
@@ -531,7 +531,7 @@ deploy:
|
||||
# Before risky deployment
|
||||
git tag -a v1.5.2-stable -m "Last known good version"
|
||||
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:**
|
||||
```bash
|
||||
docker build -f docker/ci-php.Dockerfile -t code.cannabrands.app/cannabrands/ci-php:8.3 .
|
||||
docker push 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 git.spdy.io/cannabrands/ci-php:8.3
|
||||
```
|
||||
|
||||
**Update `.woodpecker/.ci.yml`:**
|
||||
```yaml
|
||||
steps:
|
||||
php-lint:
|
||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||
commands:
|
||||
- find app routes database -name "*.php" -exec php -l {} \;
|
||||
|
||||
composer-install:
|
||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||
commands:
|
||||
- composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
code-style:
|
||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||
commands:
|
||||
- ./vendor/bin/pint --test
|
||||
```
|
||||
|
||||
@@ -107,7 +107,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: code.cannabrands.app/cannabrands/hub:latest
|
||||
image: git.spdy.io/cannabrands/hub:latest
|
||||
container_name: cannabrands_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -204,8 +204,8 @@ steps:
|
||||
build-image:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
@@ -564,7 +564,7 @@ docker images | grep cannabrands
|
||||
|
||||
```bash
|
||||
# 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
|
||||
docker compose up -d app
|
||||
|
||||
@@ -11,10 +11,10 @@ Once you implement production deployments, Woodpecker will:
|
||||
|
||||
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
|
||||
|
||||
@@ -22,7 +22,7 @@ First, verify the registry is enabled on your Gitea instance:
|
||||
|
||||
1. **Check as admin**: Admin → Site Administration → Configuration
|
||||
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`:
|
||||
```ini
|
||||
@@ -61,8 +61,8 @@ steps:
|
||||
build-and-publish:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
@@ -136,15 +136,15 @@ Once images are published, you can pull them on your production servers:
|
||||
|
||||
```bash
|
||||
# Login to Gitea registry
|
||||
docker login code.cannabrands.app
|
||||
docker login git.spdy.io
|
||||
# Username: your-gitea-username
|
||||
# Password: your-personal-access-token
|
||||
|
||||
# Pull latest image
|
||||
docker pull code.cannabrands.app/cannabrands/hub:latest
|
||||
docker pull git.spdy.io/cannabrands/hub:latest
|
||||
|
||||
# Or pull specific commit
|
||||
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
|
||||
docker pull git.spdy.io/cannabrands/hub:bef77df8
|
||||
```
|
||||
|
||||
## Image Tagging Strategy
|
||||
@@ -218,8 +218,8 @@ steps:
|
||||
build-and-publish:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
@@ -236,7 +236,7 @@ steps:
|
||||
notify-deploy:
|
||||
image: alpine:latest
|
||||
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!"
|
||||
when:
|
||||
- branch: master
|
||||
@@ -271,8 +271,8 @@ services:
|
||||
- Subsequent builds will work fine
|
||||
|
||||
**Images not appearing in Gitea packages**
|
||||
- Check Gitea packages are enabled: https://code.cannabrands.app/-/packages
|
||||
- Verify registry URL is `code.cannabrands.app` (not `ci.cannabrands.app`)
|
||||
- Check Gitea packages are enabled: https://git.spdy.io/-/packages
|
||||
- Verify registry URL is `git.spdy.io` (not `ci.cannabrands.app`)
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ git push origin 2025.11.3
|
||||
|
||||
### 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:
|
||||
- Run tests
|
||||
@@ -113,7 +113,7 @@ git push origin master
|
||||
```bash
|
||||
# Deploy specific version
|
||||
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
|
||||
kubectl rollout status deployment/cannabrands
|
||||
@@ -131,7 +131,7 @@ kubectl get pods
|
||||
```bash
|
||||
# Option 1: Rollback to previous version
|
||||
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
|
||||
kubectl rollout undo deployment/cannabrands
|
||||
@@ -154,7 +154,7 @@ git push origin 2025.11.4
|
||||
|
||||
# 4. Deploy when confident
|
||||
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:**
|
||||
```yaml
|
||||
image: code.cannabrands.app/cannabrands/hub:latest-dev
|
||||
image: git.spdy.io/cannabrands/hub:latest-dev
|
||||
imagePullPolicy: Always
|
||||
```
|
||||
|
||||
@@ -182,7 +182,7 @@ stable → Latest production release
|
||||
|
||||
**Use in K3s production:**
|
||||
```yaml
|
||||
image: code.cannabrands.app/cannabrands/hub:2025.11.3
|
||||
image: git.spdy.io/cannabrands/hub:2025.11.3
|
||||
imagePullPolicy: IfNotPresent
|
||||
```
|
||||
|
||||
@@ -214,7 +214,7 @@ docker build -t cannabrands:test .
|
||||
### View CI Status
|
||||
```bash
|
||||
# Visit Woodpecker
|
||||
open https://code.cannabrands.app/cannabrands/hub/pipelines
|
||||
open https://git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Or check latest build
|
||||
# (Visit Gitea → Repository → Pipelines)
|
||||
@@ -227,7 +227,7 @@ open https://code.cannabrands.app/cannabrands/hub/pipelines
|
||||
### CI Build Failing
|
||||
```bash
|
||||
# Check Woodpecker logs
|
||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
||||
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Run tests locally first
|
||||
./vendor/bin/sail artisan test
|
||||
@@ -362,8 +362,8 @@ Before deploying:
|
||||
- Pair with senior dev for first release
|
||||
|
||||
### CI/CD
|
||||
- Woodpecker: `code.cannabrands.app/cannabrands/hub`
|
||||
- Gitea: `code.cannabrands.app/cannabrands/hub`
|
||||
- Woodpecker: `git.spdy.io/cannabrands/hub`
|
||||
- Gitea: `git.spdy.io/cannabrands/hub`
|
||||
- K3s Dashboard: (ask devops for link)
|
||||
|
||||
---
|
||||
@@ -371,13 +371,13 @@ Before deploying:
|
||||
## Important URLs
|
||||
|
||||
**Code Repository:**
|
||||
https://code.cannabrands.app/cannabrands/hub
|
||||
https://git.spdy.io/cannabrands/hub
|
||||
|
||||
**CI/CD Pipeline:**
|
||||
https://code.cannabrands.app/cannabrands/hub/pipelines
|
||||
https://git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
**Container Registry:**
|
||||
https://code.cannabrands.app/-/packages/container/cannabrands%2Fhub
|
||||
https://git.spdy.io/-/packages/container/cannabrands%2Fhub
|
||||
|
||||
**Documentation:**
|
||||
`.woodpecker/` directory in repository
|
||||
@@ -430,7 +430,7 @@ Closes #42"
|
||||
| Deploy | `kubectl set image deployment/cannabrands app=...:2025.11.1` |
|
||||
| Rollback | `kubectl set image deployment/cannabrands app=...:2025.11.0` |
|
||||
| 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)
|
||||
3. Docker image builds (if tests pass)
|
||||
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)
|
||||
```
|
||||
|
||||
@@ -47,7 +47,7 @@ git push origin master
|
||||
**Use in K3s:**
|
||||
```yaml
|
||||
# dev/staging namespace
|
||||
image: code.cannabrands.app/cannabrands/hub:latest-dev
|
||||
image: git.spdy.io/cannabrands/hub:latest-dev
|
||||
imagePullPolicy: Always # Always pull newest
|
||||
```
|
||||
|
||||
@@ -81,7 +81,7 @@ git push origin 2025.11.1
|
||||
**Use in K3s:**
|
||||
```yaml
|
||||
# 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
|
||||
```
|
||||
|
||||
@@ -212,7 +212,7 @@ git push origin master
|
||||
./vendor/bin/sail artisan test
|
||||
|
||||
# Check CI is green
|
||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
||||
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Test in staging/dev environment
|
||||
# Verify key workflows work
|
||||
@@ -264,12 +264,12 @@ git push origin 2025.11.3
|
||||
|
||||
```bash
|
||||
# Watch Woodpecker build
|
||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
||||
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Wait for success (2-4 minutes)
|
||||
# CI will build and push:
|
||||
# - code.cannabrands.app/cannabrands/hub:2025.11.3
|
||||
# - code.cannabrands.app/cannabrands/hub:stable
|
||||
# - git.spdy.io/cannabrands/hub:2025.11.3
|
||||
# - git.spdy.io/cannabrands/hub:stable
|
||||
```
|
||||
|
||||
#### 5. Deploy to Production (When Ready)
|
||||
@@ -277,7 +277,7 @@ git push origin 2025.11.3
|
||||
```bash
|
||||
# Deploy new version
|
||||
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
|
||||
kubectl rollout status deployment/cannabrands
|
||||
@@ -328,11 +328,11 @@ git push origin master
|
||||
```bash
|
||||
# Option 1: Rollback to specific version
|
||||
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
|
||||
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
|
||||
# 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
|
||||
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:**
|
||||
```
|
||||
code.cannabrands.app/cannabrands/hub:latest # Always changes
|
||||
code.cannabrands.app/cannabrands/hub:c658193 # Commit SHA (meaningless)
|
||||
code.cannabrands.app/cannabrands/hub:master # Branch name (changes)
|
||||
git.spdy.io/cannabrands/hub:latest # Always changes
|
||||
git.spdy.io/cannabrands/hub:c658193 # Commit SHA (meaningless)
|
||||
git.spdy.io/cannabrands/hub:master # Branch name (changes)
|
||||
```
|
||||
|
||||
**Issues:**
|
||||
@@ -143,8 +143,8 @@ The CI pipeline now builds images with version metadata for both development and
|
||||
build-image-dev:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- dev # Latest dev build
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA
|
||||
@@ -170,13 +170,13 @@ build-image-release:
|
||||
**Result:**
|
||||
```
|
||||
# Development push to master
|
||||
code.cannabrands.app/cannabrands/hub:dev
|
||||
code.cannabrands.app/cannabrands/hub:sha-c658193
|
||||
code.cannabrands.app/cannabrands/hub:master
|
||||
git.spdy.io/cannabrands/hub:dev
|
||||
git.spdy.io/cannabrands/hub:sha-c658193
|
||||
git.spdy.io/cannabrands/hub:master
|
||||
|
||||
# Release (git tag 2025.10.1)
|
||||
code.cannabrands.app/cannabrands/hub:2025.10.1 # Specific version
|
||||
code.cannabrands.app/cannabrands/hub:latest # Latest stable
|
||||
git.spdy.io/cannabrands/hub:2025.10.1 # Specific version
|
||||
git.spdy.io/cannabrands/hub:latest # Latest stable
|
||||
```
|
||||
|
||||
---
|
||||
@@ -243,11 +243,11 @@ git checkout c658193
|
||||
```bash
|
||||
# Option 1: Rollback to specific version (recommended)
|
||||
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
|
||||
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)
|
||||
kubectl rollout undo deployment/cannabrands
|
||||
@@ -281,7 +281,7 @@ cat CHANGELOG.md
|
||||
|
||||
# 5. Deploy specific version
|
||||
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
|
||||
↓
|
||||
code.cannabrands.app/cannabrands/hub:dev-COMMIT
|
||||
git.spdy.io/cannabrands/hub:dev-COMMIT
|
||||
↓
|
||||
Deploy to dev/staging (optional)
|
||||
```
|
||||
@@ -486,7 +486,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- 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
|
||||
ports:
|
||||
- containerPort: 80
|
||||
@@ -535,7 +535,7 @@ git push origin master
|
||||
|
||||
# 5. Deploy to production (manual)
|
||||
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
|
||||
@@ -546,7 +546,7 @@ kubectl rollout undo deployment/cannabrands
|
||||
|
||||
# Or specific version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:v1.2.3
|
||||
app=git.spdy.io/cannabrands/hub:v1.2.3
|
||||
|
||||
# Verify
|
||||
kubectl rollout status deployment/cannabrands
|
||||
|
||||
230
CLAUDE.md
230
CLAUDE.md
@@ -65,15 +65,74 @@ ALL routes need auth + user type middleware except public pages
|
||||
**Creating PRs via Gitea API:**
|
||||
```bash
|
||||
# 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 "Content-Type: application/json" \
|
||||
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "develop"}'
|
||||
```
|
||||
|
||||
**Gitea Services:**
|
||||
- **Gitea:** `https://code.cannabrands.app`
|
||||
- **Woodpecker CI:** `https://ci.cannabrands.app`
|
||||
**Infrastructure Services:**
|
||||
|
||||
| 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)
|
||||
Users connect to businesses via `business_user` pivot table (many-to-many).
|
||||
@@ -96,7 +155,29 @@ Users connect to businesses via `business_user` pivot table (many-to-many).
|
||||
|
||||
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
|
||||
|
||||
### 10. Media Storage - MinIO Architecture (CRITICAL!)
|
||||
### 10. Suites Architecture - NOT Modules (CRITICAL!)
|
||||
❌ **NEVER use** `has_crm`, `has_marketing`, or other legacy module flags
|
||||
❌ **NEVER create** routes like `seller.crm.*` (without `.business.`)
|
||||
❌ **NEVER extend** `seller.crm.layouts.crm` layout (outdated CRM module layout)
|
||||
✅ **ALWAYS use** Suites system (Sales Suite, Processing Suite, etc.)
|
||||
✅ **ALWAYS use** route pattern `seller.business.crm.*` (includes `{business}` segment)
|
||||
✅ **ALWAYS extend** `layouts.seller` for seller views
|
||||
**Why:** We migrated from individual modules to a Suites architecture. CRM features are now part of the **Sales Suite**.
|
||||
|
||||
**See:** `docs/SUITES_AND_PRICING_MODEL.md` for full architecture
|
||||
|
||||
**The 7 Suites:**
|
||||
1. **Sales Suite** - Products, Orders, Buyers, CRM, Marketing, Analytics, Orchestrator
|
||||
2. **Processing Suite** - Extraction, Wash Reports, Yields (internal)
|
||||
3. **Manufacturing Suite** - Work Orders, BOM, Packaging (internal)
|
||||
4. **Delivery Suite** - Pick/Pack, Drivers, Manifests (internal)
|
||||
5. **Management Suite** - Finance, AP/AR, Budgets (Canopy only)
|
||||
6. **Brand Manager Suite** - Read-only brand portal (external partners)
|
||||
7. **Dispensary Suite** - Buyer marketplace (dispensaries)
|
||||
|
||||
**Legacy module flags still exist** in database but are deprecated. Suite permissions control access now.
|
||||
|
||||
### 11. Media Storage - MinIO Architecture (CRITICAL!)
|
||||
❌ **NEVER use** `Storage::disk('public')` for brand/product media
|
||||
✅ **ALWAYS use** `Storage` (respects .env FILESYSTEM_DISK=minio)
|
||||
**Why:** All media lives on MinIO (S3-compatible storage), not local disk. Using wrong disk breaks production images.
|
||||
@@ -169,6 +250,101 @@ if ($product->image_path) {
|
||||
|
||||
**This has caused multiple production outages - review docs before ANY storage changes!**
|
||||
|
||||
### 12. Dashboard & Metrics Performance (CRITICAL!)
|
||||
|
||||
**Production outages have occurred from violating these rules.**
|
||||
|
||||
#### The Golden Rule
|
||||
**NEVER compute aggregations in HTTP controllers. Dashboard data comes from Redis, period.**
|
||||
|
||||
#### What Goes Where
|
||||
|
||||
| Location | Allowed | Not Allowed |
|
||||
|----------|---------|-------------|
|
||||
| Controller | `Redis::get()`, simple lookups by ID | `->sum()`, `->count()`, `->avg()`, loops with queries |
|
||||
| Background Job | All aggregations, joins, complex queries | N/A |
|
||||
|
||||
#### ❌ BANNED Patterns in Controllers:
|
||||
|
||||
```php
|
||||
// BANNED: Aggregation in controller
|
||||
$revenue = Order::sum('total');
|
||||
|
||||
// BANNED: N+1 in loop
|
||||
$items->map(fn($i) => Order::where('product_id', $i->id)->sum('qty'));
|
||||
|
||||
// BANNED: Query per day/iteration
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$data[] = Order::whereDate('created_at', $date)->sum('total');
|
||||
}
|
||||
|
||||
// BANNED: Selecting columns that don't exist
|
||||
->select('id', 'stage_1_metadata') // Column doesn't exist!
|
||||
```
|
||||
|
||||
#### ✅ REQUIRED Pattern:
|
||||
|
||||
```php
|
||||
// Controller: Just read Redis
|
||||
public function analytics(Business $business)
|
||||
{
|
||||
$data = Redis::get("dashboard:{$business->id}:analytics");
|
||||
|
||||
if (!$data) {
|
||||
CalculateDashboardMetrics::dispatch($business->id);
|
||||
return view('dashboard.analytics', ['data' => $this->emptyState()]);
|
||||
}
|
||||
|
||||
return view('dashboard.analytics', ['data' => json_decode($data, true)]);
|
||||
}
|
||||
|
||||
// Background Job: Do all the heavy lifting
|
||||
public function handle()
|
||||
{
|
||||
// Batch query - ONE query for all products
|
||||
$salesByProduct = OrderItem::whereIn('product_id', $productIds)
|
||||
->groupBy('product_id')
|
||||
->selectRaw('product_id, SUM(quantity) as total')
|
||||
->pluck('total', 'product_id');
|
||||
|
||||
Redis::setex("dashboard:{$businessId}:analytics", 900, json_encode($data));
|
||||
}
|
||||
```
|
||||
|
||||
#### Before Merging Dashboard PRs:
|
||||
|
||||
1. Search for `->sum(`, `->count(`, `->avg(` in the controller
|
||||
2. Search for `->map(function` with queries inside
|
||||
3. If found → Move to background job
|
||||
4. Query count must be < 20 for any dashboard page
|
||||
|
||||
#### The Architecture
|
||||
|
||||
```
|
||||
BACKGROUND (every 10 min) HTTP REQUEST
|
||||
======================== =============
|
||||
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ CalculateMetricsJob │ │ DashboardController │
|
||||
│ │ │ │
|
||||
│ - Heavy queries │ │ - Redis::get() only │
|
||||
│ - Joins │──► Redis ──►│ - No aggregations │
|
||||
│ - Aggregations │ │ - No loops+queries │
|
||||
│ - Loops are OK here │ │ │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
Takes 5-30 sec Takes 10ms
|
||||
Runs in background User waits for this
|
||||
```
|
||||
|
||||
#### Prevention Checklist for Future Dashboard Work
|
||||
|
||||
- [ ] All `->sum()`, `->count()`, `->avg()` are in background jobs, not controllers
|
||||
- [ ] No `->map(function` with queries inside in controllers
|
||||
- [ ] Redis keys exist after job runs (`redis-cli KEYS "dashboard:*"`)
|
||||
- [ ] Job completes without errors (check `storage/logs/worker.log`)
|
||||
- [ ] Controller only does `Redis::get()` for metrics
|
||||
- [ ] Column names in `->select()` match actual database schema
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack by Area
|
||||
@@ -285,6 +461,48 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
|
||||
---
|
||||
|
||||
## Performance Requirements
|
||||
|
||||
**Database Queries:**
|
||||
- NEVER write N+1 queries - always use eager loading (`with()`) for relationships
|
||||
- NEVER run queries inside loops - batch them before the loop
|
||||
- Avoid multiple queries when one JOIN or subquery works
|
||||
- Dashboard/index pages should use MAX 5-10 queries total, not 50+
|
||||
- Use `DB::enableQueryLog()` mentally - if a page would log 20+ queries, refactor
|
||||
- Cache expensive aggregations (Redis, 5-min TTL) instead of recalculating every request
|
||||
- Test with `DB::listen()` or Laravel Debugbar before committing controller code
|
||||
|
||||
**Before submitting controller code, verify:**
|
||||
1. No queries inside foreach/map loops
|
||||
2. All relationships eager loaded
|
||||
3. Aggregations done in SQL, not PHP collections
|
||||
4. Would this cause a 503 under load? If unsure, simplify.
|
||||
|
||||
**Examples:**
|
||||
```php
|
||||
// ❌ N+1 query - DON'T DO THIS
|
||||
$orders = Order::all();
|
||||
foreach ($orders as $order) {
|
||||
echo $order->customer->name; // Query per iteration!
|
||||
}
|
||||
|
||||
// ✅ Eager loaded - DO THIS
|
||||
$orders = Order::with('customer')->get();
|
||||
|
||||
// ❌ Query in loop - DON'T DO THIS
|
||||
foreach ($products as $product) {
|
||||
$stock = Inventory::where('product_id', $product->id)->sum('quantity');
|
||||
}
|
||||
|
||||
// ✅ Batch query - DO THIS
|
||||
$stocks = Inventory::whereIn('product_id', $products->pluck('id'))
|
||||
->groupBy('product_id')
|
||||
->selectRaw('product_id, SUM(quantity) as total')
|
||||
->pluck('total', 'product_id');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What You Often Forget
|
||||
|
||||
✅ Scope by business_id BEFORE finding by ID
|
||||
@@ -293,3 +511,5 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
✅ DaisyUI for buyer/seller, Filament only for admin
|
||||
✅ NO inline styles - use Tailwind/DaisyUI classes only
|
||||
✅ Run tests before committing
|
||||
✅ Eager load relationships to prevent N+1 queries
|
||||
✅ No queries inside loops - batch before the loop
|
||||
|
||||
@@ -44,7 +44,7 @@ Our workflow provides audit trails regulators love:
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://code.cannabrands.app/Cannabrands/hub.git
|
||||
git clone https://git.spdy.io/Cannabrands/hub.git
|
||||
cd hub
|
||||
```
|
||||
|
||||
@@ -86,7 +86,7 @@ git commit -m "feat: add new feature"
|
||||
git push origin feature/my-feature-name
|
||||
|
||||
# 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
|
||||
# - CI will run automatically
|
||||
# - Request review from team
|
||||
@@ -630,7 +630,7 @@ git push origin chore/changelog-2025.11.1
|
||||
|
||||
### Services
|
||||
- **Woodpecker CI:** `https://ci.cannabrands.app`
|
||||
- **Gitea:** `https://code.cannabrands.app`
|
||||
- **Gitea:** `https://git.spdy.io`
|
||||
- **Production:** `https://app.cannabrands.com` (future)
|
||||
|
||||
---
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# ============================================
|
||||
|
||||
# ==================== 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
|
||||
|
||||
@@ -35,10 +35,10 @@ RUN npm run build
|
||||
|
||||
# ==================== Stage 2: Composer Builder ====================
|
||||
# Pin to PHP 8.4 - composer:2 uses latest PHP which may not be supported by dependencies yet
|
||||
FROM php:8.4-cli-alpine AS composer-builder
|
||||
FROM 10.100.9.70:5000/library/php:8.4-cli-alpine AS composer-builder
|
||||
|
||||
# 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
|
||||
|
||||
@@ -60,7 +60,7 @@ RUN composer install \
|
||||
--optimize-autoloader
|
||||
|
||||
# ==================== 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"
|
||||
|
||||
|
||||
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"]
|
||||
61
app/Console/Commands/CalculateDashboardMetricsCommand.php
Normal file
61
app/Console/Commands/CalculateDashboardMetricsCommand.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CalculateDashboardMetrics;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CalculateDashboardMetricsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dashboard:calculate-metrics
|
||||
{--business= : Specific business ID to calculate (optional)}
|
||||
{--sync : Run synchronously instead of queuing}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Pre-calculate dashboard metrics and store in Redis';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->option('business');
|
||||
$sync = $this->option('sync');
|
||||
|
||||
if ($businessId) {
|
||||
$business = Business::find($businessId);
|
||||
if (! $business) {
|
||||
$this->error("Business {$businessId} not found");
|
||||
|
||||
return 1;
|
||||
}
|
||||
$this->info("Calculating metrics for business: {$business->name}");
|
||||
} else {
|
||||
$count = Business::where('type', 'seller')->where('status', 'approved')->count();
|
||||
$this->info("Calculating metrics for {$count} businesses");
|
||||
}
|
||||
|
||||
$job = new CalculateDashboardMetrics($businessId ? (int) $businessId : null);
|
||||
|
||||
if ($sync) {
|
||||
$this->info('Running synchronously...');
|
||||
$job->handle();
|
||||
$this->info('Done!');
|
||||
} else {
|
||||
CalculateDashboardMetrics::dispatch($businessId ? (int) $businessId : null);
|
||||
$this->info('Job dispatched to queue');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use Illuminate\Console\Command;
|
||||
class DevSetup extends Command
|
||||
{
|
||||
protected $signature = 'dev:setup
|
||||
{--fresh : Drop all tables and re-run migrations}
|
||||
{--fresh : Drop all tables and re-run migrations (DESTRUCTIVE - requires confirmation)}
|
||||
{--skip-seed : Skip seeding dev fixtures}';
|
||||
|
||||
protected $description = 'Set up local development environment with migrations and dev fixtures';
|
||||
@@ -25,8 +25,18 @@ class DevSetup extends Command
|
||||
|
||||
// Run migrations
|
||||
if ($this->option('fresh')) {
|
||||
$this->warn('Dropping all tables and re-running migrations...');
|
||||
$this->call('migrate:fresh');
|
||||
$this->newLine();
|
||||
$this->error('WARNING: --fresh will DELETE ALL DATA in the database!');
|
||||
$this->warn('This includes development data being preserved for production release.');
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->confirm('Are you SURE you want to drop all tables and lose all data?', false)) {
|
||||
$this->info('Aborted. Running normal migrations instead...');
|
||||
$this->call('migrate');
|
||||
} else {
|
||||
$this->warn('Dropping all tables and re-running migrations...');
|
||||
$this->call('migrate:fresh');
|
||||
}
|
||||
} else {
|
||||
$this->info('Running migrations...');
|
||||
$this->call('migrate');
|
||||
|
||||
42
app/Console/Commands/DispatchScheduledCampaigns.php
Normal file
42
app/Console/Commands/DispatchScheduledCampaigns.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* DispatchScheduledCampaigns - Dispatch scheduled marketing campaigns.
|
||||
*
|
||||
* Run via scheduler: Schedule::command('marketing:dispatch-scheduled-campaigns')->everyMinute();
|
||||
*/
|
||||
class DispatchScheduledCampaigns extends Command
|
||||
{
|
||||
protected $signature = 'marketing:dispatch-scheduled-campaigns';
|
||||
|
||||
protected $description = 'Dispatch scheduled marketing campaigns that are ready to send';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$campaigns = MarketingCampaign::readyToSend()->get();
|
||||
|
||||
if ($campaigns->isEmpty()) {
|
||||
$this->info('No scheduled campaigns ready to send.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$campaigns->count()} campaign(s) ready to send.");
|
||||
|
||||
foreach ($campaigns as $campaign) {
|
||||
$this->info("Dispatching campaign: {$campaign->name} (ID: {$campaign->id})");
|
||||
|
||||
SendMarketingCampaignJob::dispatch($campaign->id);
|
||||
}
|
||||
|
||||
$this->info('Done.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
108
app/Console/Commands/RunDueMarketingAutomations.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\RunMarketingAutomationJob;
|
||||
use App\Models\Marketing\MarketingAutomation;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunDueMarketingAutomations extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'marketing:run-due-automations
|
||||
{--business= : Only process automations for a specific business ID}
|
||||
{--dry-run : Show which automations would run without executing them}
|
||||
{--sync : Run synchronously instead of dispatching to queue}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Check and run all due marketing automations';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->option('business');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$sync = $this->option('sync');
|
||||
|
||||
$this->info('Checking for due marketing automations...');
|
||||
|
||||
// Query active automations
|
||||
$query = MarketingAutomation::where('is_active', true)
|
||||
->whereIn('trigger_type', [
|
||||
MarketingAutomation::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
|
||||
MarketingAutomation::TRIGGER_SCHEDULED_STORE_CHECK,
|
||||
]);
|
||||
|
||||
if ($businessId) {
|
||||
$query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
$automations = $query->get();
|
||||
|
||||
if ($automations->isEmpty()) {
|
||||
$this->info('No active automations found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$automations->count()} active automation(s).");
|
||||
|
||||
$dueCount = 0;
|
||||
|
||||
foreach ($automations as $automation) {
|
||||
if (! $automation->isDue()) {
|
||||
$this->line(" - <comment>{$automation->name}</comment>: Not due yet");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$dueCount++;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" - <info>{$automation->name}</info>: Would run (dry-run mode)");
|
||||
$this->line(" Trigger: {$automation->trigger_type_label}");
|
||||
$this->line(" Frequency: {$automation->frequency_label}");
|
||||
$this->line(' Last run: '.($automation->last_run_at?->diffForHumans() ?? 'Never'));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(" - <info>{$automation->name}</info>: Dispatching...");
|
||||
|
||||
if ($sync) {
|
||||
// Run synchronously
|
||||
try {
|
||||
$job = new RunMarketingAutomationJob($automation->id);
|
||||
$job->handle(app(\App\Services\Marketing\AutomationRunner::class));
|
||||
$this->line(' <info>Completed</info>');
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" Failed: {$e->getMessage()}");
|
||||
}
|
||||
} else {
|
||||
// Dispatch to queue
|
||||
RunMarketingAutomationJob::dispatch($automation->id);
|
||||
$this->line(' <info>Dispatched to queue</info>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->newLine();
|
||||
$this->info("Dry run complete. {$dueCount} automation(s) would have been executed.");
|
||||
} else {
|
||||
$this->newLine();
|
||||
$this->info("Done. {$dueCount} automation(s) ".($sync ? 'executed' : 'dispatched').'.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
175
app/Console/Commands/RunFixedAssetDepreciation.php
Normal file
175
app/Console/Commands/RunFixedAssetDepreciation.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\FixedAssetService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Run monthly depreciation for fixed assets.
|
||||
*
|
||||
* This command calculates and posts depreciation entries for all
|
||||
* eligible fixed assets. Can be run for a specific business or all
|
||||
* businesses with Management Suite enabled.
|
||||
*
|
||||
* Safe to run multiple times in the same month - assets that have
|
||||
* already been depreciated for the period will be skipped.
|
||||
*/
|
||||
class RunFixedAssetDepreciation extends Command
|
||||
{
|
||||
protected $signature = 'fixed-assets:run-depreciation
|
||||
{business_id? : Specific business ID to run for}
|
||||
{--period= : Period date (Y-m-d format, defaults to end of current month)}
|
||||
{--dry-run : Show what would be depreciated without making changes}';
|
||||
|
||||
protected $description = 'Run monthly depreciation for fixed assets';
|
||||
|
||||
public function __construct(
|
||||
protected FixedAssetService $assetService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$businessId = $this->argument('business_id');
|
||||
$periodOption = $this->option('period');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
// Parse period date
|
||||
$periodDate = $periodOption
|
||||
? Carbon::parse($periodOption)->endOfMonth()
|
||||
: Carbon::now()->endOfMonth();
|
||||
|
||||
$this->info("Running depreciation for period: {$periodDate->format('Y-m')}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
// Get businesses to process
|
||||
$businesses = $this->getBusinesses($businessId);
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->warn('No businesses found to process.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$totalRuns = 0;
|
||||
$totalAmount = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
$this->line('');
|
||||
$this->info("Processing: {$business->name}");
|
||||
|
||||
if ($dryRun) {
|
||||
$results = $this->previewDepreciation($business, $periodDate);
|
||||
} else {
|
||||
$results = $this->assetService->runBatchDepreciation($business, $periodDate);
|
||||
}
|
||||
|
||||
$count = $results->count();
|
||||
$amount = $results->sum('depreciation_amount');
|
||||
|
||||
if ($count > 0) {
|
||||
$this->line(" - Depreciated {$count} assets");
|
||||
$this->line(" - Total amount: \${$amount}");
|
||||
$totalRuns += $count;
|
||||
$totalAmount += $amount;
|
||||
} else {
|
||||
$this->line(' - No assets to depreciate');
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->info('=== Summary ===');
|
||||
$this->info("Total assets depreciated: {$totalRuns}");
|
||||
$this->info("Total depreciation amount: \${$totalAmount}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('This was a dry run. Run without --dry-run to apply changes.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get businesses to process.
|
||||
*/
|
||||
protected function getBusinesses(?string $businessId): \Illuminate\Support\Collection
|
||||
{
|
||||
if ($businessId) {
|
||||
$business = Business::find($businessId);
|
||||
|
||||
if (! $business) {
|
||||
$this->error("Business with ID {$businessId} not found.");
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
if (! $business->hasManagementSuite()) {
|
||||
$this->warn("Business {$business->name} does not have Management Suite enabled.");
|
||||
}
|
||||
|
||||
return collect([$business]);
|
||||
}
|
||||
|
||||
// Get all businesses with Management Suite
|
||||
return Business::whereHas('suites', function ($query) {
|
||||
$query->where('key', 'management');
|
||||
})->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview depreciation without making changes.
|
||||
*/
|
||||
protected function previewDepreciation(Business $business, Carbon $periodDate): \Illuminate\Support\Collection
|
||||
{
|
||||
$period = $periodDate->format('Y-m');
|
||||
|
||||
$assets = \App\Models\Accounting\FixedAsset::where('business_id', $business->id)
|
||||
->where('status', \App\Models\Accounting\FixedAsset::STATUS_ACTIVE)
|
||||
->where('category', '!=', \App\Models\Accounting\FixedAsset::CATEGORY_LAND)
|
||||
->get();
|
||||
|
||||
$results = collect();
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
// Skip if already depreciated for this period
|
||||
$existing = \App\Models\Accounting\FixedAssetDepreciationRun::where('fixed_asset_id', $asset->id)
|
||||
->where('period', $period)
|
||||
->where('is_reversed', false)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if fully depreciated
|
||||
if ($asset->book_value <= $asset->salvage_value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$depreciationAmount = $asset->monthly_depreciation;
|
||||
$maxDepreciation = $asset->book_value - $asset->salvage_value;
|
||||
$depreciationAmount = min($depreciationAmount, $maxDepreciation);
|
||||
|
||||
if ($depreciationAmount > 0) {
|
||||
$results->push((object) [
|
||||
'fixed_asset_id' => $asset->id,
|
||||
'asset_name' => $asset->name,
|
||||
'depreciation_amount' => $depreciationAmount,
|
||||
]);
|
||||
|
||||
$this->line(" - {$asset->name}: \${$depreciationAmount}");
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
103
app/Console/Commands/RunRecurringSchedules.php
Normal file
103
app/Console/Commands/RunRecurringSchedules.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Accounting\RecurringSchedulerService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunRecurringSchedules extends Command
|
||||
{
|
||||
protected $signature = 'recurring:run
|
||||
{--date= : The date to run schedules for (YYYY-MM-DD, default: today)}
|
||||
{--business= : Specific business ID to run schedules for}
|
||||
{--dry-run : Preview what would be generated without actually creating transactions}';
|
||||
|
||||
protected $description = 'Run due recurring schedules to generate AR invoices, AP bills, and journal entries';
|
||||
|
||||
public function __construct(
|
||||
protected RecurringSchedulerService $schedulerService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dateString = $this->option('date');
|
||||
$businessId = $this->option('business') ? (int) $this->option('business') : null;
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$date = $dateString ? Carbon::parse($dateString) : now();
|
||||
|
||||
$this->info("Running recurring schedules for {$date->toDateString()}...");
|
||||
|
||||
if ($businessId) {
|
||||
$this->info("Filtering to business ID: {$businessId}");
|
||||
}
|
||||
|
||||
// Get due schedules
|
||||
$dueSchedules = $this->schedulerService->getDueSchedules($date, $businessId);
|
||||
|
||||
if ($dueSchedules->isEmpty()) {
|
||||
$this->info('No schedules are due for execution.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$dueSchedules->count()} schedule(s) due for execution.");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No transactions will be created.');
|
||||
$this->table(
|
||||
['ID', 'Name', 'Type', 'Business', 'Next Run Date', 'Auto Post'],
|
||||
$dueSchedules->map(fn ($s) => [
|
||||
$s->id,
|
||||
$s->name,
|
||||
$s->type_label,
|
||||
$s->business->name ?? 'N/A',
|
||||
$s->next_run_date->toDateString(),
|
||||
$s->auto_post ? 'Yes' : 'No',
|
||||
])
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Run all due schedules
|
||||
$results = $this->schedulerService->runAllDue($date, $businessId);
|
||||
|
||||
// Output results
|
||||
$this->newLine();
|
||||
$this->info('Execution Summary:');
|
||||
$this->line(" Processed: {$results['processed']}");
|
||||
$this->line(" Successful: {$results['success']}");
|
||||
$this->line(" Failed: {$results['failed']}");
|
||||
|
||||
if (! empty($results['generated'])) {
|
||||
$this->newLine();
|
||||
$this->info('Generated Transactions:');
|
||||
$this->table(
|
||||
['Schedule', 'Type', 'Result ID'],
|
||||
collect($results['generated'])->map(fn ($g) => [
|
||||
$g['schedule_name'],
|
||||
$g['type'],
|
||||
$g['result_id'],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (! empty($results['errors'])) {
|
||||
$this->newLine();
|
||||
$this->error('Errors:');
|
||||
foreach ($results['errors'] as $error) {
|
||||
$this->line(" [{$error['schedule_id']}] {$error['schedule_name']}: {$error['error']}");
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
43
app/Console/Commands/SafeFreshCommand.php
Normal file
43
app/Console/Commands/SafeFreshCommand.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Database\Console\Migrations\FreshCommand;
|
||||
|
||||
/**
|
||||
* Override migrate:fresh to prevent accidental data loss.
|
||||
*
|
||||
* This command blocks migrate:fresh in all environments except when
|
||||
* explicitly targeting a test database (DB_DATABASE=testing or *_test_*).
|
||||
*/
|
||||
class SafeFreshCommand extends FreshCommand
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
// Check both config and direct env (env var may not be in config yet)
|
||||
$database = env('DB_DATABASE', config('database.connections.pgsql.database'));
|
||||
|
||||
// Allow migrate:fresh ONLY for test databases
|
||||
$isTestDatabase = $database === 'testing'
|
||||
|| str_contains($database, '_test_')
|
||||
|| str_contains($database, 'testing_');
|
||||
|
||||
if (! $isTestDatabase) {
|
||||
$this->components->error('migrate:fresh is BLOCKED to prevent data loss!');
|
||||
$this->components->warn("Database: {$database}");
|
||||
$this->newLine();
|
||||
$this->components->bulletList([
|
||||
'This command drops ALL tables and destroys ALL data.',
|
||||
'It is blocked in local, dev, staging, and production.',
|
||||
'For testing: DB_DATABASE=testing ./vendor/bin/sail artisan migrate:fresh',
|
||||
'To seed existing data: php artisan db:seed --class=ProductionSyncSeeder',
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->components->info("Running migrate:fresh on TEST database: {$database}");
|
||||
|
||||
return parent::handle();
|
||||
}
|
||||
}
|
||||
113
app/Console/Commands/SyncBrandMediaPaths.php
Normal file
113
app/Console/Commands/SyncBrandMediaPaths.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class SyncBrandMediaPaths extends Command
|
||||
{
|
||||
protected $signature = 'brands:sync-media-paths
|
||||
{--dry-run : Preview changes without applying}
|
||||
{--business= : Limit to specific business slug}';
|
||||
|
||||
protected $description = 'Sync brand logo_path and banner_path from MinIO storage';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$businessFilter = $this->option('business');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN - No changes will be made');
|
||||
}
|
||||
|
||||
$this->info('Scanning MinIO for brand media...');
|
||||
|
||||
$businessDirs = Storage::directories('businesses');
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($businessDirs as $businessDir) {
|
||||
$businessSlug = basename($businessDir);
|
||||
|
||||
if ($businessFilter && $businessSlug !== $businessFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$brandsDir = $businessDir.'/brands';
|
||||
if (! Storage::exists($brandsDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$brandDirs = Storage::directories($brandsDir);
|
||||
|
||||
foreach ($brandDirs as $brandDir) {
|
||||
$brandSlug = basename($brandDir);
|
||||
$brandingDir = $brandDir.'/branding';
|
||||
|
||||
if (! Storage::exists($brandingDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$brand = Brand::where('slug', $brandSlug)->first();
|
||||
if (! $brand) {
|
||||
$this->line(" <fg=yellow>?</> {$brandSlug} - not found in database");
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = Storage::files($brandingDir);
|
||||
$logoPath = null;
|
||||
$bannerPath = null;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = strtolower(basename($file));
|
||||
if (str_starts_with($filename, 'logo.')) {
|
||||
$logoPath = $file;
|
||||
} elseif (str_starts_with($filename, 'banner.')) {
|
||||
$bannerPath = $file;
|
||||
}
|
||||
}
|
||||
|
||||
$changes = [];
|
||||
if ($logoPath && $brand->logo_path !== $logoPath) {
|
||||
$changes[] = "logo: {$logoPath}";
|
||||
}
|
||||
if ($bannerPath && $brand->banner_path !== $bannerPath) {
|
||||
$changes[] = "banner: {$bannerPath}";
|
||||
}
|
||||
|
||||
if (empty($changes)) {
|
||||
$this->line(" <fg=green>✓</> {$brandSlug} - already synced");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
if ($logoPath) {
|
||||
$brand->logo_path = $logoPath;
|
||||
}
|
||||
if ($bannerPath) {
|
||||
$brand->banner_path = $bannerPath;
|
||||
}
|
||||
$brand->save();
|
||||
}
|
||||
|
||||
$this->line(" <fg=blue>↻</> {$brandSlug} - ".implode(', ', $changes));
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Updated: {$updated} | Skipped: {$skipped}");
|
||||
|
||||
if ($dryRun && $updated > 0) {
|
||||
$this->warn('Run without --dry-run to apply changes');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,31 @@ class Kernel extends ConsoleKernel
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// DASHBOARD METRICS PRE-CALCULATION
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Pre-calculate dashboard metrics every 10 minutes
|
||||
// Stores aggregations in Redis for instant page loads
|
||||
$schedule->job(new \App\Jobs\CalculateDashboardMetrics)
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Exceptions/PeriodLockedException.php
Normal file
24
app/Exceptions/PeriodLockedException.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Models\Accounting\AccountingPeriod;
|
||||
|
||||
class PeriodLockedException extends \Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly ?AccountingPeriod $period = null,
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public function getPeriod(): ?AccountingPeriod
|
||||
{
|
||||
return $this->period;
|
||||
}
|
||||
}
|
||||
203
app/Filament/Pages/CannaiqSettings.php
Normal file
203
app/Filament/Pages/CannaiqSettings.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Services\Cannaiq\CannaiqClient;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class CannaiqSettings extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-chart-bar-square';
|
||||
|
||||
protected string $view = 'filament.pages.cannaiq-settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'Integrations';
|
||||
|
||||
protected static ?string $navigationLabel = 'CannaiQ';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static ?string $title = 'CannaiQ Settings';
|
||||
|
||||
protected static ?string $slug = 'cannaiq-settings';
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->check();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'base_url' => config('services.cannaiq.base_url'),
|
||||
'api_key' => '', // Never show the actual key
|
||||
'cache_ttl' => config('services.cannaiq.cache_ttl', 7200),
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
$apiKeyConfigured = ! empty(config('services.cannaiq.api_key'));
|
||||
$baseUrl = config('services.cannaiq.base_url');
|
||||
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('CannaiQ Integration')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Placeholder::make('status')
|
||||
->label('Connection Status')
|
||||
->content(function () use ($apiKeyConfigured, $baseUrl) {
|
||||
$statusHtml = '<div class="space-y-2">';
|
||||
|
||||
// API Key status
|
||||
if ($apiKeyConfigured) {
|
||||
$statusHtml .= '<div class="flex items-center gap-2 text-success-600 dark:text-success-400">'.
|
||||
'<span class="text-lg">✓</span>'.
|
||||
'<span>API Key configured</span>'.
|
||||
'</div>';
|
||||
} else {
|
||||
$statusHtml .= '<div class="flex items-center gap-2 text-warning-600 dark:text-warning-400">'.
|
||||
'<span class="text-lg">⚠</span>'.
|
||||
'<span>API Key not configured (using trusted origin auth)</span>'.
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Base URL
|
||||
$statusHtml .= '<div class="text-sm text-gray-500 dark:text-gray-400">'.
|
||||
'Base URL: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">'.$baseUrl.'</code>'.
|
||||
'</div>';
|
||||
|
||||
$statusHtml .= '</div>';
|
||||
|
||||
return new HtmlString($statusHtml);
|
||||
}),
|
||||
|
||||
Placeholder::make('features')
|
||||
->label('Features Enabled')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4">'.
|
||||
'<ul class="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">'.
|
||||
'<li><strong>Brand Analysis</strong> - Market positioning, SKU velocity, shelf opportunities</li>'.
|
||||
'<li><strong>Marketing Intelligence</strong> - Competitive insights and recommendations</li>'.
|
||||
'<li><strong>Promo Recommendations</strong> - AI-powered promotional strategies</li>'.
|
||||
'<li><strong>Store Playbook</strong> - Actionable insights for retail partners</li>'.
|
||||
'</ul>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
|
||||
Section::make('Configuration')
|
||||
->description('CannaiQ is configured via environment variables. Update your .env file to change these settings.')
|
||||
->schema([
|
||||
TextInput::make('base_url')
|
||||
->label('Base URL')
|
||||
->disabled()
|
||||
->helperText('Set via CANNAIQ_BASE_URL environment variable'),
|
||||
|
||||
TextInput::make('cache_ttl')
|
||||
->label('Cache TTL (seconds)')
|
||||
->disabled()
|
||||
->helperText('Set via CANNAIQ_CACHE_TTL environment variable. Default: 7200 (2 hours)'),
|
||||
|
||||
Placeholder::make('env_example')
|
||||
->label('Environment Variables')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg bg-gray-900 text-gray-100 p-4 font-mono text-sm overflow-x-auto">'.
|
||||
'<div class="text-gray-400"># CannaiQ Configuration</div>'.
|
||||
'<div>CANNAIQ_BASE_URL=https://cannaiq.co/api/v1</div>'.
|
||||
'<div>CANNAIQ_API_KEY=your-api-key-here</div>'.
|
||||
'<div>CANNAIQ_CACHE_TTL=7200</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
])
|
||||
->collapsed(),
|
||||
|
||||
Section::make('Business Access')
|
||||
->description('CannaiQ features must be enabled per-business in the Business settings.')
|
||||
->schema([
|
||||
Placeholder::make('business_info')
|
||||
->label('')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg border border-info-200 bg-info-50 dark:border-info-800 dark:bg-info-950 p-4">'.
|
||||
'<div class="flex items-start gap-3">'.
|
||||
'<span class="text-info-600 dark:text-info-400 text-lg">ⓘ</span>'.
|
||||
'<div class="text-sm">'.
|
||||
'<p class="font-medium text-info-800 dark:text-info-200">How to enable CannaiQ for a business:</p>'.
|
||||
'<ol class="list-decimal list-inside mt-2 text-info-700 dark:text-info-300 space-y-1">'.
|
||||
'<li>Go to <strong>Users → Businesses</strong></li>'.
|
||||
'<li>Edit the business</li>'.
|
||||
'<li>Go to the <strong>Integrations</strong> tab</li>'.
|
||||
'<li>Toggle <strong>Enable CannaiQ</strong></li>'.
|
||||
'</ol>'.
|
||||
'</div>'.
|
||||
'</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function testConnection(): void
|
||||
{
|
||||
try {
|
||||
$client = app(CannaiqClient::class);
|
||||
|
||||
// Try to fetch something from the API to verify connection
|
||||
// We'll use a simple health check or fetch minimal data
|
||||
$response = $client->getBrandAnalysis('test-brand', 'test-business');
|
||||
|
||||
// If we get here without exception, connection works
|
||||
// (even if the response is empty/error from CannaiQ side)
|
||||
Notification::make()
|
||||
->title('Connection Test')
|
||||
->body('Successfully connected to CannaiQ API')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title('Connection Failed')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
// Clear all CannaiQ-related cache keys
|
||||
$patterns = [
|
||||
'cannaiq:*',
|
||||
'brand_analysis:*',
|
||||
];
|
||||
|
||||
$cleared = 0;
|
||||
foreach ($patterns as $pattern) {
|
||||
// Note: This is a simplified clear - in production you might want
|
||||
// to use Redis SCAN for pattern matching
|
||||
Cache::forget($pattern);
|
||||
$cleared++;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Cache Cleared')
|
||||
->body('CannaiQ cache has been cleared')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
208
app/Filament/Pages/SiteBranding.php
Normal file
208
app/Filament/Pages/SiteBranding.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
|
||||
class SiteBranding extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-paint-brush';
|
||||
|
||||
protected static ?string $navigationLabel = 'Site Branding';
|
||||
|
||||
protected static ?string $title = 'Site Branding';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Platform Settings';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected string $view = 'filament.pages.site-branding';
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'site_name' => SiteSetting::get('site_name', 'Cannabrands Hub'),
|
||||
'favicon' => SiteSetting::get('favicon_path') ? [SiteSetting::get('favicon_path')] : [],
|
||||
'logo_light' => SiteSetting::get('logo_light_path') ? [SiteSetting::get('logo_light_path')] : [],
|
||||
'logo_dark' => SiteSetting::get('logo_dark_path') ? [SiteSetting::get('logo_dark_path')] : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Site Identity')
|
||||
->description('Configure the site name and branding assets.')
|
||||
->schema([
|
||||
TextInput::make('site_name')
|
||||
->label('Site Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Displayed in browser tabs and emails.'),
|
||||
]),
|
||||
|
||||
Section::make('Favicon')
|
||||
->description('The small icon displayed in browser tabs. Recommended: 32x32 or 64x64 PNG/ICO.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_favicon')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('favicon_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getFaviconUrl().'" alt="Favicon" class="w-8 h-8">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('favicon')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->acceptedFileTypes(['image/png', 'image/x-icon', 'image/ico', 'image/vnd.microsoft.icon'])
|
||||
->maxSize(512)
|
||||
->imagePreviewHeight('64')
|
||||
->helperText('Upload a PNG or ICO file (max 512KB).'),
|
||||
]),
|
||||
|
||||
Section::make('Logos')
|
||||
->description('Upload logo variants for different backgrounds.')
|
||||
->schema([
|
||||
Section::make('Logo (Light/White)')
|
||||
->description('For dark backgrounds (sidebar, etc.)')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_logo_light')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('logo_light_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center h-16 w-40 bg-gray-800 rounded-lg border-2 border-dashed border-gray-600">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center h-16 px-4 bg-gray-800 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getLogoLightUrl().'" alt="Logo Light" class="h-8 max-w-[150px] object-contain">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('logo_light')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->maxSize(2048)
|
||||
->imagePreviewHeight('100'),
|
||||
]),
|
||||
|
||||
Section::make('Logo (Dark)')
|
||||
->description('For light backgrounds.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('current_logo_dark')
|
||||
->label('Current')
|
||||
->content(function () {
|
||||
$path = SiteSetting::get('logo_dark_path');
|
||||
if (! $path) {
|
||||
return new HtmlString(
|
||||
'<div class="flex items-center justify-center h-16 w-40 bg-gray-100 rounded-lg border-2 border-dashed border-gray-300">'.
|
||||
'<span class="text-gray-400 text-xs">Not set</span>'.
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return new HtmlString(
|
||||
'<div class="inline-flex items-center justify-center h-16 px-4 bg-gray-100 rounded-lg">'.
|
||||
'<img src="'.SiteSetting::getLogoDarkUrl().'" alt="Logo Dark" class="h-8 max-w-[150px] object-contain">'.
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
FileUpload::make('logo_dark')
|
||||
->label('Upload New')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->visibility('public')
|
||||
->maxSize(2048)
|
||||
->imagePreviewHeight('100'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
|
||||
// Save site name
|
||||
SiteSetting::set('site_name', $data['site_name']);
|
||||
|
||||
// Save file paths
|
||||
$this->saveFileSetting('favicon_path', $data['favicon'] ?? []);
|
||||
$this->saveFileSetting('logo_light_path', $data['logo_light'] ?? []);
|
||||
$this->saveFileSetting('logo_dark_path', $data['logo_dark'] ?? []);
|
||||
|
||||
// Clear cache
|
||||
SiteSetting::clearCache();
|
||||
|
||||
Notification::make()
|
||||
->title('Branding settings saved')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function saveFileSetting(string $key, array $files): void
|
||||
{
|
||||
$path = ! empty($files) ? $files[0] : null;
|
||||
|
||||
// Handle TemporaryUploadedFile objects
|
||||
if ($path instanceof TemporaryUploadedFile) {
|
||||
$path = $path->store('branding', 'public');
|
||||
}
|
||||
|
||||
SiteSetting::set($key, $path);
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [
|
||||
Forms\Components\Actions\Action::make('save')
|
||||
->label('Save Changes')
|
||||
->submit('save'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,7 @@ class AiContentRuleResource extends Resource
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
if (! empty($data['value'])) {
|
||||
$query->where('content_type_key', 'like', $data['value'].'.%');
|
||||
$query->where('content_type_key', 'ilike', $data['value'].'.%');
|
||||
}
|
||||
}),
|
||||
])
|
||||
|
||||
292
app/Filament/Resources/BannerAdResource.php
Normal file
292
app/Filament/Resources/BannerAdResource.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?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\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
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\Resources\Resource;
|
||||
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 getNavigationBadge(): ?string
|
||||
{
|
||||
return cache()->remember('banner_ad_active_count', 60, function () {
|
||||
$count = static::getModel()::where('status', BannerAdStatus::ACTIVE)->count();
|
||||
|
||||
return $count ?: 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([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
Tables\Actions\ForceDeleteBulkAction::make(),
|
||||
Tables\Actions\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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,63 @@ class BusinessResource extends Resource
|
||||
|
||||
Tab::make('Users & Access')
|
||||
->schema([
|
||||
// Quick add from business contacts section
|
||||
Forms\Components\Placeholder::make('contacts_without_users')
|
||||
->label('Contacts Without Platform Access')
|
||||
->content(function ($livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
if (! $business) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$existingUserEmails = \App\Models\User::pluck('email')->map(fn ($e) => strtolower($e))->toArray();
|
||||
|
||||
$contacts = $business->contacts()
|
||||
->whereNotNull('email')
|
||||
->where('email', '!=', '')
|
||||
->get()
|
||||
->filter(fn ($c) => ! in_array(strtolower($c->email), $existingUserEmails));
|
||||
|
||||
if ($contacts->isEmpty()) {
|
||||
return new \Illuminate\Support\HtmlString(
|
||||
'<span class="text-gray-500 text-sm">All business contacts with emails already have platform access.</span>'
|
||||
);
|
||||
}
|
||||
|
||||
$html = '<div class="text-sm text-gray-600 mb-2">These business contacts have emails but no platform login. Click "Add Platform User" below and use "Link Existing User" or manually add them:</div>';
|
||||
$html .= '<div class="flex flex-wrap gap-2">';
|
||||
foreach ($contacts as $contact) {
|
||||
$name = trim($contact->first_name.' '.$contact->last_name) ?: 'Unknown';
|
||||
$type = $contact->contact_type ? ucfirst($contact->contact_type) : '';
|
||||
$html .= '<span class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-amber-50 text-amber-800 border border-amber-200 text-xs">';
|
||||
$html .= '<strong>'.e($name).'</strong>';
|
||||
if ($type) {
|
||||
$html .= ' <span class="text-amber-600">('.$type.')</span>';
|
||||
}
|
||||
$html .= ' - '.e($contact->email);
|
||||
$html .= '</span>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return new \Illuminate\Support\HtmlString($html);
|
||||
})
|
||||
->visible(function ($livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existingUserEmails = \App\Models\User::pluck('email')->map(fn ($e) => strtolower($e))->toArray();
|
||||
|
||||
return $business->contacts()
|
||||
->whereNotNull('email')
|
||||
->where('email', '!=', '')
|
||||
->get()
|
||||
->filter(fn ($c) => ! in_array(strtolower($c->email), $existingUserEmails))
|
||||
->isNotEmpty();
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
Repeater::make('users')
|
||||
->relationship('users')
|
||||
->helperText('Users with login credentials and access to manage this business')
|
||||
@@ -642,6 +699,11 @@ class BusinessResource extends Resource
|
||||
'</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 =====
|
||||
@@ -665,53 +727,83 @@ class BusinessResource extends Resource
|
||||
->bulkToggleable()
|
||||
->helperText('Select the suites this business should have access to. Each suite enables specific features and menu items.'),
|
||||
|
||||
Forms\Components\Placeholder::make('suite_info')
|
||||
->label('')
|
||||
->content(function () {
|
||||
// Show available suites (excluding deprecated and internal)
|
||||
$suites = \App\Models\Suite::available()->orderBy('sort_order')->get();
|
||||
$html = '<div class="grid grid-cols-2 gap-4 text-sm mt-4">';
|
||||
foreach ($suites as $suite) {
|
||||
$colorClass = match ($suite->color) {
|
||||
'emerald' => 'border-emerald-300 bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-950', // Sales
|
||||
'pink' => 'border-pink-300 bg-pink-50 dark:border-pink-700 dark:bg-pink-950', // Marketing
|
||||
'cyan' => 'border-cyan-300 bg-cyan-50 dark:border-cyan-700 dark:bg-cyan-950', // Inventory
|
||||
'blue' => 'border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-950', // Processing
|
||||
'orange' => 'border-orange-300 bg-orange-50 dark:border-orange-700 dark:bg-orange-950', // Manufacturing
|
||||
'indigo' => 'border-indigo-300 bg-indigo-50 dark:border-indigo-700 dark:bg-indigo-950', // Procurement
|
||||
'violet' => 'border-violet-300 bg-violet-50 dark:border-violet-700 dark:bg-violet-950', // Distribution
|
||||
'green' => 'border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-950', // Finance
|
||||
'amber' => 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950', // Compliance
|
||||
'sky' => 'border-sky-300 bg-sky-50 dark:border-sky-700 dark:bg-sky-950', // Inbox
|
||||
'slate' => 'border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-950', // Tools
|
||||
'gray' => 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-950', // Management
|
||||
'lime' => 'border-lime-300 bg-lime-50 dark:border-lime-700 dark:bg-lime-950', // Dispensary
|
||||
'gold' => 'border-yellow-300 bg-yellow-50 dark:border-yellow-700 dark:bg-yellow-950', // Enterprise
|
||||
'teal' => 'border-teal-300 bg-teal-50 dark:border-teal-700 dark:bg-teal-950', // Brand Manager
|
||||
'red' => 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-950',
|
||||
'rose' => 'border-rose-300 bg-rose-50 dark:border-rose-700 dark:bg-rose-950',
|
||||
'fuchsia' => 'border-fuchsia-300 bg-fuchsia-50 dark:border-fuchsia-700 dark:bg-fuchsia-950',
|
||||
default => 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-950',
|
||||
};
|
||||
$features = is_array($suite->included_features) ? implode(', ', $suite->included_features) : '';
|
||||
$html .= '<div class="border rounded-lg p-3 '.$colorClass.'">';
|
||||
$html .= '<div class="font-medium">'.e($suite->name).'</div>';
|
||||
$html .= '<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">'.e($features).'</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return new \Illuminate\Support\HtmlString($html);
|
||||
}),
|
||||
]),
|
||||
|
||||
Section::make('Navigation Settings')
|
||||
->description('Control how this business experiences the seller sidebar navigation.')
|
||||
// ===== SUITE SHARES SECTION =====
|
||||
// Allows this business to share parts of their suite TO other businesses
|
||||
Section::make('Suite Shares')
|
||||
->description('Share parts of THIS business\'s suite with other businesses. The recipient will see these menu items with a "Shared" badge.')
|
||||
->collapsed()
|
||||
->schema([
|
||||
Toggle::make('use_suite_navigation')
|
||||
->label('Use Suite Navigation (beta)')
|
||||
->helperText('When enabled, this business uses the new suite-based sidebar instead of the legacy menu.')
|
||||
->default(false),
|
||||
Forms\Components\Repeater::make('suiteShares')
|
||||
->relationship('suiteShares')
|
||||
->label('')
|
||||
->schema([
|
||||
Select::make('target_business_id')
|
||||
->label('Share TO Business')
|
||||
->options(function (callable $get) {
|
||||
$currentBusinessId = $get('../../id');
|
||||
|
||||
return \App\Models\Business::query()
|
||||
->when($currentBusinessId, fn ($q) => $q->where('id', '!=', $currentBusinessId))
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id');
|
||||
})
|
||||
->searchable()
|
||||
->required()
|
||||
->helperText('Select the business that will RECEIVE these shared menu items'),
|
||||
Select::make('shared_suite_key')
|
||||
->label('Suite to Share From')
|
||||
->options(function ($livewire) {
|
||||
// Get suites assigned to THIS business (source)
|
||||
$business = $livewire->record;
|
||||
if (! $business) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $business->suites()
|
||||
->orderBy('sort_order')
|
||||
->pluck('name', 'key')
|
||||
->toArray();
|
||||
})
|
||||
->required()
|
||||
->reactive()
|
||||
->helperText('Select which of THIS business\'s suites to share items from'),
|
||||
CheckboxList::make('shared_menu_keys')
|
||||
->label('Menu Items to Share')
|
||||
->options(function (callable $get) {
|
||||
$suiteKey = $get('shared_suite_key');
|
||||
if (! $suiteKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get menu keys for this suite from config
|
||||
$menuKeys = config("suites.menus.{$suiteKey}", []);
|
||||
$resolver = app(\App\Services\SuiteMenuResolver::class);
|
||||
|
||||
$options = [];
|
||||
foreach ($menuKeys as $key) {
|
||||
$def = $resolver->getMenuDefinition($key);
|
||||
if ($def) {
|
||||
$options[$key] = $def['label'].' ('.$def['section'].')';
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
})
|
||||
->columns(2)
|
||||
->required()
|
||||
->visible(fn (callable $get) => ! empty($get('shared_suite_key'))),
|
||||
])
|
||||
->columns(1)
|
||||
->defaultItems(0)
|
||||
->addActionLabel('Add Suite Share')
|
||||
->reorderable(false)
|
||||
->collapsible()
|
||||
->itemLabel(fn (array $state): ?string => isset($state['target_business_id'])
|
||||
? 'Share to: '.(\App\Models\Business::find($state['target_business_id'])?->name ?? 'New Share')
|
||||
: 'New Share'
|
||||
),
|
||||
]),
|
||||
|
||||
Section::make('Sales Suite Usage Limits')
|
||||
@@ -765,6 +857,40 @@ class BusinessResource extends Resource
|
||||
]),
|
||||
]),
|
||||
|
||||
// ===== INTEGRATIONS TAB =====
|
||||
// Third-party service integrations
|
||||
Tab::make('Integrations')
|
||||
->icon('heroicon-o-link')
|
||||
->schema([
|
||||
// ===== CANNAIQ SECTION =====
|
||||
Section::make('CannaiQ')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Toggle::make('cannaiq_enabled')
|
||||
->label('Enable CannaiQ')
|
||||
->helperText('When enabled, this business gets access to Brand Analysis, Intelligence, and Promos features.')
|
||||
->default(false),
|
||||
|
||||
Forms\Components\Placeholder::make('cannaiq_info')
|
||||
->label('')
|
||||
->content(new \Illuminate\Support\HtmlString(
|
||||
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4 text-sm">'.
|
||||
'<div class="font-medium text-gray-700 dark:text-gray-300 mb-2">CannaiQ Features</div>'.
|
||||
'<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1">'.
|
||||
'<li>Brand Analysis - Market positioning, SKU velocity, shelf opportunities</li>'.
|
||||
'<li>Marketing Intelligence - Competitive insights and recommendations</li>'.
|
||||
'<li>Promo Recommendations - AI-powered promotional strategies</li>'.
|
||||
'</ul>'.
|
||||
'<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">'.
|
||||
'<a href="https://cannaiq.co" target="_blank" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 font-medium inline-flex items-center gap-1">'.
|
||||
'Visit CannaiQ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>'.
|
||||
'</a>'.
|
||||
'</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ===== LEGACY MODULES TAB =====
|
||||
// These flags are kept for backward compatibility.
|
||||
// The recommended way to configure access is via Suites above.
|
||||
@@ -1668,8 +1794,8 @@ class BusinessResource extends Resource
|
||||
})
|
||||
->description(fn ($record) => $record->parent ? 'Managed by '.$record->parent->name : null)
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->where('name', 'like', "%{$search}%")
|
||||
->orWhere('dba_name', 'like', "%{$search}%");
|
||||
return $query->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('dba_name', 'ilike', "%{$search}%");
|
||||
})
|
||||
->sortable(query: fn ($query, $direction) => $query->orderBy('parent_id')->orderBy('name', $direction)),
|
||||
TextColumn::make('types.label')
|
||||
@@ -1789,9 +1915,9 @@ class BusinessResource extends Resource
|
||||
return $query->whereHas('users', function ($q) use ($search) {
|
||||
$q->wherePivot('is_primary', true)
|
||||
->where(function ($q2) use ($search) {
|
||||
$q2->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$q2->where('first_name', 'ilike', "%{$search}%")
|
||||
->orWhere('last_name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -1822,9 +1948,9 @@ class BusinessResource extends Resource
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('users', function ($q) use ($search) {
|
||||
$q->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$q->where('first_name', 'ilike', "%{$search}%")
|
||||
->orWhere('last_name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
}),
|
||||
TextColumn::make('users_count')
|
||||
@@ -1961,6 +2087,7 @@ class BusinessResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
BusinessResource\RelationManagers\DbasRelationManager::class,
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ class EditBusiness extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('view_marketing_portal')
|
||||
->label('Marketing Portal')
|
||||
->icon('heroicon-o-megaphone')
|
||||
->color('info')
|
||||
->url(fn () => route('portal.dashboard', $this->record->slug))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn () => $this->record->status === 'approved' && $this->record->business_type === 'buyer'),
|
||||
|
||||
Actions\Action::make('approve_application')
|
||||
->label('Approve Application')
|
||||
->icon('heroicon-o-check-circle')
|
||||
|
||||
@@ -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) {
|
||||
return $query->whereHas('creator', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class ProductsTable
|
||||
ImageColumn::make('image_path')
|
||||
->label('Image')
|
||||
->circular()
|
||||
->defaultImageUrl(url('/images/placeholder-product.png'))
|
||||
->defaultImageUrl(\Storage::disk('minio')->url('defaults/placeholder-product.svg'))
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('name')
|
||||
|
||||
@@ -215,7 +215,7 @@ class UserResource extends Resource
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('businesses', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
$q->where('name', 'ilike', "%{$search}%");
|
||||
});
|
||||
}),
|
||||
TextColumn::make('status')
|
||||
|
||||
@@ -55,6 +55,22 @@ class OrchestratorOutcomesChart extends ChartWidget
|
||||
->pending()
|
||||
->count();
|
||||
|
||||
// If all values are zero, show a placeholder to prevent empty doughnut rendering
|
||||
$total = $completed + $dismissed + $snoozed + $pending;
|
||||
if ($total === 0) {
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'No Data',
|
||||
'data' => [1],
|
||||
'backgroundColor' => ['rgba(209, 213, 219, 0.5)'], // gray placeholder
|
||||
'borderWidth' => 0,
|
||||
],
|
||||
],
|
||||
'labels' => ['No tasks yet'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
|
||||
207
app/Http/Controllers/Api/Accounting/ApVendorController.php
Normal file
207
app/Http/Controllers/Api/Accounting/ApVendorController.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Accounting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApVendor;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ApVendorController extends Controller
|
||||
{
|
||||
/**
|
||||
* List vendors for a business.
|
||||
*
|
||||
* GET /api/{business}/ap/vendors
|
||||
*/
|
||||
public function index(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
$query = ApVendor::where('business_id', $business->id);
|
||||
|
||||
// Search
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('code', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Active filter
|
||||
if ($request->has('active')) {
|
||||
$query->where('is_active', $request->boolean('active'));
|
||||
}
|
||||
|
||||
$vendors = $query->orderBy('name')->paginate($request->get('per_page', 50));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $vendors->items(),
|
||||
'meta' => [
|
||||
'current_page' => $vendors->currentPage(),
|
||||
'last_page' => $vendors->lastPage(),
|
||||
'per_page' => $vendors->perPage(),
|
||||
'total' => $vendors->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single vendor.
|
||||
*
|
||||
* GET /api/{business}/ap/vendors/{vendor}
|
||||
*/
|
||||
public function show(Business $business, ApVendor $vendor): JsonResponse
|
||||
{
|
||||
if ($vendor->business_id !== $business->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Vendor does not belong to this business.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $vendor,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new vendor.
|
||||
*
|
||||
* POST /api/{business}/ap/vendors
|
||||
*/
|
||||
public function store(Request $request, Business $business): JsonResponse
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'code' => 'nullable|string|max:50',
|
||||
'name' => 'required|string|max:255',
|
||||
'legal_name' => 'nullable|string|max:255',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'default_payment_terms' => 'nullable|integer|min:0',
|
||||
'default_gl_account_id' => 'nullable|integer|exists:gl_accounts,id',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'address_line1' => 'nullable|string|max:255',
|
||||
'address_line2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:100',
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'is_1099' => 'boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Generate code if not provided
|
||||
if (empty($validated['code'])) {
|
||||
$validated['code'] = $this->generateVendorCode($business->id, $validated['name']);
|
||||
}
|
||||
|
||||
$vendor = ApVendor::create([
|
||||
'business_id' => $business->id,
|
||||
...$validated,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "Vendor {$vendor->name} created.",
|
||||
'data' => $vendor,
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Vendor creation failed', [
|
||||
'business_id' => $business->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to create vendor: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a vendor.
|
||||
*
|
||||
* PUT /api/{business}/ap/vendors/{vendor}
|
||||
*/
|
||||
public function update(Request $request, Business $business, ApVendor $vendor): JsonResponse
|
||||
{
|
||||
if ($vendor->business_id !== $business->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Vendor does not belong to this business.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'code' => 'nullable|string|max:50',
|
||||
'name' => 'required|string|max:255',
|
||||
'legal_name' => 'nullable|string|max:255',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'default_payment_terms' => 'nullable|integer|min:0',
|
||||
'default_gl_account_id' => 'nullable|integer|exists:gl_accounts,id',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'address_line1' => 'nullable|string|max:255',
|
||||
'address_line2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:100',
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'is_1099' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$vendor->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "Vendor {$vendor->name} updated.",
|
||||
'data' => $vendor->fresh(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Vendor update failed', [
|
||||
'vendor_id' => $vendor->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update vendor: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate vendor code from name.
|
||||
*/
|
||||
protected function generateVendorCode(int $businessId, string $name): string
|
||||
{
|
||||
$words = preg_split('/\s+/', strtoupper($name));
|
||||
$prefix = '';
|
||||
foreach ($words as $word) {
|
||||
$prefix .= substr(preg_replace('/[^A-Z0-9]/', '', $word), 0, 3);
|
||||
if (strlen($prefix) >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$prefix = substr($prefix, 0, 6);
|
||||
|
||||
$count = ApVendor::where('business_id', $businessId)
|
||||
->where('code', 'ilike', "{$prefix}%")
|
||||
->count();
|
||||
|
||||
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']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,10 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Buyer\BuyerBrandFollow;
|
||||
use App\Models\Buyer\BuyerProductBookmark;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Models\Seller\BrandAnnouncement;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmThread;
|
||||
|
||||
class BrandHubController extends Controller
|
||||
{
|
||||
|
||||
@@ -7,12 +7,12 @@ use App\Models\Buyer\BuyerAnalyticsCache;
|
||||
use App\Models\Buyer\BuyerBrandFollow;
|
||||
use App\Models\Buyer\BuyerQuoteApproval;
|
||||
use App\Models\Buyer\BuyerTask;
|
||||
use App\Models\Crm\CrmInvoice;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Models\Order;
|
||||
use App\Models\Seller\BrandAnnouncement;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmInvoice;
|
||||
use Modules\Crm\Entities\CrmQuote;
|
||||
use Modules\Crm\Entities\CrmThread;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
|
||||
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Buyer\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Buyer\BuyerMessageSettings;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmThread;
|
||||
|
||||
class InboxController extends Controller
|
||||
{
|
||||
|
||||
@@ -5,9 +5,10 @@ namespace App\Http\Controllers\Buyer\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Buyer\BuyerInvoiceRecord;
|
||||
use App\Models\Buyer\BuyerSavedFilter;
|
||||
use App\Models\Crm\CrmInvoice;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmInvoice;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
@@ -115,7 +116,7 @@ class InvoiceController extends Controller
|
||||
}
|
||||
|
||||
// Get related thread if exists
|
||||
$thread = \Modules\Crm\Entities\CrmThread::where('buyer_business_id', $business->id)
|
||||
$thread = CrmThread::where('buyer_business_id', $business->id)
|
||||
->where(function ($q) use ($invoice) {
|
||||
$q->where('order_id', $invoice->order_id)
|
||||
->orWhere('subject', 'ilike', "%{$invoice->invoice_number}%");
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
namespace App\Http\Controllers\Buyer\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Crm\CrmChannelMessage;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmMessage;
|
||||
use Modules\Crm\Entities\CrmThread;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
@@ -64,7 +64,7 @@ class MessageController extends Controller
|
||||
return back()->with('success', 'Message sent.');
|
||||
}
|
||||
|
||||
public function destroy(CrmThread $thread, CrmMessage $message)
|
||||
public function destroy(CrmThread $thread, CrmChannelMessage $message)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
@@ -88,7 +88,7 @@ class MessageController extends Controller
|
||||
return back()->with('success', 'Message deleted.');
|
||||
}
|
||||
|
||||
public function react(Request $request, CrmThread $thread, CrmMessage $message)
|
||||
public function react(Request $request, CrmThread $thread, CrmChannelMessage $message)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
@@ -108,7 +108,7 @@ class OrderController extends Controller
|
||||
$deliveryEvents = BuyerDeliveryEvent::getTimelineForOrder($order->id);
|
||||
|
||||
// Get related thread if exists
|
||||
$thread = \Modules\Crm\Entities\CrmThread::where('order_id', $order->id)
|
||||
$thread = \App\Models\Crm\CrmThread::where('order_id', $order->id)
|
||||
->where('buyer_business_id', $business->id)
|
||||
->first();
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Buyer\BuyerQuoteApproval;
|
||||
use App\Models\Buyer\BuyerSavedFilter;
|
||||
use App\Models\Buyer\BuyerTeamMember;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Crm\Entities\CrmQuote;
|
||||
|
||||
class QuoteController extends Controller
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,33 +4,44 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductCategory;
|
||||
use App\Models\Strain;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MarketplaceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display marketplace browse page
|
||||
* Display marketplace browse page (Amazon/Shopify style)
|
||||
*/
|
||||
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
|
||||
$query = Product::query()
|
||||
->with(['brand', 'strain'])
|
||||
->with(['brand', 'strain', 'category'])
|
||||
->active();
|
||||
|
||||
// Search filter (name, SKU, description)
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('sku', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('sku', 'ilike', "%{$search}%")
|
||||
->orWhere('description', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if ($brandId = $request->input('brand_id')) {
|
||||
$query->where('brand_id', $brandId);
|
||||
// Brand filter (supports multiple)
|
||||
if ($brandIds = $request->input('brand_id')) {
|
||||
$brandIds = is_array($brandIds) ? $brandIds : [$brandIds];
|
||||
$query->whereIn('brand_id', $brandIds);
|
||||
}
|
||||
|
||||
// Category filter (uses category_id foreign key)
|
||||
if ($categoryId = $request->input('category_id')) {
|
||||
$query->where('category_id', $categoryId);
|
||||
}
|
||||
|
||||
// Strain type filter
|
||||
@@ -64,23 +75,96 @@ class MarketplaceController extends Controller
|
||||
default => $query->latest(),
|
||||
};
|
||||
|
||||
// View mode (grid/list)
|
||||
$viewMode = $request->input('view', 'grid');
|
||||
|
||||
// Paginate results
|
||||
$products = $query->paginate(12)->withQueryString();
|
||||
$perPage = $viewMode === 'list' ? 10 : 12;
|
||||
$products = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// Get all active brands for filters
|
||||
$brands = Brand::active()->orderBy('name')->get();
|
||||
// Get brands with product counts for faceted filter
|
||||
$brands = Brand::query()
|
||||
->active()
|
||||
->withCount(['products' => fn ($q) => $q->active()])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(fn ($b) => $b->products_count > 0);
|
||||
|
||||
// Get featured products for carousel (exclude from main results if in first page)
|
||||
// Get categories with product counts
|
||||
$categories = ProductCategory::query()
|
||||
->whereNull('parent_id') // Only top-level categories
|
||||
->where('is_active', true)
|
||||
->withCount(['products' => fn ($q) => $q->active()])
|
||||
->get()
|
||||
->filter(fn ($c) => $c->products_count > 0)
|
||||
->sortByDesc('products_count');
|
||||
|
||||
// Featured products for hero carousel
|
||||
$featuredProducts = Product::query()
|
||||
->with(['brand', 'strain'])
|
||||
->featured()
|
||||
->inStock()
|
||||
->limit(3)
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
$business = auth()->user()->businesses->first();
|
||||
// Top brands (by product count) for homepage section
|
||||
$topBrands = Brand::query()
|
||||
->active()
|
||||
->withCount(['products' => fn ($q) => $q->active()])
|
||||
->get()
|
||||
->filter(fn ($b) => $b->products_count > 0)
|
||||
->sortByDesc('products_count')
|
||||
->take(6);
|
||||
|
||||
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts', 'business'));
|
||||
// New arrivals (products created in last 14 days)
|
||||
$newArrivals = Product::query()
|
||||
->with(['brand', 'strain', 'category'])
|
||||
->active()
|
||||
->inStock()
|
||||
->where('created_at', '>=', now()->subDays(14))
|
||||
->orderByDesc('created_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
// Trending products (most ordered in last 30 days - simplified query)
|
||||
$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');
|
||||
|
||||
$trending = $trendingIds->isNotEmpty()
|
||||
? Product::with(['brand', 'strain', 'category'])
|
||||
->whereIn('id', $trendingIds)
|
||||
->active()
|
||||
->get()
|
||||
->sortBy(fn ($p) => array_search($p->id, $trendingIds->toArray()))
|
||||
: collect();
|
||||
|
||||
// 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',
|
||||
'business',
|
||||
'viewMode',
|
||||
'activeFilters',
|
||||
'hasFilters'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +264,7 @@ class MarketplaceController extends Controller
|
||||
|
||||
// Get featured products from this brand
|
||||
$featuredProducts = Product::query()
|
||||
->with(['strain'])
|
||||
->with(['strain', 'brand'])
|
||||
->where('brand_id', $brand->id)
|
||||
->featured()
|
||||
->inStock()
|
||||
@@ -189,7 +273,7 @@ class MarketplaceController extends Controller
|
||||
|
||||
// Get all products from this brand
|
||||
$products = Product::query()
|
||||
->with(['strain'])
|
||||
->with(['strain', 'brand'])
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->orderBy('is_featured', 'desc')
|
||||
|
||||
@@ -73,18 +73,159 @@ class OrderController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('order_number', 'like', "%{$search}%")
|
||||
$q->where('order_number', 'ILIKE', "%{$search}%")
|
||||
->orWhereHas('business', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
$q->where('name', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$orders = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $orders->map(fn ($o) => [
|
||||
'order_number' => $o->order_number,
|
||||
'name' => $o->order_number.' - '.$o->business->name,
|
||||
'customer' => $o->business->name,
|
||||
'status' => $o->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -201,6 +342,41 @@ class OrderController extends Controller
|
||||
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).
|
||||
*/
|
||||
|
||||
249
app/Http/Controllers/Portal/CampaignController.php
Normal file
249
app/Http/Controllers/Portal/CampaignController.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SendMarketingCampaignJob;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CampaignController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$campaigns = MarketingCampaign::where('business_id', $business->id)
|
||||
->with('list')
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->when($request->channel, fn ($q, $channel) => $q->where('channel', $channel))
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
$statuses = [
|
||||
'draft' => 'Draft',
|
||||
'scheduled' => 'Scheduled',
|
||||
'sending' => 'Sending',
|
||||
'sent' => 'Sent',
|
||||
'completed' => 'Completed',
|
||||
'cancelled' => 'Cancelled',
|
||||
'failed' => 'Failed',
|
||||
];
|
||||
|
||||
$channels = MarketingCampaign::CHANNELS;
|
||||
|
||||
return view('portal.campaigns.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'campaigns',
|
||||
'statuses',
|
||||
'channels'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get lists for this business
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Pre-populate from promo if provided
|
||||
$promo = null;
|
||||
if ($request->query('promo_id')) {
|
||||
$promo = MarketingPromo::where('business_id', $business->id)
|
||||
->find($request->query('promo_id'));
|
||||
}
|
||||
|
||||
// Pre-select channel if provided
|
||||
$preselectedChannel = $request->query('channel', 'email');
|
||||
|
||||
$channels = MarketingCampaign::CHANNELS;
|
||||
|
||||
return view('portal.campaigns.create', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'lists',
|
||||
'promo',
|
||||
'preselectedChannel',
|
||||
'channels'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'channel' => 'required|in:email,sms',
|
||||
'list_id' => 'required|exists:marketing_lists,id',
|
||||
'subject' => 'required_if:channel,email|nullable|string|max:255',
|
||||
'body' => 'required|string',
|
||||
'send_at' => 'nullable|date|after:now',
|
||||
'promo_id' => 'nullable|exists:marketing_promos,id',
|
||||
]);
|
||||
|
||||
// Verify list belongs to this business
|
||||
$list = MarketingList::where('business_id', $business->id)
|
||||
->findOrFail($validated['list_id']);
|
||||
|
||||
// Build campaign data
|
||||
$campaignData = [
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'list_id' => $list->id,
|
||||
'subject' => $validated['subject'] ?? null,
|
||||
'body' => $validated['body'],
|
||||
'status' => 'draft',
|
||||
'created_by' => Auth::id(),
|
||||
// Use branding defaults for from fields
|
||||
'from_name' => $branding->effective_from_name,
|
||||
'from_email' => $branding->effective_from_email,
|
||||
];
|
||||
|
||||
// Link to promo if provided
|
||||
if (! empty($validated['promo_id'])) {
|
||||
$promo = MarketingPromo::where('business_id', $business->id)
|
||||
->find($validated['promo_id']);
|
||||
|
||||
if ($promo) {
|
||||
$campaignData['source_type'] = 'promo';
|
||||
$campaignData['source_id'] = $promo->id;
|
||||
}
|
||||
}
|
||||
|
||||
// Set schedule if provided
|
||||
if (! empty($validated['send_at'])) {
|
||||
$campaignData['send_at'] = $validated['send_at'];
|
||||
$campaignData['status'] = 'scheduled';
|
||||
}
|
||||
|
||||
$campaign = MarketingCampaign::create($campaignData);
|
||||
|
||||
if ($campaign->status === 'scheduled') {
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign scheduled successfully.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign created as draft. Review and send when ready.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$campaign->load(['list', 'logs']);
|
||||
|
||||
// Get stats
|
||||
$stats = [
|
||||
'total_recipients' => $campaign->total_recipients,
|
||||
'sent' => $campaign->total_sent,
|
||||
'delivered' => $campaign->total_delivered,
|
||||
'opened' => $campaign->total_opened,
|
||||
'clicked' => $campaign->total_clicked,
|
||||
'failed' => $campaign->total_failed,
|
||||
];
|
||||
|
||||
return view('portal.campaigns.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'campaign',
|
||||
'stats'
|
||||
));
|
||||
}
|
||||
|
||||
public function sendNow(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return back()->with('error', 'This campaign cannot be sent.');
|
||||
}
|
||||
|
||||
// Count recipients
|
||||
$recipientCount = $campaign->list?->contacts()->count() ?? 0;
|
||||
|
||||
if ($recipientCount === 0) {
|
||||
return back()->with('error', 'No recipients in the selected list.');
|
||||
}
|
||||
|
||||
// Update campaign
|
||||
$campaign->update([
|
||||
'status' => 'sending',
|
||||
'total_recipients' => $recipientCount,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// Dispatch job
|
||||
SendMarketingCampaignJob::dispatch($campaign);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', "Campaign is now sending to {$recipientCount} recipients.");
|
||||
}
|
||||
|
||||
public function schedule(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($campaign->status !== 'draft') {
|
||||
return back()->with('error', 'Only draft campaigns can be scheduled.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'send_at' => 'required|date|after:now',
|
||||
]);
|
||||
|
||||
$campaign->update([
|
||||
'status' => 'scheduled',
|
||||
'send_at' => $validated['send_at'],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.show', [$business->slug, $campaign])
|
||||
->with('success', 'Campaign scheduled for '.$campaign->send_at->format('M j, Y g:i A'));
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Business $business, MarketingCampaign $campaign)
|
||||
{
|
||||
// Ensure campaign belongs to this business
|
||||
if ($campaign->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
||||
return back()->with('error', 'This campaign cannot be cancelled.');
|
||||
}
|
||||
|
||||
$campaign->update([
|
||||
'status' => 'cancelled',
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.campaigns.index', $business->slug)
|
||||
->with('success', 'Campaign cancelled.');
|
||||
}
|
||||
}
|
||||
80
app/Http/Controllers/Portal/DashboardController.php
Normal file
80
app/Http/Controllers/Portal/DashboardController.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingCampaign;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Marketing\PromoRecommendationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PromoRecommendationService $promoService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get recommended promos for this business
|
||||
$recommendedPromos = collect();
|
||||
try {
|
||||
// Get store external IDs for this business if available
|
||||
$storeExternalIds = $business->cannaiqStores()
|
||||
->pluck('external_id')
|
||||
->toArray();
|
||||
|
||||
if (! empty($storeExternalIds)) {
|
||||
$recommendations = $this->promoService->getRecommendations(
|
||||
$business,
|
||||
$storeExternalIds[0] ?? null,
|
||||
limit: 5
|
||||
);
|
||||
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// CannaiQ not configured or error - that's fine, show empty
|
||||
}
|
||||
|
||||
// Get recent campaigns for this business
|
||||
$recentCampaigns = MarketingCampaign::where('business_id', $business->id)
|
||||
->with('list')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get active promos
|
||||
$activePromos = MarketingPromo::forBusiness($business->id)
|
||||
->currentlyActive()
|
||||
->with('brand')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Get campaign stats
|
||||
$campaignStats = [
|
||||
'total' => MarketingCampaign::where('business_id', $business->id)->count(),
|
||||
'sent' => MarketingCampaign::where('business_id', $business->id)
|
||||
->whereIn('status', ['sent', 'completed'])
|
||||
->count(),
|
||||
'draft' => MarketingCampaign::where('business_id', $business->id)
|
||||
->where('status', 'draft')
|
||||
->count(),
|
||||
'scheduled' => MarketingCampaign::where('business_id', $business->id)
|
||||
->where('status', 'scheduled')
|
||||
->count(),
|
||||
];
|
||||
|
||||
return view('portal.dashboard', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'recommendedPromos',
|
||||
'recentCampaigns',
|
||||
'activePromos',
|
||||
'campaignStats'
|
||||
));
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Portal/ListController.php
Normal file
83
app/Http/Controllers/Portal/ListController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingList;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ListController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$lists = MarketingList::where('business_id', $business->id)
|
||||
->withCount('contacts')
|
||||
->orderBy('name')
|
||||
->paginate(15);
|
||||
|
||||
return view('portal.lists.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'lists'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$types = MarketingList::getTypes();
|
||||
|
||||
return view('portal.lists.create', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'types'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|in:static,smart',
|
||||
]);
|
||||
|
||||
$list = MarketingList::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'],
|
||||
'type' => $validated['type'],
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('portal.lists.show', [$business->slug, $list])
|
||||
->with('success', 'List created successfully.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingList $list)
|
||||
{
|
||||
// Ensure list belongs to this business
|
||||
if ($list->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
$contacts = $list->contacts()
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(25);
|
||||
|
||||
return view('portal.lists.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'list',
|
||||
'contacts'
|
||||
));
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Portal/PromoController.php
Normal file
75
app/Http/Controllers/Portal/PromoController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Branding\BusinessBrandingSetting;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\MarketingPromo;
|
||||
use App\Services\Marketing\PromoRecommendationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PromoController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PromoRecommendationService $promoService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
|
||||
// Get recommended promos from CannaiQ
|
||||
$recommendedPromos = collect();
|
||||
try {
|
||||
$storeExternalIds = $business->cannaiqStores()
|
||||
->pluck('external_id')
|
||||
->toArray();
|
||||
|
||||
if (! empty($storeExternalIds)) {
|
||||
$recommendations = $this->promoService->getRecommendations(
|
||||
$business,
|
||||
$storeExternalIds[0] ?? null,
|
||||
limit: 20
|
||||
);
|
||||
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// CannaiQ not available
|
||||
}
|
||||
|
||||
// Get existing promos for this business
|
||||
$existingPromos = MarketingPromo::forBusiness($business->id)
|
||||
->with('brand')
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
$statuses = MarketingPromo::getStatuses();
|
||||
|
||||
return view('portal.promos.index', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'recommendedPromos',
|
||||
'existingPromos',
|
||||
'statuses'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $business, MarketingPromo $promo)
|
||||
{
|
||||
// Ensure promo belongs to this business
|
||||
if ($promo->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$branding = BusinessBrandingSetting::forBusiness($business);
|
||||
$promo->load('brand');
|
||||
|
||||
return view('portal.promos.show', compact(
|
||||
'business',
|
||||
'branding',
|
||||
'promo'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Accounting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\ApBill;
|
||||
use App\Models\Accounting\ApVendor;
|
||||
use App\Models\Accounting\ArInvoice;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Read-only accounting alias controllers for child businesses (divisions).
|
||||
*
|
||||
* Child businesses can view limited accounting data from their parent company.
|
||||
* This provides visibility without granting write access to financial systems.
|
||||
*
|
||||
* Requirements:
|
||||
* - Business must have parent_id (be a division)
|
||||
* - User must have appropriate viewing permissions
|
||||
*/
|
||||
class DivisionAccountingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display vendor list (read-only from parent company).
|
||||
*
|
||||
* GET /s/{business}/accounting/vendors
|
||||
*/
|
||||
public function vendorsIndex(Request $request, Business $business): View
|
||||
{
|
||||
$this->authorizeChildBusiness($business);
|
||||
|
||||
// Get parent's vendors
|
||||
$parentId = $business->parent_id;
|
||||
|
||||
$query = ApVendor::where('business_id', $parentId)
|
||||
->where('is_active', true);
|
||||
|
||||
// Search filter
|
||||
if ($search = $request->get('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('code', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$vendors = $query->orderBy('name')->paginate(30)->withQueryString();
|
||||
|
||||
return view('seller.accounting.vendors.index', [
|
||||
'business' => $business,
|
||||
'vendors' => $vendors,
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display AR snapshot (read-only summary for division).
|
||||
*
|
||||
* GET /s/{business}/accounting/ar-snapshot
|
||||
*/
|
||||
public function arSnapshot(Request $request, Business $business): View
|
||||
{
|
||||
$this->authorizeChildBusiness($business);
|
||||
|
||||
$parentId = $business->parent_id;
|
||||
|
||||
// Get AR summary stats (scoped to this division's invoices if possible,
|
||||
// otherwise show high-level parent metrics)
|
||||
$stats = [
|
||||
'total_outstanding' => ArInvoice::where('business_id', $business->id)
|
||||
->where('status', '!=', 'paid')
|
||||
->sum('balance_due'),
|
||||
'overdue_count' => ArInvoice::where('business_id', $business->id)
|
||||
->where('status', '!=', 'paid')
|
||||
->where('due_date', '<', now())
|
||||
->count(),
|
||||
'overdue_amount' => ArInvoice::where('business_id', $business->id)
|
||||
->where('status', '!=', 'paid')
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due'),
|
||||
'current_month_billed' => ArInvoice::where('business_id', $business->id)
|
||||
->whereMonth('invoice_date', now()->month)
|
||||
->whereYear('invoice_date', now()->year)
|
||||
->sum('total_amount'),
|
||||
];
|
||||
|
||||
// Recent invoices for this division
|
||||
$recentInvoices = ArInvoice::where('business_id', $business->id)
|
||||
->with('customer')
|
||||
->orderByDesc('invoice_date')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('seller.accounting.ar-snapshot', [
|
||||
'business' => $business,
|
||||
'stats' => $stats,
|
||||
'recentInvoices' => $recentInvoices,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display AP snapshot (read-only summary for division).
|
||||
*
|
||||
* GET /s/{business}/accounting/ap-snapshot
|
||||
*/
|
||||
public function apSnapshot(Request $request, Business $business): View
|
||||
{
|
||||
$this->authorizeChildBusiness($business);
|
||||
|
||||
$parentId = $business->parent_id;
|
||||
|
||||
// Get AP summary stats scoped to this division's bills
|
||||
$stats = [
|
||||
'total_outstanding' => ApBill::where('business_id', $business->id)
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->sum('balance_due'),
|
||||
'overdue_count' => ApBill::where('business_id', $business->id)
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->where('due_date', '<', now())
|
||||
->count(),
|
||||
'overdue_amount' => ApBill::where('business_id', $business->id)
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->where('due_date', '<', now())
|
||||
->sum('balance_due'),
|
||||
'pending_approval' => ApBill::where('business_id', $business->id)
|
||||
->whereIn('status', ['draft', 'pending'])
|
||||
->count(),
|
||||
];
|
||||
|
||||
// Recent bills for this division
|
||||
$recentBills = ApBill::where('business_id', $business->id)
|
||||
->with('vendor')
|
||||
->orderByDesc('bill_date')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('seller.accounting.ap-snapshot', [
|
||||
'business' => $business,
|
||||
'stats' => $stats,
|
||||
'recentBills' => $recentBills,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure this is a child business with parent_id.
|
||||
*/
|
||||
protected function authorizeChildBusiness(Business $business): void
|
||||
{
|
||||
if ($business->parent_id === null) {
|
||||
abort(404, 'This feature is only available for division businesses.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ class BatchController extends Controller
|
||||
->where('quantity_available', '>', 0)
|
||||
->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->with('component')
|
||||
->with('product')
|
||||
->orderBy('batch_number')
|
||||
->get()
|
||||
->map(function ($batch) {
|
||||
@@ -102,17 +102,28 @@ class BatchController extends Controller
|
||||
$maxValue = ($request->cannabinoid_unit ?? '%') === '%' ? 100 : 1000;
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
// Accept either product_id or component_id (form sends component_id)
|
||||
'product_id' => 'required_without:component_id|exists:products,id',
|
||||
'component_id' => 'required_without:product_id|exists:products,id',
|
||||
'batch_type' => 'nullable|string|in:component,homogenized',
|
||||
'cannabinoid_unit' => 'nullable|string|in:%,MG/ML,MG/G,MG/UNIT',
|
||||
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
|
||||
'quantity_produced' => 'nullable|integer|min:0',
|
||||
'batch_number' => 'required|string|max:100|unique:batches,batch_number',
|
||||
'internal_code' => 'nullable|string|max:100',
|
||||
// Accept either quantity_produced or quantity_total (form sends quantity_total)
|
||||
'quantity_produced' => 'nullable|numeric|min:0',
|
||||
'quantity_total' => 'nullable|numeric|min:0',
|
||||
'quantity_remaining' => 'nullable|numeric|min:0',
|
||||
'quantity_unit' => 'nullable|string|max:50',
|
||||
'quantity_allocated' => 'nullable|integer|min:0',
|
||||
'expiration_date' => 'nullable|date',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_active' => 'nullable',
|
||||
'production_date' => 'nullable|date',
|
||||
'harvest_date' => 'nullable|date',
|
||||
'package_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
'test_id' => 'nullable|string|max:100',
|
||||
'lot_number' => 'nullable|string|max:100',
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'lab_name' => 'nullable|string|max:255',
|
||||
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
@@ -126,10 +137,18 @@ class BatchController extends Controller
|
||||
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
||||
]);
|
||||
|
||||
// Map component_id to product_id if provided
|
||||
$productId = $validated['product_id'] ?? $validated['component_id'];
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
})->findOrFail($productId);
|
||||
|
||||
// Map form fields to model fields
|
||||
$validated['product_id'] = $productId;
|
||||
$validated['quantity_produced'] = $validated['quantity_total'] ?? $validated['quantity_produced'] ?? 0;
|
||||
$validated['quantity_available'] = $validated['quantity_remaining'] ?? $validated['quantity_produced'];
|
||||
|
||||
// Set business_id and defaults
|
||||
$validated['business_id'] = $business->id;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,9 +29,9 @@ class BrandManagerSettingsController extends Controller
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
$q->where('first_name', 'ilike', "%{$search}%")
|
||||
->orWhere('last_name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
use App\Models\Promotion;
|
||||
@@ -14,8 +15,12 @@ use Illuminate\Support\Facades\Auth;
|
||||
/**
|
||||
* Brand Portal Controller
|
||||
*
|
||||
* Handles all Brand Portal functionality for external brand partners.
|
||||
* Brand Portal users have read-only access to data scoped to their linked brands.
|
||||
* Handles all Brand Portal functionality for external brand partners and brand managers.
|
||||
* Both user types have read-only access to data scoped to their linked brands.
|
||||
*
|
||||
* Supported access modes:
|
||||
* - Brand Portal users (in "Brand Partner" department with linked brands)
|
||||
* - Brand Manager users (contact_type = 'brand_manager' with linked brands)
|
||||
*
|
||||
* Key constraints:
|
||||
* - All data is scoped to the user's linked brands (via brand_user pivot)
|
||||
@@ -25,6 +30,26 @@ use Illuminate\Support\Facades\Auth;
|
||||
*/
|
||||
class BrandPortalController extends Controller
|
||||
{
|
||||
/**
|
||||
* Check if user has brand access (Portal or Manager) and get their brand IDs.
|
||||
*/
|
||||
protected function validateAccessAndGetBrandIds(Business $business): array
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Check for Brand Portal access
|
||||
if ($user->isBrandPortalUser($business)) {
|
||||
return $user->getBrandIdsForPortal($business);
|
||||
}
|
||||
|
||||
// Check for Brand Manager access
|
||||
if ($user->isBrandManagerUser($business)) {
|
||||
return $user->getBrandIdsForManager($business);
|
||||
}
|
||||
|
||||
abort(403, 'Access denied. Brand Portal or Brand Manager access required.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand Portal Dashboard - Brand Overview.
|
||||
*
|
||||
@@ -36,14 +61,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function dashboard(Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Ensure user is in Brand Portal mode
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// Summary stats
|
||||
@@ -90,13 +108,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function orders(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// Filter by brand if specified
|
||||
@@ -135,13 +147,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function accounts(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// Get businesses that have ordered products from linked brands
|
||||
@@ -177,13 +183,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function inventory(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// Filter by brand if specified
|
||||
@@ -233,13 +233,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function promotions(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// Filter by brand if specified
|
||||
@@ -275,25 +269,26 @@ class BrandPortalController extends Controller
|
||||
/**
|
||||
* Inbox - Conversations (messaging).
|
||||
*
|
||||
* Shows conversations related to the business.
|
||||
* Uses existing messaging infrastructure but scoped to Brand Portal context.
|
||||
* Shows CRM threads related to the user's linked brands only.
|
||||
* Uses CrmThread scoped by brand_id for filtering.
|
||||
*/
|
||||
public function inbox(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// For inbox, we show conversations but in a limited Brand Portal context
|
||||
// This integrates with existing messaging system
|
||||
// Get threads filtered to only those related to linked brands
|
||||
$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(
|
||||
'business',
|
||||
'brands'
|
||||
'brands',
|
||||
'threads'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -305,13 +300,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function contacts(Request $request, Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// For contacts, show in Brand Portal context
|
||||
@@ -326,13 +315,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function showOrder(Business $business, Order $order)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
|
||||
// Verify order contains products from user's linked brands
|
||||
$hasLinkedBrandProducts = $order->items()
|
||||
@@ -364,13 +347,7 @@ class BrandPortalController extends Controller
|
||||
*/
|
||||
public function showProduct(Business $business, Product $product)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->isBrandPortalUser($business)) {
|
||||
abort(403, 'Access denied. Brand Portal access required.');
|
||||
}
|
||||
|
||||
$brandIds = $user->getBrandIdsForPortal($business);
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
|
||||
// Verify product belongs to user's linked brands
|
||||
if (! in_array($product->brand_id, $brandIds)) {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,22 @@ class BrandSwitcherController extends Controller
|
||||
{
|
||||
$brandId = $request->input('brand_id');
|
||||
$brandHashid = $request->input('brand_hashid');
|
||||
$redirectTo = $request->input('redirect_to');
|
||||
|
||||
// If both are empty, clear the session (show all brands)
|
||||
if (empty($brandId) && empty($brandHashid)) {
|
||||
// Clear cache for current user before removing session
|
||||
$user = auth()->user();
|
||||
$business = $user?->primaryBusiness();
|
||||
$oldBrandId = session('selected_brand_id');
|
||||
|
||||
if ($user && $business && $oldBrandId) {
|
||||
\Illuminate\Support\Facades\Cache::forget("selected_brand:{$user->id}:{$business->id}:{$oldBrandId}");
|
||||
}
|
||||
|
||||
session()->forget('selected_brand_id');
|
||||
|
||||
return back();
|
||||
return $redirectTo ? redirect($redirectTo) : back();
|
||||
}
|
||||
|
||||
// Verify the brand exists and belongs to user's business
|
||||
@@ -56,6 +66,7 @@ class BrandSwitcherController extends Controller
|
||||
|
||||
/**
|
||||
* Get the currently selected brand (helper method).
|
||||
* Cached for 5 minutes to avoid repeated queries on every page load.
|
||||
*/
|
||||
public static function getSelectedBrand(): ?Brand
|
||||
{
|
||||
@@ -72,9 +83,14 @@ class BrandSwitcherController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
return Brand::forBusiness($business)
|
||||
->where('id', $brandId)
|
||||
->first();
|
||||
// Cache by user + business + brand to avoid repeated queries
|
||||
$cacheKey = "selected_brand:{$user->id}:{$business->id}:{$brandId}";
|
||||
|
||||
return \Illuminate\Support\Facades\Cache::remember($cacheKey, 300, function () use ($business, $brandId) {
|
||||
return Brand::forBusiness($business)
|
||||
->where('id', $brandId)
|
||||
->first();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,17 @@ class ContactController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of contacts (CRM Core)
|
||||
* Shows all contacts who have interacted with this seller business
|
||||
* Shows all contacts from buyer businesses (accounts)
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Get all contact IDs that have interacted with this business
|
||||
// through orders, conversations, or messages
|
||||
// Get all contacts from buyer businesses (accounts)
|
||||
// This gives a complete view of all contacts in the CRM
|
||||
$query = Contact::whereHas('business', function ($q) {
|
||||
$q->where('type', 'buyer');
|
||||
})->with(['business', 'user']);
|
||||
|
||||
// Also track which contacts have engaged for stats
|
||||
$orderContactIds = Order::whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})->whereNotNull('contact_id')->pluck('contact_id');
|
||||
@@ -28,11 +33,7 @@ class ContactController extends Controller
|
||||
->whereNotNull('primary_contact_id')
|
||||
->pluck('primary_contact_id');
|
||||
|
||||
$contactIds = $orderContactIds->merge($conversationContactIds)->unique();
|
||||
|
||||
// Build query
|
||||
$query = Contact::whereIn('id', $contactIds)
|
||||
->with(['business', 'user']);
|
||||
$engagedContactIds = $orderContactIds->merge($conversationContactIds)->unique();
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('search')) {
|
||||
@@ -60,6 +61,8 @@ class ContactController extends Controller
|
||||
$query->whereIn('id', $orderContactIds);
|
||||
} elseif ($request->activity === 'has_conversations') {
|
||||
$query->whereIn('id', $conversationContactIds);
|
||||
} elseif ($request->activity === 'engaged') {
|
||||
$query->whereIn('id', $engagedContactIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +78,14 @@ class ContactController extends Controller
|
||||
|
||||
$contacts = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Get stats
|
||||
// Get stats - count all buyer contacts and engaged contacts
|
||||
$allBuyerContactsQuery = Contact::whereHas('business', fn ($q) => $q->where('type', 'buyer'));
|
||||
$stats = [
|
||||
'total' => Contact::whereIn('id', $contactIds)->count(),
|
||||
'active' => Contact::whereIn('id', $contactIds)->where('is_active', true)->count(),
|
||||
'with_orders' => Contact::whereIn('id', $orderContactIds)->count(),
|
||||
'with_conversations' => Contact::whereIn('id', $conversationContactIds)->count(),
|
||||
'total' => (clone $allBuyerContactsQuery)->count(),
|
||||
'active' => (clone $allBuyerContactsQuery)->where('is_active', true)->count(),
|
||||
'with_orders' => $orderContactIds->count(),
|
||||
'with_conversations' => $conversationContactIds->count(),
|
||||
'engaged' => $engagedContactIds->count(),
|
||||
];
|
||||
|
||||
return view('seller.contacts.index', compact('business', 'contacts', 'stats'));
|
||||
@@ -107,41 +112,45 @@ class ContactController extends Controller
|
||||
// Load contact relationships
|
||||
$contact->load(['business', 'user']);
|
||||
|
||||
// Get conversations
|
||||
// Get conversations (limit for profile view)
|
||||
$conversations = Conversation::where('business_id', $business->id)
|
||||
->where('primary_contact_id', $contact->id)
|
||||
->with('latestMessage')
|
||||
->orderBy('last_message_at', 'desc')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Get orders
|
||||
// Get orders (limit for profile view, select only needed columns)
|
||||
$orders = Order::whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->where('contact_id', $contact->id)
|
||||
->with(['business', 'items.product'])
|
||||
->with(['business:id,name', 'items:id,order_id,product_id,quantity,unit_price', 'items.product:id,name,sku'])
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Get invoices
|
||||
// Get invoices (limit for profile view)
|
||||
$invoices = Invoice::whereHas('order', function ($q) use ($contact) {
|
||||
$q->where('contact_id', $contact->id);
|
||||
})
|
||||
->whereHas('order.items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with('order')
|
||||
->with('order:id,order_number')
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Get backorders (orders with status 'backorder')
|
||||
// Get backorders (limit for profile view)
|
||||
$backorders = Order::whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->where('contact_id', $contact->id)
|
||||
->where('status', 'backorder')
|
||||
->with(['business', 'items.product'])
|
||||
->with(['business:id,name', 'items:id,order_id,product_id,quantity', 'items.product:id,name,sku'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Premium features (gated by has_marketing)
|
||||
@@ -172,14 +181,18 @@ class ContactController extends Controller
|
||||
|
||||
/**
|
||||
* Build unified activity timeline (Premium feature)
|
||||
* Limited to most recent 30 items for performance
|
||||
*/
|
||||
private function buildTimeline(Contact $contact, Business $business): array
|
||||
{
|
||||
$timeline = [];
|
||||
|
||||
// Get all related activities
|
||||
// Get recent conversations (limit for performance)
|
||||
$conversations = Conversation::where('business_id', $business->id)
|
||||
->where('primary_contact_id', $contact->id)
|
||||
->select('id', 'subject', 'created_at')
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
foreach ($conversations as $conversation) {
|
||||
@@ -193,10 +206,14 @@ class ContactController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
// Get recent orders (limit for performance)
|
||||
$orders = Order::whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->where('contact_id', $contact->id)
|
||||
->select('id', 'order_number', 'total', 'created_at')
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
foreach ($orders as $order) {
|
||||
@@ -210,12 +227,16 @@ class ContactController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
// Get recent invoices (limit for performance)
|
||||
$invoices = Invoice::whereHas('order', function ($q) use ($contact) {
|
||||
$q->where('contact_id', $contact->id);
|
||||
})
|
||||
->whereHas('order.items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->select('id', 'invoice_number', 'payment_status', 'created_at')
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
@@ -229,11 +250,11 @@ class ContactController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
// Sort by date descending and limit total items
|
||||
usort($timeline, function ($a, $b) {
|
||||
return $b['date'] <=> $a['date'];
|
||||
});
|
||||
|
||||
return $timeline;
|
||||
return array_slice($timeline, 0, 30);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,12 @@ class ConversationController extends Controller
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('contact', function ($c) use ($search) {
|
||||
$c->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%")
|
||||
->orWhere('phone', 'like', "%{$search}%");
|
||||
$c->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('email', 'ilike', "%{$search}%")
|
||||
->orWhere('phone', 'ilike', "%{$search}%");
|
||||
})
|
||||
->orWhereHas('messages', function ($m) use ($search) {
|
||||
$m->where('message_body', 'like', "%{$search}%");
|
||||
$m->where('message_body', 'ilike', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,28 +5,180 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmEvent;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Location;
|
||||
use App\Models\SalesOpportunity;
|
||||
use App\Models\SendMenuLog;
|
||||
use App\Services\Cannaiq\CannaiqClient;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
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)
|
||||
{
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->with(['contacts'])
|
||||
->orderBy('name')
|
||||
->paginate(25);
|
||||
$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']);
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('dba_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('business_email', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Only show approved accounts (approved buyers)
|
||||
$query->where('status', 'approved');
|
||||
|
||||
// Active/Inactive filter
|
||||
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);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $accounts->map(fn ($a) => [
|
||||
'slug' => $a->slug,
|
||||
'name' => $a->name,
|
||||
'email' => $a->business_email,
|
||||
'status' => $a->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.accounts.index', compact('business', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create customer form
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.accounts.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new customer (buyer business)
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
'business_phone' => 'nullable|string|max:50',
|
||||
'physical_address' => 'nullable|string|max:255',
|
||||
'physical_city' => 'nullable|string|max:100',
|
||||
'physical_state' => 'nullable|string|max:50',
|
||||
'physical_zipcode' => 'nullable|string|max:20',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_title' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
// Create the buyer business
|
||||
$account = Business::create([
|
||||
'name' => $validated['name'],
|
||||
'dba_name' => $validated['dba_name'] ?? null,
|
||||
'license_number' => $validated['license_number'] ?? null,
|
||||
'business_email' => $validated['business_email'] ?? null,
|
||||
'business_phone' => $validated['business_phone'] ?? null,
|
||||
'physical_address' => $validated['physical_address'] ?? null,
|
||||
'physical_city' => $validated['physical_city'] ?? null,
|
||||
'physical_state' => $validated['physical_state'] ?? null,
|
||||
'physical_zipcode' => $validated['physical_zipcode'] ?? null,
|
||||
'type' => 'buyer',
|
||||
'status' => 'approved', // Auto-approve customers created by sellers
|
||||
]);
|
||||
|
||||
// Create contact if provided
|
||||
if (! empty($validated['contact_name'])) {
|
||||
$account->contacts()->create([
|
||||
'first_name' => explode(' ', $validated['contact_name'])[0],
|
||||
'last_name' => implode(' ', array_slice(explode(' ', $validated['contact_name']), 1)) ?: null,
|
||||
'email' => $validated['contact_email'] ?? null,
|
||||
'phone' => $validated['contact_phone'] ?? null,
|
||||
'title' => $validated['contact_title'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Log the creation event
|
||||
CrmEvent::log(
|
||||
sellerBusinessId: $business->id,
|
||||
eventType: 'account_created',
|
||||
summary: "Customer {$account->name} created",
|
||||
buyerBusinessId: $account->id,
|
||||
userId: auth()->id(),
|
||||
channel: 'system'
|
||||
);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'id' => $account->id,
|
||||
'name' => $account->name,
|
||||
'slug' => $account->slug,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Customer created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit customer form
|
||||
*/
|
||||
public function edit(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.edit', compact('business', 'account'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a customer (buyer business)
|
||||
*/
|
||||
public function update(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
'business_phone' => 'nullable|string|max:50',
|
||||
'physical_address' => 'nullable|string|max:255',
|
||||
'physical_city' => 'nullable|string|max:100',
|
||||
'physical_state' => 'nullable|string|max:50',
|
||||
'physical_zipcode' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$account->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Customer updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show account details
|
||||
*/
|
||||
@@ -34,17 +186,57 @@ class AccountController extends Controller
|
||||
{
|
||||
$account->load(['contacts']);
|
||||
|
||||
// Get orders for this account from this seller
|
||||
$orders = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->latest()
|
||||
->limit(10)
|
||||
// Location filtering
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
// Load all locations for this account with contacts pivot
|
||||
$locations = $account->locations()
|
||||
->with(['contacts' => function ($q) {
|
||||
$q->wherePivot('role', 'buyer');
|
||||
}])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Base order query for this seller
|
||||
$baseOrderQuery = fn () => $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
});
|
||||
|
||||
// Get orders (filtered by location if selected)
|
||||
$ordersQuery = $baseOrderQuery();
|
||||
if ($selectedLocation) {
|
||||
$ordersQuery->where('location_id', $selectedLocation->id);
|
||||
}
|
||||
$orders = $ordersQuery->with(['invoice', 'location'])->latest()->limit(10)->get();
|
||||
|
||||
// Get quotes for this account (filtered by location if selected)
|
||||
$quotesQuery = CrmQuote::where('business_id', $business->id)
|
||||
->where('account_id', $account->id);
|
||||
if ($selectedLocation) {
|
||||
$quotesQuery->where('location_id', $selectedLocation->id);
|
||||
}
|
||||
$quotes = $quotesQuery->with(['contact', 'items'])->latest()->limit(10)->get();
|
||||
|
||||
// Base invoice query
|
||||
$baseInvoiceQuery = fn () => Invoice::whereHas('order', function ($q) use ($business, $account) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
});
|
||||
|
||||
// Get invoices (filtered by location if selected)
|
||||
$invoicesQuery = $baseInvoiceQuery();
|
||||
if ($selectedLocation) {
|
||||
$invoicesQuery->whereHas('order', function ($q) use ($selectedLocation) {
|
||||
$q->where('location_id', $selectedLocation->id);
|
||||
});
|
||||
}
|
||||
$invoices = $invoicesQuery->with(['order', 'payments'])->latest()->limit(10)->get();
|
||||
|
||||
// Get opportunities for this account from this seller
|
||||
// SalesOpportunity uses business_id for the buyer
|
||||
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['stage', 'brand'])
|
||||
@@ -52,7 +244,6 @@ class AccountController extends Controller
|
||||
->get();
|
||||
|
||||
// Get tasks related to this account
|
||||
// CrmTask uses business_id for the buyer
|
||||
$tasks = CrmTask::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->whereNull('completed_at')
|
||||
@@ -84,31 +275,142 @@ class AccountController extends Controller
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Compute stats for this account (orders from this seller)
|
||||
$ordersQuery = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
});
|
||||
// Compute stats - if location selected, show location-specific stats
|
||||
if ($selectedLocation) {
|
||||
$orderStats = $baseOrderQuery()
|
||||
->where('location_id', $selectedLocation->id)
|
||||
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
|
||||
->first();
|
||||
} else {
|
||||
$orderStats = $baseOrderQuery()
|
||||
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
|
||||
->first();
|
||||
}
|
||||
|
||||
$pipelineValue = $opportunities->where('status', 'open')->sum('value');
|
||||
$opportunityStats = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->where('status', 'open')
|
||||
->selectRaw('COUNT(*) as open_count, COALESCE(SUM(value), 0) as pipeline_value')
|
||||
->first();
|
||||
|
||||
// Financial stats from invoices (location-filtered if applicable)
|
||||
$financialStatsQuery = $baseInvoiceQuery();
|
||||
if ($selectedLocation) {
|
||||
$financialStatsQuery->whereHas('order', function ($q) use ($selectedLocation) {
|
||||
$q->where('location_id', $selectedLocation->id);
|
||||
});
|
||||
}
|
||||
$financialStats = $financialStatsQuery->selectRaw('
|
||||
COALESCE(SUM(amount_due), 0) as outstanding_balance,
|
||||
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due_amount,
|
||||
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoice_count,
|
||||
MIN(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN due_date END) as oldest_past_due_date
|
||||
')
|
||||
->first();
|
||||
|
||||
// Get last payment info
|
||||
$lastPayment = \App\Models\InvoicePayment::whereHas('invoice.order', function ($q) use ($business, $account) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->latest('payment_date')
|
||||
->first();
|
||||
|
||||
$stats = [
|
||||
'total_orders' => $ordersQuery->count(),
|
||||
'total_revenue' => $ordersQuery->sum('total') ?? 0,
|
||||
'open_opportunities' => $opportunities->where('status', 'open')->count(),
|
||||
'pipeline_value' => $pipelineValue ?? 0,
|
||||
'total_orders' => $orderStats->total_orders ?? 0,
|
||||
'total_revenue' => $orderStats->total_revenue ?? 0,
|
||||
'open_opportunities' => $opportunityStats->open_count ?? 0,
|
||||
'pipeline_value' => $opportunityStats->pipeline_value ?? 0,
|
||||
];
|
||||
|
||||
$financials = [
|
||||
'outstanding_balance' => $financialStats->outstanding_balance ?? 0,
|
||||
'past_due_amount' => $financialStats->past_due_amount ?? 0,
|
||||
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
|
||||
'oldest_past_due_days' => $financialStats->oldest_past_due_date
|
||||
? (int) ceil(abs(now()->diffInDays($financialStats->oldest_past_due_date)))
|
||||
: null,
|
||||
'last_payment_amount' => $lastPayment->amount ?? null,
|
||||
'last_payment_date' => $lastPayment->payment_date ?? null,
|
||||
];
|
||||
|
||||
// Calculate unattributed orders/invoices (those without location_id)
|
||||
$unattributedOrdersCount = $baseOrderQuery()->whereNull('location_id')->count();
|
||||
$unattributedInvoicesCount = $baseInvoiceQuery()
|
||||
->whereHas('order', function ($q) {
|
||||
$q->whereNull('location_id');
|
||||
})
|
||||
->count();
|
||||
|
||||
// Calculate per-location stats for location tiles
|
||||
$locationStats = [];
|
||||
if ($locations->count() > 0) {
|
||||
$locationIds = $locations->pluck('id')->toArray();
|
||||
|
||||
// Order stats by location
|
||||
$ordersByLocation = $baseOrderQuery()
|
||||
->whereIn('location_id', $locationIds)
|
||||
->selectRaw('location_id, COUNT(*) as orders_count, COALESCE(SUM(total), 0) as revenue')
|
||||
->groupBy('location_id')
|
||||
->get()
|
||||
->keyBy('location_id');
|
||||
|
||||
// Invoice stats by location
|
||||
$invoicesByLocation = Invoice::whereHas('order', function ($q) use ($business, $account, $locationIds) {
|
||||
$q->where('business_id', $account->id)
|
||||
->whereIn('location_id', $locationIds)
|
||||
->whereHas('items.product.brand', function ($q2) use ($business) {
|
||||
$q2->where('business_id', $business->id);
|
||||
});
|
||||
})
|
||||
->selectRaw('
|
||||
(SELECT location_id FROM orders WHERE orders.id = invoices.order_id) as location_id,
|
||||
COALESCE(SUM(amount_due), 0) as outstanding,
|
||||
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due,
|
||||
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoices
|
||||
')
|
||||
->groupByRaw('(SELECT location_id FROM orders WHERE orders.id = invoices.order_id)')
|
||||
->get()
|
||||
->keyBy('location_id');
|
||||
|
||||
foreach ($locations as $location) {
|
||||
$orderData = $ordersByLocation->get($location->id);
|
||||
$invoiceData = $invoicesByLocation->get($location->id);
|
||||
|
||||
$ordersCount = $orderData->orders_count ?? 0;
|
||||
$openInvoices = $invoiceData->open_invoices ?? 0;
|
||||
|
||||
$locationStats[$location->id] = [
|
||||
'orders' => $ordersCount,
|
||||
'revenue' => $orderData->revenue ?? 0,
|
||||
'outstanding' => $invoiceData->outstanding ?? 0,
|
||||
'past_due' => $invoiceData->past_due ?? 0,
|
||||
'open_invoices' => $openInvoices,
|
||||
'has_attributed_data' => ($ordersCount + $openInvoices) > 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return view('seller.crm.accounts.show', compact(
|
||||
'business',
|
||||
'account',
|
||||
'stats',
|
||||
'financials',
|
||||
'orders',
|
||||
'quotes',
|
||||
'invoices',
|
||||
'opportunities',
|
||||
'tasks',
|
||||
'conversationEvents',
|
||||
'sendHistory',
|
||||
'activities'
|
||||
'activities',
|
||||
'locations',
|
||||
'selectedLocation',
|
||||
'locationStats',
|
||||
'unattributedOrdersCount',
|
||||
'unattributedInvoicesCount'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -117,9 +419,26 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function contacts(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$contacts = $account->contacts()->paginate(25);
|
||||
// Location filtering
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
return view('seller.crm.accounts.contacts', compact('business', 'account', 'contacts'));
|
||||
// Base query for contacts
|
||||
$contactsQuery = $account->contacts();
|
||||
|
||||
// If location selected, filter to contacts assigned to that location
|
||||
if ($selectedLocation) {
|
||||
$contactsQuery->whereHas('locations', function ($q) use ($selectedLocation) {
|
||||
$q->where('locations.id', $selectedLocation->id);
|
||||
});
|
||||
}
|
||||
|
||||
$contacts = $contactsQuery->paginate(25);
|
||||
|
||||
// Load locations for the scope bar
|
||||
$locations = $account->locations()->orderBy('name')->get();
|
||||
|
||||
return view('seller.crm.accounts.contacts', compact('business', 'account', 'contacts', 'locations', 'selectedLocation'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +446,21 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function opportunities(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.opportunities', compact('business', 'account'));
|
||||
// Location filtering (note: opportunities don't have location_id yet, so we just pass the context)
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
// Load opportunities for this account
|
||||
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['stage', 'brand', 'owner'])
|
||||
->latest()
|
||||
->paginate(25);
|
||||
|
||||
// Load locations for the scope bar
|
||||
$locations = $account->locations()->orderBy('name')->get();
|
||||
|
||||
return view('seller.crm.accounts.opportunities', compact('business', 'account', 'opportunities', 'locations', 'selectedLocation'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,7 +468,28 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function orders(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.orders', compact('business', 'account'));
|
||||
// Location filtering
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
$ordersQuery = $account->orders()
|
||||
->whereHas('items.product.brand', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
});
|
||||
|
||||
// Filter by location if selected
|
||||
if ($selectedLocation) {
|
||||
$ordersQuery->where('location_id', $selectedLocation->id);
|
||||
}
|
||||
|
||||
$orders = $ordersQuery->with(['items.product.brand', 'location'])
|
||||
->latest()
|
||||
->paginate(25);
|
||||
|
||||
// Load locations for the scope bar
|
||||
$locations = $account->locations()->orderBy('name')->get();
|
||||
|
||||
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders', 'locations', 'selectedLocation'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +497,20 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function activity(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.activity', compact('business', 'account'));
|
||||
// Location filtering (note: activities don't have location_id yet, so we just pass the context)
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
$activities = Activity::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['causer'])
|
||||
->latest()
|
||||
->paginate(50);
|
||||
|
||||
// Load locations for the scope bar
|
||||
$locations = $account->locations()->orderBy('name')->get();
|
||||
|
||||
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities', 'locations', 'selectedLocation'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,7 +518,22 @@ class AccountController extends Controller
|
||||
*/
|
||||
public function tasks(Request $request, Business $business, Business $account)
|
||||
{
|
||||
return view('seller.crm.accounts.tasks', compact('business', 'account'));
|
||||
// Location filtering (note: tasks don't have location_id yet, so we just pass the context)
|
||||
$locationId = $request->query('location');
|
||||
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
|
||||
|
||||
// Load tasks for this account
|
||||
$tasks = CrmTask::where('seller_business_id', $business->id)
|
||||
->where('business_id', $account->id)
|
||||
->with(['assignee', 'opportunity'])
|
||||
->orderByRaw('completed_at IS NOT NULL')
|
||||
->orderBy('due_at')
|
||||
->paginate(25);
|
||||
|
||||
// Load locations for the scope bar
|
||||
$locations = $account->locations()->orderBy('name')->get();
|
||||
|
||||
return view('seller.crm.accounts.tasks', compact('business', 'account', 'tasks', 'locations', 'selectedLocation'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,4 +558,258 @@ class AccountController extends Controller
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Note added successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new contact for an account
|
||||
*/
|
||||
public function storeContact(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:100',
|
||||
'last_name' => 'nullable|string|max:100',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$contact = $account->contacts()->create($validated);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'id' => $contact->id,
|
||||
'first_name' => $contact->first_name,
|
||||
'last_name' => $contact->last_name,
|
||||
'email' => $contact->email,
|
||||
'phone' => $contact->phone,
|
||||
'title' => $contact->title,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
|
||||
->with('success', 'Contact added successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit contact form
|
||||
*/
|
||||
public function editContact(Request $request, Business $business, Business $account, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to this account
|
||||
if ($contact->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.accounts.contacts-edit', compact('business', 'account', 'contact'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contact
|
||||
*/
|
||||
public function updateContact(Request $request, Business $business, Business $account, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to this account
|
||||
if ($contact->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:100',
|
||||
'last_name' => 'nullable|string|max:100',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// Handle checkbox - if not sent, default to false
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
$contact->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
|
||||
->with('success', 'Contact updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact
|
||||
*/
|
||||
public function destroyContact(Request $request, Business $business, Business $account, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to this account
|
||||
if ($contact->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$contact->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
|
||||
->with('success', 'Contact deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show location edit form
|
||||
*/
|
||||
public function editLocation(Request $request, Business $business, Business $account, Location $location)
|
||||
{
|
||||
// Verify location belongs to this account
|
||||
if ($location->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Load contacts that can be assigned to this location
|
||||
$contacts = $account->contacts()->orderBy('first_name')->get();
|
||||
|
||||
// Load currently assigned contacts with their roles
|
||||
$locationContacts = $location->contacts()->get();
|
||||
|
||||
// Available roles for location contacts
|
||||
$contactRoles = [
|
||||
'buyer' => 'Buyer',
|
||||
'ap' => 'Accounts Payable',
|
||||
'marketing' => 'Marketing',
|
||||
'gm' => 'General Manager',
|
||||
'inventory' => 'Inventory Manager',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
// CannaiQ platforms
|
||||
$cannaiqPlatforms = [
|
||||
'dutchie' => 'Dutchie',
|
||||
'jane' => 'Jane',
|
||||
'weedmaps' => 'Weedmaps',
|
||||
'leafly' => 'Leafly',
|
||||
'iheartjane' => 'iHeartJane',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
return view('seller.crm.accounts.locations-edit', compact(
|
||||
'business',
|
||||
'account',
|
||||
'location',
|
||||
'contacts',
|
||||
'locationContacts',
|
||||
'contactRoles',
|
||||
'cannaiqPlatforms'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update location
|
||||
*/
|
||||
public function updateLocation(Request $request, Business $business, Business $account, Location $location)
|
||||
{
|
||||
// Verify location belongs to this account
|
||||
if ($location->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:50',
|
||||
'zipcode' => 'nullable|string|max:20',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'is_active' => 'boolean',
|
||||
'cannaiq_platform' => 'nullable|string|max:50',
|
||||
'cannaiq_store_slug' => 'nullable|string|max:255',
|
||||
'cannaiq_store_id' => 'nullable|string|max:100',
|
||||
'cannaiq_store_name' => 'nullable|string|max:255',
|
||||
'contact_roles' => 'nullable|array',
|
||||
'contact_roles.*.contact_id' => 'required|exists:contacts,id',
|
||||
'contact_roles.*.role' => 'required|string|max:50',
|
||||
'contact_roles.*.is_primary' => 'boolean',
|
||||
]);
|
||||
|
||||
// Handle checkbox
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
// Clear CannaiQ fields if platform is cleared
|
||||
if (empty($validated['cannaiq_platform'])) {
|
||||
$validated['cannaiq_store_slug'] = null;
|
||||
$validated['cannaiq_store_id'] = null;
|
||||
$validated['cannaiq_store_name'] = null;
|
||||
}
|
||||
|
||||
// Update location
|
||||
$location->update([
|
||||
'name' => $validated['name'],
|
||||
'address' => $validated['address'] ?? null,
|
||||
'city' => $validated['city'] ?? null,
|
||||
'state' => $validated['state'] ?? null,
|
||||
'zipcode' => $validated['zipcode'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'email' => $validated['email'] ?? null,
|
||||
'is_active' => $validated['is_active'],
|
||||
'cannaiq_platform' => $validated['cannaiq_platform'] ?? null,
|
||||
'cannaiq_store_slug' => $validated['cannaiq_store_slug'] ?? null,
|
||||
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
|
||||
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
|
||||
]);
|
||||
|
||||
// Sync location contacts
|
||||
if (isset($validated['contact_roles'])) {
|
||||
$syncData = [];
|
||||
foreach ($validated['contact_roles'] as $contactRole) {
|
||||
// Verify contact belongs to this account
|
||||
$contact = Contact::where('business_id', $account->id)
|
||||
->where('id', $contactRole['contact_id'])
|
||||
->first();
|
||||
|
||||
if ($contact) {
|
||||
$syncData[$contact->id] = [
|
||||
'role' => $contactRole['role'],
|
||||
'is_primary' => $contactRole['is_primary'] ?? false,
|
||||
];
|
||||
}
|
||||
}
|
||||
$location->contacts()->sync($syncData);
|
||||
} else {
|
||||
$location->contacts()->detach();
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
|
||||
->with('success', 'Location updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search CannaiQ stores for linking
|
||||
*/
|
||||
public function searchCannaiqStores(Request $request, Business $business, Business $account, Location $location)
|
||||
{
|
||||
// Verify location belongs to this account
|
||||
if ($location->business_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'platform' => 'required|string|max:50',
|
||||
'query' => 'required|string|min:2|max:100',
|
||||
]);
|
||||
|
||||
try {
|
||||
$client = app(CannaiqClient::class);
|
||||
$results = $client->searchStores(
|
||||
platform: $request->input('platform'),
|
||||
query: $request->input('query')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'stores' => $results,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to search stores: '.$e->getMessage(),
|
||||
'stores' => [],
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,19 +22,19 @@ class AutomationController extends Controller
|
||||
->orderByDesc('created_at')
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.crm.automations.index', compact('automations'));
|
||||
return view('seller.crm.automations.index', compact('automations', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show automation builder
|
||||
*/
|
||||
public function create(Request $request)
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$triggers = CrmAutomation::TRIGGERS;
|
||||
$operators = CrmAutomationCondition::OPERATORS;
|
||||
$actionTypes = CrmAutomationAction::TYPES;
|
||||
|
||||
return view('seller.crm.automations.create', compact('triggers', 'operators', 'actionTypes'));
|
||||
return view('seller.crm.automations.create', compact('triggers', 'operators', 'actionTypes', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +97,7 @@ class AutomationController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('seller.crm.automations.show', $automation)
|
||||
return redirect()->route('seller.business.crm.automations.show', [$business, $automation])
|
||||
->with('success', 'Automation created successfully.');
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ class AutomationController extends Controller
|
||||
|
||||
$automation->load(['conditions', 'actions', 'logs' => fn ($q) => $q->latest()->limit(50)]);
|
||||
|
||||
return view('seller.crm.automations.show', compact('automation'));
|
||||
return view('seller.crm.automations.show', compact('automation', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +130,7 @@ class AutomationController extends Controller
|
||||
$operators = CrmAutomationCondition::OPERATORS;
|
||||
$actionTypes = CrmAutomationAction::TYPES;
|
||||
|
||||
return view('seller.crm.automations.edit', compact('automation', 'triggers', 'operators', 'actionTypes'));
|
||||
return view('seller.crm.automations.edit', compact('automation', 'triggers', 'operators', 'actionTypes', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,7 +202,7 @@ class AutomationController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('seller.crm.automations.show', $automation)
|
||||
return redirect()->route('seller.business.crm.automations.show', [$business, $automation])
|
||||
->with('success', 'Automation updated successfully.');
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ class AutomationController extends Controller
|
||||
|
||||
$copy = $automation->duplicate();
|
||||
|
||||
return redirect()->route('seller.crm.automations.edit', $copy)
|
||||
return redirect()->route('seller.business.crm.automations.edit', [$business, $copy])
|
||||
->with('success', 'Automation duplicated. Make your changes and activate when ready.');
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ class AutomationController extends Controller
|
||||
|
||||
$automation->delete();
|
||||
|
||||
return redirect()->route('seller.crm.automations.index')
|
||||
return redirect()->route('seller.business.crm.automations.index', $business)
|
||||
->with('success', 'Automation deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
196
app/Http/Controllers/Seller/Crm/ChannelController.php
Normal file
196
app/Http/Controllers/Seller/Crm/ChannelController.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessEmailIdentity;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ChannelController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all CRM channels for a business.
|
||||
*/
|
||||
public function index(Business $business)
|
||||
{
|
||||
$channels = CrmChannel::forBusiness($business->id)
|
||||
->orderBy('type')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.channels.index', compact('business', 'channels'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the create channel form.
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
// Get available email identities
|
||||
$emailIdentities = BusinessEmailIdentity::forBusiness($business->id)
|
||||
->active()
|
||||
->with('mailSettings')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.channels.create', [
|
||||
'business' => $business,
|
||||
'channel' => null,
|
||||
'emailIdentities' => $emailIdentities,
|
||||
'types' => [
|
||||
CrmChannel::TYPE_EMAIL => 'Email',
|
||||
CrmChannel::TYPE_SMS => 'SMS',
|
||||
],
|
||||
'departments' => CrmChannel::DEPARTMENTS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new channel.
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'type' => ['required', 'string', Rule::in([CrmChannel::TYPE_EMAIL, CrmChannel::TYPE_SMS])],
|
||||
'department' => ['required', 'string', Rule::in(array_keys(CrmChannel::DEPARTMENTS))],
|
||||
'is_active' => ['boolean'],
|
||||
// Email-specific
|
||||
'identity_id' => ['nullable', 'required_if:type,email', 'exists:business_email_identities,id'],
|
||||
// SMS-specific
|
||||
'phone_number' => ['nullable', 'required_if:type,sms', 'string', 'max:20'],
|
||||
]);
|
||||
|
||||
// Build config based on type
|
||||
$config = ['department' => $validated['department']];
|
||||
$identifier = null;
|
||||
|
||||
if ($validated['type'] === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
|
||||
$identity = BusinessEmailIdentity::where('business_id', $business->id)
|
||||
->findOrFail($validated['identity_id']);
|
||||
|
||||
$config['identity_id'] = $identity->id;
|
||||
$config['mail_settings_id'] = $identity->mail_settings_id;
|
||||
$identifier = $identity->email;
|
||||
}
|
||||
|
||||
if ($validated['type'] === CrmChannel::TYPE_SMS && ! empty($validated['phone_number'])) {
|
||||
$config['phone_number'] = $validated['phone_number'];
|
||||
$identifier = $validated['phone_number'];
|
||||
}
|
||||
|
||||
$channel = CrmChannel::create([
|
||||
'business_id' => $business->id,
|
||||
'type' => $validated['type'],
|
||||
'name' => $validated['name'],
|
||||
'department' => $validated['department'],
|
||||
'identifier' => $identifier,
|
||||
'config' => $config,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
'can_send' => true,
|
||||
'can_receive' => true,
|
||||
]);
|
||||
|
||||
// Link the email identity to this channel
|
||||
if ($validated['type'] === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
|
||||
BusinessEmailIdentity::where('id', $validated['identity_id'])
|
||||
->update(['crm_channel_id' => $channel->id]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.channels.index', $business)
|
||||
->with('success', 'Channel created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the edit channel form.
|
||||
*/
|
||||
public function edit(Business $business, CrmChannel $channel)
|
||||
{
|
||||
// Security: ensure channel belongs to business
|
||||
if ($channel->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Get available email identities
|
||||
$emailIdentities = BusinessEmailIdentity::forBusiness($business->id)
|
||||
->active()
|
||||
->with('mailSettings')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.channels.edit', [
|
||||
'business' => $business,
|
||||
'channel' => $channel,
|
||||
'emailIdentities' => $emailIdentities,
|
||||
'types' => [
|
||||
CrmChannel::TYPE_EMAIL => 'Email',
|
||||
CrmChannel::TYPE_SMS => 'SMS',
|
||||
],
|
||||
'departments' => CrmChannel::DEPARTMENTS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing channel.
|
||||
*/
|
||||
public function update(Request $request, Business $business, CrmChannel $channel)
|
||||
{
|
||||
// Security: ensure channel belongs to business
|
||||
if ($channel->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'department' => ['required', 'string', Rule::in(array_keys(CrmChannel::DEPARTMENTS))],
|
||||
'is_active' => ['boolean'],
|
||||
// Email-specific
|
||||
'identity_id' => ['nullable', 'exists:business_email_identities,id'],
|
||||
// SMS-specific
|
||||
'phone_number' => ['nullable', 'string', 'max:20'],
|
||||
]);
|
||||
|
||||
// Build config based on type
|
||||
$config = $channel->config ?? [];
|
||||
$config['department'] = $validated['department'];
|
||||
$identifier = $channel->identifier;
|
||||
|
||||
if ($channel->type === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
|
||||
$identity = BusinessEmailIdentity::where('business_id', $business->id)
|
||||
->findOrFail($validated['identity_id']);
|
||||
|
||||
// Unlink old identity if different
|
||||
$oldIdentityId = $config['identity_id'] ?? null;
|
||||
if ($oldIdentityId && $oldIdentityId != $identity->id) {
|
||||
BusinessEmailIdentity::where('id', $oldIdentityId)
|
||||
->update(['crm_channel_id' => null]);
|
||||
}
|
||||
|
||||
$config['identity_id'] = $identity->id;
|
||||
$config['mail_settings_id'] = $identity->mail_settings_id;
|
||||
$identifier = $identity->email;
|
||||
|
||||
// Link new identity
|
||||
$identity->update(['crm_channel_id' => $channel->id]);
|
||||
}
|
||||
|
||||
if ($channel->type === CrmChannel::TYPE_SMS && ! empty($validated['phone_number'])) {
|
||||
$config['phone_number'] = $validated['phone_number'];
|
||||
$identifier = $validated['phone_number'];
|
||||
}
|
||||
|
||||
$channel->update([
|
||||
'name' => $validated['name'],
|
||||
'department' => $validated['department'],
|
||||
'identifier' => $identifier,
|
||||
'config' => $config,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.channels.index', $business)
|
||||
->with('success', 'Channel updated successfully.');
|
||||
}
|
||||
}
|
||||
252
app/Http/Controllers/Seller/Crm/ContactController.php
Normal file
252
app/Http/Controllers/Seller/Crm/ContactController.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display all CRM contacts (contacts from buyer businesses).
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = Contact::query()
|
||||
->whereHas('business', function ($q) {
|
||||
$q->where('type', 'buyer');
|
||||
})
|
||||
->with(['business', 'location']);
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('first_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('last_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('email', 'ILIKE', "%{$search}%")
|
||||
->orWhere('phone', 'ILIKE', "%{$search}%")
|
||||
->orWhere('position', 'ILIKE', "%{$search}%")
|
||||
->orWhereHas('business', function ($q) use ($search) {
|
||||
$q->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('dba_name', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Account filter
|
||||
if ($request->filled('account')) {
|
||||
$query->where('business_id', $request->account);
|
||||
}
|
||||
|
||||
// Contact type filter
|
||||
if ($request->filled('type')) {
|
||||
$query->where('contact_type', $request->type);
|
||||
}
|
||||
|
||||
// Active filter - default to active
|
||||
if ($request->filled('status')) {
|
||||
if ($request->status === 'inactive') {
|
||||
$query->where('is_active', false);
|
||||
} elseif ($request->status === 'all') {
|
||||
// Show all
|
||||
} else {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
} else {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
$contacts = $query
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $contacts->map(fn ($c) => [
|
||||
'hashid' => $c->hashid,
|
||||
'name' => $c->getFullName(),
|
||||
'email' => $c->email,
|
||||
'account' => $c->business?->name,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Get accounts for filter dropdown
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'dba_name']);
|
||||
|
||||
return view('seller.crm.contacts.index', compact('business', 'contacts', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new contact.
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'dba_name']);
|
||||
|
||||
$selectedAccount = $request->filled('account')
|
||||
? Business::find($request->account)
|
||||
: null;
|
||||
|
||||
return view('seller.crm.contacts.create', compact('business', 'accounts', 'selectedAccount'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created contact.
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|exists:businesses,id',
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'mobile' => 'nullable|string|max:50',
|
||||
'position' => 'nullable|string|max:255',
|
||||
'contact_type' => 'nullable|string|in:'.implode(',', array_keys(Contact::CONTACT_TYPES)),
|
||||
'preferred_contact_method' => 'nullable|string|in:'.implode(',', array_keys(Contact::COMMUNICATION_METHODS)),
|
||||
'is_primary' => 'nullable|boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Verify the target business is a buyer
|
||||
$targetBusiness = Business::findOrFail($validated['business_id']);
|
||||
if ($targetBusiness->type !== 'buyer') {
|
||||
return redirect()->back()->with('error', 'Contacts can only be added to customer accounts.');
|
||||
}
|
||||
|
||||
// If setting as primary, remove primary from other contacts
|
||||
if ($request->boolean('is_primary')) {
|
||||
Contact::where('business_id', $validated['business_id'])->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
$contact = Contact::create([
|
||||
'business_id' => $validated['business_id'],
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'mobile' => $validated['mobile'] ?? null,
|
||||
'position' => $validated['position'] ?? null,
|
||||
'contact_type' => $validated['contact_type'] ?? 'general',
|
||||
'preferred_contact_method' => $validated['preferred_contact_method'] ?? 'email',
|
||||
'is_primary' => $request->boolean('is_primary', false),
|
||||
'is_active' => true,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.contacts.index', $business)
|
||||
->with('success', "Contact '{$contact->getFullName()}' created successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing a contact.
|
||||
*/
|
||||
public function edit(Business $business, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to a buyer business
|
||||
if ($contact->business->type !== 'buyer') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'dba_name']);
|
||||
|
||||
return view('seller.crm.contacts.edit', compact('business', 'contact', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contact.
|
||||
*/
|
||||
public function update(Request $request, Business $business, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to a buyer business
|
||||
if ($contact->business->type !== 'buyer') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|exists:businesses,id',
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'mobile' => 'nullable|string|max:50',
|
||||
'position' => 'nullable|string|max:255',
|
||||
'contact_type' => 'nullable|string|in:'.implode(',', array_keys(Contact::CONTACT_TYPES)),
|
||||
'preferred_contact_method' => 'nullable|string|in:'.implode(',', array_keys(Contact::COMMUNICATION_METHODS)),
|
||||
'is_primary' => 'nullable|boolean',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Verify the target business is a buyer
|
||||
$targetBusiness = Business::findOrFail($validated['business_id']);
|
||||
if ($targetBusiness->type !== 'buyer') {
|
||||
return redirect()->back()->with('error', 'Contacts can only belong to customer accounts.');
|
||||
}
|
||||
|
||||
// If setting as primary, remove primary from other contacts
|
||||
if ($request->boolean('is_primary') && ! $contact->is_primary) {
|
||||
Contact::where('business_id', $validated['business_id'])
|
||||
->where('id', '!=', $contact->id)
|
||||
->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
$contact->update([
|
||||
'business_id' => $validated['business_id'],
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'mobile' => $validated['mobile'] ?? null,
|
||||
'position' => $validated['position'] ?? null,
|
||||
'contact_type' => $validated['contact_type'] ?? 'general',
|
||||
'preferred_contact_method' => $validated['preferred_contact_method'] ?? 'email',
|
||||
'is_primary' => $request->boolean('is_primary', false),
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.contacts.index', $business)
|
||||
->with('success', "Contact '{$contact->getFullName()}' updated successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive/delete a contact.
|
||||
*/
|
||||
public function destroy(Business $business, Contact $contact)
|
||||
{
|
||||
// Verify contact belongs to a buyer business
|
||||
if ($contact->business->type !== 'buyer') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$name = $contact->getFullName();
|
||||
|
||||
$contact->archive('Deleted via CRM', auth()->user());
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.contacts.index', $business)
|
||||
->with('success', "Contact '{$name}' has been archived.");
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,13 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Crm\SyncCalendarJob;
|
||||
use App\Models\Business;
|
||||
use App\Models\CalendarEvent;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmCalendarConnection;
|
||||
use App\Models\Crm\CrmMeetingBooking;
|
||||
use App\Models\Crm\CrmSyncedEvent;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\User;
|
||||
use App\Services\Crm\CrmCalendarService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -17,7 +22,7 @@ class CrmCalendarController extends Controller
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Calendar view
|
||||
* Calendar view - unified activity calendar
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
@@ -28,51 +33,403 @@ class CrmCalendarController extends Controller
|
||||
->where('user_id', $user->id)
|
||||
->get();
|
||||
|
||||
// Get events for calendar view
|
||||
$startDate = $request->input('start', now()->startOfMonth());
|
||||
$endDate = $request->input('end', now()->endOfMonth());
|
||||
// Get team members for assignment dropdown
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->get();
|
||||
|
||||
$events = CrmSyncedEvent::whereIn('connection_id', $connections->pluck('id'))
|
||||
->whereBetween('start_time', [$startDate, $endDate])
|
||||
// Get contacts for event creation
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
$contacts = Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->with('business:id,name')
|
||||
->orderBy('first_name')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
// Get event types and colors for legend/forms
|
||||
$eventTypes = CalendarEvent::TYPES;
|
||||
$eventColors = CalendarEvent::TYPE_COLORS;
|
||||
|
||||
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
|
||||
return view('seller.crm.calendar.index', compact(
|
||||
'business',
|
||||
'connections',
|
||||
'teamMembers',
|
||||
'contacts',
|
||||
'eventTypes',
|
||||
'eventColors'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Get all events for date range (unified: internal + synced + bookings + tasks)
|
||||
*/
|
||||
public function events(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'start' => 'required|date',
|
||||
'end' => 'required|date|after:start',
|
||||
]);
|
||||
|
||||
$startDate = $validated['start'];
|
||||
$endDate = $validated['end'];
|
||||
$allEvents = collect();
|
||||
|
||||
// 1. Internal CalendarEvents
|
||||
$internalEvents = CalendarEvent::forSellerBusiness($business->id)
|
||||
->inDateRange($startDate, $endDate)
|
||||
->with(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name'])
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'id' => $e->id,
|
||||
'id' => 'event_'.$e->id,
|
||||
'title' => $e->title,
|
||||
'start' => $e->start_time->toIso8601String(),
|
||||
'end' => $e->end_time->toIso8601String(),
|
||||
'allDay' => $e->is_all_day,
|
||||
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
|
||||
'start' => $e->start_at->toIso8601String(),
|
||||
'end' => $e->end_at?->toIso8601String(),
|
||||
'allDay' => $e->all_day,
|
||||
'color' => $e->getColor(),
|
||||
'classNames' => ['calendar-event-internal', 'event-type-'.$e->type],
|
||||
'extendedProps' => [
|
||||
'source' => 'internal',
|
||||
'event_id' => $e->id,
|
||||
'type' => $e->type,
|
||||
'type_label' => $e->getTypeLabel(),
|
||||
'status' => $e->status,
|
||||
'location' => $e->location,
|
||||
'description' => $e->description,
|
||||
'attendees' => $e->attendees,
|
||||
'contact_id' => $e->contact_id,
|
||||
'contact_name' => $e->contact ? $e->contact->first_name.' '.$e->contact->last_name : null,
|
||||
'assigned_to' => $e->assigned_to,
|
||||
'assignee_name' => $e->assignee ? $e->assignee->first_name.' '.$e->assignee->last_name : null,
|
||||
'editable' => true,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($internalEvents);
|
||||
|
||||
// Get meeting bookings
|
||||
$bookings = \Modules\Crm\Entities\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
|
||||
// 2. Synced external events (Google/Outlook)
|
||||
$connections = CrmCalendarConnection::where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('sync_enabled', true)
|
||||
->pluck('id');
|
||||
|
||||
if ($connections->isNotEmpty()) {
|
||||
$syncedEvents = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
|
||||
->whereBetween('start_at', [$startDate, $endDate])
|
||||
->with('connection:id,provider')
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'id' => 'synced_'.$e->id,
|
||||
'title' => $e->title,
|
||||
'start' => $e->start_at->toIso8601String(),
|
||||
'end' => $e->end_at?->toIso8601String(),
|
||||
'allDay' => $e->all_day,
|
||||
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
|
||||
'classNames' => ['calendar-event-synced', 'provider-'.$e->connection->provider],
|
||||
'extendedProps' => [
|
||||
'source' => 'synced',
|
||||
'provider' => $e->connection->provider,
|
||||
'location' => $e->location,
|
||||
'description' => $e->description,
|
||||
'attendees' => $e->attendees,
|
||||
'external_link' => $e->external_link,
|
||||
'editable' => false,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($syncedEvents);
|
||||
}
|
||||
|
||||
// 3. Meeting bookings
|
||||
$bookings = CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
|
||||
$q->where('business_id', $business->id)
|
||||
->where('user_id', $user->id);
|
||||
})
|
||||
->whereBetween('start_time', [$startDate, $endDate])
|
||||
->with(['meetingLink', 'contact'])
|
||||
->whereBetween('start_at', [$startDate, $endDate])
|
||||
->where('status', '!=', 'cancelled')
|
||||
->with(['meetingLink:id,name', 'contact:id,first_name,last_name'])
|
||||
->get()
|
||||
->map(fn ($b) => [
|
||||
'id' => 'booking_'.$b->id,
|
||||
'title' => $b->meetingLink->name.' - '.$b->guest_name,
|
||||
'start' => $b->start_time->toIso8601String(),
|
||||
'end' => $b->end_time->toIso8601String(),
|
||||
'title' => ($b->meetingLink->name ?? 'Meeting').' - '.$b->booker_name,
|
||||
'start' => $b->start_at->toIso8601String(),
|
||||
'end' => $b->end_at->toIso8601String(),
|
||||
'color' => '#10b981',
|
||||
'classNames' => ['calendar-event-booking'],
|
||||
'extendedProps' => [
|
||||
'type' => 'booking',
|
||||
'source' => 'booking',
|
||||
'booking_id' => $b->id,
|
||||
'status' => $b->status,
|
||||
'booker_name' => $b->booker_name,
|
||||
'booker_email' => $b->booker_email,
|
||||
'contact_id' => $b->contact_id,
|
||||
'guest_email' => $b->guest_email,
|
||||
'contact_name' => $b->contact ? $b->contact->first_name.' '.$b->contact->last_name : null,
|
||||
'location' => $b->location,
|
||||
'editable' => false,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($bookings);
|
||||
|
||||
$allEvents = $events->merge($bookings);
|
||||
// 4. CRM Tasks with due dates (shown as all-day markers) - only show user's assigned tasks
|
||||
$tasks = CrmTask::forSellerBusiness($business->id)
|
||||
->where('assigned_to', $user->id)
|
||||
->incomplete()
|
||||
->whereNotNull('due_at')
|
||||
->whereBetween('due_at', [$startDate, $endDate])
|
||||
->with(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name'])
|
||||
->get()
|
||||
->map(fn ($t) => [
|
||||
'id' => 'task_'.$t->id,
|
||||
'title' => '📋 '.$t->title,
|
||||
'start' => $t->due_at->toDateString(),
|
||||
'allDay' => true,
|
||||
'color' => $t->isOverdue() ? '#EF4444' : '#F59E0B',
|
||||
'classNames' => ['calendar-event-task', $t->isOverdue() ? 'task-overdue' : ''],
|
||||
'extendedProps' => [
|
||||
'source' => 'task',
|
||||
'task_id' => $t->id,
|
||||
'type' => $t->type,
|
||||
'priority' => $t->priority,
|
||||
'contact_id' => $t->contact_id,
|
||||
'contact_name' => $t->contact ? $t->contact->first_name.' '.$t->contact->last_name : null,
|
||||
'assigned_to' => $t->assigned_to,
|
||||
'assignee_name' => $t->assignee ? $t->assignee->first_name.' '.$t->assignee->last_name : null,
|
||||
'is_overdue' => $t->isOverdue(),
|
||||
'editable' => false,
|
||||
],
|
||||
]);
|
||||
$allEvents = $allEvents->merge($tasks);
|
||||
|
||||
return view('seller.crm.calendar.index', compact('connections', 'allEvents'));
|
||||
return response()->json($allEvents->values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new calendar event
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:5000',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'start_at' => 'required|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
'all_day' => 'boolean',
|
||||
'type' => 'required|string|in:'.implode(',', array_keys(CalendarEvent::TYPES)),
|
||||
'contact_id' => 'nullable|exists:contacts,id',
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
'reminder_minutes' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Security: verify contact belongs to a customer business
|
||||
if (! empty($validated['contact_id'])) {
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->findOrFail($validated['contact_id']);
|
||||
}
|
||||
|
||||
// Security: verify assignee belongs to business
|
||||
if (! empty($validated['assigned_to'])) {
|
||||
User::where('id', $validated['assigned_to'])
|
||||
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
$event = CalendarEvent::create([
|
||||
'seller_business_id' => $business->id,
|
||||
'created_by' => $request->user()->id,
|
||||
'assigned_to' => $validated['assigned_to'] ?? $request->user()->id,
|
||||
'title' => $validated['title'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'location' => $validated['location'] ?? null,
|
||||
'start_at' => $validated['start_at'],
|
||||
'end_at' => $validated['end_at'] ?? null,
|
||||
'all_day' => $validated['all_day'] ?? false,
|
||||
'type' => $validated['type'],
|
||||
'status' => 'scheduled',
|
||||
'contact_id' => $validated['contact_id'] ?? null,
|
||||
'reminder_at' => isset($validated['reminder_minutes']) && $validated['reminder_minutes'] > 0
|
||||
? now()->parse($validated['start_at'])->subMinutes($validated['reminder_minutes'])
|
||||
: null,
|
||||
]);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'event' => $event->load(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name']),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a calendar event
|
||||
*/
|
||||
public function update(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|required|string|max:255',
|
||||
'description' => 'nullable|string|max:5000',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'start_at' => 'sometimes|required|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
'all_day' => 'boolean',
|
||||
'type' => 'sometimes|required|string|in:'.implode(',', array_keys(CalendarEvent::TYPES)),
|
||||
'status' => 'sometimes|required|string|in:scheduled,completed,cancelled',
|
||||
'contact_id' => 'nullable|exists:contacts,id',
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
'reminder_minutes' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Security checks for contact and assignee
|
||||
if (isset($validated['contact_id']) && $validated['contact_id']) {
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->findOrFail($validated['contact_id']);
|
||||
}
|
||||
|
||||
if (isset($validated['assigned_to']) && $validated['assigned_to']) {
|
||||
User::where('id', $validated['assigned_to'])
|
||||
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
// Handle reminder
|
||||
if (isset($validated['reminder_minutes'])) {
|
||||
$validated['reminder_at'] = $validated['reminder_minutes'] > 0
|
||||
? now()->parse($validated['start_at'] ?? $event->start_at)->subMinutes($validated['reminder_minutes'])
|
||||
: null;
|
||||
$validated['reminder_sent'] = false;
|
||||
unset($validated['reminder_minutes']);
|
||||
}
|
||||
|
||||
$event->update($validated);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'event' => $event->fresh()->load(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name']),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick reschedule via drag-and-drop
|
||||
*/
|
||||
public function reschedule(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'start_at' => 'required|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
'all_day' => 'boolean',
|
||||
]);
|
||||
|
||||
$event->reschedule(
|
||||
$validated['start_at'],
|
||||
$validated['end_at'] ?? null,
|
||||
$request->user()
|
||||
);
|
||||
|
||||
if (isset($validated['all_day'])) {
|
||||
$event->update(['all_day' => $validated['all_day']]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'event' => $event->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark event as complete
|
||||
*/
|
||||
public function complete(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->markComplete($request->user());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'event' => $event->fresh()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event marked as complete.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an event
|
||||
*/
|
||||
public function cancel(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->cancel($request->user());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'event' => $event->fresh()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event cancelled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->delete();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Event deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single event details (for modal)
|
||||
*/
|
||||
public function show(Request $request, Business $business, CalendarEvent $event)
|
||||
{
|
||||
if ($event->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$event->load([
|
||||
'contact:id,first_name,last_name,email,phone',
|
||||
'business:id,name',
|
||||
'assignee:id,first_name,last_name,email',
|
||||
'creator:id,first_name,last_name',
|
||||
]);
|
||||
|
||||
return response()->json($event);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +443,8 @@ class CrmCalendarController extends Controller
|
||||
->where('user_id', $user->id)
|
||||
->get();
|
||||
|
||||
return view('seller.crm.calendar.connections', compact('connections'));
|
||||
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
|
||||
return view('seller.crm.calendar.connections', compact('business', 'connections'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +460,7 @@ class CrmCalendarController extends Controller
|
||||
|
||||
$params = http_build_query([
|
||||
'client_id' => config('services.google.client_id'),
|
||||
'redirect_uri' => route('seller.crm.calendar.callback'),
|
||||
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events',
|
||||
'access_type' => 'offline',
|
||||
@@ -126,7 +484,7 @@ class CrmCalendarController extends Controller
|
||||
|
||||
$params = http_build_query([
|
||||
'client_id' => config('services.microsoft.client_id'),
|
||||
'redirect_uri' => route('seller.crm.calendar.callback'),
|
||||
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'offline_access Calendars.ReadWrite',
|
||||
'state' => $state,
|
||||
@@ -138,17 +496,17 @@ class CrmCalendarController extends Controller
|
||||
/**
|
||||
* OAuth callback
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
public function callback(Request $request, Business $business)
|
||||
{
|
||||
if ($request->has('error')) {
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->withErrors(['error' => 'Authorization failed: '.$request->input('error_description')]);
|
||||
}
|
||||
|
||||
try {
|
||||
$state = decrypt($request->input('state'));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->withErrors(['error' => 'Invalid state parameter.']);
|
||||
}
|
||||
|
||||
@@ -159,7 +517,7 @@ class CrmCalendarController extends Controller
|
||||
$tokens = $this->calendarService->exchangeCodeForTokens($provider, $code);
|
||||
|
||||
if (! $tokens) {
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->withErrors(['error' => 'Failed to obtain access token.']);
|
||||
}
|
||||
|
||||
@@ -187,7 +545,7 @@ class CrmCalendarController extends Controller
|
||||
// Queue initial sync
|
||||
SyncCalendarJob::dispatch($state['user_id'], $provider);
|
||||
|
||||
return redirect()->route('seller.crm.calendar.connections')
|
||||
return redirect()->route('seller.business.crm.calendar.connections', $business)
|
||||
->with('success', ucfirst($provider).' Calendar connected successfully.');
|
||||
}
|
||||
|
||||
@@ -236,34 +594,4 @@ class CrmCalendarController extends Controller
|
||||
|
||||
return back()->with('success', 'Calendar sync started. Events will appear shortly.');
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Get events for date range (for calendar JS)
|
||||
*/
|
||||
public function events(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'start' => 'required|date',
|
||||
'end' => 'required|date|after:start',
|
||||
]);
|
||||
|
||||
$connections = CrmCalendarConnection::where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->pluck('id');
|
||||
|
||||
$events = CrmSyncedEvent::whereIn('connection_id', $connections)
|
||||
->whereBetween('start_time', [$validated['start'], $validated['end']])
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'id' => $e->id,
|
||||
'title' => $e->title,
|
||||
'start' => $e->start_time->toIso8601String(),
|
||||
'end' => $e->end_time->toIso8601String(),
|
||||
'allDay' => $e->is_all_day,
|
||||
]);
|
||||
|
||||
return response()->json($events);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmPipeline;
|
||||
use App\Models\Crm\CrmRepMetric;
|
||||
use App\Models\Crm\CrmSlaTimer;
|
||||
use App\Models\Crm\CrmThread;
|
||||
@@ -32,6 +33,9 @@ class CrmDashboardController extends Controller
|
||||
return $this->getDashboardData($business, $user);
|
||||
});
|
||||
|
||||
// Ensure $business is always passed to view (not cached)
|
||||
$data['business'] = $business;
|
||||
|
||||
return view('seller.crm.dashboard.index', $data);
|
||||
}
|
||||
|
||||
@@ -40,13 +44,26 @@ class CrmDashboardController extends Controller
|
||||
*/
|
||||
public function sales(Request $request, Business $business)
|
||||
{
|
||||
// Get the default pipeline for stage name mapping
|
||||
$defaultPipeline = CrmPipeline::where('business_id', $business->id)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
|
||||
// Pipeline summary
|
||||
$stageMap = collect($defaultPipeline?->stages ?? [])->mapWithKeys(function ($stage, $index) {
|
||||
return [$index => $stage['name'] ?? "Stage {$index}"];
|
||||
})->all();
|
||||
|
||||
// Pipeline summary - group by stage_id (index into pipeline stages JSON array)
|
||||
$pipelineSummary = CrmDeal::forBusiness($business->id)
|
||||
->open()
|
||||
->selectRaw('stage, count(*) as count, sum(value) as total_value, sum(weighted_value) as weighted_value')
|
||||
->groupBy('stage')
|
||||
->get();
|
||||
->selectRaw('stage_id, count(*) as count, sum(value) as total_value, sum(weighted_value) as weighted_value')
|
||||
->groupBy('stage_id')
|
||||
->get()
|
||||
->map(function ($item) use ($stageMap) {
|
||||
$item->stage_name = $stageMap[$item->stage_id] ?? "Stage {$item->stage_id}";
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
// Won/Lost this month
|
||||
$monthlyStats = [
|
||||
@@ -83,7 +100,8 @@ class CrmDashboardController extends Controller
|
||||
'monthlyStats',
|
||||
'closingThisMonth',
|
||||
'atRiskDeals',
|
||||
'leaderboard'
|
||||
'leaderboard',
|
||||
'business'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -123,7 +141,8 @@ class CrmDashboardController extends Controller
|
||||
'slaMetrics',
|
||||
'repMetrics',
|
||||
'threadDistribution',
|
||||
'dealDistribution'
|
||||
'dealDistribution',
|
||||
'business'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -163,19 +182,33 @@ class CrmDashboardController extends Controller
|
||||
->with('thread.contact')
|
||||
->get();
|
||||
|
||||
// Quick stats
|
||||
// Quick stats - consolidated into efficient queries
|
||||
$threadStats = CrmThread::forBusiness($business->id)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_threads,
|
||||
SUM(CASE WHEN is_read = false AND status = 'open' THEN 1 ELSE 0 END) as unread_threads
|
||||
")
|
||||
->first();
|
||||
|
||||
$dealStats = CrmDeal::forBusiness($business->id)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_deals,
|
||||
SUM(CASE WHEN status = 'open' AND owner_id = ? THEN 1 ELSE 0 END) as my_deals,
|
||||
SUM(CASE WHEN status = 'open' THEN value ELSE 0 END) as pipeline_value,
|
||||
SUM(CASE WHEN status = 'open' THEN weighted_value ELSE 0 END) as weighted_pipeline,
|
||||
SUM(CASE WHEN status = 'won' AND EXTRACT(MONTH FROM actual_close_date) = ? AND EXTRACT(YEAR FROM actual_close_date) = ? THEN value ELSE 0 END) as won_this_month
|
||||
", [$user->id, now()->month, now()->year])
|
||||
->first();
|
||||
|
||||
$stats = [
|
||||
'open_threads' => CrmThread::forBusiness($business->id)->open()->count(),
|
||||
'open_threads' => $threadStats->open_threads ?? 0,
|
||||
'my_threads' => $myThreads->count(),
|
||||
'unread_threads' => CrmThread::forBusiness($business->id)->unread()->count(),
|
||||
'open_deals' => CrmDeal::forBusiness($business->id)->open()->count(),
|
||||
'my_deals' => CrmDeal::forBusiness($business->id)->ownedBy($user->id)->open()->count(),
|
||||
'pipeline_value' => CrmDeal::forBusiness($business->id)->open()->sum('value'),
|
||||
'weighted_pipeline' => CrmDeal::forBusiness($business->id)->open()->sum('weighted_value'),
|
||||
'won_this_month' => CrmDeal::forBusiness($business->id)
|
||||
->won()
|
||||
->whereMonth('actual_close_date', now()->month)
|
||||
->sum('value'),
|
||||
'unread_threads' => $threadStats->unread_threads ?? 0,
|
||||
'open_deals' => $dealStats->open_deals ?? 0,
|
||||
'my_deals' => $dealStats->my_deals ?? 0,
|
||||
'pipeline_value' => $dealStats->pipeline_value ?? 0,
|
||||
'weighted_pipeline' => $dealStats->weighted_pipeline ?? 0,
|
||||
'won_this_month' => $dealStats->won_this_month ?? 0,
|
||||
'sla_compliance' => $this->slaService->getMetrics($business->id, 30)['compliance_rate'] ?? 100,
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\ChatQuickReply;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use App\Models\Crm\CrmMessageTemplate;
|
||||
use App\Models\Crm\CrmPipeline;
|
||||
@@ -28,7 +29,7 @@ class CrmSettingsController extends Controller
|
||||
'tags' => CrmTag::where('business_id', $business->id)->count(),
|
||||
];
|
||||
|
||||
return view('seller.crm.settings.index', compact('stats'));
|
||||
return view('seller.crm.settings.index', compact('stats', 'business'));
|
||||
}
|
||||
|
||||
// ================== CHANNELS ==================
|
||||
@@ -44,17 +45,17 @@ class CrmSettingsController extends Controller
|
||||
->get()
|
||||
->groupBy('type');
|
||||
|
||||
return view('seller.crm.settings.channels.index', compact('channels'));
|
||||
return view('seller.crm.settings.channels.index', compact('channels', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create channel form
|
||||
*/
|
||||
public function createChannel(Request $request)
|
||||
public function createChannel(Request $request, Business $business)
|
||||
{
|
||||
$types = CrmChannel::TYPES;
|
||||
|
||||
return view('seller.crm.settings.channels.create', compact('types'));
|
||||
return view('seller.crm.settings.channels.create', compact('types', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +87,7 @@ class CrmSettingsController extends Controller
|
||||
'is_default' => $validated['is_default'] ?? false,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.channels')
|
||||
return redirect()->route('seller.business.crm.settings.channels', $business)
|
||||
->with('success', 'Channel created successfully.');
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$types = CrmChannel::TYPES;
|
||||
|
||||
return view('seller.crm.settings.channels.edit', compact('channel', 'types'));
|
||||
return view('seller.crm.settings.channels.edit', compact('channel', 'types', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +133,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$channel->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.channels')
|
||||
return redirect()->route('seller.business.crm.settings.channels', $business)
|
||||
->with('success', 'Channel updated.');
|
||||
}
|
||||
|
||||
@@ -164,15 +165,15 @@ class CrmSettingsController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.settings.pipelines.index', compact('pipelines'));
|
||||
return view('seller.crm.settings.pipelines.index', compact('pipelines', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create pipeline form
|
||||
*/
|
||||
public function createPipeline()
|
||||
public function createPipeline(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.settings.pipelines.create');
|
||||
return view('seller.crm.settings.pipelines.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,7 +207,7 @@ class CrmSettingsController extends Controller
|
||||
'is_default' => $validated['is_default'] ?? false,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.pipelines')
|
||||
return redirect()->route('seller.business.crm.settings.pipelines', $business)
|
||||
->with('success', 'Pipeline created.');
|
||||
}
|
||||
|
||||
@@ -220,7 +221,7 @@ class CrmSettingsController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.settings.pipelines.edit', compact('pipeline'));
|
||||
return view('seller.crm.settings.pipelines.edit', compact('pipeline', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,7 +254,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$pipeline->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.pipelines')
|
||||
return redirect()->route('seller.business.crm.settings.pipelines', $business)
|
||||
->with('success', 'Pipeline updated.');
|
||||
}
|
||||
|
||||
@@ -288,15 +289,15 @@ class CrmSettingsController extends Controller
|
||||
->orderBy('priority')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.settings.sla.index', compact('policies'));
|
||||
return view('seller.crm.settings.sla.index', compact('policies', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SLA policy form
|
||||
*/
|
||||
public function createSlaPolicy()
|
||||
public function createSlaPolicy(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.settings.sla.create');
|
||||
return view('seller.crm.settings.sla.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,7 +331,7 @@ class CrmSettingsController extends Controller
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.sla')
|
||||
return redirect()->route('seller.business.crm.settings.sla', $business)
|
||||
->with('success', 'SLA policy created.');
|
||||
}
|
||||
|
||||
@@ -344,7 +345,7 @@ class CrmSettingsController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.settings.sla.edit', compact('policy'));
|
||||
return view('seller.crm.settings.sla.edit', compact('policy', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,7 +372,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$policy->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.sla')
|
||||
return redirect()->route('seller.business.crm.settings.sla', $business)
|
||||
->with('success', 'SLA policy updated.');
|
||||
}
|
||||
|
||||
@@ -403,7 +404,7 @@ class CrmSettingsController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.settings.tags.index', compact('tags'));
|
||||
return view('seller.crm.settings.tags.index', compact('tags', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -478,7 +479,7 @@ class CrmSettingsController extends Controller
|
||||
->get()
|
||||
->groupBy('category');
|
||||
|
||||
return view('seller.crm.settings.templates.index', compact('templates'));
|
||||
return view('seller.crm.settings.templates.index', compact('templates', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,7 +490,7 @@ class CrmSettingsController extends Controller
|
||||
$categories = CrmMessageTemplate::CATEGORIES;
|
||||
$channels = CrmChannel::TYPES;
|
||||
|
||||
return view('seller.crm.settings.templates.create', compact('categories', 'channels'));
|
||||
return view('seller.crm.settings.templates.create', compact('categories', 'channels', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -519,7 +520,7 @@ class CrmSettingsController extends Controller
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.settings.templates')
|
||||
return redirect()->route('seller.business.crm.settings.templates', $business)
|
||||
->with('success', 'Template created.');
|
||||
}
|
||||
|
||||
@@ -536,7 +537,7 @@ class CrmSettingsController extends Controller
|
||||
$categories = CrmMessageTemplate::CATEGORIES;
|
||||
$channels = CrmChannel::TYPES;
|
||||
|
||||
return view('seller.crm.settings.templates.edit', compact('template', 'categories', 'channels'));
|
||||
return view('seller.crm.settings.templates.edit', compact('template', 'categories', 'channels', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,7 +561,7 @@ class CrmSettingsController extends Controller
|
||||
|
||||
$template->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.settings.templates')
|
||||
return redirect()->route('seller.business.crm.settings.templates', $business)
|
||||
->with('success', 'Template updated.');
|
||||
}
|
||||
|
||||
@@ -592,7 +593,7 @@ class CrmSettingsController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.settings.roles.index', compact('roles'));
|
||||
return view('seller.crm.settings.roles.index', compact('roles', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -649,4 +650,81 @@ class CrmSettingsController extends Controller
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ class DealController extends Controller
|
||||
?? CrmPipeline::forBusiness($business->id)->default()->first()
|
||||
?? CrmPipeline::createDefault($business->id);
|
||||
|
||||
// Get deals grouped by stage
|
||||
// Build base query for deals
|
||||
$dealsQuery = CrmDeal::forBusiness($business->id)
|
||||
->where('pipeline_id', $pipeline->id)
|
||||
->with(['contact', 'account', 'owner']);
|
||||
->with(['contact:id,first_name,last_name,email', 'account:id,name', 'owner:id,first_name,last_name,email']);
|
||||
|
||||
// Filters
|
||||
if ($request->filled('owner_id')) {
|
||||
@@ -52,26 +52,50 @@ class DealController extends Controller
|
||||
$dealsQuery->open();
|
||||
}
|
||||
|
||||
$deals = $dealsQuery->get()->groupBy('stage');
|
||||
// Get deals grouped by stage using database grouping for efficiency
|
||||
// Limit to reasonable number per stage for board view
|
||||
$stages = $pipeline->stages ?? [];
|
||||
$deals = collect();
|
||||
foreach ($stages as $stage) {
|
||||
$stageDeals = (clone $dealsQuery)
|
||||
->where('stage', $stage['name'] ?? $stage)
|
||||
->orderByDesc('value')
|
||||
->limit(50)
|
||||
->get();
|
||||
$deals[$stage['name'] ?? $stage] = $stageDeals;
|
||||
}
|
||||
|
||||
// Get pipelines for selector
|
||||
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
|
||||
// Get pipelines for selector (limited fields)
|
||||
$pipelines = CrmPipeline::forBusiness($business->id)
|
||||
->active()
|
||||
->select('id', 'name', 'stages', 'is_default')
|
||||
->get();
|
||||
|
||||
// Get team members
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
// Get team members (limited fields)
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->get();
|
||||
|
||||
// Calculate stats with single efficient query using selectRaw
|
||||
$statsResult = CrmDeal::forBusiness($business->id)
|
||||
->open()
|
||||
->selectRaw('SUM(value) as total_value, SUM(weighted_value) as weighted_value, COUNT(*) as deals_count')
|
||||
->first();
|
||||
|
||||
$wonThisMonth = CrmDeal::forBusiness($business->id)
|
||||
->won()
|
||||
->whereMonth('actual_close_date', now()->month)
|
||||
->whereYear('actual_close_date', now()->year)
|
||||
->sum('value');
|
||||
|
||||
// Calculate stats
|
||||
$stats = [
|
||||
'total_value' => CrmDeal::forBusiness($business->id)->open()->sum('value'),
|
||||
'weighted_value' => CrmDeal::forBusiness($business->id)->open()->sum('weighted_value'),
|
||||
'deals_count' => CrmDeal::forBusiness($business->id)->open()->count(),
|
||||
'won_this_month' => CrmDeal::forBusiness($business->id)
|
||||
->won()
|
||||
->whereMonth('actual_close_date', now()->month)
|
||||
->sum('value'),
|
||||
'total_value' => $statsResult->total_value ?? 0,
|
||||
'weighted_value' => $statsResult->weighted_value ?? 0,
|
||||
'deals_count' => $statsResult->deals_count ?? 0,
|
||||
'won_this_month' => $wonThisMonth,
|
||||
];
|
||||
|
||||
return view('seller.crm.deals.index', compact('pipeline', 'deals', 'pipelines', 'teamMembers', 'stats'));
|
||||
return view('seller.crm.deals.index', compact('business', 'pipeline', 'deals', 'pipelines', 'teamMembers', 'stats'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,15 +103,37 @@ class DealController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
|
||||
$contacts = Contact::where('business_id', $business->id)->get();
|
||||
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||
})->get();
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$pipelines = CrmPipeline::forBusiness($business->id)
|
||||
->active()
|
||||
->select('id', 'name', 'stages', 'is_default')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.deals.create', compact('pipelines', 'contacts', 'accounts', 'teamMembers', 'brands'));
|
||||
// Limit contacts for dropdown - most recent 100
|
||||
$contacts = Contact::where('business_id', $business->id)
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
// Limit accounts for dropdown - most recent 100
|
||||
// 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));
|
||||
})
|
||||
->select('id', 'name')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'first_name', 'last_name', 'email')
|
||||
->get();
|
||||
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.deals.create', compact('pipelines', 'contacts', 'accounts', 'teamMembers', 'brands', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,7 +201,7 @@ class DealController extends Controller
|
||||
'status' => CrmDeal::STATUS_OPEN,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.deals.show', $deal)
|
||||
return redirect()->route('seller.business.crm.deals.show', [$business, $deal])
|
||||
->with('success', 'Deal created successfully.');
|
||||
}
|
||||
|
||||
@@ -191,7 +237,7 @@ class DealController extends Controller
|
||||
$deal->refresh();
|
||||
}
|
||||
|
||||
return view('seller.crm.deals.show', compact('deal', 'suggestions'));
|
||||
return view('seller.crm.deals.show', compact('deal', 'suggestions', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,7 +374,7 @@ class DealController extends Controller
|
||||
|
||||
$deal->delete();
|
||||
|
||||
return redirect()->route('seller.crm.deals.index')
|
||||
return redirect()->route('seller.business.crm.deals.index', $business)
|
||||
->with('success', 'Deal deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\InvoiceMail;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmInvoice;
|
||||
use App\Models\Crm\CrmInvoiceItem;
|
||||
use App\Models\Crm\CrmInvoicePayment;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
@@ -32,24 +36,33 @@ class InvoiceController extends Controller
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('invoice_number', 'like', "%{$request->search}%")
|
||||
->orWhere('title', 'like', "%{$request->search}%");
|
||||
$q->where('invoice_number', 'ILIKE', "%{$request->search}%")
|
||||
->orWhere('title', 'ILIKE', "%{$request->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$invoices = $query->orderByDesc('created_at')->paginate(25);
|
||||
|
||||
// Stats
|
||||
// Stats - single efficient query with conditional aggregation
|
||||
$invoiceStats = CrmInvoice::forBusiness($business->id)
|
||||
->selectRaw("
|
||||
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 balance_due ELSE 0 END) as overdue
|
||||
")
|
||||
->first();
|
||||
|
||||
$paidThisMonth = CrmInvoicePayment::whereHas('invoice', fn ($q) => $q->where('business_id', $business->id))
|
||||
->whereMonth('payment_date', now()->month)
|
||||
->whereYear('payment_date', now()->year)
|
||||
->sum('amount');
|
||||
|
||||
$stats = [
|
||||
'outstanding' => CrmInvoice::forBusiness($business->id)->outstanding()->sum('amount_due'),
|
||||
'overdue' => CrmInvoice::forBusiness($business->id)->overdue()->sum('amount_due'),
|
||||
'paid_this_month' => CrmInvoicePayment::whereHas('invoice', fn ($q) => $q->where('business_id', $business->id))
|
||||
->whereMonth('payment_date', now()->month)
|
||||
->whereYear('payment_date', now()->year)
|
||||
->sum('amount'),
|
||||
'outstanding' => $invoiceStats->outstanding ?? 0,
|
||||
'overdue' => $invoiceStats->overdue ?? 0,
|
||||
'paid_this_month' => $paidThisMonth,
|
||||
];
|
||||
|
||||
return view('seller.crm.invoices.index', compact('invoices', 'stats'));
|
||||
return view('seller.crm.invoices.index', compact('invoices', 'stats', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,9 +74,9 @@ class InvoiceController extends Controller
|
||||
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'));
|
||||
return view('seller.crm.invoices.show', compact('invoice', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,13 +84,76 @@ class InvoiceController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$contacts = \App\Models\Contact::where('business_id', $business->id)->get();
|
||||
// Get all approved buyer businesses as potential customers (matching quotes)
|
||||
$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();
|
||||
|
||||
// Limit quotes to accepted without invoices
|
||||
$quotes = CrmQuote::forBusiness($business->id)
|
||||
->where('status', CrmQuote::STATUS_ACCEPTED)
|
||||
->whereDoesntHave('invoice')
|
||||
->select('id', 'quote_number', 'title', 'total', 'contact_id', 'account_id', 'location_id')
|
||||
->with(['contact:id,first_name,last_name', 'items.product'])
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
return view('seller.crm.invoices.create', compact('contacts', 'quotes'));
|
||||
// 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'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,21 +165,28 @@ class InvoiceController extends Controller
|
||||
'title' => 'required|string|max:255',
|
||||
'contact_id' => 'required|exists:contacts,id',
|
||||
'account_id' => 'nullable|exists:businesses,id',
|
||||
'location_id' => 'nullable|exists:locations,id',
|
||||
'quote_id' => 'nullable|exists:crm_quotes,id',
|
||||
'deal_id' => 'nullable|exists:crm_deals,id',
|
||||
'due_date' => 'required|date|after_or_equal:today',
|
||||
'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 business
|
||||
\App\Models\Contact::where('id', $validated['contact_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
// 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 quote belongs to business if provided
|
||||
if (! empty($validated['quote_id'])) {
|
||||
@@ -112,22 +195,33 @@ class InvoiceController extends Controller
|
||||
->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);
|
||||
|
||||
$invoice = CrmInvoice::create([
|
||||
'business_id' => $business->id,
|
||||
'contact_id' => $validated['contact_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,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'title' => $validated['title'],
|
||||
'status' => CrmInvoice::STATUS_DRAFT,
|
||||
'issue_date' => now(),
|
||||
'invoice_date' => now(),
|
||||
'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'],
|
||||
'payment_terms' => $validated['payment_terms'],
|
||||
'terms' => $validated['payment_terms'],
|
||||
'currency' => 'USD',
|
||||
]);
|
||||
|
||||
@@ -135,19 +229,150 @@ class InvoiceController extends Controller
|
||||
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'],
|
||||
'sort_order' => $index,
|
||||
'discount_percent' => $item['discount_percent'] ?? 0,
|
||||
'position' => $index,
|
||||
]);
|
||||
}
|
||||
|
||||
$invoice->calculateTotals();
|
||||
|
||||
return redirect()->route('seller.crm.invoices.show', $invoice)
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->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
|
||||
*/
|
||||
@@ -161,9 +386,31 @@ class InvoiceController extends Controller
|
||||
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.');
|
||||
}
|
||||
@@ -240,8 +487,33 @@ class InvoiceController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// TODO: Generate PDF
|
||||
return back()->with('info', 'PDF generation coming soon.');
|
||||
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,7 +531,7 @@ class InvoiceController extends Controller
|
||||
|
||||
$invoice->delete();
|
||||
|
||||
return redirect()->route('seller.crm.invoices.index')
|
||||
return redirect()->route('seller.business.crm.invoices.index', $business)
|
||||
->with('success', 'Invoice deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
171
app/Http/Controllers/Seller/Crm/LeadController.php
Normal file
171
app/Http/Controllers/Seller/Crm/LeadController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmLead;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LeadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display leads listing
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = CrmLead::forSeller($business)
|
||||
->with('assignee')
|
||||
->notConverted();
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('company_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('contact_name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('contact_email', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$leads = $query->latest()->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $leads->map(fn ($l) => [
|
||||
'hashid' => $l->hashid,
|
||||
'name' => $l->company_name,
|
||||
'contact' => $l->contact_name,
|
||||
'email' => $l->contact_email,
|
||||
'status' => $l->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.leads.index', compact('business', 'leads'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create lead form
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.leads.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new lead
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'company_name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'contact_name' => 'required|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_title' => 'nullable|string|max:100',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'zip_code' => 'nullable|string|max:20',
|
||||
'source' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::SOURCES)),
|
||||
'notes' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$validated['seller_business_id'] = $business->id;
|
||||
$validated['status'] = 'new';
|
||||
|
||||
$lead = CrmLead::create($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.leads.show', [$business->slug, $lead->hashid])
|
||||
->with('success', 'Lead created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show lead details
|
||||
*/
|
||||
public function show(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$lead->load('assignee');
|
||||
|
||||
return view('seller.crm.leads.show', compact('business', 'lead'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit lead form
|
||||
*/
|
||||
public function edit(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.leads.edit', compact('business', 'lead'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a lead
|
||||
*/
|
||||
public function update(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'company_name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'nullable|string|max:100',
|
||||
'contact_name' => 'required|string|max:255',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_title' => 'nullable|string|max:100',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'state' => 'nullable|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'zip_code' => 'nullable|string|max:20',
|
||||
'source' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::SOURCES)),
|
||||
'status' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::STATUSES)),
|
||||
'notes' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$lead->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.leads.show', [$business->slug, $lead->hashid])
|
||||
->with('success', 'Lead updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a lead
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, CrmLead $lead)
|
||||
{
|
||||
// Ensure lead belongs to this seller
|
||||
if ($lead->seller_business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$lead->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.leads.index', $business->slug)
|
||||
->with('success', 'Lead deleted.');
|
||||
}
|
||||
}
|
||||
@@ -28,15 +28,15 @@ class MeetingLinkController extends Controller
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return view('seller.crm.meetings.links.index', compact('meetingLinks'));
|
||||
return view('seller.crm.meetings.links.index', compact('meetingLinks', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create meeting link form
|
||||
*/
|
||||
public function create()
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
return view('seller.crm.meetings.links.create');
|
||||
return view('seller.crm.meetings.links.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +81,7 @@ class MeetingLinkController extends Controller
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
|
||||
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
|
||||
->with('success', 'Meeting link created. Share the booking URL with contacts.');
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ class MeetingLinkController extends Controller
|
||||
|
||||
$meetingLink->load(['bookings' => fn ($q) => $q->upcoming()->with('contact')]);
|
||||
|
||||
return view('seller.crm.meetings.links.show', compact('meetingLink'));
|
||||
return view('seller.crm.meetings.links.show', compact('meetingLink', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +108,7 @@ class MeetingLinkController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('seller.crm.meetings.links.edit', compact('meetingLink'));
|
||||
return view('seller.crm.meetings.links.edit', compact('meetingLink', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +136,7 @@ class MeetingLinkController extends Controller
|
||||
|
||||
$meetingLink->update($validated);
|
||||
|
||||
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
|
||||
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
|
||||
->with('success', 'Meeting link updated.');
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class MeetingLinkController extends Controller
|
||||
|
||||
$meetingLink->delete();
|
||||
|
||||
return redirect()->route('seller.crm.meetings.links.index')
|
||||
return redirect()->route('seller.business.crm.meetings.links.index', $business)
|
||||
->with('success', 'Meeting link deleted.');
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ class MeetingLinkController extends Controller
|
||||
->orderBy('start_time')
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.crm.meetings.bookings.index', compact('bookings'));
|
||||
return view('seller.crm.meetings.bookings.index', compact('bookings', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,13 +3,21 @@
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\QuoteMail;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use App\Models\Crm\CrmQuoteItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\Location;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Services\Accounting\ArService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class QuoteController extends Controller
|
||||
{
|
||||
@@ -29,14 +37,27 @@ class QuoteController extends Controller
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('quote_number', 'like', "%{$request->search}%")
|
||||
->orWhere('title', 'like', "%{$request->search}%");
|
||||
$q->where('quote_number', 'ilike', "%{$request->search}%")
|
||||
->orWhere('title', 'ilike', "%{$request->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$quotes = $query->orderByDesc('created_at')->paginate(25);
|
||||
|
||||
return view('seller.crm.quotes.index', compact('quotes'));
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $quotes->map(fn ($q) => [
|
||||
'id' => $q->id,
|
||||
'name' => $q->quote_number.' - '.($q->title ?? 'Untitled'),
|
||||
'contact' => $q->contact?->name ?? '-',
|
||||
'status' => $q->status,
|
||||
'total' => '$'.number_format($q->total, 2),
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.quotes.index', compact('quotes', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,21 +65,96 @@ class QuoteController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$contacts = Contact::where('business_id', $business->id)->get();
|
||||
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||
})->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 all approved buyer businesses as potential customers
|
||||
// Contacts are loaded dynamically via /search/contacts?customer_id={account_id}
|
||||
// Include locations for delivery address selection
|
||||
// Note: We don't filter by whereHas('contacts') because newly created customers
|
||||
// may not have contacts yet - contacts can be added after selecting the account
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
->with('locations:id,business_id,name,is_primary')
|
||||
->orderBy('name')
|
||||
->select(['id', 'name', 'slug'])
|
||||
->get();
|
||||
$deals = CrmDeal::forBusiness($business->id)->open()->get();
|
||||
// Products are loaded via AJAX search (/search/products) for better performance
|
||||
|
||||
// Pre-fill from deal if provided
|
||||
$deal = $request->filled('deal_id')
|
||||
? CrmDeal::forBusiness($business->id)->find($request->deal_id)
|
||||
: null;
|
||||
|
||||
return view('seller.crm.quotes.create', compact('contacts', 'accounts', 'deals', 'products', 'deal'));
|
||||
// Pre-fill from URL parameters (coming from customer dashboard)
|
||||
$selectedAccount = null;
|
||||
$selectedLocation = null;
|
||||
$selectedContact = null;
|
||||
$locationContacts = collect();
|
||||
|
||||
// Handle clear actions
|
||||
if ($request->has('clearAccount')) {
|
||||
// Redirect without any prefills
|
||||
return redirect()->route('seller.business.crm.quotes.create', $business);
|
||||
}
|
||||
if ($request->has('clearLocation')) {
|
||||
// Keep account but clear location
|
||||
return redirect()->route('seller.business.crm.quotes.create', [$business, 'account_id' => $request->account_id]);
|
||||
}
|
||||
if ($request->has('clearContact')) {
|
||||
// Keep account and location but clear contact
|
||||
$params = ['account_id' => $request->account_id];
|
||||
if ($request->location_id) {
|
||||
$params['location_id'] = $request->location_id;
|
||||
}
|
||||
|
||||
return redirect()->route('seller.business.crm.quotes.create', array_merge([$business], $params));
|
||||
}
|
||||
|
||||
// Pre-fill account
|
||||
if ($request->filled('account_id')) {
|
||||
$selectedAccount = $accounts->firstWhere('id', $request->account_id);
|
||||
}
|
||||
|
||||
// Pre-fill location (must belong to selected account)
|
||||
if ($selectedAccount && $request->filled('location_id')) {
|
||||
$selectedLocation = $selectedAccount->locations->firstWhere('id', $request->location_id);
|
||||
}
|
||||
|
||||
// If location selected, get contacts assigned to that location
|
||||
if ($selectedLocation) {
|
||||
$locationContacts = $selectedLocation->contacts()
|
||||
->with('pivot')
|
||||
->get()
|
||||
->map(fn ($c) => [
|
||||
'value' => $c->id,
|
||||
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
|
||||
'is_primary' => $c->pivot->is_primary ?? false,
|
||||
'role' => $c->pivot->role ?? 'buyer',
|
||||
]);
|
||||
|
||||
// Try to find primary buyer for this location
|
||||
$primaryBuyer = $locationContacts->firstWhere('is_primary', true)
|
||||
?? $locationContacts->firstWhere('role', 'buyer');
|
||||
|
||||
if ($primaryBuyer && ! $request->filled('contact_id')) {
|
||||
$selectedContact = Contact::find($primaryBuyer['value']);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fill contact if explicitly provided
|
||||
if ($request->filled('contact_id')) {
|
||||
$selectedContact = Contact::find($request->contact_id);
|
||||
}
|
||||
|
||||
return view('seller.crm.quotes.create', compact(
|
||||
'accounts',
|
||||
'deals',
|
||||
'deal',
|
||||
'business',
|
||||
'selectedAccount',
|
||||
'selectedLocation',
|
||||
'selectedContact',
|
||||
'locationContacts'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +173,6 @@ class QuoteController extends Controller
|
||||
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'terms' => 'nullable|string|max:5000',
|
||||
'notes' => 'nullable|string|max:2000',
|
||||
'signature_requested' => 'boolean',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'nullable|exists:products,id',
|
||||
'items.*.description' => 'required|string|max:500',
|
||||
@@ -86,10 +181,13 @@ class QuoteController extends Controller
|
||||
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
// SECURITY: Verify contact belongs to business
|
||||
Contact::where('id', $validated['contact_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
// SECURITY: Verify contact belongs to the selected account (customer business)
|
||||
// Contacts are associated with buyer businesses, not the seller
|
||||
if (! empty($validated['account_id'])) {
|
||||
Contact::where('id', $validated['contact_id'])
|
||||
->where('business_id', $validated['account_id'])
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
// SECURITY: Verify deal belongs to business if provided
|
||||
if (! empty($validated['deal_id'])) {
|
||||
@@ -110,13 +208,13 @@ class QuoteController extends Controller
|
||||
'quote_number' => $quoteNumber,
|
||||
'title' => $validated['title'],
|
||||
'status' => CrmQuote::STATUS_DRAFT,
|
||||
'quote_date' => now(),
|
||||
'valid_until' => $validated['valid_until'] ?? now()->addDays($business->crm_quote_validity_days ?? 30),
|
||||
'discount_type' => $validated['discount_type'],
|
||||
'discount_value' => $validated['discount_value'],
|
||||
'tax_rate' => $validated['tax_rate'] ?? 0,
|
||||
'terms' => $validated['terms'] ?? $business->crm_default_terms,
|
||||
'notes' => $validated['notes'],
|
||||
'signature_requested' => $validated['signature_requested'] ?? false,
|
||||
'currency' => 'USD',
|
||||
]);
|
||||
|
||||
@@ -135,7 +233,7 @@ class QuoteController extends Controller
|
||||
|
||||
$quote->calculateTotals();
|
||||
|
||||
return redirect()->route('seller.crm.quotes.show', $quote)
|
||||
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
|
||||
->with('success', 'Quote created successfully.');
|
||||
}
|
||||
|
||||
@@ -148,9 +246,9 @@ class QuoteController extends Controller
|
||||
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'));
|
||||
return view('seller.crm.quotes.show', compact('quote', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,16 +264,9 @@ class QuoteController extends Controller
|
||||
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();
|
||||
$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'));
|
||||
return view('seller.crm.quotes.edit', compact('quote', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,12 +325,12 @@ class QuoteController extends Controller
|
||||
|
||||
$quote->calculateTotals();
|
||||
|
||||
return redirect()->route('seller.crm.quotes.show', $quote)
|
||||
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
|
||||
->with('success', 'Quote updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send quote to contact
|
||||
* Send quote via email
|
||||
*/
|
||||
public function send(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
@@ -247,21 +338,256 @@ class QuoteController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $quote->canBeSent()) {
|
||||
return back()->withErrors(['error' => 'This quote cannot be sent.']);
|
||||
$validated = $request->validate([
|
||||
'to' => 'required|email',
|
||||
'cc' => 'nullable|string',
|
||||
'message' => 'nullable|string|max:2000',
|
||||
'attach_pdf' => 'boolean',
|
||||
]);
|
||||
|
||||
// Generate PDF if needed
|
||||
$pdfPath = null;
|
||||
if ($validated['attach_pdf'] ?? true) {
|
||||
$pdfPath = $this->generateQuotePdf($quote, $business);
|
||||
}
|
||||
|
||||
$quote->send($request->user());
|
||||
// Send email
|
||||
$ccEmails = [];
|
||||
if (! empty($validated['cc'])) {
|
||||
$ccEmails = array_map('trim', explode(',', $validated['cc']));
|
||||
}
|
||||
|
||||
// TODO: Send email notification to contact
|
||||
Mail::to($validated['to'])
|
||||
->cc($ccEmails)
|
||||
->send(new QuoteMail($quote, $business, $validated['message'] ?? null, $pdfPath));
|
||||
|
||||
// Update quote status if draft
|
||||
if ($quote->status === CrmQuote::STATUS_DRAFT) {
|
||||
$quote->send($request->user());
|
||||
}
|
||||
|
||||
// Log activity
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.emailed',
|
||||
description: "Quote {$quote->quote_number} emailed to {$validated['to']}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
return back()->with('success', 'Quote sent successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quote status (accept/decline/expire)
|
||||
*/
|
||||
public function updateStatus(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:accepted,rejected,expired',
|
||||
'note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$oldStatus = $quote->status;
|
||||
|
||||
if ($validated['status'] === 'accepted') {
|
||||
$quote->accept();
|
||||
} elseif ($validated['status'] === 'rejected') {
|
||||
$quote->reject($validated['note'] ?? 'Declined by seller');
|
||||
} else {
|
||||
$quote->update([
|
||||
'status' => CrmQuote::STATUS_EXPIRED,
|
||||
]);
|
||||
}
|
||||
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.status_changed',
|
||||
description: "Quote {$quote->quote_number} status changed from {$oldStatus} to {$validated['status']}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
return back()->with('success', 'Quote status updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert quote to order
|
||||
*/
|
||||
public function convertToOrder(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($quote->order_id) {
|
||||
return back()->withErrors(['error' => 'This quote already has an order.']);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'also_create_invoice' => 'boolean',
|
||||
]);
|
||||
|
||||
// Create order from quote
|
||||
$orderNumber = 'ORD-'.strtoupper(uniqid());
|
||||
|
||||
$order = Order::create([
|
||||
'order_number' => $orderNumber,
|
||||
'business_id' => $quote->account_id, // Buyer business
|
||||
'seller_business_id' => $business->id,
|
||||
'contact_id' => $quote->contact_id,
|
||||
'user_id' => $request->user()->id,
|
||||
'subtotal' => $quote->subtotal,
|
||||
'surcharge' => 0,
|
||||
'tax' => $quote->tax_amount,
|
||||
'total' => $quote->total,
|
||||
'status' => 'new',
|
||||
'created_by' => 'seller',
|
||||
'payment_terms' => 'net_30',
|
||||
'notes' => $quote->notes,
|
||||
]);
|
||||
|
||||
// Copy line items
|
||||
foreach ($quote->items as $item) {
|
||||
OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $item->product_id,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_price' => $item->unit_price,
|
||||
'line_total' => $item->line_total,
|
||||
'product_name' => $item->product?->name ?? $item->description,
|
||||
'product_sku' => $item->product?->sku ?? '',
|
||||
'brand_name' => $item->product?->brand?->name ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
// Link quote to order and update status
|
||||
$quote->update([
|
||||
'order_id' => $order->id,
|
||||
'status' => CrmQuote::STATUS_ACCEPTED,
|
||||
'accepted_at' => now(),
|
||||
]);
|
||||
|
||||
// Log activity
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.converted_to_order',
|
||||
description: "Quote {$quote->quote_number} converted to Order {$orderNumber}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
// Optionally create invoice
|
||||
if ($validated['also_create_invoice'] ?? false) {
|
||||
$invoice = $quote->convertToInvoice();
|
||||
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Order and invoice created from quote.');
|
||||
}
|
||||
|
||||
return redirect()->route('seller.business.orders.show', [$business, $order])
|
||||
->with('success', 'Order created from quote.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate invoice from quote (or its order)
|
||||
*/
|
||||
public function generateInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($quote->invoice) {
|
||||
return back()->withErrors(['error' => 'This quote already has an invoice.']);
|
||||
}
|
||||
|
||||
// Credit check if there's a buyer account
|
||||
if ($quote->account_id) {
|
||||
$buyerBusiness = Business::find($quote->account_id);
|
||||
|
||||
if ($buyerBusiness) {
|
||||
$creditCheck = $arService->checkCreditForAccount(
|
||||
$business,
|
||||
$buyerBusiness,
|
||||
(float) $quote->total
|
||||
);
|
||||
|
||||
if (! $creditCheck['can_extend']) {
|
||||
return back()->withErrors([
|
||||
'error' => 'Cannot create invoice: '.$creditCheck['reason'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$invoice = $quote->convertToInvoice();
|
||||
|
||||
Activity::log(
|
||||
sellerBusinessId: $business->id,
|
||||
subject: $quote,
|
||||
type: 'quote.invoice_generated',
|
||||
description: "Invoice {$invoice->invoice_number} generated from Quote {$quote->quote_number}",
|
||||
causer: $request->user(),
|
||||
contactId: $quote->contact_id,
|
||||
);
|
||||
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Invoice created from quote.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and store quote PDF
|
||||
*/
|
||||
protected function generateQuotePdf(CrmQuote $quote, Business $business): ?string
|
||||
{
|
||||
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
|
||||
|
||||
$pdf = Pdf::loadView('pdfs.crm-quote', [
|
||||
'quote' => $quote,
|
||||
'business' => $business,
|
||||
'sellerBusiness' => $business,
|
||||
]);
|
||||
|
||||
$filename = "quotes/{$quote->quote_number}.pdf";
|
||||
Storage::put($filename, $pdf->output());
|
||||
|
||||
$quote->update(['pdf_path' => $filename]);
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* View quote PDF
|
||||
*/
|
||||
public function pdf(Request $request, Business $business, CrmQuote $quote)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
|
||||
|
||||
$pdf = Pdf::loadView('pdfs.crm-quote', [
|
||||
'quote' => $quote,
|
||||
'business' => $business,
|
||||
'sellerBusiness' => $business,
|
||||
]);
|
||||
|
||||
return $pdf->stream("{$quote->quote_number}.pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert quote to invoice
|
||||
*/
|
||||
public function convertToInvoice(Request $request, Business $business, CrmQuote $quote)
|
||||
public function convertToInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
|
||||
{
|
||||
if ($quote->business_id !== $business->id) {
|
||||
abort(404);
|
||||
@@ -275,9 +601,33 @@ class QuoteController extends Controller
|
||||
return back()->withErrors(['error' => 'This quote already has an invoice.']);
|
||||
}
|
||||
|
||||
// Credit check enforcement - only if there's an account (buyer business)
|
||||
if ($quote->account_id) {
|
||||
$buyerBusiness = Business::find($quote->account_id);
|
||||
|
||||
if ($buyerBusiness) {
|
||||
$creditCheck = $arService->checkCreditForAccount(
|
||||
$business,
|
||||
$buyerBusiness,
|
||||
(float) $quote->total
|
||||
);
|
||||
|
||||
if (! $creditCheck['can_extend']) {
|
||||
return back()->withErrors([
|
||||
'error' => 'Cannot create invoice: '.$creditCheck['reason'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Store warning in session if present
|
||||
if (! empty($creditCheck['details']['warning'])) {
|
||||
session()->flash('warning', $creditCheck['details']['warning']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$invoice = $quote->convertToInvoice();
|
||||
|
||||
return redirect()->route('seller.crm.invoices.show', $invoice)
|
||||
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
||||
->with('success', 'Invoice created from quote.');
|
||||
}
|
||||
|
||||
@@ -305,7 +655,7 @@ class QuoteController extends Controller
|
||||
|
||||
$quote->delete();
|
||||
|
||||
return redirect()->route('seller.crm.quotes.index')
|
||||
return redirect()->route('seller.business.crm.quotes.index', $business)
|
||||
->with('success', 'Quote deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller\Crm;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmTask;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TaskController extends Controller
|
||||
@@ -16,13 +17,17 @@ class TaskController extends Controller
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$tasksQuery = CrmTask::where('business_id', $business->id)
|
||||
->with(['assignee', 'creator', 'related'])
|
||||
->orderBy('due_date');
|
||||
$tasksQuery = CrmTask::where('seller_business_id', $business->id)
|
||||
->with(['assignee', 'creator', 'contact', 'business'])
|
||||
->orderBy('due_at');
|
||||
|
||||
// Filter by status
|
||||
// Filter by status (completed vs incomplete)
|
||||
if ($request->filled('status')) {
|
||||
$tasksQuery->where('status', $request->status);
|
||||
if ($request->status === 'completed') {
|
||||
$tasksQuery->whereNotNull('completed_at');
|
||||
} elseif ($request->status === 'pending') {
|
||||
$tasksQuery->whereNull('completed_at');
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by assignee
|
||||
@@ -35,25 +40,56 @@ class TaskController extends Controller
|
||||
$tasksQuery->where('type', $request->type);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$tasksQuery->where(function ($q) use ($search) {
|
||||
$q->where('title', 'ILIKE', "%{$search}%")
|
||||
->orWhere('details', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$tasks = $tasksQuery->paginate(25);
|
||||
|
||||
// Get stats
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $tasks->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'name' => $t->title,
|
||||
'type' => $t->type,
|
||||
'assignee' => $t->assignee?->name ?? 'Unassigned',
|
||||
'due_at' => $t->due_at?->format('M j, Y'),
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Get stats with single efficient query
|
||||
$statsQuery = CrmTask::where('seller_business_id', $business->id)
|
||||
->selectRaw('
|
||||
SUM(CASE WHEN assigned_to = ? AND completed_at IS NULL THEN 1 ELSE 0 END) as my_tasks,
|
||||
SUM(CASE WHEN completed_at IS NULL AND due_at < NOW() THEN 1 ELSE 0 END) as overdue,
|
||||
SUM(CASE WHEN completed_at IS NULL AND DATE(due_at) = CURRENT_DATE THEN 1 ELSE 0 END) as due_today
|
||||
', [$user->id])
|
||||
->first();
|
||||
|
||||
$stats = [
|
||||
'my_tasks' => CrmTask::where('business_id', $business->id)
|
||||
->where('assigned_to', $user->id)
|
||||
->where('status', '!=', 'completed')
|
||||
->count(),
|
||||
'overdue' => CrmTask::where('business_id', $business->id)
|
||||
->where('status', '!=', 'completed')
|
||||
->where('due_date', '<', now())
|
||||
->count(),
|
||||
'due_today' => CrmTask::where('business_id', $business->id)
|
||||
->where('status', '!=', 'completed')
|
||||
->whereDate('due_date', today())
|
||||
->count(),
|
||||
'my_tasks' => $statsQuery->my_tasks ?? 0,
|
||||
'overdue' => $statsQuery->overdue ?? 0,
|
||||
'due_today' => $statsQuery->due_today ?? 0,
|
||||
];
|
||||
|
||||
return view('seller.crm.tasks.index', compact('business', 'tasks', 'stats'));
|
||||
$counts = $stats; // View expects $counts
|
||||
|
||||
// Get team members for assignment filter
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
// Get buyer businesses (accounts) for filtering
|
||||
$buyerBusinesses = Business::where('type', 'buyer')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers', 'buyerBusinesses'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,7 +97,19 @@ class TaskController extends Controller
|
||||
*/
|
||||
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'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,19 +119,26 @@ class TaskController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|in:call,email,meeting,task,follow_up',
|
||||
'priority' => 'required|in:low,medium,high,urgent',
|
||||
'due_date' => 'required|date',
|
||||
'details' => 'nullable|string',
|
||||
'type' => 'required|in:call,email,meeting,follow_up,demo,other',
|
||||
'priority' => 'required|in:low,normal,high,urgent',
|
||||
'due_at' => 'required|date',
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
'contact_id' => 'nullable|exists:contacts,id',
|
||||
'business_id' => 'nullable|exists:businesses,id',
|
||||
]);
|
||||
|
||||
$task = CrmTask::create([
|
||||
...$validated,
|
||||
'business_id' => $business->id,
|
||||
'title' => $validated['title'],
|
||||
'details' => $validated['details'] ?? null,
|
||||
'type' => $validated['type'],
|
||||
'priority' => $validated['priority'],
|
||||
'due_at' => $validated['due_at'],
|
||||
'contact_id' => $validated['contact_id'] ?? null,
|
||||
'business_id' => $validated['business_id'] ?? null,
|
||||
'seller_business_id' => $business->id,
|
||||
'created_by' => $request->user()->id,
|
||||
'assigned_to' => $validated['assigned_to'] ?? $request->user()->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
@@ -96,7 +151,7 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function show(Request $request, Business $business, CrmTask $task)
|
||||
{
|
||||
$task->load(['assignee', 'creator', 'related']);
|
||||
$task->load(['assignee', 'creator', 'contact', 'business', 'opportunity', 'order']);
|
||||
|
||||
return view('seller.crm.tasks.show', compact('business', 'task'));
|
||||
}
|
||||
@@ -108,11 +163,10 @@ class TaskController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'sometimes|in:call,email,meeting,task,follow_up',
|
||||
'priority' => 'sometimes|in:low,medium,high,urgent',
|
||||
'status' => 'sometimes|in:pending,in_progress,completed,cancelled',
|
||||
'due_date' => 'sometimes|date',
|
||||
'details' => 'nullable|string',
|
||||
'type' => 'sometimes|in:call,email,meeting,follow_up,demo,other',
|
||||
'priority' => 'sometimes|in:low,normal,high,urgent',
|
||||
'due_at' => 'sometimes|date',
|
||||
'assigned_to' => 'nullable|exists:users,id',
|
||||
]);
|
||||
|
||||
@@ -140,10 +194,7 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function complete(Request $request, Business $business, CrmTask $task)
|
||||
{
|
||||
$task->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$task->markComplete($request->user());
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
|
||||
namespace App\Http\Controllers\Seller\Crm;
|
||||
|
||||
use App\Events\CrmTypingIndicator;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AgentStatus;
|
||||
use App\Models\Business;
|
||||
use App\Models\ChatQuickReply;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Crm\CrmActiveView;
|
||||
use App\Models\Crm\CrmChannel;
|
||||
use App\Models\Crm\CrmInternalNote;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Models\SalesRepAssignment;
|
||||
use App\Models\User;
|
||||
use App\Services\Crm\CrmAiService;
|
||||
use App\Services\Crm\CrmChannelService;
|
||||
use App\Services\Crm\CrmSlaService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ThreadController extends Controller
|
||||
{
|
||||
@@ -22,13 +29,113 @@ class ThreadController extends Controller
|
||||
protected CrmAiService $aiService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show compose form for new thread
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Get customer business IDs (businesses that have ordered from this seller)
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
// Get contacts from customer businesses (accounts)
|
||||
$contacts = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->with('business:id,name')
|
||||
->orderBy('first_name')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
// Get available channels
|
||||
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||
|
||||
// Pre-select contact if provided
|
||||
$selectedContact = null;
|
||||
if ($request->filled('contact_id')) {
|
||||
$selectedContact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->find($request->contact_id);
|
||||
}
|
||||
|
||||
return view('seller.crm.threads.create', compact('business', 'contacts', 'channels', 'selectedContact'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new thread and send initial message
|
||||
*/
|
||||
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',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
'body' => 'required|string|max:10000',
|
||||
'attachments.*' => 'nullable|file|max:10240',
|
||||
]);
|
||||
|
||||
// Get customer business IDs (businesses that have ordered from this seller)
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
// SECURITY: Verify contact belongs to a customer business
|
||||
$contact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||
->findOrFail($validated['contact_id']);
|
||||
|
||||
// Determine recipient address
|
||||
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
||||
? $contact->email
|
||||
: $contact->phone;
|
||||
|
||||
if (! $to) {
|
||||
return back()->withInput()->withErrors([
|
||||
'channel_type' => 'Contact does not have the required contact info for this channel.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Create thread first
|
||||
$thread = CrmThread::create([
|
||||
'business_id' => $business->id,
|
||||
'contact_id' => $contact->id,
|
||||
'account_id' => $contact->account_id,
|
||||
'subject' => $validated['subject'],
|
||||
'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: $validated['subject'] ?? null,
|
||||
threadId: $thread->id,
|
||||
contactId: $contact->id,
|
||||
userId: $request->user()->id,
|
||||
attachments: $request->file('attachments', [])
|
||||
);
|
||||
|
||||
if (! $success) {
|
||||
// Delete the thread if message failed
|
||||
$thread->delete();
|
||||
|
||||
return back()->withInput()->withErrors(['body' => 'Failed to send message.']);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.crm.threads.show', [$business, $thread])
|
||||
->with('success', 'Conversation started successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display unified inbox
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = CrmThread::forBusiness($business->id)
|
||||
->with(['contact', 'assignee', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->withCount('messages');
|
||||
|
||||
// Filters
|
||||
@@ -52,11 +159,21 @@ class ThreadController extends Controller
|
||||
$query->withPriority($request->priority);
|
||||
}
|
||||
|
||||
// Department filter
|
||||
if ($request->filled('department')) {
|
||||
$query->forDepartment($request->department);
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if ($request->filled('brand_id')) {
|
||||
$query->forBrand($request->brand_id);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('subject', 'like', "%{$request->search}%")
|
||||
->orWhere('last_message_preview', 'like', "%{$request->search}%")
|
||||
->orWhereHas('contact', fn ($c) => $c->where('name', 'like', "%{$request->search}%"));
|
||||
$q->where('subject', 'ilike', "%{$request->search}%")
|
||||
->orWhere('last_message_preview', 'ilike', "%{$request->search}%")
|
||||
->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +187,16 @@ class ThreadController extends Controller
|
||||
// Get available channels
|
||||
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||
|
||||
return view('seller.crm.threads.index', compact('threads', 'teamMembers', 'channels'));
|
||||
// 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;
|
||||
|
||||
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels', 'brands', 'departments'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +214,8 @@ class ThreadController extends Controller
|
||||
'contact',
|
||||
'account',
|
||||
'assignee',
|
||||
'brand',
|
||||
'channel',
|
||||
'messages.attachments',
|
||||
'messages.user',
|
||||
'deals',
|
||||
@@ -117,6 +245,7 @@ class ThreadController extends Controller
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||
|
||||
return view('seller.crm.threads.show', compact(
|
||||
'business',
|
||||
'thread',
|
||||
'otherViewers',
|
||||
'slaStatus',
|
||||
@@ -167,6 +296,12 @@ class ThreadController extends Controller
|
||||
return back()->withErrors(['body' => 'Failed to send message.']);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -318,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'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class EmailSettingsController extends Controller
|
||||
'business' => $business,
|
||||
'settings' => $settings,
|
||||
'drivers' => BusinessMailSettings::DRIVERS,
|
||||
'providers' => BusinessMailSettings::PROVIDERS,
|
||||
'encryptions' => BusinessMailSettings::ENCRYPTIONS,
|
||||
'commonPorts' => BusinessMailSettings::COMMON_PORTS,
|
||||
]);
|
||||
@@ -34,6 +35,7 @@ class EmailSettingsController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'driver' => ['required', 'string', Rule::in(array_keys(BusinessMailSettings::DRIVERS))],
|
||||
'provider' => ['required', 'string', Rule::in(array_keys(BusinessMailSettings::PROVIDERS))],
|
||||
'host' => ['nullable', 'string', 'max:255'],
|
||||
'port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'encryption' => ['nullable', 'string', Rule::in(['tls', 'ssl', ''])],
|
||||
@@ -43,6 +45,9 @@ class EmailSettingsController extends Controller
|
||||
'from_email' => ['nullable', 'email', 'max:255'],
|
||||
'reply_to_email' => ['nullable', 'email', 'max:255'],
|
||||
'is_active' => ['boolean'],
|
||||
// Postal-specific config fields
|
||||
'postal_server_url' => ['nullable', 'url', 'max:255'],
|
||||
'postal_webhook_secret' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
// Handle empty encryption value
|
||||
@@ -55,6 +60,21 @@ class EmailSettingsController extends Controller
|
||||
unset($validated['password']);
|
||||
}
|
||||
|
||||
// Build provider_config from provider-specific fields
|
||||
$providerConfig = [];
|
||||
if ($validated['provider'] === BusinessMailSettings::PROVIDER_POSTAL) {
|
||||
if (! empty($validated['postal_server_url'])) {
|
||||
$providerConfig['server_url'] = $validated['postal_server_url'];
|
||||
}
|
||||
if (! empty($validated['postal_webhook_secret'])) {
|
||||
$providerConfig['webhook_secret'] = $validated['postal_webhook_secret'];
|
||||
}
|
||||
}
|
||||
$validated['provider_config'] = ! empty($providerConfig) ? $providerConfig : null;
|
||||
|
||||
// Remove provider-specific fields from main validated array
|
||||
unset($validated['postal_server_url'], $validated['postal_webhook_secret']);
|
||||
|
||||
$settings = BusinessMailSettings::getOrCreate($business);
|
||||
$settings->update($validated);
|
||||
|
||||
|
||||
335
app/Http/Controllers/Seller/ExpensesController.php
Normal file
335
app/Http/Controllers/Seller/ExpensesController.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\Expense;
|
||||
use App\Models\Accounting\GlAccount;
|
||||
use App\Models\Business;
|
||||
use App\Models\Department;
|
||||
use App\Services\Accounting\ExpenseService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Staff/Child Business Expense Controller.
|
||||
*
|
||||
* Handles expense creation and submission by employees.
|
||||
* Approval and payment are handled by Management controller.
|
||||
*/
|
||||
class ExpensesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ExpenseService $expenseService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List expenses for the current business.
|
||||
*
|
||||
* GET /s/{business}/expenses
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$query = Expense::where('business_id', $business->id)
|
||||
->with(['department', 'createdBy', 'items']);
|
||||
|
||||
// Non-admins only see their own expenses
|
||||
if (! $this->canViewAllExpenses($user, $business)) {
|
||||
$query->where('created_by_user_id', $user->id);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// Department filter
|
||||
if ($request->filled('department_id')) {
|
||||
$query->where('department_id', $request->department_id);
|
||||
}
|
||||
|
||||
$expenses = $query->orderByDesc('expense_date')->paginate(20)->withQueryString();
|
||||
|
||||
// Get departments for filter
|
||||
$departments = Department::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Stats for current user
|
||||
$myStats = [
|
||||
'draft' => Expense::where('business_id', $business->id)
|
||||
->where('created_by_user_id', $user->id)
|
||||
->status(Expense::STATUS_DRAFT)
|
||||
->count(),
|
||||
'submitted' => Expense::where('business_id', $business->id)
|
||||
->where('created_by_user_id', $user->id)
|
||||
->status(Expense::STATUS_SUBMITTED)
|
||||
->count(),
|
||||
'approved' => Expense::where('business_id', $business->id)
|
||||
->where('created_by_user_id', $user->id)
|
||||
->status(Expense::STATUS_APPROVED)
|
||||
->count(),
|
||||
'total_pending' => Expense::where('business_id', $business->id)
|
||||
->where('created_by_user_id', $user->id)
|
||||
->whereIn('status', [Expense::STATUS_SUBMITTED, Expense::STATUS_APPROVED])
|
||||
->sum('total_amount'),
|
||||
];
|
||||
|
||||
return view('seller.expenses.index', compact(
|
||||
'business',
|
||||
'expenses',
|
||||
'departments',
|
||||
'myStats'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create expense form.
|
||||
*
|
||||
* GET /s/{business}/expenses/create
|
||||
*/
|
||||
public function create(Request $request, Business $business): View
|
||||
{
|
||||
$departments = Department::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->where('is_header', false)
|
||||
->where('account_type', 'expense')
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
$paymentMethods = Expense::getPaymentMethods();
|
||||
|
||||
return view('seller.expenses.create', compact(
|
||||
'business',
|
||||
'departments',
|
||||
'glAccounts',
|
||||
'paymentMethods'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new expense.
|
||||
*
|
||||
* POST /s/{business}/expenses
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'expense_date' => 'required|date',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'payment_method' => 'required|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
|
||||
'reference' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string|max:255',
|
||||
'items.*.amount' => 'required|numeric|min:0.01',
|
||||
'items.*.gl_expense_account_id' => 'required|integer|exists:gl_accounts,id',
|
||||
'items.*.department_id' => 'nullable|integer|exists:departments,id',
|
||||
'items.*.tax_amount' => 'nullable|numeric|min:0',
|
||||
'submit' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
$items = $validated['items'];
|
||||
unset($validated['items'], $validated['submit']);
|
||||
|
||||
// Set default status
|
||||
$validated['status'] = $request->boolean('submit')
|
||||
? Expense::STATUS_SUBMITTED
|
||||
: Expense::STATUS_DRAFT;
|
||||
|
||||
$expense = $this->expenseService->createExpense($business, $user, $validated, $items);
|
||||
|
||||
$message = $expense->isSubmitted()
|
||||
? "Expense {$expense->expense_number} submitted for approval."
|
||||
: "Expense {$expense->expense_number} saved as draft.";
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.expenses.show', [$business, $expense])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show expense details.
|
||||
*
|
||||
* GET /s/{business}/expenses/{expense}
|
||||
*/
|
||||
public function show(Request $request, Business $business, Expense $expense): View
|
||||
{
|
||||
$this->authorizeExpenseAccess($expense, $business);
|
||||
|
||||
$expense->load(['items.glAccount', 'items.department', 'department', 'createdBy', 'approvedBy', 'paidBy']);
|
||||
|
||||
return view('seller.expenses.show', compact('business', 'expense'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit expense form (draft only).
|
||||
*
|
||||
* GET /s/{business}/expenses/{expense}/edit
|
||||
*/
|
||||
public function edit(Request $request, Business $business, Expense $expense): View
|
||||
{
|
||||
$this->authorizeExpenseAccess($expense, $business);
|
||||
|
||||
if (! $expense->canEdit()) {
|
||||
abort(403, 'Only draft expenses can be edited.');
|
||||
}
|
||||
|
||||
$expense->load(['items']);
|
||||
|
||||
$departments = Department::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$glAccounts = GlAccount::where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->where('is_header', false)
|
||||
->where('account_type', 'expense')
|
||||
->orderBy('account_number')
|
||||
->get();
|
||||
|
||||
$paymentMethods = Expense::getPaymentMethods();
|
||||
|
||||
return view('seller.expenses.edit', compact(
|
||||
'business',
|
||||
'expense',
|
||||
'departments',
|
||||
'glAccounts',
|
||||
'paymentMethods'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an expense (draft only).
|
||||
*
|
||||
* PUT /s/{business}/expenses/{expense}
|
||||
*/
|
||||
public function update(Request $request, Business $business, Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->authorizeExpenseAccess($expense, $business);
|
||||
|
||||
if (! $expense->canEdit()) {
|
||||
return back()->with('error', 'Only draft expenses can be edited.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'expense_date' => 'required|date',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'payment_method' => 'required|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
|
||||
'reference' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string|max:255',
|
||||
'items.*.amount' => 'required|numeric|min:0.01',
|
||||
'items.*.gl_expense_account_id' => 'required|integer|exists:gl_accounts,id',
|
||||
'items.*.department_id' => 'nullable|integer|exists:departments,id',
|
||||
'items.*.tax_amount' => 'nullable|numeric|min:0',
|
||||
'submit' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$items = $validated['items'];
|
||||
unset($validated['items'], $validated['submit']);
|
||||
|
||||
// Update status if submitting
|
||||
if ($request->boolean('submit')) {
|
||||
$validated['status'] = Expense::STATUS_SUBMITTED;
|
||||
}
|
||||
|
||||
$expense = $this->expenseService->updateExpense($expense, $validated, $items);
|
||||
|
||||
$message = $expense->isSubmitted()
|
||||
? "Expense {$expense->expense_number} submitted for approval."
|
||||
: "Expense {$expense->expense_number} updated.";
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.expenses.show', [$business, $expense])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an expense for approval.
|
||||
*
|
||||
* POST /s/{business}/expenses/{expense}/submit
|
||||
*/
|
||||
public function submit(Request $request, Business $business, Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->authorizeExpenseAccess($expense, $business);
|
||||
|
||||
try {
|
||||
$this->expenseService->submitExpense($expense, auth()->user());
|
||||
|
||||
return back()->with('success', "Expense {$expense->expense_number} submitted for approval.");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a draft expense.
|
||||
*
|
||||
* DELETE /s/{business}/expenses/{expense}
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->authorizeExpenseAccess($expense, $business);
|
||||
|
||||
try {
|
||||
$this->expenseService->deleteExpense($expense);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.expenses.index', $business)
|
||||
->with('success', 'Expense deleted.');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can view all expenses (not just their own).
|
||||
*/
|
||||
protected function canViewAllExpenses($user, Business $business): bool
|
||||
{
|
||||
// Business owners and admins can view all
|
||||
$pivot = $user->businesses()
|
||||
->where('businesses.id', $business->id)
|
||||
->first()
|
||||
?->pivot;
|
||||
|
||||
if ($pivot && in_array($pivot->role ?? $pivot->contact_type ?? '', ['owner', 'primary', 'admin'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->user_type === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize access to a specific expense.
|
||||
*/
|
||||
protected function authorizeExpenseAccess(Expense $expense, Business $business): void
|
||||
{
|
||||
// Must belong to this business
|
||||
if ($expense->business_id !== $business->id) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Must be creator or have view-all permission
|
||||
if ($expense->created_by_user_id !== $user->id && ! $this->canViewAllExpenses($user, $business)) {
|
||||
abort(403, 'Access denied.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\Invoices\InvoiceSentMail;
|
||||
use App\Models\Business;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoicePayment;
|
||||
use App\Services\InvoiceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -25,64 +28,7 @@ class InvoiceController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get all products from brands owned by this business with images, stock levels, and batches
|
||||
$products = \App\Models\Product::forBusiness($business)
|
||||
->where('is_active', true)
|
||||
->with(['brand', 'images', 'availableBatches.labs'])
|
||||
->select('id', 'brand_id', 'name', 'sku', 'description', 'wholesale_price', 'msrp_price',
|
||||
'quantity_on_hand', 'quantity_allocated', 'type', 'image_path')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function ($product) use ($business) {
|
||||
// Map batches with their COA data
|
||||
$batches = $product->availableBatches->map(function ($batch) {
|
||||
$latestLab = $batch->getLatestLab();
|
||||
|
||||
return [
|
||||
'id' => $batch->id,
|
||||
'batch_number' => $batch->batch_number,
|
||||
'quantity_available' => $batch->quantity_available,
|
||||
'production_date' => $batch->production_date?->format('M j, Y'),
|
||||
'expiration_date' => $batch->expiration_date?->format('M j, Y'),
|
||||
'is_expiring_soon' => $batch->isExpiringSoon(),
|
||||
'lab' => $latestLab ? [
|
||||
'total_thc' => $latestLab->total_thc,
|
||||
'total_cbd' => $latestLab->total_cbd,
|
||||
'test_date' => $latestLab->test_date->format('M j, Y'),
|
||||
'lab_name' => $latestLab->lab_name,
|
||||
'compliance_pass' => $latestLab->compliance_pass,
|
||||
'terpene_profile' => $latestLab->terpene_profile,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
// 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,
|
||||
'description' => $product->description,
|
||||
'brand_name' => $product->brand?->name,
|
||||
'wholesale_price' => $product->wholesale_price,
|
||||
'msrp_price' => $product->msrp_price,
|
||||
'quantity_on_hand' => $totalOnHand,
|
||||
'quantity_allocated' => $totalAllocated,
|
||||
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
|
||||
'type' => $product->type,
|
||||
'image_url' => $product->images->first()?->path
|
||||
? \Storage::url($product->images->first()->path)
|
||||
: ($product->image_path ? \Storage::url($product->image_path) : null),
|
||||
'batches' => $batches,
|
||||
'has_batches' => $batches->count() > 0,
|
||||
];
|
||||
});
|
||||
// Products are loaded via API search (/search/invoice-products) for better performance
|
||||
|
||||
// Get recently invoiced products (last 30 days, top 10 most common)
|
||||
$recentProducts = \App\Models\Product::forBusiness($business)
|
||||
@@ -118,7 +64,7 @@ class InvoiceController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
return view('seller.invoices.create', compact('business', 'buyers', 'products', 'recentProducts'));
|
||||
return view('seller.invoices.create', compact('business', 'buyers', 'recentProducts'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,24 +118,68 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Display a listing of invoices for the business.
|
||||
*/
|
||||
public function index(Business $business)
|
||||
public function index(Business $business, Request $request)
|
||||
{
|
||||
// Get invoices where orders contain items from brands under this business
|
||||
$invoices = Invoice::with(['order.items.product.brand', 'order.contact', 'order.user', 'business'])
|
||||
->whereHas('order.items.product', function ($query) use ($business) {
|
||||
$query->forBusiness($business);
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
// Get brand IDs for this business (single query, reused for filtering)
|
||||
$brandIds = $business->brands()->pluck('id');
|
||||
|
||||
// Base query: invoices where orders contain items from this business's brands
|
||||
$baseQuery = Invoice::whereHas('order.items.product', function ($query) use ($brandIds) {
|
||||
$query->whereIn('brand_id', $brandIds);
|
||||
});
|
||||
|
||||
// Calculate stats with efficient database aggregates (not in-memory iteration)
|
||||
$stats = [
|
||||
'total' => $invoices->count(),
|
||||
'unpaid' => $invoices->where('payment_status', 'unpaid')->count(),
|
||||
'partially_paid' => $invoices->where('payment_status', 'partially_paid')->count(),
|
||||
'paid' => $invoices->where('payment_status', 'paid')->count(),
|
||||
'overdue' => $invoices->filter(fn ($inv) => $inv->isOverdue())->count(),
|
||||
'total' => (clone $baseQuery)->count(),
|
||||
'unpaid' => (clone $baseQuery)->where('payment_status', 'unpaid')->count(),
|
||||
'partially_paid' => (clone $baseQuery)->where('payment_status', 'partially_paid')->count(),
|
||||
'paid' => (clone $baseQuery)->where('payment_status', 'paid')->count(),
|
||||
'overdue' => (clone $baseQuery)->where('payment_status', '!=', 'paid')
|
||||
->where('due_date', '<', now())->count(),
|
||||
];
|
||||
|
||||
// Apply search filter - search by customer business name or invoice number
|
||||
$search = $request->input('search');
|
||||
if ($search) {
|
||||
$baseQuery->where(function ($query) use ($search) {
|
||||
$query->where('invoice_number', 'ilike', "%{$search}%")
|
||||
->orWhereHas('business', function ($q) use ($search) {
|
||||
$q->where('name', 'ilike', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
$status = $request->input('status');
|
||||
if ($status === 'unpaid') {
|
||||
$baseQuery->where('payment_status', 'unpaid');
|
||||
} elseif ($status === 'paid') {
|
||||
$baseQuery->where('payment_status', 'paid');
|
||||
} elseif ($status === 'overdue') {
|
||||
$baseQuery->where('payment_status', '!=', 'paid')
|
||||
->where('due_date', '<', now());
|
||||
}
|
||||
|
||||
// Paginate with only the relations needed for display
|
||||
$invoices = (clone $baseQuery)
|
||||
->with(['business:id,name,primary_contact_email,business_email', 'order:id,contact_id,user_id', 'order.contact:id,first_name,last_name,email', 'order.user:id,email'])
|
||||
->latest()
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $invoices->map(fn ($i) => [
|
||||
'hashid' => $i->hashid,
|
||||
'name' => $i->invoice_number.' - '.$i->business->name,
|
||||
'invoice_number' => $i->invoice_number,
|
||||
'customer' => $i->business->name,
|
||||
'status' => $i->payment_status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.invoices.index', compact('business', 'invoices', 'stats'));
|
||||
}
|
||||
|
||||
@@ -199,7 +189,13 @@ class InvoiceController extends Controller
|
||||
public function show(Business $business, Invoice $invoice)
|
||||
{
|
||||
// Verify invoice belongs to this business through order items
|
||||
$invoice->load(['order.items.product.brand', 'business']);
|
||||
$invoice->load([
|
||||
'order.items.product.brand',
|
||||
'order.contact',
|
||||
'order.user',
|
||||
'business',
|
||||
'payments.recordedByUser',
|
||||
]);
|
||||
|
||||
// Check if any of the order's items belong to brands owned by this business
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
@@ -289,4 +285,102 @@ class InvoiceController extends Controller
|
||||
'contacts' => $contacts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invoice by email.
|
||||
*/
|
||||
public function send(Business $business, Invoice $invoice, Request $request, InvoiceService $invoiceService): Response
|
||||
{
|
||||
// Verify invoice belongs to this business through order items
|
||||
$invoice->load('order.items.product.brand');
|
||||
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
abort(403, 'This invoice does not belong to your business');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'to' => ['required', 'email'],
|
||||
'cc' => ['nullable', 'email'],
|
||||
'message' => ['nullable', 'string', 'max:2000'],
|
||||
'attach_pdf' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
// Generate PDF if requested
|
||||
$pdfContent = null;
|
||||
if ($validated['attach_pdf'] ?? false) {
|
||||
// Regenerate PDF if it doesn't exist
|
||||
if (! $invoice->pdf_path || ! Storage::disk('local')->exists($invoice->pdf_path)) {
|
||||
$invoiceService->regeneratePdf($invoice);
|
||||
$invoice->refresh();
|
||||
}
|
||||
|
||||
if ($invoice->pdf_path && Storage::disk('local')->exists($invoice->pdf_path)) {
|
||||
$pdfContent = Storage::disk('local')->get($invoice->pdf_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Send email
|
||||
$mail = Mail::to($validated['to']);
|
||||
|
||||
if (! empty($validated['cc'])) {
|
||||
$mail->cc($validated['cc']);
|
||||
}
|
||||
|
||||
$mail->send(new InvoiceSentMail(
|
||||
$invoice,
|
||||
$validated['message'] ?? null,
|
||||
$pdfContent
|
||||
));
|
||||
|
||||
return back()->with('success', 'Invoice sent successfully to '.$validated['to']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a payment for an invoice.
|
||||
*/
|
||||
public function recordPayment(Business $business, Invoice $invoice, Request $request): Response
|
||||
{
|
||||
// Verify invoice belongs to this business through order items
|
||||
$invoice->load('order.items.product.brand');
|
||||
|
||||
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
|
||||
return $item->product && $item->product->belongsToBusiness($business);
|
||||
});
|
||||
|
||||
if (! $belongsToBusiness) {
|
||||
abort(403, 'This invoice does not belong to your business');
|
||||
}
|
||||
|
||||
if ($invoice->payment_status === 'paid') {
|
||||
return back()->withErrors(['error' => 'This invoice is already fully paid.']);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'amount' => ['required', 'numeric', 'min:0.01', 'max:'.$invoice->amount_due],
|
||||
'payment_date' => ['required', 'date'],
|
||||
'payment_method' => ['required', 'string', 'in:cash,check,wire,ach,credit_card,bank_transfer,other'],
|
||||
'reference' => ['nullable', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
InvoicePayment::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'amount' => $validated['amount'],
|
||||
'payment_date' => $validated['payment_date'],
|
||||
'payment_method' => $validated['payment_method'],
|
||||
'reference' => $validated['reference'],
|
||||
'notes' => $validated['notes'],
|
||||
'recorded_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
$statusMessage = $invoice->fresh()->payment_status === 'paid'
|
||||
? 'Payment recorded. Invoice is now fully paid.'
|
||||
: 'Payment recorded successfully.';
|
||||
|
||||
return back()->with('success', $statusMessage);
|
||||
}
|
||||
}
|
||||
|
||||
171
app/Http/Controllers/Seller/Management/AccountingController.php
Normal file
171
app/Http/Controllers/Seller/Management/AccountingController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\AccountingReportingService;
|
||||
use App\Services\Accounting\ReportExportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AccountingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AccountingReportingService $reportingService,
|
||||
protected ReportExportService $exportService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* General Ledger Account Detail.
|
||||
*
|
||||
* GET /s/{business}/management/accounting/gl
|
||||
*/
|
||||
public function gl(Request $request, Business $business)
|
||||
{
|
||||
$fromDate = $request->get('from_date', now()->startOfMonth()->format('Y-m-d'));
|
||||
$toDate = $request->get('to_date', now()->format('Y-m-d'));
|
||||
$accountId = $request->get('account_id');
|
||||
|
||||
$accounts = $this->reportingService->getAccountsForSelect($business);
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$ledgerData = null;
|
||||
if ($accountId) {
|
||||
$ledgerData = $this->reportingService->getGeneralLedger(
|
||||
$business,
|
||||
(int) $accountId,
|
||||
$fromDate,
|
||||
$toDate
|
||||
);
|
||||
}
|
||||
|
||||
return view('seller.management.accounting.gl', compact(
|
||||
'business',
|
||||
'accounts',
|
||||
'ledgerData',
|
||||
'fromDate',
|
||||
'toDate',
|
||||
'accountId',
|
||||
'isParent'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Journal Entry Browser.
|
||||
*
|
||||
* GET /s/{business}/management/accounting/journals
|
||||
*/
|
||||
public function journals(Request $request, Business $business)
|
||||
{
|
||||
$filters = [
|
||||
'from_date' => $request->get('from_date', now()->startOfMonth()->format('Y-m-d')),
|
||||
'to_date' => $request->get('to_date', now()->format('Y-m-d')),
|
||||
'source_type' => $request->get('source_type'),
|
||||
'status' => $request->get('status'),
|
||||
'division_id' => $request->get('division_id'),
|
||||
'include_children' => true,
|
||||
];
|
||||
|
||||
$entries = $this->reportingService->getJournalEntries($business, $filters);
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
$divisions = $isParent ? $this->reportingService->getDivisions($business) : collect();
|
||||
|
||||
$sourceTypes = [
|
||||
'manual' => 'Manual Entry',
|
||||
'ap_bill' => 'AP Bill',
|
||||
'ap_payment' => 'AP Payment',
|
||||
'inter_company' => 'Inter-Company',
|
||||
];
|
||||
|
||||
$statuses = [
|
||||
'draft' => 'Draft',
|
||||
'posted' => 'Posted',
|
||||
'reversed' => 'Reversed',
|
||||
];
|
||||
|
||||
return view('seller.management.accounting.journals', compact(
|
||||
'business',
|
||||
'entries',
|
||||
'filters',
|
||||
'isParent',
|
||||
'divisions',
|
||||
'sourceTypes',
|
||||
'statuses'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trial Balance Report.
|
||||
*
|
||||
* GET /s/{business}/management/accounting/trial-balance
|
||||
*/
|
||||
public function trialBalance(Request $request, Business $business)
|
||||
{
|
||||
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$toDate = $request->get('to_date', now()->format('Y-m-d'));
|
||||
$includeChildren = $request->boolean('include_children', true);
|
||||
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$filters = [
|
||||
'include_children' => $isParent && $includeChildren,
|
||||
];
|
||||
|
||||
$trialBalance = $this->reportingService->getTrialBalance(
|
||||
$business,
|
||||
$fromDate,
|
||||
$toDate,
|
||||
$filters
|
||||
);
|
||||
|
||||
// Calculate totals
|
||||
$totals = [
|
||||
'debits' => $trialBalance->sum('debits'),
|
||||
'credits' => $trialBalance->sum('credits'),
|
||||
'net_debit' => $trialBalance->where('closing_balance', '>', 0)->sum('closing_balance'),
|
||||
'net_credit' => abs($trialBalance->where('closing_balance', '<', 0)->sum('closing_balance')),
|
||||
];
|
||||
|
||||
return view('seller.management.accounting.trial-balance', compact(
|
||||
'business',
|
||||
'trialBalance',
|
||||
'totals',
|
||||
'fromDate',
|
||||
'toDate',
|
||||
'includeChildren',
|
||||
'isParent'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Trial Balance as CSV.
|
||||
*
|
||||
* GET /s/{business}/management/accounting/trial-balance/export
|
||||
*/
|
||||
public function exportTrialBalance(Request $request, Business $business): StreamedResponse
|
||||
{
|
||||
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
|
||||
$toDate = $request->get('to_date', now()->format('Y-m-d'));
|
||||
$includeChildren = $request->boolean('include_children', true);
|
||||
|
||||
$isParent = $this->reportingService->isParentCompany($business);
|
||||
|
||||
$filters = [
|
||||
'include_children' => $isParent && $includeChildren,
|
||||
];
|
||||
|
||||
$trialBalance = $this->reportingService->getTrialBalance(
|
||||
$business,
|
||||
$fromDate,
|
||||
$toDate,
|
||||
$filters
|
||||
);
|
||||
|
||||
$filename = 'trial_balance_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
|
||||
|
||||
return $this->exportService->exportTrialBalance($trialBalance, $filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Seller\Management;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accounting\AccountingPeriod;
|
||||
use App\Models\Business;
|
||||
use App\Services\Accounting\PeriodLockService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AccountingPeriodsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PeriodLockService $periodLockService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display accounting periods.
|
||||
*/
|
||||
public function index(Request $request, Business $business): View
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
|
||||
$year = $request->input('year', now()->year);
|
||||
$periods = $this->periodLockService->getPeriodsForBusiness($business, (int) $year);
|
||||
|
||||
// Get available years
|
||||
$yearsWithPeriods = AccountingPeriod::forBusiness($business->id)
|
||||
->selectRaw('EXTRACT(YEAR FROM period_start) as year')
|
||||
->distinct()
|
||||
->pluck('year')
|
||||
->map(fn ($y) => (int) $y)
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
// Always include current and next year
|
||||
$availableYears = $yearsWithPeriods
|
||||
->push(now()->year)
|
||||
->push(now()->year + 1)
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
$canClosePeriods = $this->periodLockService->userHasPermission($business, $request->user(), 'can_close_periods');
|
||||
$canReopenPeriods = $this->periodLockService->userHasPermission($business, $request->user(), 'can_reopen_periods');
|
||||
|
||||
return view('seller.management.accounting.periods.index', [
|
||||
'business' => $business,
|
||||
'periods' => $periods,
|
||||
'year' => (int) $year,
|
||||
'availableYears' => $availableYears,
|
||||
'canClosePeriods' => $canClosePeriods,
|
||||
'canReopenPeriods' => $canReopenPeriods,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate periods for a year.
|
||||
*/
|
||||
public function generate(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requirePermission($business, $request->user(), 'can_close_periods');
|
||||
|
||||
$validated = $request->validate([
|
||||
'year' => 'required|integer|min:2000|max:2100',
|
||||
]);
|
||||
|
||||
$periods = $this->periodLockService->ensurePeriodsExist($business, (int) $validated['year']);
|
||||
|
||||
return back()->with('success', 'Generated '.count($periods).' periods for '.$validated['year'].'.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a period.
|
||||
*/
|
||||
public function close(Request $request, Business $business, AccountingPeriod $period): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requirePermission($business, $request->user(), 'can_close_periods');
|
||||
|
||||
// Ensure period belongs to this business
|
||||
if ($period->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:soft_closed,hard_closed',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$this->periodLockService->closePeriod(
|
||||
$period,
|
||||
$validated['status'],
|
||||
$request->user(),
|
||||
$validated['notes'] ?? null
|
||||
);
|
||||
|
||||
$statusLabel = $validated['status'] === 'soft_closed' ? 'soft closed' : 'hard closed';
|
||||
|
||||
return back()->with('success', "Period {$period->period_label} has been {$statusLabel}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen a period.
|
||||
*/
|
||||
public function reopen(Request $request, Business $business, AccountingPeriod $period): RedirectResponse
|
||||
{
|
||||
$this->requireManagementSuite($business);
|
||||
$this->requirePermission($business, $request->user(), 'can_reopen_periods');
|
||||
|
||||
// Ensure period belongs to this business
|
||||
if ($period->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$this->periodLockService->reopenPeriod(
|
||||
$period,
|
||||
$request->user(),
|
||||
$validated['notes'] ?? null
|
||||
);
|
||||
|
||||
return back()->with('success', "Period {$period->period_label} has been reopened.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Require Management Suite access.
|
||||
*/
|
||||
private function requireManagementSuite(Business $business): void
|
||||
{
|
||||
if (! $business->hasManagementSuite()) {
|
||||
abort(403, 'Management Suite access required.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a specific finance permission.
|
||||
*/
|
||||
private function requirePermission(Business $business, $user, string $permission): void
|
||||
{
|
||||
// Business owners always have access
|
||||
if ($business->owner_user_id === $user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check bypass mode
|
||||
if (config('finance_roles.bypass_permissions', false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->periodLockService->userHasPermission($business, $user, $permission)) {
|
||||
abort(403, 'You do not have permission for this action.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user