Compare commits

...

212 Commits

Author SHA1 Message Date
kelly
1e7e1b5934 feat: add omnichannel unified inbox for sales reps
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Implements a Chatwoot-style unified inbox with real-time messaging
for sales reps to manage communications across all channels.

Features:
- Real-time messaging via Laravel Reverb WebSockets
- Sales rep filtering ("My Accounts Only") for assigned accounts
- Typing indicators showing when others are composing
- Agent status (online/away/busy) with heartbeat monitoring
- Email engagement sidebar (opens/clicks from marketing)
- Quick replies with variable substitution
- Presence awareness (who's online in team)
- Three-column layout: thread list, conversation, context sidebar

New files:
- Broadcasting events for real-time updates
- Unified inbox view with Alpine.js component
- 9 Blade partials for modular UI

Access via: Inbox → Conversations in sidebar
Route: /s/{business}/crm/inbox
2025-12-16 15:40:59 -07:00
kelly
3ac4358c0b fix: create supervisor log directory in Dockerfile
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-16 12:19:04 -07:00
kelly
cc997cfa20 Merge feat/chat-ui: unified chat inbox (Chatwoot-style)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 10:37:42 -07:00
kelly
37dd49f9ec fix(ci): use registry.spdy.io (HTTPS) instead of internal HTTP registry
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 10:36:55 -07:00
kelly
e16281e237 fix(ci): use registry.spdy.io (HTTPS) instead of internal HTTP registry 2025-12-16 10:36:36 -07:00
kelly
64479a5c84 fix: include coworkers (own business contacts) in chat targets 2025-12-16 10:36:17 -07:00
kelly
5b1b085e06 feat: add unified chat UI at /s/{business}/chat
Chatwoot-style 3-panel chat interface:
- Left panel: conversation list with filters (status, assignee, search)
- Center panel: message thread with reply box and AI draft
- Right panel: contact details, assignment, internal notes

Features:
- Real-time thread loading via fetch API
- Keyboard shortcuts (Cmd+Enter to send)
- Collision detection heartbeat
- New conversation modal
- Thread status management (close/reopen)
- AI reply generation
- Internal notes

Routes added at /s/{business}/chat/*
Sidebar link added under Inbox section (requires Sales Suite)
2025-12-16 10:29:24 -07:00
kelly
e0caa83325 fix: use CDN URL for default images instead of internal MinIO URL
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 10:12:55 -07:00
kelly
90bc7f3907 fix: resolve Crystal's issues #5, #6, #7
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Issue #7 - Description Characters:
- Remove minlength/maxlength constraints from product description fields
- Increase backend validation limit from 500 to 5000 characters
- Update help text to remove character range guidance

Issue #6 - Edit Quote error:
- Create missing edit.blade.php view for quotes
- Simplify controller edit method - don't need to load all accounts/contacts
- Pre-populate form with existing quote data

Issue #5 - Quotes PDF (storage fix):
- Ensure storage/fonts directory exists for DomPDF font caching
2025-12-16 09:56:22 -07:00
kelly
b7fb6c5a66 style: fix pint formatting issues
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 09:36:47 -07:00
kelly
0d38f6dc5e fix: use MinIO paths for default images instead of local paths
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Upload dispensary.svg and placeholder-product.svg to MinIO
- Update stores/index.blade.php to use Storage::disk('minio')->url()
- Update stores/show.blade.php to use MinIO for dispensary icon and placeholder
- Update stores/orders.blade.php to use MinIO dispensary icon
- Update Filament ProductsTable to use MinIO placeholder

Fixes missing images on production where local paths don't exist.
2025-12-16 09:35:07 -07:00
kelly
8c4b424eb6 fix(ci): use correct Kaniko flags for internal registry
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 09:33:45 -07:00
kelly
2cf335d019 fix(ci): use internal registry (no auth needed)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 09:32:00 -07:00
kelly
9f0678a17c ci: retry build
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 09:24:40 -07:00
kelly
ad9c41dd28 ci: trigger build with updated secrets
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 09:20:17 -07:00
kelly
1732bcbee2 fix(ci): use username/password format for registry auth
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 01:47:59 -07:00
kelly
96276cc118 fix(ci): use printf for proper base64 auth encoding
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 01:40:02 -07:00
kelly
dc69033ca4 ci: retry with cannabrands user
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 01:18:54 -07:00
kelly
bcf25eba38 ci: retry
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 01:16:15 -07:00
kelly
9116d9b055 ci: retry
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 01:08:11 -07:00
kelly
b7a3b5c924 ci: retry with updated secrets
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 01:04:42 -07:00
kelly
5b9be3368a ci: retry
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 01:01:30 -07:00
kelly
5c7ea61937 ci: retry with org token
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 00:59:11 -07:00
kelly
29a8bdc85f ci: retry 2025-12-15 23:30:09 -07:00
kelly
8116de4659 ci: retry
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 23:24:55 -07:00
kelly
578753235d ci: retry
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 22:45:24 -07:00
kelly
8eef5c265e ci: trigger
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 21:59:44 -07:00
kelly
1fe1749d6f ci: trigger build
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 21:53:01 -07:00
kelly
a9c7b3034c ci: trigger build (secrets syntax fixed)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 21:27:00 -07:00
kelly
0d17575f56 fix(ci): Update secrets syntax for Woodpecker v3
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Replace deprecated 'secrets:' with 'environment' + 'from_secret'
2025-12-15 21:25:12 -07:00
kelly
9366f099ec ci: trigger build 2025-12-15 21:21:11 -07:00
kelly
b3edc4bf87 ci: trigger build with setup-registry-auth step 2025-12-15 21:20:01 -07:00
kelly
00aa796daf fix: use Docker Hub for base image (buildx can't access insecure registry) 2025-12-15 21:18:49 -07:00
kelly
9153d4e950 ci: switch from Kaniko to docker-buildx plugin for proper secret handling
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 21:11:44 -07:00
kelly
c7250e26e2 fix: use seller_business_id in ProspectController (matches migration)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 21:07:19 -07:00
kelly
49677fefdc ci: test secrets (recreated)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 20:59:54 -07:00
kelly
bebb3874f9 ci: test build with secrets
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 20:54:58 -07:00
kelly
a79ffe343f ci: trigger build (secrets configured)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 20:41:40 -07:00
kelly
283420e898 ci: revert to depth 50 (depth 1 caused pipeline not found error) 2025-12-15 20:34:54 -07:00
kelly
6dd53f17ae ci: shallow clone (depth 1) + fix dockerignore for Dockerfile.fast
- Change git clone depth from 50 to 1 (faster checkout)
- Keep vendor/ and public/build/ in Docker context (needed by Dockerfile.fast)
2025-12-15 20:32:47 -07:00
kelly
08dc3b389a docs: add Gitea container registry credentials to CLAUDE.md 2025-12-15 20:31:08 -07:00
kelly
57e81c002d ci: trigger build (registry secrets configured) 2025-12-15 20:30:46 -07:00
kelly
523ea5093e ci: trigger build (testing registry auth) 2025-12-15 20:25:56 -07:00
kelly
a77a5b1b11 ci: add registry authentication for git.spdy.io
Kaniko needs credentials to push to git.spdy.io registry.
Uses secrets: registry_user, registry_password

TODO: Add these secrets in Woodpecker CI settings:
  - registry_user: (gitea username)
  - registry_password: (gitea password or token)
2025-12-15 20:17:20 -07:00
kelly
3842ffd893 ci: switch to git.spdy.io registry + split tests + add caching
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Push images to git.spdy.io (k8s can pull without insecure config)
- Split tests into unit (sqlite) + feature (postgres) for parallelism
- Add composer cache between builds
- Add npm cache configuration
- Keep using internal registry for base images (Kaniko handles insecure)
2025-12-15 20:07:19 -07:00
kelly
c0c3c2a754 fix: Dockerfile.fast now self-contained (no base image dependency)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Inline PHP extension installation so it works without pre-pushing
the base image to the registry. Still faster than multi-stage
Dockerfile because composer+frontend are built in parallel CI steps.

Future optimization: Run ./docker/base/build-and-push.sh from a
server with registry access, then switch FROM back to hub-base:latest
2025-12-15 19:42:47 -07:00
kelly
486c16d0fa ci: optimize deploy time with pre-built base image and parallel steps
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add docker/base/Dockerfile with pre-compiled PHP extensions
- Add Dockerfile.fast using pre-built base image (~2-3 min vs 15-20 min)
- Add docker/base/build-and-push.sh script for base image management
- Update CI to run composer-install and build-frontend in PARALLEL
- Both steps complete before build-image starts

Expected improvement: 20-30 min → ~10 min deploys

To activate: Run ./docker/base/build-and-push.sh once from a Docker host
2025-12-15 19:40:13 -07:00
kelly
1c2afe416f fix: use official woodpeckerci/plugin-git for clone
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Local registry image not on netrc allow list for git auth injection
2025-12-15 19:22:08 -07:00
kelly
cf30040161 fix: correct Kaniko context path to full workspace path
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Woodpecker clones to /woodpecker/src/git.spdy.io/Cannabrands/hub/
not /woodpecker/src/
2025-12-15 19:15:20 -07:00
kelly
df48d581ee config: update .env.example with external service credentials
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- PostgreSQL: 10.100.6.50:5432 (cannabrands_dev)
- Redis: 10.100.9.50:6379
- MinIO: 10.100.9.80:9000 (cannabrands bucket)
2025-12-15 19:03:33 -07:00
kelly
f489b8e789 ci: use external PostgreSQL, remove ephemeral postgres service
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Remove postgres service container
- Use external PostgreSQL at 10.100.6.50:5432
- Use external Redis at 10.100.9.50:6379
- Simplified pipeline, removed validate-migrations step
- Removed success notification step (verbose)
2025-12-15 19:00:51 -07:00
kelly
88768334aa docs: add full infrastructure credentials to CLAUDE.md
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Includes connection details for:
- PostgreSQL (dev + CI)
- Redis
- MinIO (S3 storage)
2025-12-15 18:59:38 -07:00
kelly
55ec2b833d docs: add infrastructure services table to CLAUDE.md
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Documents all service endpoints:
- Git/CI: git.spdy.io, ci.spdy.io
- Docker registry: 10.100.9.70:5000
- PostgreSQL: dev (10.100.6.50) and CI (ephemeral)
- Redis: 10.100.9.50
- MinIO: 10.100.9.80

Also documents Kaniko usage and base image caching.
2025-12-15 18:56:23 -07:00
kelly
b503cc284f fix: use hardcoded /woodpecker/src path for Kaniko context
Some checks failed
ci/woodpecker/push/ci Pipeline failed
CI_WORKSPACE variable is empty in Kaniko container.
Use the actual Woodpecker workspace path directly.
2025-12-15 18:52:06 -07:00
kelly
550da56b4e fix: use CI_WORKSPACE for Kaniko context path
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Kaniko couldn't find source files at hardcoded /woodpecker/src path.
Use CI_WORKSPACE variable which Woodpecker sets correctly.

Also includes CSS fix for sidebar collapse click interception.
2025-12-15 18:50:15 -07:00
CI Trigger
327aec34cc ci: verify local registry with corrected paths
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 18:45:41 -07:00
kelly
14cb5194e8 ci: add node:22-slim to base image sync
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 18:43:33 -07:00
CI Trigger
a33de047fd ci: trigger build to verify local registry setup
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 18:35:35 -07:00
CI Trigger
04f09f2cd4 ci: use local registry for all base images
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Switch from mirror.gcr.io to local registry (10.100.9.70:5000):
- All CI step images now pull from local registry
- Dockerfile base images pull from local registry
- No more external pulls during builds
- Daily cron job updates local cache from Google mirror

Images cached locally:
- node:22-alpine, node:22-slim, node:20-alpine
- php:8.4-cli-alpine, php:8.3-fpm-alpine
- composer:2.8, nginx:alpine, busybox
- laravel-test-runner, drone-cache, kubectl, kaniko
2025-12-15 18:33:05 -07:00
kelly
d87d22ab27 ci: use local registry for base images to avoid DNS issues
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Update Dockerfile to pull all base images from 10.100.9.70:5000
- Add sync-base-images.sh script to populate local registry from Docker Hub
- Run script daily via cron to keep images fresh

Base images cached:
- node:22-alpine
- php:8.4-cli-alpine
- php:8.3-fpm-alpine
- composer:2.8
2025-12-15 18:30:55 -07:00
CI Trigger
d7fa02aeff ci: trigger build to verify Google mirror setup
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 18:25:06 -07:00
kelly
c3f81b10f1 ci: switch from BuildX to Kaniko for more reliable builds
Some checks failed
ci/woodpecker/push/ci Pipeline failed
BuildX was experiencing DNS resolution failures in the K8s environment.
Kaniko runs as a regular container without Docker daemon, using the
pod's native DNS stack which is more reliable.

Changes:
- Replace plugins/docker with gcr.io/kaniko-project/executor:debug
- Add layer caching via --cache-repo to local registry
- Keep insecure flags for local registry (10.100.9.70:5000)
2025-12-15 18:23:02 -07:00
kelly
2424e35435 ci: use mirror.gcr.io to avoid Docker Hub rate limits
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 18:13:25 -07:00
a48b76a1f4 ci: Use local registry + disable base image pulls
Some checks are pending
ci/woodpecker/push/ci Pipeline is running
2025-12-16 01:02:30 +00:00
2417dedce2 ci: Use local registry + disable base image pulls
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 01:02:03 +00:00
a6e934e4a4 ci: Use local registry + disable base image pulls to avoid rate limits
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 00:59:04 +00:00
kelly
0aa2cf4ee3 docs: update git URLs to git.spdy.io and ci.spdy.io
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 17:59:02 -07:00
fdba05140b ci: Switch to local registry
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 00:52:46 +00:00
0b29cac5eb ci: Switch to local registry (10.100.9.70:5000) for k8s access
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 00:51:41 +00:00
cc7cf86ea9 ci: Switch to local registry (10.100.9.70:5000) for k8s access
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 00:51:30 +00:00
7143222cd0 ci: Switch to local registry (10.100.9.70:5000) for k8s internal network access
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Use plugins/docker instead of buildx to avoid DNS issues
- Push images to local registry instead of git.spdy.io
- k8s workers can pull from internal network
- External Redis at redis.spdy.io (10.100.9.50)
2025-12-16 00:49:06 +00:00
kelly
e6c8fd8c3c Merge feat/standardize-list-pages into develop
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 17:47:25 -07:00
kelly
cea7ca5119 Merge feat/dashboard-sidebar-updates into develop 2025-12-15 17:46:40 -07:00
kelly
a849e9cd34 Merge branch 'feat/brand-profile-kpi-redesign' into develop 2025-12-15 17:44:29 -07:00
kelly
fcb0a158ea Merge branch 'feat/sales-rep-system' into develop
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 17:36:55 -07:00
kelly
7614ed0fdd perf: optimize page load performance and add dispensary default image
- Disable Telescope in local environment (enable in dev/staging/prod)
- Fix N+1 query in BrandStoresController (move avg price calc outside loop)
- Optimize route model binding to use exists() instead of loading all businesses
- Optimize sidebar to use route business instead of primaryBusiness() query
- Add dispensary.svg default image for stores with business_type=dispensary
- Update store views to show dispensary icon for dispensary-type stores
2025-12-15 17:36:41 -07:00
kelly
6c96aaa11b feat: add Export functionality for sales data (Sprint 6)
ExportController with CSV exports for:
- accounts: All assigned accounts with order history summary
- account-history: Detailed order history for meeting prep
- prospects: Lead data with insights for pitch preparation
- competitors: Competitor replacement mappings for sales training
- pitch: Pitch builder with contact info, insights, success stories

Added export buttons to:
- Accounts index (Export CSV button)
- Account show (Export History button)
- Prospects index (Export CSV button)
- Prospect show (Export Pitch button)
- Competitors index (Export CSV button)

All exports stream as CSV for instant download without memory issues.
2025-12-15 17:33:19 -07:00
7b5f3db26a Merge pull request 'feat: add sales rep system with territories, commissions, and dashboard' (#4) from feat/sales-rep-system into develop
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Reviewed-on: #4
2025-12-16 00:30:07 +00:00
kelly
51047fc315 feat: add Competitor Replacements and Prospect Management (Sprint 5)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Competitor Intelligence:
- CompetitorController with store/destroy for replacement mappings
- Map competitor products to our alternatives with advantage notes
- Competitor index view with grouped replacements by brand

Prospect Management:
- ProspectController with full CSV import functionality
- Upload CSV, map columns, and process imports
- Prospect insights (gaps, pain points, opportunities, objections)
- Success story matching for similar accounts

Views:
- competitors/index - replacement mappings with modal form
- prospects/index - assigned leads with insight summary badges
- prospects/imports - upload form and import history
- prospects/map-columns - CSV column mapping interface
- prospects/show - lead detail with insights and success stories

Dashboard:
- Added Prospects and Competitors buttons to sales dashboard
2025-12-15 17:28:34 -07:00
kelly
dff1475550 feat: add Commission Management system for sales reps
- CommissionController with rep earnings view and admin management
- Commission index: view personal earnings, pending/approved/paid stats
- Commission management: approve commissions, bulk actions, mark as paid
- Commission rates: create/manage rates by type (default, account, product, brand)
- Made Pending Commission stat clickable on dashboard
- Added routes for all commission operations
2025-12-15 17:17:14 -07:00
9fdeaaa7b2 ci: Use external Redis (redis.spdy.io) instead of container
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 00:14:29 +00:00
kelly
f1827aba18 feat: add Territory Management UI for sales reps
- TerritoryController with full CRUD operations
- Territory index view with grid layout showing areas and assigned reps
- Create/edit forms with dynamic area management (zip, city, state, county)
- Primary rep assignment per territory
- Added territories button to sales dashboard
2025-12-15 17:13:40 -07:00
kelly
39aa92d116 feat: add Reorder Alerts view with prediction intelligence
- ReorderController with index action showing accounts approaching reorder
- Reorder alerts view with overdue, due soon, and upcoming sections
- Smart product suggestions based on order history
- Confidence indicators for predictions (high/medium/low)
- Added reorder alerts button to sales dashboard
2025-12-15 17:04:40 -07:00
kelly
7e82c3d343 fix: use locations relationship in ReorderPredictionService 2025-12-15 16:58:34 -07:00
kelly
7020f51ac7 fix: replace undefined primaryLocation with locations relationship
The Business model has locations() HasMany relationship, not primaryLocation.
Changed all eager loading and view templates to use locations->first()
instead of the non-existent primaryLocation relationship.
2025-12-15 16:57:04 -07:00
737eed473e Merge pull request 'feat: add sales rep system with territories, commissions, and dashboard' (#3) from feat/sales-rep-system into develop
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Reviewed-on: #3
2025-12-15 23:55:05 +00:00
kelly
4c8412a47b feat: add sales rep system with territories, commissions, and dashboard
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Sprint 1 implementation of the Sales Person Features:

Models & Migrations:
- SalesRepAssignment: Polymorphic assignment of reps to accounts/stores
- SalesTerritory, SalesTerritoryArea, SalesTerritoryAssignment: Territory management
- SalesCommissionRate, SalesCommission: Commission tracking with rate hierarchy
- AccountNote: Sales rep notes on buyer accounts (competitor intel, pain points)
- CompetitorReplacement: Maps CannaiQ competitor products to our replacements
- ProspectInsight, ProspectImport: Prospect gap analysis and CSV import tracking

Controllers & Views:
- Sales Dashboard: My accounts overview with health status metrics
- Accounts Index: Filterable list with at-risk/needs-attention badges
- Account Show: Full account detail with order history, contacts, notes

Services:
- ReorderPredictionService: Predicts reorder windows based on order patterns

Routes & Navigation:
- Added /s/{business}/sales/* routes under sales suite middleware
- Added sales_rep_dashboard and sales_rep_accounts to sidebar menu
2025-12-15 16:41:34 -07:00
kelly
093bcb6e58 fix: only disable telescope in local env
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 16:36:55 -07:00
kelly
5fc6e008a5 perf: disable telescope in local/development, enable in staging/prod
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 16:35:45 -07:00
kelly
0591eabfee perf: disable telescope in local env, enable in staging/prod
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 16:34:52 -07:00
kelly
3451a4b86a perf: disable telescope by default, require explicit opt-in
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 16:34:40 -07:00
kelly
9c321b86c1 feat: implement B2B marketplace chat system
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Backend:
- Add MarketplaceChatParticipant model for tracking thread participants
- Extend CrmThread with marketplace relationships (buyerBusiness, sellerBusiness, order)
- Add marketplace scopes to CrmThread for filtering B2B threads
- Create MarketplaceChatService for thread/message operations
- Create NewMarketplaceMessage broadcast event for real-time updates
- Create MarketplaceChatController API with thread/message endpoints

API Routes:
- GET /api/marketplace/chat/threads - List threads
- POST /api/marketplace/chat/threads - Create thread
- GET /api/marketplace/chat/threads/{id} - Get thread with messages
- POST /api/marketplace/chat/threads/{id}/messages - Send message
- POST /api/marketplace/chat/threads/{id}/read - Mark as read
- GET /api/marketplace/chat/unread-count - Get unread count

Frontend:
- Create marketplace-chat-widget component with Alpine.js
- Add floating chat button with unread badge
- Implement thread list and message views
- Add real-time message updates via Reverb/Echo
- Include widget in seller and buyer layouts

Broadcasting:
- Add marketplace-chat.{businessId} private channel
2025-12-15 16:14:31 -07:00
kelly
1f08ea8f12 feat: add chat settings UI with agent status and quick replies
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add AgentStatus model for tracking user availability (online/away/busy/offline)
- Add ChatQuickReply model for pre-written chat responses
- Add agent status toggle to seller account dropdown menu
- Add quick replies management page under CRM settings
- Create migration for chat_quick_replies, chat_attachments, agent_statuses tables
- Add API endpoint for updating agent status
2025-12-15 16:08:27 -07:00
kelly
de3faece35 ci: trigger rebuild
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 15:52:16 -07:00
kelly
370bb99e8f fix: remove Source label from CannaiQ badge
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 14:16:30 -07:00
kelly
62f71d5c8d fix: replace Hoodie with CannaiQ badge and remove POS toggle
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 14:12:52 -07:00
kelly
239a0ff2c0 fix: restore Products button, reorder to View | Products | Orders | Stores | Analytics
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 13:28:07 -07:00
kelly
660f982d71 fix: change Products button to Orders on brand tiles
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 13:26:06 -07:00
kelly
3321f8e593 feat: link brand name to profile page on brand tiles
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 13:25:14 -07:00
kelly
3984307e44 feat: add brand stores and orders dashboards with CannaiQ integration
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add BrandStoresController with stores index, store detail, and orders pages
- Add routes for /brands/{brand}/stores and /brands/{brand}/orders
- Add stores_url and orders_url to brand tiles on index page
- Add getBrandStoreMetrics stub method to CannaiqClient
- Fix sidebar double-active issue with exact_match and url_fallback
- Fix user invite using wrong user_type (manufacturer -> seller)
2025-12-15 13:20:55 -07:00
kelly
9c5b8f3cfb Merge branch 'fix/crystal-quote-date' into develop
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 12:29:15 -07:00
kelly
d2a3a05ea1 fix: add migration to fix CRM quote schema mismatches
- Make crm_quotes.account_id nullable (controller allows null)
- Make crm_quotes.valid_until nullable (controller sets default)
- Rename crm_quote_items.position to sort_order (match model)
- Make crm_quote_items.name nullable (controller doesn't provide it)

Part of Crystal's Issue #1: Quotes - Cannot Submit
2025-12-15 12:24:48 -07:00
kelly
eac1d4cb0a chore: trigger CI build
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 09:35:32 -07:00
kelly
a0c0dafe34 fix: skip validate-migrations until Woodpecker services work
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Woodpecker services (postgres) are not starting - the hostname
'postgres' cannot be resolved. This is a server configuration
issue. Skipping migration validation for now to unblock builds.
2025-12-15 09:33:18 -07:00
kelly
91b7e0c0e0 fix: add postgres wait loop to validate-migrations step
Some checks failed
ci/woodpecker/push/ci Pipeline failed
The validate-migrations step was failing because postgres service
wasn't ready yet. Added the same wait loop used in the tests step.
2025-12-15 09:10:22 -07:00
kelly
c2692a3e86 feat: redesign brand profile KPI tiles with store metrics
- Add calculateStoreStats() method for store intelligence
- Add 3 large summary tiles: Sales, Stores, Promotions
- Sales tile shows: total sales, $/store, avg order value
- Stores tile shows: store count, SKU stock rate, avg SKUs/store
- Promotions tile shows: active count, total, recommendations
- Add secondary KPI row with larger typography (text-xl)
- Add tooltips for complex metrics like SKU stock rate
- Upgrade labels from text-[10px] to text-xs for readability
2025-12-15 09:05:57 -07:00
kelly
ad2c680cda feat: enable PWA with manifest link in all layouts
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add manifest.webmanifest link to all layout files
- Add PWA meta tags (theme-color, apple-mobile-web-app-capable)
- Enables 'Add to Home Screen' functionality
2025-12-15 08:30:56 -07:00
kelly
d46d587687 chore: trigger CI build on new git.spdy.io infrastructure
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 08:19:45 -07:00
kelly
f06bc254c8 chore: trigger CI (privileged plugin enabled)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 08:00:03 -07:00
kelly
ad517a6332 chore: trigger CI (Woodpecker v3.12.0) 2025-12-15 07:58:51 -07:00
kelly
6cb74eab7f chore: trigger CI (secrets fixed)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-15 07:49:17 -07:00
kelly
2ea43f7c8b chore: trigger CI (trusted enabled) 2025-12-15 07:48:05 -07:00
kelly
90ae8dcf23 chore: trigger CI build for new registry 2025-12-15 07:47:00 -07:00
kelly
9648247fe3 Merge branch 'fix/crystal-quote-customer-dropdown' into develop 2025-12-15 06:56:38 -07:00
kelly
fd30bb4f27 feat(crm): enhance invoice management with email and PDF improvements
- Add email sending capability for invoices
- Improve PDF invoice layout and formatting
- Enhance invoice create/show views with better UX
- Fix customer dropdown in quotes create view
- Add new routes for invoice email functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-15 06:56:33 -07:00
kelly
9e83341c89 chore(ci): migrate Docker registry from code.cannabrands.app to git.spdy.io
Update all registry references in Woodpecker CI/CD pipeline:
- Build image push targets
- Deployment image references
- Cache layer references
- Success notification output

Part of infrastructure migration to new Spdy.io K8s cluster.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-15 06:54:23 -07:00
kelly
93e521f440 refactor: standardize CRM views with enterprise UI patterns
- Standardize page headers to text-lg font-semibold with text-xs subtitles
- Replace nx-shell/nx-page patterns with max-w-7xl containers
- Convert all badges to ghost style (badge badge-ghost badge-sm)
- Update card sections to rounded-2xl with header/content structure
- Replace inline SVG icons with heroicons
- Change 'Customer' terminology to 'Account' throughout
- Create missing invoices/create.blade.php view
- Standardize back arrow links with hover transitions
- Fix grid gaps from gap-6 to gap-4 for consistency

Files updated:
- accounts: index, create, edit, contacts-edit, locations-edit
- contacts: index
- deals: index, create, show
- inbox: index
- invoices: index, create (new), show
- leads: index, create, show
- quotes: index, create
- tasks: index, create, show
2025-12-14 16:35:06 -07:00
kelly
ec9853c571 fix: sidebar double-click bug and standardize 'Accounts' terminology
- Add width constraint (w-8) to collapse checkbox in CSS to prevent
  it from overlaying menu items below the collapse title
- Rename 'Customers' to 'Accounts' across CRM views:
  - accounts/index.blade.php: title, button labels, empty state text
  - accounts/create.blade.php: page title and submit button
  - accounts/edit.blade.php: page title and breadcrumb
  - accounts/contacts-edit.blade.php: breadcrumb
  - accounts/locations-edit.blade.php: breadcrumb
- Update SuiteMenuResolver route from seller.business.customers.index
  to seller.business.crm.accounts.index
2025-12-14 16:08:14 -07:00
kelly
636bdafc9e Merge pull request 'feat: dashboard redesign and sidebar consolidation' (#215) from feat/dashboard-sidebar-updates into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/215
2025-12-14 22:41:48 +00:00
kelly
c7d6ee5e21 feat: multiple UI/UX improvements and case-insensitive search
- Refactor New Quote page to enterprise data-entry layout (2-column, dense)
- Add Payment Terms dropdown (COD, NET 15, NET 30, NET 60)
- Fix sidebar menu active states and route names
- Fix brand filter badge visibility on brands page
- Remove company_name references (use business instead)
- Polish Promotions page layout
- Fix double-click issue on sidebar menu collapse
- Make all searches case-insensitive (like -> ilike for PostgreSQL)
2025-12-14 15:36:00 -07:00
kelly
496ca61489 feat: dashboard redesign and sidebar consolidation
- Redesign dashboard as daily briefing format with action-first layout
- Consolidate sidebar menu structure (Dashboard as single link)
- Fix CRM form styling to use consistent UI patterns
- Add PWA icons and push notification groundwork
- Update SuiteMenuResolver for cleaner navigation
2025-12-14 03:41:31 -07:00
kelly
a812380b32 refactor: consolidate sidebar menu structure
- Dashboard: single link (removed Overview section)
- Connect: new section with Overview, Contacts, Conversations, Tasks, Calendar
- Commerce: Accounts, Orders, Quotes, Invoices (removed Backorders, Promotions)
- Brands: All Brands, Promotions, Menus (brand-context aware)
- Inventory: Products, Stock, Batch Management
- Marketing: renamed from Growth, removed Channels & Templates
- Removed: Relationships section (Tasks/Calendar moved to Connect)
2025-12-13 23:48:05 -07:00
kelly
9bb0f6d373 fix: add DaisyUI v5 compatibility for removed -bordered classes 2025-12-13 21:03:55 -07:00
kelly
df7c41887c feat: apply Brand Dashboard color language to more list pages
Updated pages to use centralized cb-status-pill component:
- Contacts index (active/inactive status)
- CRM Invoices index (draft/sent/paid/overdue)
- Purchasing Requisitions index (draft/submitted/approved)

Extended cb-status-pill with additional statuses:
- Blue (in-progress): submitted, assigned, running, scheduled
- Amber (attention): partial, on_hold, paused
- Red (problems): void
- Green (success): confirmed

All pages now share the same semantic color language as Brand Dashboard:
- Green-tinted badges for success states
- Blue-tinted badges for in-progress states
- Amber-tinted badges for attention states
- Red-tinted badges for problem states

Tables remain neutral - only badges carry color.
2025-12-13 20:51:13 -07:00
kelly
00410478c0 fix: constrain sidebar collapse checkbox to title height
DaisyUI collapse inputs have z-index:1, width:100%, and padding:1rem
by default, causing them to overlay content below the title and
intercept clicks meant for menu items.

Fix: Set max-h-8 on the checkbox input to constrain it to the
collapse-title height only (matching min-h-8). This prevents
the double-click issue where clicking a menu item first toggled
the parent collapse.
2025-12-13 20:46:51 -07:00
kelly
a943a412ac feat: apply Brand Dashboard color language to status pills
Status badges now use the same semantic tints as Brand Dashboard tiles:
- Green (bg-success/10): Completed, Paid, Delivered, Active
- Blue (bg-info/10): In Progress, Processing, Sent
- Amber (bg-warning/10): New, Draft, Unpaid, Needs Attention
- Red (bg-error/10): Overdue, Failed, Rejected, Cancelled

Tables remain neutral - semantic color lives ONLY in badges/pills.
This creates visual consistency between dashboard and list pages
through shared color language in the badges themselves.
2025-12-13 20:42:48 -07:00
kelly
6550ecff12 refactor: remove semantic surface tints from table cells
Tables must remain neutral. Semantic color now belongs ONLY in:
- Status badges/pills (color in the badge itself)
- Text color for money values (cb-cell-money, no backgrounds)
- Dashboard cards (cb-surface-* classes retained for this use)

Changes:
- Remove background from cb-cell-id class
- Remove surface class logic from cb-status-cell component
- Update CSS documentation to reflect neutral table policy
- Add centralized cb-money-cell and cb-status-cell components

Enterprise tables should look neutral at a glance.
2025-12-13 20:28:14 -07:00
kelly
c72c73e88c style: add semantic surface tints to Orders, Invoices, Quotes
Extend Brand Dashboard's semantic color language to list pages at micro scale:

- cb-cell-id: Soft green tint on identifier cells (order#, invoice#, quote#)
- cb-cell-money: Green text for non-zero monetary values
- cb-cell-money-zero: Muted gray for zero values
- cb-status-surface-new: Warm amber tint for new/pending states
- cb-status-surface-active: Cool blue tint for in-progress states
- cb-status-surface-complete: Neutral tint for completed states

This creates visual continuity between dashboard tiles (macro) and list
rows (micro) - same semantic language at different zoom levels.
2025-12-13 20:14:09 -07:00
kelly
d4ec430790 feat: add PWA support with update notifications
- Add web app manifest for installability
- Add service worker with Workbox for asset caching
- Add update detection with DaisyUI toast notification
- Include PWA partial in all main layouts

Users can now install the app and will see a toast when
a new version is available with a Refresh button.
2025-12-13 20:07:04 -07:00
kelly
5cce19d849 style: align Orders, Invoices, Quotes with Brand Dashboard surface language
- Update cb-section to use rounded-2xl (matches Brand Dashboard cards)
- Table headers: font-semibold text-base-content/50 (muted, structural)
- Table row hover: bg-base-200/30 transition-colors (subtle, consistent)
- Remove bg-base-200/50 from thead, use border-b border-base-200 instead

These execution views now inherit the same surface depth and color
tokens as the Brand Dashboard, creating visual continuity across
the system.
2025-12-13 19:48:53 -07:00
kelly
6ae2be604f fix: replace nested Blade comment with HTML comment in cb-list-page
The nested {{-- --}} comment inside the outer documentation block was
causing a Blade parse error. Changed to HTML comment <!-- --> which
is valid inside a Blade comment block.
2025-12-13 19:29:34 -07:00
kelly
11edda5411 style: enterprise polish for Promotions dashboard
- Add subtle surface depth with bg-base-200/30 tonal separation
- Strengthen section headers with font-semibold and tracking-wide
- Improve empty states with better hierarchy (informational vs actionable)
- Simplify KPI cards to instrument-like design
- Make primary/secondary button distinction clearer
- Remove icons from KPI labels for cleaner appearance
2025-12-13 19:27:34 -07:00
kelly
44d21fa146 style: enterprise contrast polish for dashboards and list pages
- Replace deals with quotes throughout UI (sidebar, dashboard, accounts)
- Apply enterprise contrast discipline to Revenue Command Center
- Apply same contrast discipline to Brands dashboard
- Fix Tailwind v4 @apply issue with DaisyUI classes
- Update card borders from base-200 to base-300
- Remove shadows from dashboard cards
- Strengthen section headers with font-semibold
- Mute KPI labels, strengthen KPI values
- Update table headers with proper enterprise styling
2025-12-13 19:24:38 -07:00
kelly
798476e991 feat: standardize list pages with canonical cb-list-page component
- Create x-cb-list-page shared component for all list pages
- Create x-cb-status-pill component (blue for in-progress, gray for rest)
- Add CSS primitives: cb-filter-bar, cb-filter-search, cb-filter-select
- Migrate Invoices, Orders, Accounts, Quotes, Backorders to use component
- Standardize table headers (uppercase, tracking-wide, text-base-content/70)
- Use text-primary for links (matches dashboard exactly)
- Add dashboard components: stat-card, panel, preview-table, rail-card
- Add CommandCenterService for dashboard data
2025-12-13 18:28:09 -07:00
kelly
bad6c24597 Merge pull request 'feat: add Nuvata products to missing_products.php' (#213) from fix/add-nuvata-products into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/213
2025-12-12 18:46:31 +00:00
kelly
5b7898f478 fix: remove configuration-snippet annotation blocking ingress 2025-12-12 10:49:04 -07:00
kelly
9cc582b869 feat: add Nuvata products to missing_products.php
Added 8 Nuvata products (NU-*) to the data file so they get created
on production without needing MySQL connection.
2025-12-12 09:50:13 -07:00
kelly
ac70cc0247 Merge pull request 'fix: reimport product descriptions with proper UTF-8 emoji encoding' (#212) from fix/product-description-emoji-import into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/212
2025-12-12 16:13:32 +00:00
kelly
eb95528b76 style: fix pint formatting issues 2025-12-12 09:10:05 -07:00
kelly
879d1c61df fix: reimport product descriptions with proper UTF-8 emoji encoding
The original data export had encoding issues that corrupted emojis to
'?' characters or stripped them entirely. Re-exported from MySQL with
proper UTF-8 encoding to preserve emojis (🍬🌊, 🧄, etc).

- Regenerated product_descriptions_non_hf.php (266 products)
- Regenerated product_descriptions_hf.php (15 products)
- Added migration to re-import consumer_long_description
2025-12-12 08:47:14 -07:00
kelly
0af6db4461 fix: auto-generate view_token on quote creation
CrmQuote model now auto-generates a unique view_token in boot() method.
Added view_token to fillable array and imported Str helper.

Fixes Crystal issue: null value in column 'view_token' violates not-null constraint
2025-12-11 20:39:15 -07:00
kelly
0f5901e55f Merge pull request 'fix: convert literal escape sequences in product descriptions' (#210) from fix/product-description-literals into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/210
2025-12-12 02:32:49 +00:00
kelly
8fcc3629bd Merge pull request 'fix: add missing quote_date field to quote creation' (#209) from fix/crystal-quote-date into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/209
2025-12-12 02:27:52 +00:00
kelly
0b54c251bc fix: convert literal escape sequences in product descriptions
- Replace literal '\r\n' strings (4 chars) with actual newlines
- Remove '??' corrupted emoji placeholders
- Clean up excessive newlines

Data was imported with escape sequences as literal strings instead of
actual control characters.
2025-12-11 19:14:41 -07:00
kelly
8995c60d88 fix: add missing quote_date field to quote creation
- Add quote_date to CrmQuote model fillable array
- Add quote_date to CrmQuote model casts
- Set quote_date to now() when creating new quotes in controller

Fixes Crystal issue: null value in column 'quote_date' violates not-null constraint
2025-12-11 19:13:24 -07:00
kelly
c4e178a900 Merge pull request 'fix: product image upload improvements' (#208) from fix/product-image-upload into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/208
2025-12-12 01:25:55 +00:00
kelly
6688bbf8a1 fix: product image upload improvements
- Change max images from 8 to 6
- Fix drag and drop with proper event handlers (prevent, stop propagation)
- Stay on page after upload instead of redirecting
- Use proper storage path: businesses/{slug}/brands/{slug}/products/{sku}/images/
- Return image URLs in upload response for dynamic UI update
- Change button text from 'Replace Image' to 'Add Image' for clarity
- Maintain validation: JPG/PNG, max 2MB, 750x384px minimum
2025-12-11 18:18:51 -07:00
kelly
bb5f2c8aaa Merge pull request 'fix(#161): add missing crm_quotes columns' (#206) from fix/crystal-issues-batch-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/206
2025-12-12 01:15:49 +00:00
kelly
a9d0f328a8 Merge pull request 'fix: oldest past due days and product description encoding' (#207) from fix/oldest-past-due-days into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/207
2025-12-12 01:15:27 +00:00
kelly
3b769905b7 fix: remove corrupt emoji characters from product descriptions
The original product description import had encoding issues where emojis
became ? or replacement characters (U+FFFD). This migration:

- Removes U+FFFD replacement characters
- Removes stray ? at start/end of lines (were emoji headers)
- Normalizes Windows line endings to Unix
2025-12-11 17:57:46 -07:00
kelly
f7727d8c17 fix: round oldest past due days to whole number
- Use abs() to ensure positive value
- Use ceil() to round up
- Cast to int for clean display
2025-12-11 17:48:59 -07:00
kelly
6d7eb4f151 fix(#161): add missing crm_quotes columns and remove signature_requested validation
- Add migration to add missing columns that the CrmQuote model expects:
  signature_requested, signed_by_name, signed_by_email, signature_ip,
  rejection_reason, order_id, notes_customer, notes_internal
- Remove signature_requested from validation rules (no longer required)
- Migration is idempotent with hasColumn checks
2025-12-11 17:41:19 -07:00
kelly
0c260f69b0 Merge pull request 'fix: resolve Crystal issues #161, #200, #203' (#205) from fix/crystal-issues-batch-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/205
2025-12-12 00:39:09 +00:00
kelly
63b9372372 ci: trigger rebuild 2025-12-11 17:14:18 -07:00
kelly
aaff332937 fix: resolve Crystal issues #161, #200, #203
Issue #161: Quote submission error
- Added missing tax_rate column to crm_quotes table
- Column was referenced in model but never created in migration

Issue #200: Batch 404 error after save
- Batches missing hashids caused 404 (hashid-based routing)
- Migration backfills hashids for all existing batches

Issue #203: Product image upload error
- Fixed route name: images.product -> image.product (singular)

Additional improvements:
- Quote create page prefill from CRM account dashboard
- Product hashid backfill migration
2025-12-11 17:12:21 -07:00
kelly
964548ba38 Merge pull request 'fix: product descriptions, hashids, and CRM updates' (#204) from fix/product-brand-descriptions into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/204
2025-12-12 00:10:40 +00:00
kelly
cf05d8cad1 style: fix pint formatting in migration 2025-12-11 16:59:56 -07:00
kelly
05dca8f847 fix: location detail partial update 2025-12-11 16:32:35 -07:00
kelly
27328c9106 fix: normalize CRLF line endings in all product descriptions 2025-12-11 16:28:45 -07:00
kelly
b3dd9a8e23 fix: decode HTML entities and escape sequences in product descriptions
Fixes literal \r\n and HTML entity emoji codes (&#127793;) in descriptions
imported from MySQL.
2025-12-11 15:58:37 -07:00
kelly
1cd6c15cb3 fix: backfill missing product hashids
Products without hashids are filtered out in ProductController,
causing the products page to show empty table.
This migration generates hashids for all products that don't have one.
2025-12-11 15:26:32 -07:00
kelly
3554578554 fix: handle soft-deleted products in MySQL sync migrations
- Use withTrashed() to check for existing SKUs (unique constraint applies to all)
- Restore and update soft-deleted products instead of creating duplicates
- Remove invalid DB::statement comment that caused SQL error
2025-12-11 15:07:24 -07:00
kelly
3962807fc6 Merge pull request 'fix: sync product descriptions and SKUs from MySQL source' (#202) from fix/product-brand-descriptions into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/202
2025-12-11 21:50:42 +00:00
kelly
32054ddcce fix: sync product descriptions and SKUs from MySQL source
Migrations to synchronize PostgreSQL products with MySQL source data:

1. Null out existing product descriptions (clean slate)
2. Import descriptions for non-Hash Factory brands (266 products)
3. Import descriptions for Hash Factory brand (21 products)
4. Create 31 missing products from MySQL data
5. Soft-delete orphan products not in MySQL source

Data files contain hardcoded MySQL product data since remote
environment cannot access MySQL directly.

Products affected:
- 287 products get description updates
- 31 new products created
- Orphan products (not in MySQL) soft-deleted
2025-12-11 14:44:43 -07:00
kelly
5905699ca1 Merge pull request 'fix: Gitea issues batch - contacts, batches, products' (#201) from fix/gitea-issues-batch into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/201
2025-12-11 20:51:24 +00:00
kelly
eb8e2a89c4 fix: update Short Description field in product content tab
- Rename 'Short & Tagline' section to 'Short Description'
- Change from input to textarea (rows=6) to match consumer description size
- Update character hint to '200-300 characters recommended' (no validation)
2025-12-11 13:42:26 -07:00
kelly
8286aebf4e fix: remove is_primary from contact forms and displays
- Remove 'Set as Primary Contact' checkbox from add/edit modals
- Remove 'Primary' badge from contact lists and dropdowns
- Update column header from 'Primary Contact' to 'Contact'
- Remove is_primary from validation rules and controller logic
- Remove delete danger zone from contact edit page
- Contacts now show first contact instead of filtering by is_primary
2025-12-11 13:42:26 -07:00
kelly
4cff4af841 fix: remove delete button from contact edit page 2025-12-11 13:41:48 -07:00
kelly
8abcd3291e fix(#200): use hashid instead of id for batch edit/update routes
The batch edit form was using $batch->id for the form action and
QR code endpoints. Since Batch uses HasHashid trait, these should
use $batch->hashid for proper route model binding.
2025-12-11 13:41:48 -07:00
kelly
a7c3eb4183 Merge pull request 'fix: Gitea issues batch - #190, #194, #195, #197' (#199) from fix/gitea-issues-batch into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/199
2025-12-11 18:03:08 +00:00
kelly
1ed62fe0de fix(#190): wrap product creation in transaction with proper error handling
If image upload fails, the product creation will now be rolled back.
Added explicit check for storeAs() failure to provide clearer error.
2025-12-11 10:35:03 -07:00
kelly
160b312ca5 fix(#195, #197): handle checkbox fields in contact create/update
Checkboxes don't send values when unchecked. Use request->boolean()
to properly handle is_primary and is_active fields, defaulting to
false when not present in the request.
2025-12-11 10:33:44 -07:00
kelly
6d22a99259 Merge pull request 'fix: make seller table pages responsive' (#198) from fix/responsive-tables into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/198
2025-12-11 17:33:41 +00:00
kelly
febfd75016 fix(#194): use hashid instead of id for batch edit/activate URLs
The Batch model uses HasHashid trait which binds routes by hashid,
but the Alpine.js links were using batch.id. Fixed to use batch.hashid.

Also added hashid to the batch data passed to Alpine.js.
2025-12-11 10:21:14 -07:00
kelly
fbb72f902b Merge pull request 'feat: add server-side search to all index pages' (#196) from feature/server-side-search into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/196
2025-12-11 17:16:21 +00:00
kelly
fd11ae0fe0 feat: add mobile responsiveness to tables and sidebar
- Fix sidebar collapse CSS
- Remove sidebar checkbox name attributes
- Add responsive hidden columns for:
  - accounts index
  - automations index
  - leads index
  - invoices create
  - marketing contacts index
  - products index
2025-12-11 10:07:52 -07:00
kelly
16c5c455fa fix: make CRM Accounts page responsive
- Header stacks vertically on mobile
- Hide Primary Contact and Status columns on xs screens
- Hide Orders column on sm screens
- Hide Open Opps column on md screens
2025-12-11 10:05:54 -07:00
kelly
df587fdda3 fix: make Orders and Invoices pages responsive
- Orders: hide Date, Items, Fulfillment columns on mobile
- Invoices: hide Customer, Invoice Date, Due Date on mobile
- Headers stack vertically on mobile with proper spacing
- Essential columns visible on all screen sizes
2025-12-11 10:04:27 -07:00
kelly
3fb5747aa2 feat: add server-side search to all index pages
Standardize search functionality across all listing pages:
- Products, Contacts, Quotes, Tasks, Leads, Accounts, Invoices, Orders

All pages now use simple form-based server-side search:
- Type search term, press Enter or click magnifying glass
- Full database search (not limited to current page)
- Removed confusing live-search dropdowns that only searched current page
- Added JSON response support for AJAX requests in controllers

Updated filter-bar component to support alpine mode with optional
server-side search on Enter key press.
2025-12-11 10:01:35 -07:00
kelly
33c9420b00 Merge pull request 'fix: UI standardization and sidebar improvements' (#193) from fix/ui-standardization-and-sidebar into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/193
2025-12-11 15:09:58 +00:00
kelly
37204edfd7 fix: UI standardization and sidebar improvements
- Fix sidebar menu items requiring double-click (checkbox overlay issue)
- Remove jittery scroll animation on sidebar navigation
- Remove green search button, use neutral icon instead
- Standardize dropdown menus across all pages (btn instead of label, menu-sm)
- Fix brand dashboard topPerformer undefined key error
- Fix product hashid validation for image routes
- Initialize Alpine.js search state from URL params on products page
- Update theme colors in tailwind config
2025-12-11 01:37:41 -07:00
kelly
8d9725b501 fix: replace inline SVGs with HTML entities in CannaiQ settings
SVG icons were rendering at full page size due to missing size
constraints. Replaced with HTML character entities instead.
2025-12-11 00:09:25 -07:00
kelly
6cf8ad1854 feat(admin): add CannaiQ settings page under Integrations
- Add CannaiQ page to sidebar navigation under Integrations group
- Shows connection status (API key configured or trusted origin)
- Displays available features (Brand Analysis, Intelligence, Promos)
- Shows environment variable configuration
- Includes Test Connection and Clear Cache buttons
- Documents how to enable CannaiQ per-business
2025-12-11 00:07:06 -07:00
kelly
58f787feb0 feat(admin): add Integrations tab with CannaiQ section
- Move CannaiQ settings from Suites tab to new Integrations tab
- Add feature list placeholder explaining CannaiQ capabilities
- Add link to CannaiQ website for more information
2025-12-11 00:02:38 -07:00
kelly
970ce05846 Merge pull request 'feat: Brand Analysis page + Crystal bug fixes (#176, #180, #182)' (#186) from fix/brand-analysis-404 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/186
2025-12-11 05:33:54 +00:00
kelly
672b0d5f6b chore: re-trigger CI 2025-12-10 22:21:11 -07:00
kelly
4415194b28 fix: require CannaiQ enabled for Brand Analysis, show connection errors
- Block access to Brand Analysis page when CannaiQ is disabled
  - Show analysis-disabled.blade.php with feature info and contact support CTA
  - Add checks to analysis() and analysisRefresh() controller methods

- Add connectionError property to BrandAnalysisDTO
  - When CannaiQ is enabled but API fails, show error instead of silent fallback
  - cannaiqEnabled stays true (feature IS enabled, just API unavailable)

- Update analysis.blade.php to display connection errors
  - Red 'Connection Error' badge in header when API fails
  - Alert banner with error message and 'Contact Support' link
  - Users can see the issue clearly and know to contact support
2025-12-10 22:21:11 -07:00
kelly
213b0ef8f2 feat: add cannaiq_enabled check to brand analysis endpoints 2025-12-10 22:21:11 -07:00
kelly
13dbe046e1 fix: add missing CannaiQ brand analytics API methods
BrandAnalysisService calls getBrandMetrics(), getBrandCompetitors(),
getBrandPromoMetrics(), and getBrandSlippage() methods that were not
defined in CannaiqClient. These v1.5 brand analytics endpoints enable:

- Whitespace and regional penetration data
- Competitor head-to-head comparisons
- Promotion velocity lift metrics
- Slippage/churn detection

All methods return graceful error responses if the API endpoints
don't exist yet, allowing the service to fall back to basic analysis.
2025-12-10 22:21:11 -07:00
kelly
592df4de44 fix: resolve Crystal issues #176, #180, #182
Issue #176: Products - Pricing Not Listed
- Cast wholesale_price to float when building product listings JSON
- PostgreSQL numeric columns return strings, breaking JS `.toFixed(2)`
- Fixed in ProductController index() and listings() methods

Issue #180: Quotes - New Customer Not in Dropdown
- Removed `whereHas('contacts')` filter from account query
- Newly created customers without contacts were being excluded
- Added `where('status', 'approved')` filter instead

Issue #182: Invoices - Search Not Working
- Added search/status parameter handling to index() method
- Search filters by invoice number or customer business name
- Status filters by unpaid/paid/overdue
- Added withQueryString() to pagination for filter persistence
2025-12-10 22:21:11 -07:00
kelly
ae581b4d5c Merge pull request 'fix: Crystal issues batch 2 - products, batches, images' (#192) from fix/crystal-issues-batch-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/192
2025-12-11 05:10:15 +00:00
kelly
8a8f83cc0c feat: backfill product/brand descriptions from MySQL
- Remove min/max validation from tagline, description, long_description
- Add migration to import long_description from product_extras
- Restore 8 soft-deleted Nuvata products
- Update 13 brands with tagline/description/long_description from MySQL
2025-12-10 21:38:06 -07:00
kelly
722904d487 fix: Crystal issues batch 2 - products, batches, images
- Fix #190: Product image upload now uses MinIO (default disk) with proper
  path structure: businesses/{slug}/brands/{slug}/products/{sku}/images/

- Fix #176: Products index now uses effective_price accessor instead of
  just wholesale_price, so sale prices display correctly

- Fix #163: Batch create page was referencing non-existent 'component'
  relationship - changed to 'product' which is the actual relationship
2025-12-10 21:37:24 -07:00
kelly
ddc84f6730 Merge pull request 'fix(#187): Customer Edit/Delete Contact Errors' (#191) from fix/crystal-customer-edits-187 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/191
2025-12-11 02:55:03 +00:00
kelly
2c510844f0 Merge pull request 'feat: Brand Analysis v4 + fix #172 brand dashboard products tab' (#181) from fix/brand-analysis-v4-fixes into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/181
2025-12-11 02:03:53 +00:00
kelly
105a1e8ce0 Merge pull request 'fix(#182): add search and status filtering to invoice index' (#189) from fix/invoice-search-182 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/189
2025-12-11 02:02:53 +00:00
kelly
7e06ff3488 fix(#187): use contact hashid for route parameters instead of id
Contact model uses HasHashid trait which sets getRouteKeyName() to 'hashid'.
Routes expecting {contact} parameter require hashid, not numeric id.

Fixed in:
- contacts-edit.blade.php: update and destroy form actions
- contacts/index.blade.php: destroy form action in archive dropdown
2025-12-10 18:57:38 -07:00
kelly
aed1e62c65 style: fix Pint issues in brand analysis files
- single_quote fixes
- unary_operator_spaces fixes
- concat_space fixes
2025-12-10 18:36:36 -07:00
kelly
f9f1b8dc46 Merge pull request 'fix: resolve Crystal issues #161 and #180 (Quotes customer dropdown)' (#188) from fix/crystal-issues-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/188
2025-12-11 01:34:22 +00:00
kelly
89d3a54988 fix(#182): add search and status filtering to invoice index
- Added search by invoice number or customer business name (case-insensitive)
- Added status filter (unpaid/paid/overdue)
- Added withQueryString() to preserve filters during pagination
2025-12-10 18:33:58 -07:00
kelly
0c60e5c519 fix(#161): show all approved buyers in quotes customer dropdown
The quote create form was filtering accounts by whereHas('contacts'),
which excluded newly created buyer businesses that don't have contacts
yet. Changed to filter by status='approved' instead, allowing contacts
to be added after selecting the account.

This also fixes #180 (new customer not in quotes dropdown).
2025-12-10 18:19:29 -07:00
kelly
1ecc4a916b Merge pull request 'fix: case-insensitive product search and hashid defensive checks' (#179) from fix/product-search-case-insensitive into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/179
2025-12-11 00:49:35 +00:00
kelly
d4ec8c16f3 chore: re-trigger CI 2025-12-10 17:29:05 -07:00
kelly
f9d7573cb4 chore: re-trigger CI 2025-12-10 17:28:17 -07:00
kelly
e48e9c9b82 Merge pull request 'feat: add build date/time to sidebar version display' (#184) from feat/sidebar-build-date into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/184
2025-12-11 00:19:56 +00:00
kelly
afbb1ba79c Merge pull request 'fix(ci): use explicit git clone plugin for auth' (#185) from fix/ci-git-auth into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/185
2025-12-11 00:16:59 +00:00
kelly
08f5a3adac feat: add build date/time to sidebar version display
- Add appBuildDate variable from AppServiceProvider
- In local dev: shows commit date (e.g., 'Dec 10, 2:30pm')
- In production: reads BUILD_DATE from version.env
- Updated all sidebars: seller-suites, buyer, seller-legacy, brand-portal
- Updated Filament admin footer
2025-12-10 16:49:56 -07:00
kelly
e62ea5c809 fix: correct Blade syntax error in Promo Performance section
- Wrap promo table in @if(!empty($promosList)) check
- Add proper @else block for empty state message
- Fixes ParseError: unexpected token endif at line 1254
2025-12-10 16:17:21 -07:00
kelly
8d43953cad fix(#172): add defensive hashid filtering for brand products tab
Products without hashids would cause route generation errors.
Added whereNotNull('hashid') to query and filter() to collection.
2025-12-10 16:16:29 -07:00
kelly
a628f2b207 feat: add v3 comparables, supporting signals, and improve slippage display 2025-12-10 16:07:33 -07:00
kelly
367daadfe9 feat: add Brand Analysis page with CannaiQ intelligence
- Add BrandAnalysisService for market intelligence data
- Add BrandAnalysisDTO for structured analysis data
- Add AdvancedV3IntelligenceService for advanced metrics
- Add analysis(), analysisRefresh(), storePlaybook() to BrandController
- Add brand analysis routes
- Add analysis.blade.php view with:
  - Retail partner placement metrics
  - Competitor landscape analysis
  - Inventory projection alerts
  - Promo performance tracking
  - Slippage/action required alerts
  - V3 market signals and shelf opportunities
2025-12-10 15:40:38 -07:00
kelly
b33ebac9bf fix: make product search case-insensitive and add defensive hashid checks
- Use ILIKE instead of LIKE for PostgreSQL case-insensitive search
- Add hashid fallback in Brand::getLogoUrl() and getBannerUrl()
- Prevents route generation errors when hashid is missing from eager load
- Reduce eager loading in index() to only needed columns
- Add pagination to listings() method
- Filter hashid at DB level instead of PHP collection
2025-12-08 17:06:25 -07:00
307 changed files with 42438 additions and 5535 deletions

View File

@@ -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

View File

@@ -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}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

View File

@@ -1,29 +1,24 @@
# 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) IN PARALLEL
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
# - Tags: Build versioned release
#
# Optimization Notes:
# - php-lint, code-style, and tests run in parallel after composer install
# - Uses parallel-lint for faster PHP syntax checking
# - PostgreSQL tuned for CI (fsync disabled)
# - Cache rebuild only on merge builds
# 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]
# Use explicit git clone plugin to fix auth issues
# The default clone was failing with "could not read Username"
clone:
git:
image: woodpeckerci/plugin-git
@@ -34,422 +29,265 @@ clone:
steps:
# ============================================
# DEPENDENCY INSTALLATION (Sequential)
# PARALLEL: Composer + Frontend (with caching)
# ============================================
# Restore Composer cache
restore-composer-cache:
image: meltwater/drone-cache:dev
settings:
backend: "filesystem"
restore: true
cache_key: "composer-{{ checksum \"composer.lock\" }}"
archive_format: "gzip"
mount:
- "vendor"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# Install dependencies (uses pre-built Laravel image with all extensions)
composer-install:
image: kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- restore-composer-cache
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=production
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
- composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-progress
# 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 (only on merge builds, not PRs)
rebuild-composer-cache:
image: meltwater/drone-cache:dev
depends_on:
- composer-install
settings:
backend: "filesystem"
rebuild: true
cache_key: "composer-{{ checksum \"composer.lock\" }}"
archive_format: "gzip"
mount:
- "vendor"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
when:
branch: [develop, master]
event: push
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 (Run in Parallel for Speed)
# PR CHECKS (Parallel: lint, style, tests)
# ============================================
# PHP Syntax Check - Uses parallel-lint for 5-10x speed improvement
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 (parallel)..."
- ./vendor/bin/parallel-lint app routes database config --colors --blame
- echo "✅ PHP syntax check complete!"
when:
event: pull_request
# Run Laravel Pint (code style)
code-style:
image: kirschbaumdevelopment/laravel-test-runner:8.3
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- 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
# 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 (fast, no DB)
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_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_CONNECTION: sqlite
DB_DATABASE: ":memory:"
commands:
- echo "Setting up Laravel environment..."
- cp .env.example .env
- php artisan key:generate
- echo "Waiting for PostgreSQL to be ready..."
- |
for i in 1 2 3 4 5 6 7 8 9 10; do
if pg_isready -h postgres -p 5432 -U testing 2>/dev/null; then
echo "✅ PostgreSQL is ready!"
break
fi
echo "Waiting for postgres... attempt $i/10"
sleep 3
done
- echo "Starting Reverb server in background..."
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
- sleep 2
- echo "Running tests in parallel..."
- php artisan test --parallel
- echo "✅ Tests complete!"
- php artisan test --testsuite=Unit --parallel
- echo "✅ Unit tests passed"
# ============================================
# MERGE BUILD STEPS (Sequential, after PR passes)
# ============================================
# Validate migrations before deployment
# Only runs pending migrations - never fresh or seed
validate-migrations:
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: production
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.6.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 migrations..."
- cp .env.example .env
- php artisan key:generate
- echo "Running pending migrations only..."
- php artisan migrate --force
- echo "✅ Migration validation complete!"
- php artisan test --testsuite=Feature --parallel
- 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
# Build and push Docker image for DEV environment (develop branch)
build-image-dev:
image: woodpeckerci/plugin-docker-buildx
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- validate-migrations
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
- 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
# 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
# Build and push Docker image for PRODUCTION (master branch)
build-image-production:
image: woodpeckerci/plugin-docker-buildx
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- validate-migrations
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
- 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
# 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
# Build and push Docker image for tagged releases (optional versioned releases)
build-image-release:
image: woodpeckerci/plugin-docker-buildx
# For tags, setup auth first
setup-registry-auth-release:
image: alpine
depends_on:
- composer-install
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
- 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
# 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 (optimized for CI speed)
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: testing
POSTGRES_PASSWORD: testing
POSTGRES_DB: testing
# CI-optimized settings via environment (faster writes, safe for ephemeral test DB)
POSTGRES_INITDB_ARGS: "--data-checksums"
POSTGRES_HOST_AUTH_METHOD: trust
redis:
image: redis:7-alpine
commands:
- redis-server --bind 0.0.0.0
/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

View File

@@ -65,15 +65,72 @@ 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** | `10.100.9.70:5000` | Local registry (insecure) |
**PostgreSQL (Dev)**
```
Host: 10.100.6.50
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 `git.spdy.io/cannabrands/hub` (k8s can pull without insecure config)
- Base images pulled from local registry `10.100.9.70:5000` (Kaniko handles insecure)
- 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).

View File

@@ -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
View 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"]

View 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(),
];
}
}

View 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,
],
];
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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';
}
}

View 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">&#10003;</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">&#9888;</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">&#9432;</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 &rarr; 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();
}
}

View File

@@ -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'].'.%');
}
}),
])

View File

@@ -701,17 +701,6 @@ class BusinessResource extends Resource
}),
]),
// ===== CANNAIQ SECTION =====
// CannaiQ Marketing Intelligence Engine
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 Intelligence and Promos features under the Growth menu.')
->default(false),
]),
// ===== SUITE ASSIGNMENT SECTION =====
// Suites control feature access (menus, screens, capabilities)
Section::make('Suite Assignment')
@@ -863,6 +852,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.
@@ -1766,8 +1789,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')
@@ -1887,9 +1910,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}%");
});
});
})
@@ -1920,9 +1943,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')

View File

@@ -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}%");
});
}),

View File

@@ -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')

View File

@@ -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')

View File

@@ -26,8 +26,8 @@ class ApVendorController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('code', 'ilike', "%{$search}%");
});
}
@@ -199,7 +199,7 @@ class ApVendorController extends Controller
$prefix = substr($prefix, 0, 6);
$count = ApVendor::where('business_id', $businessId)
->where('code', 'like', "{$prefix}%")
->where('code', 'ilike', "{$prefix}%")
->count();
return $count > 0 ? "{$prefix}-{$count}" : $prefix;

View 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]);
}
}

View 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(),
];
}
}

View 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',
]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Crm\CrmMeetingBooking;
use App\Models\Crm\CrmTask;
use App\Models\Crm\CrmThread;
use App\Services\Crm\CrmSlaService;
use App\Services\Dashboard\CommandCenterService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -19,6 +20,10 @@ class DashboardController extends Controller
*/
private const DASHBOARD_CACHE_TTL = 300;
public function __construct(
protected CommandCenterService $commandCenterService,
) {}
/**
* Main dashboard redirect - automatically routes to business context
* Redirects to /s/{business}/dashboard based on user's primary business
@@ -40,104 +45,25 @@ class DashboardController extends Controller
}
/**
* Dashboard Overview - Main overview page
* Dashboard Overview - Revenue Command Center
*
* NOTE: All metrics are pre-calculated by CalculateDashboardMetrics job
* and stored in Redis. This method only reads from Redis for instant response.
* Single source of truth for all seller dashboard metrics.
* Uses CommandCenterService which provides:
* - DB/service as source of truth
* - Redis as cache layer
* - Explicit scoping (business|brand|user) per metric
*/
public function overview(Request $request, Business $business)
{
// Read pre-calculated metrics from Redis
$redisKey = "dashboard:{$business->id}:overview";
$cachedMetrics = \Illuminate\Support\Facades\Redis::get($redisKey);
$user = $request->user();
if ($cachedMetrics) {
$data = json_decode($cachedMetrics, true);
// Get all Command Center data via the single service
$commandCenterData = $this->commandCenterService->getData($business, $user);
// Map cached data to view variables
$revenueLast30 = $data['kpis']['revenue_last_30'] ?? 0;
$ordersLast30 = $data['kpis']['orders_last_30'] ?? 0;
$unitsSoldLast30 = $data['kpis']['units_sold_last_30'] ?? 0;
$averageOrderValueLast30 = $data['kpis']['average_order_value_last_30'] ?? 0;
$revenueGrowth = $data['kpis']['revenue_growth'] ?? 0;
$ordersGrowth = $data['kpis']['orders_growth'] ?? 0;
$unitsGrowth = $data['kpis']['units_growth'] ?? 0;
$aovGrowth = $data['kpis']['aov_growth'] ?? 0;
$activeBrandCount = $data['kpis']['active_brand_count'] ?? 0;
$activeBuyerCount = $data['kpis']['active_buyer_count'] ?? 0;
$activeInventoryAlertsCount = $data['kpis']['active_inventory_alerts_count'] ?? 0;
$activePromotionCount = $data['kpis']['active_promotion_count'] ?? 0;
// Convert arrays to objects and parse timestamps back to Carbon
$topProducts = collect($data['top_products'] ?? [])->map(fn ($item) => (object) $item);
$topBrands = collect($data['top_brands'] ?? [])->map(fn ($item) => (object) $item);
$needsAttention = collect($data['needs_attention'] ?? [])->map(function ($item) {
if (isset($item['timestamp']) && is_string($item['timestamp'])) {
$item['timestamp'] = \Carbon\Carbon::parse($item['timestamp']);
}
return $item; // Keep as array since view uses array syntax
});
$recentActivity = collect($data['recent_activity'] ?? [])->map(function ($item) {
if (isset($item['timestamp']) && is_string($item['timestamp'])) {
$item['timestamp'] = \Carbon\Carbon::parse($item['timestamp']);
}
return $item; // Keep as array since view uses array syntax
});
} else {
// No cached data - dispatch job and return empty state
\App\Jobs\CalculateDashboardMetrics::dispatch($business->id);
$revenueLast30 = 0;
$ordersLast30 = 0;
$unitsSoldLast30 = 0;
$averageOrderValueLast30 = 0;
$revenueGrowth = 0;
$ordersGrowth = 0;
$unitsGrowth = 0;
$aovGrowth = 0;
$activeBrandCount = 0;
$activeBuyerCount = 0;
$activeInventoryAlertsCount = 0;
$activePromotionCount = 0;
$topProducts = collect([]);
$topBrands = collect([]);
$needsAttention = collect([]);
$recentActivity = collect([]);
}
// Orchestrator Widget Data (if enabled)
$orchestratorWidget = (new \App\Http\Controllers\Seller\OrchestratorController)->getWidgetData($business);
// Hub Tiles Data (CRM, Tasks, Calendar, etc.)
$hubTiles = $this->getHubTilesData($business, $request->user());
// Sales Inbox - unified view of items needing attention
$salesInbox = $this->getSalesInboxData($business, $request->user());
return view('seller.dashboard.overview', compact(
'business',
'revenueLast30',
'ordersLast30',
'unitsSoldLast30',
'averageOrderValueLast30',
'revenueGrowth',
'ordersGrowth',
'unitsGrowth',
'aovGrowth',
'activeBrandCount',
'activeBuyerCount',
'activeInventoryAlertsCount',
'activePromotionCount',
'topProducts',
'topBrands',
'needsAttention',
'recentActivity',
'orchestratorWidget',
'hubTiles',
'salesInbox'
));
return view('seller.dashboard.overview', [
'business' => $business,
'commandCenter' => $commandCenterData,
]);
}
/**
@@ -1248,7 +1174,7 @@ class DashboardController extends Controller
$overdueTasks = CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '<', now())
->with(['contact:id,first_name,last_name,company_name'])
->with(['contact:id,first_name,last_name'])
->orderBy('due_at', 'asc')
->limit(10)
->get();
@@ -1256,7 +1182,7 @@ class DashboardController extends Controller
foreach ($overdueTasks as $task) {
$daysOverdue = now()->diffInDays($task->due_at, false);
$contactName = $task->contact
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: 'Contact'
: 'Unknown';
$overdue[] = [
'type' => 'task',
@@ -1295,7 +1221,7 @@ class DashboardController extends Controller
->whereNull('completed_at')
->where('due_at', '>=', now())
->where('due_at', '<=', now()->addDays(7))
->with(['contact:id,first_name,last_name,company_name'])
->with(['contact:id,first_name,last_name'])
->orderBy('due_at', 'asc')
->limit(10)
->get();
@@ -1303,7 +1229,7 @@ class DashboardController extends Controller
foreach ($upcomingTasks as $task) {
$daysUntilDue = now()->diffInDays($task->due_at, false);
$contactName = $task->contact
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: 'Contact'
: 'Unknown';
$upcoming[] = [
'type' => 'task',
@@ -1318,7 +1244,7 @@ class DashboardController extends Controller
->where('status', 'scheduled')
->where('start_at', '>=', now())
->where('start_at', '<=', now()->addDays(7))
->with(['contact:id,first_name,last_name,company_name'])
->with(['contact:id,first_name,last_name'])
->orderBy('start_at', 'asc')
->limit(5)
->get();
@@ -1326,7 +1252,7 @@ class DashboardController extends Controller
foreach ($upcomingMeetings as $meeting) {
$daysUntil = now()->diffInDays($meeting->start_at, false);
$contactName = $meeting->contact
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: $meeting->contact->company_name
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: 'Contact'
: 'Unknown';
$upcoming[] = [
'type' => 'meeting',

View File

@@ -22,9 +22,9 @@ class MarketplaceController extends Controller
// 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}%");
});
}

View File

@@ -82,6 +82,18 @@ class OrderController extends Controller
$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'));
}

View File

@@ -42,9 +42,9 @@ class DivisionAccountingController extends Controller
// Search filter
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('code', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
}

View File

@@ -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) {

View File

@@ -60,8 +60,11 @@ class BrandController extends Controller
'website_url' => $brand->website_url,
'preview_url' => route('seller.business.brands.preview', [$business->slug, $brand]),
'dashboard_url' => route('seller.business.brands.dashboard', [$business->slug, $brand]),
'profile_url' => route('seller.business.brands.profile', [$business->slug, $brand]),
'stats_url' => route('seller.business.brands.stats', [$business->slug, $brand]),
'edit_url' => route('seller.business.brands.edit', [$business->slug, $brand]),
'stores_url' => route('seller.business.brands.stores.index', [$business->slug, $brand]),
'orders_url' => route('seller.business.brands.orders', [$business->slug, $brand]),
'isNewBrand' => $brand->created_at && $brand->created_at->diffInDays(now()) <= 30,
];
})->values();
@@ -333,11 +336,14 @@ class BrandController extends Controller
{
$perPage = $request->get('per_page', 50);
$productsPaginator = $brand->products()
->whereNotNull('hashid')
->where('hashid', '!=', '')
->with('images')
->orderBy('created_at', 'desc')
->paginate($perPage);
$products = $productsPaginator->getCollection()
->filter(fn ($product) => ! empty($product->hashid))
->map(function ($product) use ($business, $brand) {
$product->setRelation('brand', $brand);
@@ -354,7 +360,8 @@ class BrandController extends Controller
'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]),
'preview_url' => route('seller.business.products.preview', [$business->slug, $product->hashid]),
];
});
})
->values();
return [
'products' => $products,
@@ -763,6 +770,11 @@ class BrandController extends Controller
// ═══════════════════════════════════════════════════════════════
$salesStats = $this->calculateBrandStats($brand, $ninetyDaysAgo, now());
// ═══════════════════════════════════════════════════════════════
// STORE INTELLIGENCE (90 days)
// ═══════════════════════════════════════════════════════════════
$storeStats = $this->calculateStoreStats($brand, 90);
// ═══════════════════════════════════════════════════════════════
// PRODUCT VELOCITY DATA
// ═══════════════════════════════════════════════════════════════
@@ -876,6 +888,7 @@ class BrandController extends Controller
'isBrandManager' => $isBrandManager,
// Core stats
'salesStats' => $salesStats,
'storeStats' => $storeStats,
'productCategories' => $productCategories,
'productVelocity' => $productVelocity,
// Product states
@@ -1948,4 +1961,182 @@ class BrandController extends Controller
'visibilityIssues' => $visibilityIssues,
];
}
/**
* Display brand market analysis / intelligence page.
*
* v4 endpoint with optional store_id filtering for per-store projections.
*/
public function analysis(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
// CannaiQ must be enabled to access Brand Analysis
if (! $business->cannaiq_enabled) {
return view('seller.brands.analysis-disabled', [
'business' => $business,
'brand' => $brand,
]);
}
// v4: Get optional store_id filter for shelf value projections
$storeId = $request->query('store_id');
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
$analysis = $analysisService->getAnalysis($brand, $business, $storeId);
// Load all brands for the brand selector
$brands = $business->brands()
->where('is_active', true)
->withCount('products')
->orderBy('name')
->get();
// Build store list from placement data for store selector
$storeList = [];
if ((bool) $business->cannaiq_enabled) {
$placementStores = $analysis->placement['stores'] ?? $analysis->placement ?? [];
$whitespaceStores = $analysis->placement['whitespaceStores'] ?? [];
foreach ($placementStores as $store) {
$storeList[] = [
'id' => $store['storeId'] ?? '',
'name' => $store['storeName'] ?? 'Unknown',
'state' => $store['state'] ?? null,
];
}
foreach ($whitespaceStores as $store) {
$storeList[] = [
'id' => $store['storeId'] ?? '',
'name' => $store['storeName'] ?? 'Unknown',
'state' => $store['state'] ?? null,
];
}
}
return view('seller.brands.analysis', [
'business' => $business,
'brand' => $brand,
'brands' => $brands,
'analysis' => $analysis,
'cannaiqEnabled' => (bool) $business->cannaiq_enabled,
'storeList' => $storeList,
'selectedStoreId' => $storeId,
]);
}
/**
* Refresh brand analysis data (clears cache and re-fetches).
*/
public function analysisRefresh(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
// CannaiQ must be enabled to refresh analysis
if (! $business->cannaiq_enabled) {
if ($request->wantsJson()) {
return response()->json([
'success' => false,
'message' => 'CannaiQ is not enabled for this business. Please contact support.',
], 403);
}
return back()->with('error', 'CannaiQ is not enabled for this business.');
}
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
$analysis = $analysisService->refreshAnalysis($brand, $business);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'Analysis data refreshed',
'data' => $analysis->toArray(),
]);
}
return redirect()
->route('seller.business.brands.analysis', [$business->slug, $brand->hashid])
->with('success', 'Analysis data refreshed successfully');
}
/**
* Get store-level playbook for a specific store.
*
* Returns targeted recommendations for a single retail account.
*/
public function storePlaybook(Request $request, Business $business, Brand $brand, string $storeId)
{
$this->authorize('view', [$brand, $business]);
if (! $business->cannaiq_enabled) {
if ($request->wantsJson()) {
return response()->json([
'success' => false,
'message' => 'CannaiQ is not enabled for this business',
], 403);
}
return back()->with('error', 'CannaiQ is not enabled for this business');
}
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
$playbook = $analysisService->getStorePlaybook($brand, $business, $storeId);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'data' => $playbook,
]);
}
// For non-JSON requests, redirect to analysis page with store selected
return redirect()
->route('seller.business.brands.analysis', [
$business->slug,
$brand->hashid,
'store_id' => $storeId,
]);
}
/**
* Calculate store/distribution metrics for the brand.
*
* Returns metrics about store penetration, SKU stock rate, and average SKUs per store.
*/
private function calculateStoreStats(Brand $brand, int $days = 90): array
{
// Count unique buyer businesses (stores) that ordered this brand in current period
$currentStores = \App\Models\Order::whereHas('items.product', fn ($q) => $q->where('brand_id', $brand->id))
->where('created_at', '>=', now()->subDays($days))
->distinct('business_id')
->count('business_id');
// Previous period for comparison
$previousStores = \App\Models\Order::whereHas('items.product', fn ($q) => $q->where('brand_id', $brand->id))
->whereBetween('created_at', [now()->subDays($days * 2), now()->subDays($days)])
->distinct('business_id')
->count('business_id');
// SKU stock rate: % of brand's active SKUs that have been ordered
$activeSkus = $brand->products()->where('is_active', true)->count();
$orderedSkus = \App\Models\OrderItem::whereHas('product', fn ($q) => $q->where('brand_id', $brand->id))
->whereHas('order', fn ($q) => $q->where('created_at', '>=', now()->subDays($days)))
->distinct('product_id')
->count('product_id');
$stockRate = $activeSkus > 0 ? round(($orderedSkus / $activeSkus) * 100, 1) : 0;
// Avg SKUs per store
$avgSkusPerStore = $currentStores > 0 ? round($orderedSkus / $currentStores, 1) : 0;
return [
'currentStores' => $currentStores,
'storeChange' => $currentStores - $previousStores,
'stockRate' => $stockRate,
'avgSkusPerStore' => $avgSkusPerStore,
'orderedSkus' => $orderedSkus,
'activeSkus' => $activeSkus,
];
}
}

View File

@@ -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}%");
});
}

View 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;
}
}

View 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,
]),
]);
}
}

View File

@@ -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}%");
});
});
}

View File

@@ -10,18 +10,23 @@ 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)
{
$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
@@ -43,6 +48,18 @@ class AccountController extends Controller
$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'));
}
@@ -90,7 +107,7 @@ class AccountController extends Controller
'status' => 'approved', // Auto-approve customers created by sellers
]);
// Create primary contact if provided
// Create contact if provided
if (! empty($validated['contact_name'])) {
$account->contacts()->create([
'first_name' => explode(' ', $validated['contact_name'])[0],
@@ -98,7 +115,6 @@ class AccountController extends Controller
'email' => $validated['contact_email'] ?? null,
'phone' => $validated['contact_phone'] ?? null,
'title' => $validated['contact_title'] ?? null,
'is_primary' => true,
]);
}
@@ -165,35 +181,55 @@ class AccountController extends Controller
{
$account->load(['contacts']);
// Get orders for this account from this seller (with invoices)
$orders = $account->orders()
// 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);
})
->with(['invoice'])
->latest()
->limit(10)
->get();
});
// Get quotes for this account
$quotes = CrmQuote::where('business_id', $business->id)
->where('account_id', $account->id)
->with(['contact', 'items'])
->latest()
->limit(10)
->get();
// 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 invoices for this account (via orders)
$invoices = Invoice::whereHas('order', function ($q) use ($business, $account) {
// 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);
});
})
->with(['order', 'payments'])
->latest()
->limit(10)
->get();
});
// 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
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
@@ -234,13 +270,17 @@ class AccountController extends Controller
->limit(20)
->get();
// Compute stats for this account with efficient queries
$orderStats = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
->first();
// 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();
}
$opportunityStats = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
@@ -248,14 +288,14 @@ class AccountController extends Controller
->selectRaw('COUNT(*) as open_count, COALESCE(SUM(value), 0) as pipeline_value')
->first();
// Financial stats from invoices
$financialStats = 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);
});
})
->selectRaw('
// 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,
@@ -285,12 +325,69 @@ class AccountController extends Controller
'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
? now()->diffInDays($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',
@@ -303,7 +400,12 @@ class AccountController extends Controller
'tasks',
'conversationEvents',
'sendHistory',
'activities'
'activities',
'locations',
'selectedLocation',
'locationStats',
'unattributedOrdersCount',
'unattributedInvoicesCount'
));
}
@@ -312,9 +414,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'));
}
/**
@@ -322,7 +441,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'));
}
/**
@@ -330,15 +463,28 @@ class AccountController extends Controller
*/
public function orders(Request $request, Business $business, Business $account)
{
$orders = $account->orders()
// 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);
})
->with(['items.product.brand'])
});
// Filter by location if selected
if ($selectedLocation) {
$ordersQuery->where('location_id', $selectedLocation->id);
}
$orders = $ordersQuery->with(['items.product.brand', 'location'])
->latest()
->paginate(25);
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders'));
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders', 'locations', 'selectedLocation'));
}
/**
@@ -346,13 +492,20 @@ class AccountController extends Controller
*/
public function activity(Request $request, Business $business, 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);
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities'));
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities', 'locations', 'selectedLocation'));
}
/**
@@ -360,7 +513,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'));
}
/**
@@ -397,14 +565,8 @@ class AccountController extends Controller
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'title' => 'nullable|string|max:100',
'is_primary' => 'boolean',
]);
// If setting as primary, unset other primary contacts
if ($validated['is_primary'] ?? false) {
$account->contacts()->update(['is_primary' => false]);
}
$contact = $account->contacts()->create($validated);
// Return JSON for AJAX requests
@@ -453,14 +615,11 @@ class AccountController extends Controller
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'title' => 'nullable|string|max:100',
'is_primary' => 'boolean',
'is_active' => 'boolean',
]);
// If setting as primary, unset other primary contacts
if ($validated['is_primary'] ?? false) {
$account->contacts()->where('id', '!=', $contact->id)->update(['is_primary' => false]);
}
// Handle checkbox - if not sent, default to false
$validated['is_active'] = $request->boolean('is_active');
$contact->update($validated);
@@ -485,4 +644,167 @@ class AccountController extends Controller
->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);
}
}
}

View File

@@ -67,6 +67,18 @@ class ContactController extends Controller
->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')

View File

@@ -172,8 +172,9 @@ class CrmCalendarController extends Controller
]);
$allEvents = $allEvents->merge($bookings);
// 4. CRM Tasks with due dates (shown as all-day markers)
// 4. CRM Tasks with due dates (shown as all-day markers) - only show user's assigned tasks
$tasks = CrmTask::forSellerBusiness($business->id)
->where('assigned_to', $user->id)
->incomplete()
->whereNotNull('due_at')
->whereBetween('due_at', [$startDate, $endDate])

View File

@@ -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;
@@ -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.');
}
}

View File

@@ -116,7 +116,8 @@ class DealController extends Controller
->get();
// Limit accounts for dropdown - most recent 100
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
// Get businesses that have placed orders containing this seller's products
$accounts = Business::whereHas('orders', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})
->select('id', 'name')

View File

@@ -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
{
@@ -42,8 +46,8 @@ class InvoiceController extends Controller
// Stats - single efficient query with conditional aggregation
$invoiceStats = CrmInvoice::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN amount_due ELSE 0 END) as outstanding,
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN amount_due ELSE 0 END) as overdue
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();
@@ -70,7 +74,7 @@ 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', 'business'));
}
@@ -80,23 +84,76 @@ class InvoiceController extends Controller
*/
public function create(Request $request, Business $business)
{
// Limit contacts for dropdown - most recent 100
$contacts = \App\Models\Contact::where('business_id', $business->id)
->select('id', 'first_name', 'last_name', 'email', 'company_name')
->orderByDesc('updated_at')
->limit(100)
// 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')
->with('contact:id,first_name,last_name')
->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', 'business'));
// Transform quotes for Alpine.js (avoid complex closures in Blade @json)
$quotesForJs = $quotes->map(fn ($q) => [
'id' => $q->id,
'account_id' => $q->account_id,
'contact_id' => $q->contact_id,
'location_id' => $q->location_id,
'items' => $q->items->map(fn ($i) => [
'product_id' => $i->product_id,
'description' => $i->description,
'quantity' => $i->quantity,
'unit_price' => $i->unit_price,
'discount_percent' => $i->discount_percent ?? 0,
])->values(),
])->values();
// Pre-fill from URL parameters
$selectedAccount = null;
$selectedLocation = null;
$selectedContact = null;
$locationContacts = collect();
if ($request->filled('account_id')) {
$selectedAccount = $accounts->firstWhere('id', $request->account_id);
}
if ($request->filled('location_id') && $selectedAccount) {
$selectedLocation = $selectedAccount->locations->firstWhere('id', $request->location_id);
}
// Pre-fill from quote if provided
$quote = null;
if ($request->filled('quote_id')) {
$quote = $quotes->firstWhere('id', $request->quote_id);
if ($quote) {
$selectedAccount = $accounts->firstWhere('id', $quote->account_id);
}
}
return view('seller.crm.invoices.create', compact(
'accounts',
'deals',
'quotes',
'quotesForJs',
'business',
'selectedAccount',
'selectedLocation',
'selectedContact',
'locationContacts',
'quote'
));
}
/**
@@ -108,21 +165,28 @@ class InvoiceController extends Controller
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
'account_id' => 'nullable|exists:businesses,id',
'location_id' => 'nullable|exists:business_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'])) {
@@ -131,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',
]);
@@ -154,10 +229,12 @@ 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,
]);
}
@@ -167,6 +244,135 @@ class InvoiceController extends Controller
->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:business_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
*/
@@ -180,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.');
}
@@ -259,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');
}
/**

View File

@@ -35,6 +35,19 @@ class LeadController extends Controller
$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'));
}

View File

@@ -10,9 +10,9 @@ use App\Models\Contact;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmQuoteItem;
use App\Models\Location;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Services\Accounting\ArService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
@@ -37,13 +37,26 @@ 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 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'));
}
@@ -52,11 +65,13 @@ class QuoteController extends Controller
*/
public function create(Request $request, Business $business)
{
// Get buyer businesses that have contacts (potential and existing customers)
// 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')
->whereHas('contacts')
->where('status', 'approved')
->with('locations:id,business_id,name,is_primary')
->orderBy('name')
->select(['id', 'name', 'slug'])
@@ -69,7 +84,77 @@ class QuoteController extends Controller
? CrmDeal::forBusiness($business->id)->find($request->deal_id)
: null;
return view('seller.crm.quotes.create', compact('accounts', 'deals', 'deal', 'business'));
// 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'
));
}
/**
@@ -88,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',
@@ -124,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',
]);
@@ -180,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', 'business'));
return view('seller.crm.quotes.edit', compact('quote', 'business'));
}
/**

View File

@@ -40,8 +40,30 @@ 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);
// 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('
@@ -75,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'));
}
/**

View File

@@ -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
{
@@ -164,9 +171,9 @@ class ThreadController extends Controller
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}%"));
});
}
@@ -446,4 +453,354 @@ 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
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->select('id', 'name')
->get();
// 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'
));
}
}

View File

@@ -118,7 +118,7 @@ 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 brand IDs for this business (single query, reused for filtering)
$brandIds = $business->brands()->pluck('id');
@@ -138,11 +138,47 @@ class InvoiceController extends Controller
->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);
->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'));
}

View File

@@ -34,9 +34,9 @@ class ApVendorsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('contact_email', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('code', 'ilike', "%{$search}%")
->orWhere('contact_email', 'ilike', "%{$search}%");
});
}
@@ -320,7 +320,7 @@ class ApVendorsController extends Controller
// Check for uniqueness
$count = ApVendor::where('business_id', $businessId)
->where('code', 'like', "{$prefix}%")
->where('code', 'ilike', "{$prefix}%")
->count();
return $count > 0 ? "{$prefix}-{$count}" : $prefix;

View File

@@ -42,8 +42,8 @@ class ChartOfAccountsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('account_number', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
$q->where('account_number', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%");
});
}

View File

@@ -63,8 +63,8 @@ class RequisitionsApprovalController extends Controller
// Search
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('requisition_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
$q->where('requisition_number', 'ilike', "%{$search}%")
->orWhere('notes', 'ilike', "%{$search}%");
});
}

View File

@@ -22,7 +22,7 @@ class BiomassController extends Controller
}
if ($request->filled('search')) {
$query->where('lot_number', 'like', '%'.$request->search.'%');
$query->where('lot_number', 'ilike', '%'.$request->search.'%');
}
$biomassLots = $query->paginate(25);

View File

@@ -26,7 +26,7 @@ class MaterialLotController extends Controller
}
if ($request->filled('search')) {
$query->where('lot_number', 'like', '%'.$request->search.'%');
$query->where('lot_number', 'ilike', '%'.$request->search.'%');
}
$materialLots = $query->paginate(25);

View File

@@ -28,7 +28,7 @@ class ProcessingSalesOrderController extends Controller
}
if ($request->filled('search')) {
$query->where('order_number', 'like', '%'.$request->search.'%');
$query->where('order_number', 'ilike', '%'.$request->search.'%');
}
$salesOrders = $query->paginate(25);

View File

@@ -25,7 +25,7 @@ class ProcessingShipmentController extends Controller
}
if ($request->filled('search')) {
$query->where('shipment_number', 'like', '%'.$request->search.'%');
$query->where('shipment_number', 'ilike', '%'.$request->search.'%');
}
$shipments = $query->paginate(25);

View File

@@ -29,6 +29,11 @@ class ProductController extends Controller
// Get brand IDs to filter by (respects brand context switcher)
$brandIds = BrandSwitcherController::getFilteredBrandIds();
// Get all brands for the business for the filter dropdown
$brands = \App\Models\Brand::where('business_id', $business->id)
->orderBy('name')
->get(['id', 'name']);
// Calculate missing BOM count for health alert
$missingBomCount = Product::whereIn('brand_id', $brandIds)
->where('is_assembly', true)
@@ -106,7 +111,7 @@ class ProductController extends Controller
'hashid' => $variety->hashid,
'name' => $variety->name,
'sku' => $variety->sku ?? 'N/A',
'price' => $variety->wholesale_price ?? 0,
'price' => $variety->effective_price ?? $variety->wholesale_price ?? 0,
'status' => $variety->is_active ? 'active' : 'inactive',
'image_url' => $variety->getImageUrl('thumb'),
'edit_url' => route('seller.business.products.edit', [$business->slug, $variety->hashid]),
@@ -123,7 +128,7 @@ class ProductController extends Controller
'sku' => $product->sku ?? 'N/A',
'brand' => $product->brand->name ?? 'N/A',
'channel' => 'Marketplace', // TODO: Add channel field to products
'price' => $product->wholesale_price ?? 0,
'price' => $product->effective_price ?? $product->wholesale_price ?? 0,
'views' => rand(500, 3000), // TODO: Replace with real view tracking
'orders' => rand(10, 200), // TODO: Replace with real order count
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
@@ -150,7 +155,20 @@ class ProductController extends Controller
'to' => $paginator->lastItem(),
];
return view('seller.products.index', compact('business', 'products', 'missingBomCount', 'paginator', 'pagination'));
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $products->map(fn ($p) => [
'hashid' => $p['hashid'],
'name' => $p['product'],
'sku' => $p['sku'],
'brand' => $p['brand'],
])->values()->toArray(),
'pagination' => $pagination,
]);
}
return view('seller.products.index', compact('business', 'brands', 'products', 'missingBomCount', 'paginator', 'pagination'));
}
/**
@@ -475,20 +493,34 @@ class ProductController extends Controller
// Set default value for price_unit if not provided
$validated['price_unit'] = $validated['price_unit'] ?? 'each';
// Create product
$product = Product::create($validated);
// Create product and handle images in a transaction
$product = \DB::transaction(function () use ($validated, $request, $business) {
$product = Product::create($validated);
// Handle image uploads if present
if ($request->hasFile('images')) {
foreach ($request->file('images') as $index => $image) {
$path = $image->store('products', 'public');
$product->images()->create([
'path' => $path,
'type' => 'product',
'is_primary' => $index === 0,
]);
// Handle image uploads if present
// Note: Uses default disk (minio) per CLAUDE.md rules - never use 'public' disk for media
if ($request->hasFile('images')) {
$brand = $product->brand;
$basePath = "businesses/{$business->slug}/brands/{$brand->slug}/products/{$product->sku}/images";
foreach ($request->file('images') as $index => $image) {
$filename = $image->hashName();
$path = $image->storeAs($basePath, $filename);
if ($path === false) {
throw new \RuntimeException('Failed to upload image to storage');
}
$product->images()->create([
'path' => $path,
'type' => 'product',
'is_primary' => $index === 0,
]);
}
}
}
return $product;
});
return redirect()
->route('seller.business.products.index', $business->slug)
@@ -849,9 +881,9 @@ class ProductController extends Controller
'content' => [
'description' => ['nullable', 'string', 'max:255'],
'tagline' => ['nullable', 'string', 'max:100'],
'long_description' => ['nullable', 'string', 'max:500'],
'consumer_long_description' => ['nullable', 'string', 'max:500'],
'buyer_long_description' => ['nullable', 'string', 'max:500'],
'long_description' => ['nullable', 'string', 'max:5000'],
'consumer_long_description' => ['nullable', 'string', 'max:5000'],
'buyer_long_description' => ['nullable', 'string', 'max:5000'],
'product_link' => 'nullable|url|max:255',
'creatives_json' => 'nullable|json',
'seo_title' => ['nullable', 'string', 'max:70'],
@@ -891,10 +923,10 @@ class ProductController extends Controller
// Define checkbox fields per tab
$checkboxesByTab = [
'overview' => ['is_active', 'is_featured', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
'overview' => ['is_active', 'is_featured', 'is_sellable', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
'pricing' => ['is_case', 'is_box'],
'inventory' => ['sync_bamboo', 'low_stock_alert_enabled', 'is_assembly', 'show_inventory_to_buyers', 'has_varieties'],
'advanced' => ['is_sellable', 'is_fpr', 'is_raw_material'],
'advanced' => ['is_fpr', 'is_raw_material'],
];
// Convert checkboxes to boolean - only for fields in current validation scope
@@ -906,7 +938,7 @@ class ProductController extends Controller
if (array_key_exists($checkbox, $rules)) {
// Use boolean() for fields that send actual values (hidden inputs with 0/1)
// Use has() for traditional checkboxes that are absent when unchecked
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'low_stock_alert_enabled', 'has_varieties']);
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'is_sellable', 'low_stock_alert_enabled', 'has_varieties']);
$validated[$checkbox] = $useBoolean
? $request->boolean($checkbox)
: $request->has($checkbox);
@@ -1032,7 +1064,7 @@ class ProductController extends Controller
'sku' => $product->sku ?? 'N/A',
'brand' => $product->brand->name ?? 'N/A',
'channel' => 'Marketplace', // TODO: Add channel field to products
'price' => $product->wholesale_price ?? 0,
'price' => (float) ($product->wholesale_price ?? 0),
'views' => rand(500, 3000), // TODO: Replace with real view tracking
'orders' => rand(10, 200), // TODO: Replace with real order count
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation

View File

@@ -29,16 +29,25 @@ class ProductImageController extends Controller
'image' => 'required|image|mimes:jpeg,jpg,png|max:2048|dimensions:min_width=750,min_height=384', // 2MB max, 750x384 min
]);
// Check if product already has 8 images
if ($product->images()->count() >= 8) {
// Check if product already has 6 images
if ($product->images()->count() >= 6) {
return response()->json([
'success' => false,
'message' => 'Maximum of 8 images allowed per product',
'message' => 'Maximum of 6 images allowed per product',
], 422);
}
// Store the image using trait method
$path = $this->storeFile($request->file('image'), 'products');
// Build proper storage path: businesses/{business_slug}/brands/{brand_slug}/products/{sku}/images/
$brand = $product->brand;
$storagePath = sprintf(
'businesses/%s/brands/%s/products/%s/images',
$business->slug,
$brand->slug,
$product->sku
);
// Store the image with proper path
$path = $this->storeFile($request->file('image'), $storagePath);
// Determine if this should be the primary image (first one)
$isPrimary = $product->images()->count() === 0;
@@ -61,6 +70,8 @@ class ProductImageController extends Controller
'id' => $image->id,
'path' => $image->path,
'is_primary' => $image->is_primary,
'url' => route('image.product', ['product' => $product->hashid, 'width' => 400]),
'thumb_url' => route('image.product', ['product' => $product->hashid, 'width' => 80]),
],
]);
}

View File

@@ -16,17 +16,32 @@ class PromotionController extends Controller
protected PromoCalculator $promoCalculator
) {}
public function index(Business $business)
public function index(Request $request, Business $business)
{
// TODO: Future deprecation - This global promotions page will be replaced by brand-scoped promotions
// Once brand-scoped promotions are stable and rolled out, this route should redirect to:
// return redirect()->route('seller.business.brands.promotions.index', [$business, $defaultBrand]);
// Where $defaultBrand is determined by business context or user preference
$promotions = Promotion::where('business_id', $business->id)
->withCount('products')
->orderBy('created_at', 'desc')
->get();
// Get brands for filter dropdown
$brands = \App\Models\Brand::where('business_id', $business->id)
->orderBy('name')
->get(['id', 'name', 'hashid']);
$query = Promotion::where('business_id', $business->id)
->withCount('products');
// Filter by brand
if ($request->filled('brand')) {
$query->where('brand_id', $request->brand);
}
// Filter by status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$promotions = $query->orderBy('created_at', 'desc')->get();
// Load pending recommendations with product data
// Gracefully handle if promo_recommendations table doesn't exist yet
@@ -41,7 +56,7 @@ class PromotionController extends Controller
->get();
}
return view('seller.promotions.index', compact('business', 'promotions', 'recommendations'));
return view('seller.promotions.index', compact('business', 'promotions', 'recommendations', 'brands'));
}
public function create(Business $business)

View File

@@ -44,8 +44,8 @@ class RequisitionsController extends Controller
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('requisition_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
$q->where('requisition_number', 'ilike', "%{$search}%")
->orWhere('notes', 'ilike', "%{$search}%");
});
}

View File

@@ -0,0 +1,274 @@
<?php
namespace App\Http\Controllers\Seller\Sales;
use App\Http\Controllers\Controller;
use App\Models\AccountNote;
use App\Models\Business;
use App\Models\Order;
use App\Models\SalesRepAssignment;
use Illuminate\Http\Request;
class AccountsController extends Controller
{
/**
* My Accounts - list of accounts assigned to this sales rep
*/
public function index(Request $request, Business $business)
{
$user = $request->user();
// Get account assignments with eager loading
$assignments = SalesRepAssignment::forBusiness($business->id)
->forUser($user->id)
->accounts()
->with(['assignable', 'assignable.locations', 'assignable.contacts'])
->get();
// Get account metrics in batch
$accountIds = $assignments->pluck('assignable_id');
$metrics = $this->getAccountMetrics($accountIds);
// Build account list with metrics
$accounts = $assignments->map(function ($assignment) use ($metrics) {
$accountId = $assignment->assignable_id;
$accountMetrics = $metrics[$accountId] ?? [];
return [
'assignment' => $assignment,
'account' => $assignment->assignable,
'metrics' => $accountMetrics,
'health' => $this->calculateHealth($accountMetrics),
];
});
// Apply filters
$statusFilter = $request->get('status');
if ($statusFilter) {
$accounts = $accounts->filter(fn ($a) => $a['health']['status'] === $statusFilter);
}
// Sort by health priority (at_risk first)
$accounts = $accounts->sortBy(fn ($a) => $a['health']['priority'])->values();
return view('seller.sales.accounts.index', compact('business', 'accounts'));
}
/**
* Show account detail with full history
*/
public function show(Request $request, Business $business, Business $account)
{
$user = $request->user();
// Verify user is assigned to this account
$assignment = SalesRepAssignment::forBusiness($business->id)
->forUser($user->id)
->where('assignable_type', Business::class)
->where('assignable_id', $account->id)
->first();
if (! $assignment) {
abort(403, 'You are not assigned to this account.');
}
// Get account details with relationships
$account->load(['locations', 'contacts']);
// Get order history
$orders = Order::where('business_id', $account->id)
->with(['items.product.brand'])
->orderByDesc('created_at')
->limit(20)
->get();
// Get account notes
$notes = AccountNote::forBusiness($business->id)
->forAccount($account->id)
->with('author')
->orderByDesc('is_pinned')
->orderByDesc('created_at')
->get();
// Get account metrics
$metrics = $this->getAccountDetailMetrics($account);
return view('seller.sales.accounts.show', compact(
'business',
'account',
'assignment',
'orders',
'notes',
'metrics'
));
}
/**
* Store a new account note
*/
public function storeNote(Request $request, Business $business, Business $account)
{
$validated = $request->validate([
'note_type' => 'required|in:general,competitor,pain_point,opportunity,objection',
'content' => 'required|string|max:5000',
]);
$note = AccountNote::create([
'business_id' => $business->id,
'account_id' => $account->id,
'user_id' => $request->user()->id,
'note_type' => $validated['note_type'],
'content' => $validated['content'],
]);
return back()->with('success', 'Note added successfully.');
}
/**
* Toggle note pinned status
*/
public function toggleNotePin(Request $request, Business $business, AccountNote $note)
{
// Verify note belongs to this business
if ($note->business_id !== $business->id) {
abort(403);
}
$note->is_pinned = ! $note->is_pinned;
$note->save();
return back()->with('success', $note->is_pinned ? 'Note pinned.' : 'Note unpinned.');
}
/**
* Delete an account note
*/
public function destroyNote(Request $request, Business $business, AccountNote $note)
{
// Verify note belongs to this business
if ($note->business_id !== $business->id) {
abort(403);
}
// Only allow deletion by author or admin
if ($note->user_id !== $request->user()->id && ! $request->user()->isAdmin()) {
abort(403);
}
$note->delete();
return back()->with('success', 'Note deleted.');
}
/**
* Get batch metrics for multiple accounts
*/
protected function getAccountMetrics($accountIds): array
{
if ($accountIds->isEmpty()) {
return [];
}
$metrics = Order::whereIn('business_id', $accountIds)
->where('status', 'completed')
->groupBy('business_id')
->selectRaw('
business_id,
COUNT(*) as order_count,
SUM(total) as total_revenue,
MAX(created_at) as last_order_date
')
->get()
->keyBy('business_id');
// Get 4-week rolling revenue
$recentRevenue = Order::whereIn('business_id', $accountIds)
->where('status', 'completed')
->where('created_at', '>=', now()->subWeeks(4))
->groupBy('business_id')
->selectRaw('business_id, SUM(total) as four_week_revenue')
->pluck('four_week_revenue', 'business_id');
return $accountIds->mapWithKeys(function ($id) use ($metrics, $recentRevenue) {
$m = $metrics[$id] ?? null;
return [$id => [
'order_count' => $m?->order_count ?? 0,
'total_revenue' => ($m?->total_revenue ?? 0) / 100,
'four_week_revenue' => ($recentRevenue[$id] ?? 0) / 100,
'last_order_date' => $m?->last_order_date,
'days_since_order' => $m?->last_order_date
? now()->diffInDays($m->last_order_date)
: null,
]];
})->all();
}
/**
* Get detailed metrics for a single account
*/
protected function getAccountDetailMetrics(Business $account): array
{
$orders = Order::where('business_id', $account->id)
->where('status', 'completed')
->get();
if ($orders->isEmpty()) {
return [
'lifetime_revenue' => 0,
'lifetime_orders' => 0,
'avg_order_value' => 0,
'four_week_revenue' => 0,
'last_order_date' => null,
'days_since_order' => null,
'avg_order_interval' => null,
];
}
$lifetime = $orders->sum('total') / 100;
$recentOrders = $orders->where('created_at', '>=', now()->subWeeks(4));
$fourWeekRevenue = $recentOrders->sum('total') / 100;
// Calculate average order interval
$sortedDates = $orders->pluck('created_at')->sort()->values();
$intervals = [];
for ($i = 1; $i < $sortedDates->count(); $i++) {
$intervals[] = $sortedDates[$i]->diffInDays($sortedDates[$i - 1]);
}
$avgInterval = count($intervals) > 0 ? array_sum($intervals) / count($intervals) : null;
return [
'lifetime_revenue' => $lifetime,
'lifetime_orders' => $orders->count(),
'avg_order_value' => $orders->count() > 0 ? $lifetime / $orders->count() : 0,
'four_week_revenue' => $fourWeekRevenue,
'last_order_date' => $orders->max('created_at'),
'days_since_order' => $orders->max('created_at')
? now()->diffInDays($orders->max('created_at'))
: null,
'avg_order_interval' => $avgInterval ? round($avgInterval) : null,
];
}
/**
* Calculate health status
*/
protected function calculateHealth(array $metrics): array
{
$days = $metrics['days_since_order'] ?? null;
if ($days === null) {
return ['status' => 'new', 'label' => 'New', 'color' => 'info', 'priority' => 2];
}
if ($days >= 60) {
return ['status' => 'at_risk', 'label' => 'At Risk', 'color' => 'error', 'priority' => 0];
}
if ($days >= 30) {
return ['status' => 'needs_attention', 'label' => 'Needs Attention', 'color' => 'warning', 'priority' => 1];
}
return ['status' => 'healthy', 'label' => 'Healthy', 'color' => 'success', 'priority' => 3];
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace App\Http\Controllers\Seller\Sales;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\SalesCommission;
use App\Models\SalesCommissionRate;
use App\Models\User;
use Illuminate\Http\Request;
class CommissionController extends Controller
{
/**
* Commission dashboard for sales rep - see their earnings
*/
public function index(Request $request, Business $business)
{
$user = $request->user();
// Get commission summary
$summary = $this->getCommissionSummary($business, $user);
// Get recent commissions
$commissions = SalesCommission::forBusiness($business->id)
->forUser($user->id)
->with(['order', 'order.business'])
->orderByDesc('created_at')
->paginate(20);
// Get commission rates for this user
$rates = SalesCommissionRate::forBusiness($business->id)
->where(function ($q) use ($user) {
$q->whereNull('user_id')
->orWhere('user_id', $user->id);
})
->active()
->effective()
->orderBy('rate_type')
->get();
return view('seller.sales.commissions.index', compact(
'business',
'summary',
'commissions',
'rates'
));
}
/**
* Admin view - manage all commission rates and approve commissions
*/
public function manage(Request $request, Business $business)
{
// Get pending commissions
$pendingCommissions = SalesCommission::forBusiness($business->id)
->pending()
->with(['salesRep', 'order', 'order.business'])
->orderByDesc('created_at')
->get();
// Get approved commissions ready for payment
$approvedCommissions = SalesCommission::forBusiness($business->id)
->approved()
->with(['salesRep', 'order'])
->orderByDesc('approved_at')
->get();
// Get commission rates
$rates = SalesCommissionRate::forBusiness($business->id)
->with('user')
->orderBy('rate_type')
->orderBy('user_id')
->get();
// Get sales reps for rate assignment
$salesReps = $this->getAvailableSalesReps($business);
// Summary stats
$stats = [
'pending_count' => $pendingCommissions->count(),
'pending_total' => $pendingCommissions->sum('commission_amount') / 100,
'approved_count' => $approvedCommissions->count(),
'approved_total' => $approvedCommissions->sum('commission_amount') / 100,
'paid_this_month' => SalesCommission::forBusiness($business->id)
->paid()
->whereMonth('paid_at', now()->month)
->whereYear('paid_at', now()->year)
->sum('commission_amount') / 100,
];
return view('seller.sales.commissions.manage', compact(
'business',
'pendingCommissions',
'approvedCommissions',
'rates',
'salesReps',
'stats'
));
}
/**
* Approve a commission
*/
public function approve(Request $request, Business $business, SalesCommission $commission)
{
if ($commission->business_id !== $business->id) {
abort(403);
}
$commission->approve($request->user());
return back()->with('success', 'Commission approved.');
}
/**
* Bulk approve commissions
*/
public function bulkApprove(Request $request, Business $business)
{
$validated = $request->validate([
'commission_ids' => 'required|array',
'commission_ids.*' => 'exists:sales_commissions,id',
]);
$count = SalesCommission::forBusiness($business->id)
->whereIn('id', $validated['commission_ids'])
->pending()
->update([
'status' => SalesCommission::STATUS_APPROVED,
'approved_at' => now(),
'approved_by' => $request->user()->id,
]);
return back()->with('success', "{$count} commissions approved.");
}
/**
* Mark commissions as paid
*/
public function markPaid(Request $request, Business $business)
{
$validated = $request->validate([
'commission_ids' => 'required|array',
'commission_ids.*' => 'exists:sales_commissions,id',
'payment_reference' => 'nullable|string|max:255',
]);
$count = SalesCommission::forBusiness($business->id)
->whereIn('id', $validated['commission_ids'])
->approved()
->update([
'status' => SalesCommission::STATUS_PAID,
'paid_at' => now(),
'payment_reference' => $validated['payment_reference'] ?? null,
]);
return back()->with('success', "{$count} commissions marked as paid.");
}
/**
* Store a new commission rate
*/
public function storeRate(Request $request, Business $business)
{
$validated = $request->validate([
'user_id' => 'nullable|exists:users,id',
'rate_type' => 'required|in:default,account,product,brand',
'commission_percent' => 'required|numeric|min:0|max:100',
'effective_from' => 'required|date',
'effective_to' => 'nullable|date|after:effective_from',
]);
SalesCommissionRate::create([
'business_id' => $business->id,
'user_id' => $validated['user_id'],
'rate_type' => $validated['rate_type'],
'commission_percent' => $validated['commission_percent'],
'effective_from' => $validated['effective_from'],
'effective_to' => $validated['effective_to'] ?? null,
'is_active' => true,
]);
return back()->with('success', 'Commission rate created.');
}
/**
* Update a commission rate
*/
public function updateRate(Request $request, Business $business, SalesCommissionRate $rate)
{
if ($rate->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'commission_percent' => 'required|numeric|min:0|max:100',
'effective_to' => 'nullable|date',
'is_active' => 'boolean',
]);
$rate->update([
'commission_percent' => $validated['commission_percent'],
'effective_to' => $validated['effective_to'] ?? null,
'is_active' => $validated['is_active'] ?? true,
]);
return back()->with('success', 'Commission rate updated.');
}
/**
* Delete a commission rate
*/
public function destroyRate(Request $request, Business $business, SalesCommissionRate $rate)
{
if ($rate->business_id !== $business->id) {
abort(403);
}
// Don't delete if commissions reference this rate
if ($rate->commissions()->exists()) {
$rate->update(['is_active' => false]);
return back()->with('warning', 'Rate deactivated (has existing commissions).');
}
$rate->delete();
return back()->with('success', 'Commission rate deleted.');
}
/**
* Get commission summary for a user
*/
protected function getCommissionSummary(Business $business, User $user): array
{
$baseQuery = SalesCommission::forBusiness($business->id)->forUser($user->id);
return [
'pending' => (clone $baseQuery)->pending()->sum('commission_amount') / 100,
'approved' => (clone $baseQuery)->approved()->sum('commission_amount') / 100,
'paid_this_month' => (clone $baseQuery)
->paid()
->whereMonth('paid_at', now()->month)
->whereYear('paid_at', now()->year)
->sum('commission_amount') / 100,
'paid_total' => (clone $baseQuery)->paid()->sum('commission_amount') / 100,
'total_orders' => (clone $baseQuery)->count(),
];
}
/**
* Get available sales reps for this business
*/
protected function getAvailableSalesReps(Business $business)
{
return User::whereHas('businesses', function ($q) use ($business) {
$q->where('businesses.id', $business->id);
})->orderBy('name')->get();
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers\Seller\Sales;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\CompetitorReplacement;
use App\Models\Product;
use Illuminate\Http\Request;
class CompetitorController extends Controller
{
/**
* List competitor replacements - when you see competitor X, pitch product Y
*/
public function index(Request $request, Business $business)
{
$replacements = CompetitorReplacement::forBusiness($business->id)
->with(['product', 'product.brand', 'creator'])
->orderBy('competitor_name')
->get()
->groupBy('competitor_name');
// Get products for the add form
$products = Product::whereHas('brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->with('brand')
->orderBy('name')
->get();
// Get unique competitor names for filtering
$competitors = CompetitorReplacement::forBusiness($business->id)
->distinct()
->pluck('competitor_name')
->sort();
return view('seller.sales.competitors.index', compact(
'business',
'replacements',
'products',
'competitors'
));
}
/**
* Store a new competitor replacement mapping
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'competitor_name' => 'required|string|max:255',
'competitor_product_name' => 'nullable|string|max:255',
'cannaiq_product_id' => 'nullable|string|max:255',
'product_id' => 'required|exists:products,id',
'advantage_notes' => 'nullable|string|max:2000',
]);
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
CompetitorReplacement::create([
'business_id' => $business->id,
'cannaiq_product_id' => $validated['cannaiq_product_id'] ?? uniqid('manual_'),
'competitor_name' => $validated['competitor_name'],
'competitor_product_name' => $validated['competitor_product_name'],
'product_id' => $product->id,
'advantage_notes' => $validated['advantage_notes'],
'created_by' => $request->user()->id,
]);
return back()->with('success', 'Competitor replacement added.');
}
/**
* Update a competitor replacement
*/
public function update(Request $request, Business $business, CompetitorReplacement $replacement)
{
if ($replacement->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'competitor_product_name' => 'nullable|string|max:255',
'product_id' => 'required|exists:products,id',
'advantage_notes' => 'nullable|string|max:2000',
]);
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
$replacement->update([
'competitor_product_name' => $validated['competitor_product_name'],
'product_id' => $product->id,
'advantage_notes' => $validated['advantage_notes'],
]);
return back()->with('success', 'Replacement updated.');
}
/**
* Delete a competitor replacement
*/
public function destroy(Request $request, Business $business, CompetitorReplacement $replacement)
{
if ($replacement->business_id !== $business->id) {
abort(403);
}
$replacement->delete();
return back()->with('success', 'Replacement deleted.');
}
/**
* Quick lookup - get our replacement for a competitor product
*/
public function lookup(Request $request, Business $business)
{
$competitorName = $request->get('competitor');
$productName = $request->get('product');
$query = CompetitorReplacement::forBusiness($business->id)
->with(['product', 'product.brand']);
if ($competitorName) {
$query->where('competitor_name', 'ILIKE', "%{$competitorName}%");
}
if ($productName) {
$query->where('competitor_product_name', 'ILIKE', "%{$productName}%");
}
$replacements = $query->limit(10)->get();
return response()->json([
'replacements' => $replacements->map(fn ($r) => [
'id' => $r->id,
'competitor' => $r->competitor_name,
'competitor_product' => $r->competitor_product_name,
'our_product' => $r->product->name,
'our_sku' => $r->product->sku,
'advantage' => $r->advantage_notes,
'pitch' => $r->getPitchSummary(),
]),
]);
}
}

View File

@@ -0,0 +1,285 @@
<?php
namespace App\Http\Controllers\Seller\Sales;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Order;
use App\Models\SalesRepAssignment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
/**
* Sales Rep Dashboard - My Accounts with health status
*/
public function index(Request $request, Business $business)
{
$user = $request->user();
// Cache dashboard data for 5 minutes
$cacheKey = "sales_dashboard_{$business->id}_{$user->id}";
$data = Cache::remember($cacheKey, 300, function () use ($business, $user) {
return $this->getDashboardData($business, $user);
});
$data['business'] = $business;
return view('seller.sales.dashboard.index', $data);
}
/**
* My Accounts view - accounts assigned to this sales rep
*/
public function myAccounts(Request $request, Business $business)
{
$user = $request->user();
// Get account assignments for this user
$assignments = SalesRepAssignment::forBusiness($business->id)
->forUser($user->id)
->accounts()
->with(['assignable', 'assignable.locations'])
->get();
// Get account IDs
$accountIds = $assignments->pluck('assignable_id');
// Calculate account health metrics in a single efficient query
$accountMetrics = $this->getAccountMetrics($accountIds);
// Combine assignments with metrics
$accounts = $assignments->map(function ($assignment) use ($accountMetrics) {
$accountId = $assignment->assignable_id;
$metrics = $accountMetrics[$accountId] ?? [];
return [
'assignment' => $assignment,
'account' => $assignment->assignable,
'metrics' => $metrics,
'health_status' => $this->calculateHealthStatus($metrics),
];
})->sortBy(fn ($a) => $a['health_status']['priority']);
return view('seller.sales.accounts.index', compact('business', 'accounts'));
}
/**
* Get dashboard data
*/
protected function getDashboardData(Business $business, $user): array
{
// Get assigned accounts count
$accountAssignments = SalesRepAssignment::forBusiness($business->id)
->forUser($user->id)
->accounts()
->count();
// Get assigned locations count
$locationAssignments = SalesRepAssignment::forBusiness($business->id)
->forUser($user->id)
->locations()
->count();
// Get accounts needing attention (no order in 30+ days)
$needsAttention = $this->getAccountsNeedingAttention($business, $user);
// Get accounts at risk (no order in 60+ days)
$atRisk = $this->getAccountsAtRisk($business, $user);
// Get recent orders for assigned accounts
$recentOrders = $this->getRecentOrders($business, $user, 10);
// Get commission summary
$commissionSummary = $this->getCommissionSummary($business, $user);
return [
'stats' => [
'assigned_accounts' => $accountAssignments,
'assigned_locations' => $locationAssignments,
'needs_attention' => $needsAttention->count(),
'at_risk' => $atRisk->count(),
],
'needs_attention' => $needsAttention,
'at_risk' => $atRisk,
'recent_orders' => $recentOrders,
'commission_summary' => $commissionSummary,
];
}
/**
* Get account metrics for multiple accounts efficiently
*/
protected function getAccountMetrics($accountIds): array
{
if ($accountIds->isEmpty()) {
return [];
}
// Get order metrics per account
$orderMetrics = Order::whereIn('business_id', $accountIds)
->where('status', 'completed')
->groupBy('business_id')
->selectRaw('business_id,
COUNT(*) as order_count,
SUM(total) as total_revenue,
MAX(created_at) as last_order_date,
AVG(EXTRACT(EPOCH FROM (created_at - LAG(created_at) OVER (PARTITION BY business_id ORDER BY created_at)))) as avg_order_interval_seconds
')
->get()
->keyBy('business_id');
return $orderMetrics->mapWithKeys(function ($metrics, $accountId) {
return [$accountId => [
'order_count' => $metrics->order_count,
'total_revenue' => $metrics->total_revenue ?? 0,
'last_order_date' => $metrics->last_order_date,
'days_since_last_order' => $metrics->last_order_date
? now()->diffInDays($metrics->last_order_date)
: null,
'avg_order_interval_days' => $metrics->avg_order_interval_seconds
? round($metrics->avg_order_interval_seconds / 86400)
: null,
]];
})->all();
}
/**
* Calculate health status based on metrics
*/
protected function calculateHealthStatus(array $metrics): array
{
$daysSinceOrder = $metrics['days_since_last_order'] ?? null;
$avgInterval = $metrics['avg_order_interval_days'] ?? 30;
if ($daysSinceOrder === null) {
return [
'status' => 'new',
'label' => 'New Account',
'color' => 'info',
'priority' => 2,
];
}
// At risk: More than 2x their average order interval, or 60+ days
if ($daysSinceOrder >= max($avgInterval * 2, 60)) {
return [
'status' => 'at_risk',
'label' => 'At Risk',
'color' => 'error',
'priority' => 0,
];
}
// Needs attention: More than 1.5x their average order interval, or 30+ days
if ($daysSinceOrder >= max($avgInterval * 1.5, 30)) {
return [
'status' => 'needs_attention',
'label' => 'Needs Attention',
'color' => 'warning',
'priority' => 1,
];
}
return [
'status' => 'healthy',
'label' => 'Healthy',
'color' => 'success',
'priority' => 3,
];
}
/**
* Get accounts needing attention (no order in 30-59 days)
*/
protected function getAccountsNeedingAttention(Business $business, $user)
{
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
->forUser($user->id)
->accounts()
->pluck('assignable_id');
return Business::whereIn('id', $assignedAccountIds)
->whereHas('orders', function ($q) {
$q->where('status', 'completed')
->where('created_at', '<', now()->subDays(30))
->where('created_at', '>=', now()->subDays(60));
})
->orWhereDoesntHave('orders')
->with('locations')
->limit(10)
->get();
}
/**
* Get accounts at risk (no order in 60+ days)
*/
protected function getAccountsAtRisk(Business $business, $user)
{
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
->forUser($user->id)
->accounts()
->pluck('assignable_id');
return Business::whereIn('id', $assignedAccountIds)
->whereHas('orders', function ($q) {
$q->where('status', 'completed')
->where('created_at', '<', now()->subDays(60));
})
->with('locations')
->limit(10)
->get();
}
/**
* Get recent orders for assigned accounts
*/
protected function getRecentOrders(Business $business, $user, int $limit)
{
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
->forUser($user->id)
->accounts()
->pluck('assignable_id');
return Order::whereIn('business_id', $assignedAccountIds)
->with(['business', 'items.product'])
->orderByDesc('created_at')
->limit($limit)
->get();
}
/**
* Get commission summary for the current user
*/
protected function getCommissionSummary(Business $business, $user): array
{
$pendingCommission = DB::table('sales_commissions')
->where('business_id', $business->id)
->where('user_id', $user->id)
->where('status', 'pending')
->sum('commission_amount');
$approvedCommission = DB::table('sales_commissions')
->where('business_id', $business->id)
->where('user_id', $user->id)
->where('status', 'approved')
->sum('commission_amount');
$paidThisMonth = DB::table('sales_commissions')
->where('business_id', $business->id)
->where('user_id', $user->id)
->where('status', 'paid')
->whereMonth('paid_at', now()->month)
->whereYear('paid_at', now()->year)
->sum('commission_amount');
return [
'pending' => $pendingCommission / 100,
'approved' => $approvedCommission / 100,
'paid_this_month' => $paidThisMonth / 100,
];
}
}

View File

@@ -0,0 +1,334 @@
<?php
namespace App\Http\Controllers\Seller\Sales;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmLead;
use App\Models\Order;
use App\Models\SalesRepAssignment;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ExportController extends Controller
{
/**
* Export accounts assigned to the current sales rep as CSV.
*/
public function accounts(Business $business): StreamedResponse
{
$user = Auth::user();
// Get assigned account IDs
$assignedAccountIds = SalesRepAssignment::where('business_id', $business->id)
->where('user_id', $user->id)
->where('assignable_type', Business::class)
->pluck('assignable_id');
$accounts = Business::whereIn('id', $assignedAccountIds)
->with(['locations', 'contacts'])
->get();
$filename = 'my-accounts-'.now()->format('Y-m-d').'.csv';
return $this->streamCsv($filename, function () use ($accounts, $business) {
$handle = fopen('php://output', 'w');
// Header row
fputcsv($handle, [
'Account Name',
'Status',
'Primary Location',
'City',
'State',
'ZIP',
'Primary Contact',
'Email',
'Phone',
'Last Order Date',
'Last Order Total',
'Days Since Order',
]);
foreach ($accounts as $account) {
$location = $account->locations->first();
$contact = $account->contacts->first();
// Get last order
$lastOrder = Order::where('business_id', $account->id)
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->orderBy('created_at', 'desc')
->first();
fputcsv($handle, [
$account->name,
$account->status ?? 'active',
$location?->name ?? '',
$location?->city ?? '',
$location?->state ?? '',
$location?->zipcode ?? '',
$contact?->name ?? '',
$contact?->email ?? '',
$contact?->phone ?? '',
$lastOrder?->created_at?->format('Y-m-d') ?? '',
$lastOrder ? '$'.number_format($lastOrder->total / 100, 2) : '',
$lastOrder ? now()->diffInDays($lastOrder->created_at) : '',
]);
}
fclose($handle);
});
}
/**
* Export a single account's order history for meeting prep.
*/
public function accountHistory(Business $business, Business $account): StreamedResponse
{
// Verify assignment
$isAssigned = SalesRepAssignment::where('business_id', $business->id)
->where('user_id', Auth::id())
->where('assignable_type', Business::class)
->where('assignable_id', $account->id)
->exists();
if (! $isAssigned) {
abort(403, 'Account not assigned to you');
}
$orders = Order::where('business_id', $account->id)
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->with(['items.product'])
->orderBy('created_at', 'desc')
->limit(50)
->get();
$filename = Str::slug($account->name).'-history-'.now()->format('Y-m-d').'.csv';
return $this->streamCsv($filename, function () use ($orders, $account) {
$handle = fopen('php://output', 'w');
// Account summary header
fputcsv($handle, ['Account Summary: '.$account->name]);
fputcsv($handle, ['Generated: '.now()->format('F j, Y')]);
fputcsv($handle, ['Total Orders: '.$orders->count()]);
fputcsv($handle, ['']);
// Order details header
fputcsv($handle, [
'Order Number',
'Date',
'Status',
'Product',
'SKU',
'Quantity',
'Unit Price',
'Line Total',
'Order Total',
]);
foreach ($orders as $order) {
$firstItem = true;
foreach ($order->items as $item) {
fputcsv($handle, [
$firstItem ? $order->order_number : '',
$firstItem ? $order->created_at->format('Y-m-d') : '',
$firstItem ? ucfirst($order->status) : '',
$item->product?->name ?? 'Unknown',
$item->product?->sku ?? '',
$item->quantity,
'$'.number_format($item->price / 100, 2),
'$'.number_format(($item->price * $item->quantity) / 100, 2),
$firstItem ? '$'.number_format($order->total / 100, 2) : '',
]);
$firstItem = false;
}
}
fclose($handle);
});
}
/**
* Export prospect data with insights for pitch preparation.
*/
public function prospects(Business $business): StreamedResponse
{
$user = Auth::user();
$leads = CrmLead::where('business_id', $business->id)
->where('assigned_to', $user->id)
->with(['insights'])
->get();
$filename = 'prospects-'.now()->format('Y-m-d').'.csv';
return $this->streamCsv($filename, function () use ($leads) {
$handle = fopen('php://output', 'w');
// Header row
fputcsv($handle, [
'Company Name',
'Contact Name',
'Email',
'Phone',
'City',
'State',
'Status',
'Source',
'License Number',
'Gaps',
'Pain Points',
'Opportunities',
'Notes',
]);
foreach ($leads as $lead) {
$gaps = $lead->insights->where('insight_type', 'gap')->pluck('description')->implode('; ');
$painPoints = $lead->insights->where('insight_type', 'pain_point')->pluck('description')->implode('; ');
$opportunities = $lead->insights->where('insight_type', 'opportunity')->pluck('description')->implode('; ');
fputcsv($handle, [
$lead->company_name,
$lead->contact_name ?? '',
$lead->email ?? '',
$lead->phone ?? '',
$lead->city ?? '',
$lead->state ?? '',
ucfirst($lead->status),
$lead->source ?? '',
$lead->license_number ?? '',
$gaps,
$painPoints,
$opportunities,
$lead->notes ?? '',
]);
}
fclose($handle);
});
}
/**
* Export competitor replacement data for sales training.
*/
public function competitors(Business $business): StreamedResponse
{
$replacements = \App\Models\CompetitorReplacement::where('business_id', $business->id)
->with(['product.brand'])
->orderBy('competitor_name')
->get();
$filename = 'competitor-replacements-'.now()->format('Y-m-d').'.csv';
return $this->streamCsv($filename, function () use ($replacements) {
$handle = fopen('php://output', 'w');
fputcsv($handle, [
'Competitor Brand',
'Competitor Product',
'Our Product',
'Our SKU',
'Our Brand',
'Why Ours is Better',
]);
foreach ($replacements as $replacement) {
fputcsv($handle, [
$replacement->competitor_name,
$replacement->competitor_product_name ?? 'Any product',
$replacement->product->name,
$replacement->product->sku,
$replacement->product->brand?->name ?? '',
$replacement->advantage_notes ?? '',
]);
}
fclose($handle);
});
}
/**
* Generate a pitch builder export for a specific prospect.
*/
public function pitchBuilder(Business $business, CrmLead $lead): StreamedResponse
{
// Verify assignment
if ($lead->assigned_to !== Auth::id()) {
abort(403, 'Lead not assigned to you');
}
// Get similar successful accounts for reference
$successStories = Business::where('type', 'buyer')
->whereHas('orders', function ($query) use ($business) {
$query->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->where('created_at', '>=', now()->subMonths(3));
})
->when($lead->city, fn ($q) => $q->whereHas('locations', fn ($l) => $l->where('city', $lead->city)))
->with(['locations'])
->limit(5)
->get();
$filename = 'pitch-'.Str::slug($lead->company_name).'-'.now()->format('Y-m-d').'.csv';
return $this->streamCsv($filename, function () use ($lead, $successStories) {
$handle = fopen('php://output', 'w');
// Prospect info
fputcsv($handle, ['PITCH PREPARATION: '.$lead->company_name]);
fputcsv($handle, ['Generated: '.now()->format('F j, Y')]);
fputcsv($handle, ['']);
// Contact info
fputcsv($handle, ['CONTACT INFORMATION']);
fputcsv($handle, ['Contact Name', $lead->contact_name ?? 'N/A']);
fputcsv($handle, ['Email', $lead->email ?? 'N/A']);
fputcsv($handle, ['Phone', $lead->phone ?? 'N/A']);
fputcsv($handle, ['Location', ($lead->city ?? '').($lead->city && $lead->state ? ', ' : '').($lead->state ?? '')]);
fputcsv($handle, ['License', $lead->license_number ?? 'N/A']);
fputcsv($handle, ['']);
// Insights
fputcsv($handle, ['IDENTIFIED GAPS & OPPORTUNITIES']);
foreach ($lead->insights as $insight) {
fputcsv($handle, [
ucfirst(str_replace('_', ' ', $insight->insight_type)),
$insight->description,
]);
}
fputcsv($handle, ['']);
// Success stories
fputcsv($handle, ['SIMILAR SUCCESSFUL ACCOUNTS (Reference for pitch)']);
fputcsv($handle, ['Account Name', 'Location']);
foreach ($successStories as $account) {
$location = $account->locations->first();
fputcsv($handle, [
$account->name,
($location?->city ?? '').($location?->city && $location?->state ? ', ' : '').($location?->state ?? ''),
]);
}
fputcsv($handle, ['']);
fputcsv($handle, ['NOTES']);
fputcsv($handle, [$lead->notes ?? 'No additional notes']);
fclose($handle);
});
}
/**
* Helper to stream a CSV response.
*/
private function streamCsv(string $filename, callable $callback): StreamedResponse
{
return response()->stream($callback, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
'Pragma' => 'no-cache',
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
'Expires' => '0',
]);
}
}

View File

@@ -0,0 +1,314 @@
<?php
namespace App\Http\Controllers\Seller\Sales;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmLead;
use App\Models\ProspectImport;
use App\Models\ProspectInsight;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ProspectController extends Controller
{
/**
* List prospects (leads) assigned to this sales rep
*/
public function index(Request $request, Business $business)
{
$user = $request->user();
// Get leads assigned to this user
$leads = CrmLead::where('seller_business_id', $business->id)
->where('assigned_to', $user->id)
->with('insights')
->orderByDesc('created_at')
->paginate(20);
// Get insight counts by type
$insightCounts = ProspectInsight::forBusiness($business->id)
->whereNotNull('lead_id')
->selectRaw('insight_type, COUNT(*) as count')
->groupBy('insight_type')
->pluck('count', 'insight_type');
return view('seller.sales.prospects.index', compact(
'business',
'leads',
'insightCounts'
));
}
/**
* Show prospect detail with insights
*/
public function show(Request $request, Business $business, CrmLead $lead)
{
if ($lead->seller_business_id !== $business->id) {
abort(403);
}
$lead->load('insights.creator');
// Get similar successful accounts for reference
$successStories = $this->findSimilarSuccessStories($business, $lead);
return view('seller.sales.prospects.show', compact(
'business',
'lead',
'successStories'
));
}
/**
* Add insight to a prospect
*/
public function storeInsight(Request $request, Business $business, CrmLead $lead)
{
if ($lead->seller_business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'insight_type' => 'required|in:gap,pain_point,opportunity,objection,competitor_weakness',
'category' => 'nullable|in:price_point,quality,consistency,service,margin,reliability,selection',
'description' => 'required|string|max:2000',
]);
ProspectInsight::create([
'business_id' => $business->id,
'lead_id' => $lead->id,
'insight_type' => $validated['insight_type'],
'category' => $validated['category'],
'description' => $validated['description'],
'created_by' => $request->user()->id,
]);
return back()->with('success', 'Insight added.');
}
/**
* Delete an insight
*/
public function destroyInsight(Request $request, Business $business, ProspectInsight $insight)
{
if ($insight->business_id !== $business->id) {
abort(403);
}
$insight->delete();
return back()->with('success', 'Insight deleted.');
}
/**
* Show import history and upload form
*/
public function imports(Request $request, Business $business)
{
$imports = ProspectImport::forBusiness($business->id)
->with('importer')
->orderByDesc('created_at')
->paginate(10);
return view('seller.sales.prospects.imports', compact('business', 'imports'));
}
/**
* Upload and process import file
*/
public function upload(Request $request, Business $business)
{
$validated = $request->validate([
'file' => 'required|file|mimes:csv,txt|max:5120', // 5MB max
]);
$file = $request->file('file');
$filename = $file->getClientOriginalName();
$path = $file->store("imports/{$business->id}", 'local');
// Count rows
$content = file_get_contents($file->getRealPath());
$lines = explode("\n", trim($content));
$totalRows = count($lines) - 1; // Exclude header
// Create import record
$import = ProspectImport::create([
'business_id' => $business->id,
'user_id' => $request->user()->id,
'filename' => $filename,
'status' => ProspectImport::STATUS_PENDING,
'total_rows' => max(0, $totalRows),
'processed_rows' => 0,
'created_count' => 0,
'updated_count' => 0,
'skipped_count' => 0,
'error_count' => 0,
]);
// Get headers for mapping
$headers = str_getcsv($lines[0]);
return view('seller.sales.prospects.map-columns', compact(
'business',
'import',
'headers',
'path'
));
}
/**
* Process import with column mapping
*/
public function processImport(Request $request, Business $business, ProspectImport $import)
{
if ($import->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'mapping' => 'required|array',
'mapping.company_name' => 'required|string',
'path' => 'required|string',
]);
$import->update([
'column_mapping' => $validated['mapping'],
'status' => ProspectImport::STATUS_PROCESSING,
]);
// Process synchronously for now (could dispatch to queue for large files)
$this->processImportFile($import, $validated['path'], $validated['mapping']);
return redirect()
->route('seller.business.sales.prospects.imports', $business)
->with('success', "Import completed. {$import->created_count} created, {$import->updated_count} updated, {$import->error_count} errors.");
}
/**
* Process the import file
*/
protected function processImportFile(ProspectImport $import, string $path, array $mapping): void
{
$content = Storage::disk('local')->get($path);
$lines = explode("\n", trim($content));
$headers = str_getcsv(array_shift($lines));
// Create column index map
$columnMap = [];
foreach ($mapping as $field => $column) {
$index = array_search($column, $headers);
if ($index !== false) {
$columnMap[$field] = $index;
}
}
foreach ($lines as $lineNum => $line) {
if (empty(trim($line))) {
continue;
}
$row = str_getcsv($line);
$import->incrementProcessed();
try {
$companyName = $row[$columnMap['company_name']] ?? null;
if (! $companyName) {
$import->addError($lineNum + 2, 'Missing company name');
continue;
}
// Check for duplicate
$existing = CrmLead::where('seller_business_id', $import->business_id)
->where('company_name', $companyName)
->first();
if ($existing) {
// Update existing
$this->updateLeadFromRow($existing, $row, $columnMap);
$import->incrementUpdated();
} else {
// Create new
$this->createLeadFromRow($import, $row, $columnMap);
$import->incrementCreated();
}
} catch (\Exception $e) {
$import->addError($lineNum + 2, $e->getMessage());
}
}
$import->markCompleted();
// Clean up file
Storage::disk('local')->delete($path);
}
/**
* Create a new lead from import row
*/
protected function createLeadFromRow(ProspectImport $import, array $row, array $columnMap): CrmLead
{
return CrmLead::create([
'seller_business_id' => $import->business_id,
'company_name' => $row[$columnMap['company_name']] ?? null,
'contact_name' => $row[$columnMap['contact_name'] ?? -1] ?? null,
'email' => $row[$columnMap['email'] ?? -1] ?? null,
'phone' => $row[$columnMap['phone'] ?? -1] ?? null,
'address' => $row[$columnMap['address'] ?? -1] ?? null,
'city' => $row[$columnMap['city'] ?? -1] ?? null,
'state' => $row[$columnMap['state'] ?? -1] ?? null,
'zipcode' => $row[$columnMap['zipcode'] ?? -1] ?? null,
'license_number' => $row[$columnMap['license_number'] ?? -1] ?? null,
'notes' => $row[$columnMap['notes'] ?? -1] ?? null,
'source' => 'import',
'status' => 'new',
'assigned_to' => $import->user_id,
]);
}
/**
* Update existing lead from import row
*/
protected function updateLeadFromRow(CrmLead $lead, array $row, array $columnMap): void
{
$updates = [];
foreach (['contact_name', 'email', 'phone', 'address', 'city', 'state', 'zipcode', 'license_number'] as $field) {
if (isset($columnMap[$field]) && ! empty($row[$columnMap[$field]])) {
$updates[$field] = $row[$columnMap[$field]];
}
}
if (! empty($updates)) {
$lead->update($updates);
}
}
/**
* Find similar successful accounts for a prospect
*/
protected function findSimilarSuccessStories(Business $business, CrmLead $lead): \Illuminate\Support\Collection
{
// Get successful accounts in same city/state
$query = Business::query()
->whereHas('orders', function ($q) {
$q->where('status', 'completed');
})
->with('locations');
if ($lead->city) {
$query->whereHas('locations', function ($q) use ($lead) {
$q->where('city', 'ILIKE', "%{$lead->city}%");
});
} elseif ($lead->state) {
$query->whereHas('locations', function ($q) use ($lead) {
$q->where('state', $lead->state);
});
}
return $query->limit(5)->get();
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Seller\Sales;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Services\Sales\ReorderPredictionService;
use Illuminate\Http\Request;
class ReorderController extends Controller
{
public function __construct(
protected ReorderPredictionService $reorderService
) {}
/**
* Reorder Alerts - accounts approaching their reorder window
*/
public function index(Request $request, Business $business)
{
$user = $request->user();
// Get accounts approaching reorder with predictions
$accounts = $this->reorderService->getReorderAlerts($business->id, $user->id);
// Separate into categories
$overdue = $accounts->filter(fn ($a) => ($a['days_until_predicted_order'] ?? 0) < 0);
$dueSoon = $accounts->filter(fn ($a) => ($a['days_until_predicted_order'] ?? 0) >= 0 && ($a['days_until_predicted_order'] ?? 999) <= 7);
$upcoming = $accounts->filter(fn ($a) => ($a['days_until_predicted_order'] ?? 999) > 7 && ($a['days_until_predicted_order'] ?? 999) <= 14);
return view('seller.sales.reorders.index', compact(
'business',
'accounts',
'overdue',
'dueSoon',
'upcoming'
));
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Http\Controllers\Seller\Sales;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\SalesTerritory;
use App\Models\SalesTerritoryArea;
use App\Models\SalesTerritoryAssignment;
use App\Models\User;
use Illuminate\Http\Request;
class TerritoryController extends Controller
{
/**
* List all territories for this business
*/
public function index(Request $request, Business $business)
{
$territories = SalesTerritory::forBusiness($business->id)
->with(['areas', 'salesReps'])
->withCount('assignments')
->orderBy('name')
->get();
// Get available sales reps for assignment
$salesReps = $this->getAvailableSalesReps($business);
return view('seller.sales.territories.index', compact('business', 'territories', 'salesReps'));
}
/**
* Show create territory form
*/
public function create(Request $request, Business $business)
{
$salesReps = $this->getAvailableSalesReps($business);
return view('seller.sales.territories.create', compact('business', 'salesReps'));
}
/**
* Store a new territory
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'color' => 'required|string|max:7',
'areas' => 'nullable|array',
'areas.*.type' => 'required_with:areas|in:zip,city,state,county',
'areas.*.value' => 'required_with:areas|string|max:255',
'primary_rep_id' => 'nullable|exists:users,id',
]);
$territory = SalesTerritory::create([
'business_id' => $business->id,
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'color' => $validated['color'],
'is_active' => true,
]);
// Add areas
if (! empty($validated['areas'])) {
foreach ($validated['areas'] as $area) {
SalesTerritoryArea::create([
'territory_id' => $territory->id,
'area_type' => $area['type'],
'area_value' => $area['value'],
]);
}
}
// Assign primary rep
if (! empty($validated['primary_rep_id'])) {
SalesTerritoryAssignment::create([
'territory_id' => $territory->id,
'user_id' => $validated['primary_rep_id'],
'assignment_type' => 'primary',
'assigned_at' => now(),
'assigned_by' => $request->user()->id,
]);
}
return redirect()
->route('seller.business.sales.territories', $business)
->with('success', 'Territory created successfully.');
}
/**
* Show edit territory form
*/
public function edit(Request $request, Business $business, SalesTerritory $territory)
{
// Verify territory belongs to this business
if ($territory->business_id !== $business->id) {
abort(403);
}
$territory->load(['areas', 'salesReps']);
$salesReps = $this->getAvailableSalesReps($business);
return view('seller.sales.territories.edit', compact('business', 'territory', 'salesReps'));
}
/**
* Update a territory
*/
public function update(Request $request, Business $business, SalesTerritory $territory)
{
// Verify territory belongs to this business
if ($territory->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'color' => 'required|string|max:7',
'is_active' => 'boolean',
'areas' => 'nullable|array',
'areas.*.type' => 'required_with:areas|in:zip,city,state,county',
'areas.*.value' => 'required_with:areas|string|max:255',
'primary_rep_id' => 'nullable|exists:users,id',
]);
$territory->update([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'color' => $validated['color'],
'is_active' => $validated['is_active'] ?? true,
]);
// Replace areas
$territory->areas()->delete();
if (! empty($validated['areas'])) {
foreach ($validated['areas'] as $area) {
SalesTerritoryArea::create([
'territory_id' => $territory->id,
'area_type' => $area['type'],
'area_value' => $area['value'],
]);
}
}
// Update primary rep
$territory->assignments()->where('assignment_type', 'primary')->delete();
if (! empty($validated['primary_rep_id'])) {
SalesTerritoryAssignment::create([
'territory_id' => $territory->id,
'user_id' => $validated['primary_rep_id'],
'assignment_type' => 'primary',
'assigned_at' => now(),
'assigned_by' => $request->user()->id,
]);
}
return redirect()
->route('seller.business.sales.territories', $business)
->with('success', 'Territory updated successfully.');
}
/**
* Delete a territory
*/
public function destroy(Request $request, Business $business, SalesTerritory $territory)
{
// Verify territory belongs to this business
if ($territory->business_id !== $business->id) {
abort(403);
}
$territory->areas()->delete();
$territory->assignments()->delete();
$territory->delete();
return redirect()
->route('seller.business.sales.territories', $business)
->with('success', 'Territory deleted successfully.');
}
/**
* Get available sales reps for this business
*/
protected function getAvailableSalesReps(Business $business)
{
return User::whereHas('businesses', function ($q) use ($business) {
$q->where('businesses.id', $business->id);
})->orderBy('name')->get();
}
}

View File

@@ -55,12 +55,16 @@ class SearchController extends Controller
/**
* Search contacts for a specific customer or the seller's own contacts.
*
* GET /s/{business}/search/contacts?q=...&customer_id=...
* GET /s/{business}/search/contacts?q=...&customer_id=...&location_id=...
*
* If location_id is provided, returns only contacts assigned to that location
* via the location_contact pivot table.
*/
public function contacts(Request $request, Business $business): JsonResponse
{
$query = $request->input('q', '');
$customerId = $request->input('customer_id');
$locationId = $request->input('location_id');
$contactsQuery = Contact::query()
->where('is_active', true);
@@ -73,6 +77,13 @@ class SearchController extends Controller
$contactsQuery->where('business_id', $business->id);
}
// If location_id is provided, filter to contacts assigned to that location
if ($locationId) {
$contactsQuery->whereHas('locations', function ($q) use ($locationId) {
$q->where('locations.id', $locationId);
});
}
$contacts = $contactsQuery
->when($query, function ($q) use ($query) {
$q->where(function ($q2) use ($query) {
@@ -87,6 +98,26 @@ class SearchController extends Controller
->limit(25)
->get(['id', 'first_name', 'last_name', 'email', 'title']);
// If filtering by location, include pivot data for is_primary
if ($locationId) {
// Reload contacts with pivot data
$contactIds = $contacts->pluck('id')->toArray();
$pivotData = \DB::table('location_contact')
->whereIn('contact_id', $contactIds)
->where('location_id', $locationId)
->get()
->keyBy('contact_id');
return response()->json(
$contacts->map(fn ($c) => [
'value' => $c->id,
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
'is_primary' => $pivotData[$c->id]->is_primary ?? false,
'role' => $pivotData[$c->id]->role ?? null,
])
);
}
return response()->json(
$contacts->map(fn ($c) => [
'value' => $c->id,

View File

@@ -147,8 +147,8 @@ class SettingsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
}
@@ -216,7 +216,7 @@ class SettingsController extends Controller
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
'position' => $validated['position'] ?? null,
'user_type' => $business->business_type, // Match business type
'user_type' => 'seller', // Users in seller area are sellers
'password' => bcrypt(str()->random(32)), // Temporary password
]);
@@ -917,11 +917,11 @@ class SettingsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('event', 'like', "%{$search}%")
$q->where('description', 'ilike', "%{$search}%")
->orWhere('event', 'ilike', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search) {
$userQuery->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$userQuery->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
});
}
@@ -1123,11 +1123,11 @@ class SettingsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('event', 'like', "%{$search}%")
$q->where('description', 'ilike', "%{$search}%")
->orWhere('event', 'ilike', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search) {
$userQuery->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$userQuery->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
});
}

View File

@@ -29,9 +29,9 @@ class StoreBrandRequest extends FormRequest
return [
'name' => 'required|string|max:255',
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
'description' => ['nullable', 'string', 'min:100', 'max:150'],
'long_description' => ['nullable', 'string', 'max:500'],
'tagline' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'long_description' => ['nullable', 'string', 'max:5000'],
'brand_announcement' => ['nullable', 'string', 'max:500'],
'website_url' => 'nullable|string|max:255',

View File

@@ -30,9 +30,9 @@ class UpdateBrandRequest extends FormRequest
return [
'name' => 'required|string|max:255',
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
'description' => ['nullable', 'string', 'min:100', 'max:150'],
'long_description' => ['nullable', 'string', 'max:500'],
'tagline' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'long_description' => ['nullable', 'string', 'max:5000'],
'brand_announcement' => ['nullable', 'string', 'max:500'],
'website_url' => 'nullable|string|max:255',

View File

@@ -0,0 +1,159 @@
<?php
namespace App\Jobs;
use App\Models\Brand;
use App\Models\Business;
use App\Services\Cannaiq\BrandAnalysisService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Background job to pre-calculate Brand Analysis metrics.
*
* This job runs in the background to compute expensive engagement and sentiment
* metrics for brands, caching the results for 2 hours. This prevents N+1 queries
* and expensive aggregations from running on page load.
*
* Schedule: Every 2 hours via Horizon
* Queue: default (or 'analytics' if available)
*
* Key benefits:
* - Aggregates CRM message counts, response rates, and quote/order metrics in batch
* - Pre-computes buyer engagement scores
* - For CannaiQ-enabled businesses, also pre-computes sentiment scores
* - Uses existing BrandAnalysisService caching mechanism (2-hour TTL)
*/
class CalculateBrandAnalysisMetrics implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The business to calculate metrics for (null = all seller businesses)
*/
public ?int $businessId;
/**
* The brand to calculate metrics for (null = all brands in business)
*/
public ?int $brandId;
/**
* Create a new job instance.
*/
public function __construct(?int $businessId = null, ?int $brandId = null)
{
$this->businessId = $businessId;
$this->brandId = $brandId;
}
/**
* Execute the job.
*/
public function handle(BrandAnalysisService $service): void
{
$startTime = microtime(true);
$processedCount = 0;
try {
if ($this->businessId && $this->brandId) {
// Single brand calculation
$this->calculateForBrand($service, $this->businessId, $this->brandId);
$processedCount = 1;
} elseif ($this->businessId) {
// All brands for a single business
$processedCount = $this->calculateForBusiness($service, $this->businessId);
} else {
// All seller businesses with active brands
$processedCount = $this->calculateForAllBusinesses($service);
}
$duration = round(microtime(true) - $startTime, 2);
Log::info('CalculateBrandAnalysisMetrics completed', [
'business_id' => $this->businessId ?? 'all',
'brand_id' => $this->brandId ?? 'all',
'brands_processed' => $processedCount,
'duration_seconds' => $duration,
]);
} catch (\Exception $e) {
Log::error('CalculateBrandAnalysisMetrics failed', [
'business_id' => $this->businessId,
'brand_id' => $this->brandId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Calculate metrics for all seller businesses
*/
private function calculateForAllBusinesses(BrandAnalysisService $service): int
{
$processedCount = 0;
Business::where('type', 'seller')
->where('status', 'approved')
->chunk(10, function ($businesses) use ($service, &$processedCount) {
foreach ($businesses as $business) {
$processedCount += $this->calculateForBusiness($service, $business->id);
}
});
return $processedCount;
}
/**
* Calculate metrics for all active brands in a business
*/
private function calculateForBusiness(BrandAnalysisService $service, int $businessId): int
{
$business = Business::find($businessId);
if (! $business) {
return 0;
}
$brands = Brand::where('business_id', $businessId)
->where('is_active', true)
->get();
foreach ($brands as $brand) {
$this->calculateForBrand($service, $businessId, $brand->id);
}
return $brands->count();
}
/**
* Calculate metrics for a single brand
*/
private function calculateForBrand(BrandAnalysisService $service, int $businessId, int $brandId): void
{
$business = Business::find($businessId);
$brand = Brand::find($brandId);
if (! $business || ! $brand) {
return;
}
// This triggers the full analysis calculation and caches it
// The BrandAnalysisService handles caching internally with 2-hour TTL
$service->refreshAnalysis($brand, $business);
}
/**
* The job failed to process.
*/
public function failed(\Throwable $exception): void
{
Log::error('CalculateBrandAnalysisMetrics job failed', [
'business_id' => $this->businessId,
'brand_id' => $this->brandId,
'exception' => $exception->getMessage(),
]);
}
}

127
app/Models/AccountNote.php Normal file
View File

@@ -0,0 +1,127 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Account Note - Sales rep notes on buyer accounts
*
* @property int $id
* @property int $business_id
* @property int $account_id
* @property int $user_id
* @property string $note_type
* @property string $content
* @property bool $is_pinned
*/
class AccountNote extends Model
{
public const TYPE_GENERAL = 'general';
public const TYPE_COMPETITOR = 'competitor';
public const TYPE_PAIN_POINT = 'pain_point';
public const TYPE_OPPORTUNITY = 'opportunity';
public const TYPE_OBJECTION = 'objection';
public const TYPES = [
self::TYPE_GENERAL => 'General',
self::TYPE_COMPETITOR => 'Competitor Intel',
self::TYPE_PAIN_POINT => 'Pain Point',
self::TYPE_OPPORTUNITY => 'Opportunity',
self::TYPE_OBJECTION => 'Objection',
];
protected $fillable = [
'business_id',
'account_id',
'user_id',
'note_type',
'content',
'is_pinned',
];
protected $casts = [
'is_pinned' => 'boolean',
];
// ==================== Relationships ====================
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function account(): BelongsTo
{
return $this->belongsTo(Business::class, 'account_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
// ==================== Scopes ====================
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForAccount($query, int $accountId)
{
return $query->where('account_id', $accountId);
}
public function scopePinned($query)
{
return $query->where('is_pinned', true);
}
public function scopeOfType($query, string $type)
{
return $query->where('note_type', $type);
}
public function scopeCompetitor($query)
{
return $query->where('note_type', self::TYPE_COMPETITOR);
}
public function scopePainPoints($query)
{
return $query->where('note_type', self::TYPE_PAIN_POINT);
}
public function scopeOpportunities($query)
{
return $query->where('note_type', self::TYPE_OPPORTUNITY);
}
// ==================== Helpers ====================
public function getTypeLabel(): string
{
return self::TYPES[$this->note_type] ?? ucfirst($this->note_type);
}
public function pin(): void
{
$this->update(['is_pinned' => true]);
}
public function unpin(): void
{
$this->update(['is_pinned' => false]);
}
}

View File

@@ -93,7 +93,7 @@ class InterBusinessSettlement extends Model
return DB::transaction(function () use ($parentBusinessId, $prefix) {
$lastSettlement = static::where('parent_business_id', $parentBusinessId)
->where('settlement_number', 'like', "{$prefix}%")
->where('settlement_number', 'ilike', "{$prefix}%")
->orderByDesc('settlement_number')
->lockForUpdate()
->first();

View File

@@ -204,7 +204,7 @@ class JournalEntry extends Model implements AuditableContract
// Get the last entry for this business+day, ordered by entry_number descending
// Lock the row to serialize concurrent access (PostgreSQL-safe)
$lastEntry = static::where('business_id', $businessId)
->where('entry_number', 'like', "{$prefix}%")
->where('entry_number', 'ilike', "{$prefix}%")
->orderByDesc('entry_number')
->lockForUpdate()
->first();

View File

@@ -158,7 +158,7 @@ class Activity extends Model
*/
public function scopeOfTypeGroup($query, string $prefix)
{
return $query->where('type', 'like', $prefix.'%');
return $query->where('type', 'ilike', $prefix.'%');
}
/**

View File

@@ -151,7 +151,7 @@ class Address extends Model
public function scopeInCity($query, string $city)
{
return $query->where('city', 'like', "%{$city}%");
return $query->where('city', 'ilike', "%{$city}%");
}
// Helper Methods

111
app/Models/AgentStatus.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AgentStatus extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'business_id',
'status',
'status_message',
'last_seen_at',
'status_changed_at',
];
protected $casts = [
'last_seen_at' => 'datetime',
'status_changed_at' => 'datetime',
];
public const STATUS_ONLINE = 'online';
public const STATUS_AWAY = 'away';
public const STATUS_BUSY = 'busy';
public const STATUS_OFFLINE = 'offline';
public static function statuses(): array
{
return [
self::STATUS_ONLINE => 'Online',
self::STATUS_AWAY => 'Away',
self::STATUS_BUSY => 'Busy',
self::STATUS_OFFLINE => 'Offline',
];
}
public static function statusColors(): array
{
return [
self::STATUS_ONLINE => 'success',
self::STATUS_AWAY => 'warning',
self::STATUS_BUSY => 'error',
self::STATUS_OFFLINE => 'ghost',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public static function getOrCreate(int $userId, int $businessId): self
{
return self::firstOrCreate(
['user_id' => $userId, 'business_id' => $businessId],
['status' => self::STATUS_OFFLINE, 'status_changed_at' => now()]
);
}
public function setStatus(string $status, ?string $message = null): self
{
$this->update([
'status' => $status,
'status_message' => $message,
'status_changed_at' => now(),
]);
return $this;
}
public function isOnline(): bool
{
return $this->status === self::STATUS_ONLINE;
}
public function getStatusColor(): string
{
return self::statusColors()[$this->status] ?? 'ghost';
}
/**
* Update last_seen_at timestamp (for heartbeat)
*/
public function updateLastSeen(): self
{
$this->update(['last_seen_at' => now()]);
return $this;
}
/**
* Check if agent is active (seen within last 5 minutes)
*/
public function isActive(): bool
{
return $this->last_seen_at && $this->last_seen_at->gte(now()->subMinutes(5));
}
}

View File

@@ -104,7 +104,7 @@ class AiContentRule extends Model
public function scopeForContext(Builder $query, string $context): Builder
{
return $query->where('content_type_key', 'like', $context.'.%');
return $query->where('content_type_key', 'ilike', $context.'.%');
}
// ========================================

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChatQuickReply extends Model
{
use HasFactory;
protected $table = 'chat_quick_replies';
protected $fillable = [
'business_id',
'label',
'message',
'category',
'usage_count',
'is_active',
'sort_order',
];
protected $casts = [
'is_active' => 'boolean',
'usage_count' => 'integer',
'sort_order' => 'integer',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeByCategory($query, string $category)
{
return $query->where('category', $category);
}
public function incrementUsage(): void
{
$this->increment('usage_count');
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Competitor Replacement - Maps CannaiQ competitor products to our products
*
* @property int $id
* @property int $business_id
* @property string $cannaiq_product_id
* @property string $competitor_name
* @property string|null $competitor_product_name
* @property int $product_id
* @property string|null $advantage_notes
* @property int $created_by
*/
class CompetitorReplacement extends Model
{
protected $fillable = [
'business_id',
'cannaiq_product_id',
'competitor_name',
'competitor_product_name',
'product_id',
'advantage_notes',
'created_by',
];
// ==================== Relationships ====================
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// ==================== Scopes ====================
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForCompetitor($query, string $competitorName)
{
return $query->where('competitor_name', $competitorName);
}
public function scopeForProduct($query, int $productId)
{
return $query->where('product_id', $productId);
}
// ==================== Helpers ====================
/**
* Get display label showing competitor our product
*/
public function getDisplayLabel(): string
{
$competitor = $this->competitor_product_name
? "{$this->competitor_name} - {$this->competitor_product_name}"
: $this->competitor_name;
return "{$competitor}{$this->product->name}";
}
/**
* Get short pitch summary
*/
public function getPitchSummary(): string
{
if (! $this->advantage_notes) {
return "Replace with {$this->product->name}";
}
// Return first sentence or 100 chars
$notes = $this->advantage_notes;
$firstSentence = strtok($notes, '.');
return strlen($firstSentence) > 100
? substr($notes, 0, 97).'...'
: $firstSentence.'.';
}
}

View File

@@ -8,6 +8,7 @@ use DateTime;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -81,6 +82,7 @@ class Contact extends Model
'work_hours', // JSON: schedule
'availability_notes',
'emergency_contact',
'best_time_to_contact',
// Status & Settings
'is_primary', // Primary contact for business/location
@@ -97,6 +99,7 @@ class Contact extends Model
'last_contact_date',
'next_followup_date',
'relationship_notes',
'working_notes', // Sales notes on how they prefer to work
// Account Management
'archived_at',
@@ -134,6 +137,17 @@ class Contact extends Model
return $this->belongsTo(Location::class);
}
/**
* Locations this contact is assigned to via the location_contact pivot table.
* This is the many-to-many relationship for location-specific contact assignments.
*/
public function locations(): BelongsToMany
{
return $this->belongsToMany(Location::class, 'location_contact')
->withPivot(['role', 'is_primary', 'notes'])
->withTimestamps();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

View File

@@ -87,7 +87,7 @@ class CrmInternalNote extends Model
foreach (array_unique($matches[1]) as $username) {
$user = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $this->business_id))
->where('name', 'like', "%{$username}%")
->where('name', 'ilike', "%{$username}%")
->first();
if ($user && $user->id !== $this->user_id) {

View File

@@ -5,6 +5,7 @@ namespace App\Models\Crm;
use App\Models\Accounting\ArInvoice;
use App\Models\Activity;
use App\Models\Business;
use App\Models\BusinessLocation;
use App\Models\Contact;
use App\Models\Order;
use App\Models\User;
@@ -44,6 +45,7 @@ class CrmInvoice extends Model
protected $fillable = [
'business_id',
'account_id',
'location_id',
'contact_id',
'deal_id',
'quote_id',
@@ -102,6 +104,11 @@ class CrmInvoice extends Model
return $this->belongsTo(Business::class, 'account_id');
}
public function location(): BelongsTo
{
return $this->belongsTo(BusinessLocation::class, 'location_id');
}
public function contact(): BelongsTo
{
return $this->belongsTo(Contact::class);
@@ -331,6 +338,17 @@ class CrmInvoice extends Model
return $this->status === self::STATUS_DRAFT;
}
public function canBeSent(): bool
{
return in_array($this->status, [
self::STATUS_DRAFT,
self::STATUS_SENT,
self::STATUS_VIEWED,
self::STATUS_PARTIAL,
self::STATUS_OVERDUE,
]);
}
public function getDaysOverdue(): int
{
if (! $this->isOverdue()) {

View File

@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
/**
* CRM Quote - Sales quotation with line items
@@ -26,6 +27,17 @@ class CrmQuote extends Model
protected $table = 'crm_quotes';
protected static function boot(): void
{
parent::boot();
static::creating(function ($quote) {
if (empty($quote->view_token)) {
$quote->view_token = Str::random(32);
}
});
}
public const STATUS_DRAFT = 'draft';
public const STATUS_SENT = 'sent';
@@ -47,6 +59,7 @@ class CrmQuote extends Model
'quote_number',
'title',
'status',
'quote_date',
'subtotal',
'discount_type',
'discount_value',
@@ -73,6 +86,7 @@ class CrmQuote extends Model
'order_id',
'notes_customer',
'notes_internal',
'view_token',
];
protected $casts = [
@@ -83,6 +97,7 @@ class CrmQuote extends Model
'tax_amount' => 'decimal:2',
'total' => 'decimal:2',
'signature_requested' => 'boolean',
'quote_date' => 'date',
'valid_until' => 'date',
'sent_at' => 'datetime',
'viewed_at' => 'datetime',

View File

@@ -7,6 +7,9 @@ use App\Models\Brand;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Conversation;
use App\Models\MarketplaceChatParticipant;
use App\Models\Order;
use App\Models\SalesRepAssignment;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -49,6 +52,10 @@ class CrmThread extends Model
public const SENTIMENT_NEGATIVE = 'negative';
public const TYPE_CRM = 'crm';
public const TYPE_MARKETPLACE = 'marketplace_b2b';
protected $fillable = [
'business_id',
'brand_id',
@@ -80,6 +87,11 @@ class CrmThread extends Model
'ai_suggested_actions',
'currently_viewing_user_id',
'currently_viewing_since',
// Marketplace B2B fields
'buyer_business_id',
'seller_business_id',
'thread_type',
'order_id',
];
protected $casts = [
@@ -183,6 +195,28 @@ class CrmThread extends Model
return $this->belongsTo(User::class, 'currently_viewing_user_id');
}
// Marketplace B2B relationships
public function buyerBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'buyer_business_id');
}
public function sellerBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'seller_business_id');
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function marketplaceParticipants(): HasMany
{
return $this->hasMany(MarketplaceChatParticipant::class, 'thread_id');
}
// Scopes
public function scopeForBusiness($query, int $businessId)
@@ -234,6 +268,20 @@ class CrmThread extends Model
return $query->where('brand_id', $brandId);
}
public function scopeMarketplace($query)
{
return $query->where('thread_type', self::TYPE_MARKETPLACE);
}
public function scopeForMarketplaceBusiness($query, int $businessId)
{
return $query->marketplace()
->where(function ($q) use ($businessId) {
$q->where('buyer_business_id', $businessId)
->orWhere('seller_business_id', $businessId);
});
}
public function scopeNeedingAttention($query)
{
return $query->open()
@@ -247,6 +295,44 @@ class CrmThread extends Model
->where('snoozed_until', '<=', now());
}
/**
* Scope to filter threads for a sales rep.
*
* Shows threads where:
* - The account (buyer business) is assigned to this sales rep, OR
* - The thread is directly assigned to this user
*
* @param int $businessId The seller business ID
* @param int $userId The sales rep user ID
*/
public function scopeForSalesRep($query, int $businessId, int $userId)
{
// Get account IDs assigned to this sales rep
$assignedAccountIds = SalesRepAssignment::where('business_id', $businessId)
->where('user_id', $userId)
->where('assignable_type', Business::class)
->pluck('assignable_id');
return $query->where(function ($q) use ($assignedAccountIds, $userId) {
// Threads for assigned accounts
$q->whereIn('account_id', $assignedAccountIds)
// OR threads directly assigned to this user
->orWhere('assigned_to', $userId);
});
}
/**
* Scope to filter threads for a brand portal user.
*
* Shows only threads related to brands the user has access to.
*
* @param array $brandIds Brand IDs the user can access
*/
public function scopeForBrandPortal($query, array $brandIds)
{
return $query->whereIn('brand_id', $brandIds);
}
// Accessors
public function getIsSnoozedAttribute(): bool

View File

@@ -6,6 +6,7 @@ use App\Traits\BelongsToBusinessDirectly;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -94,6 +95,12 @@ class Location extends Model
'transferred_to_business_id', // For ownership transfers
'settings', // JSON
'notes',
// CannaiQ Integration
'cannaiq_platform',
'cannaiq_store_slug',
'cannaiq_store_id',
'cannaiq_store_name',
];
protected $casts = [
@@ -122,11 +129,57 @@ class Location extends Model
return $this->hasMany(License::class);
}
public function contacts(): HasMany
/**
* Contacts directly associated with this location (location_id on contact)
*/
public function directContacts(): HasMany
{
return $this->hasMany(Contact::class);
}
/**
* Contacts assigned to this location via pivot with roles
*/
public function contacts(): BelongsToMany
{
return $this->belongsToMany(Contact::class, 'location_contact')
->withPivot(['role', 'is_primary', 'notes'])
->withTimestamps();
}
/**
* Get contacts with a specific role for this location
*/
public function contactsByRole(string $role)
{
return $this->contacts()->wherePivot('role', $role);
}
/**
* Get the primary buyer contact for this location
*/
public function getPrimaryBuyer()
{
return $this->contacts()
->wherePivot('role', 'buyer')
->wherePivot('is_primary', true)
->first();
}
/**
* Get buyer names as a comma-separated label
*/
public function getBuyersLabelAttribute(): ?string
{
$buyers = $this->contacts()->wherePivot('role', 'buyer')->get();
if ($buyers->isEmpty()) {
return null;
}
return $buyers->map(fn ($c) => $c->getFullName())->implode(', ');
}
public function addresses(): MorphMany
{
return $this->morphMany(Address::class, 'addressable');
@@ -250,4 +303,25 @@ class Location extends Model
'archived_reason' => null,
]);
}
/**
* Check if this location has CannaiQ store mapping
*/
public function hasCannaiqMapping(): bool
{
return ! empty($this->cannaiq_store_slug);
}
/**
* Clear the CannaiQ store mapping
*/
public function clearCannaiqMapping(): void
{
$this->update([
'cannaiq_platform' => null,
'cannaiq_store_slug' => null,
'cannaiq_store_id' => null,
'cannaiq_store_name' => null,
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MarketplaceChatParticipant extends Model
{
use HasFactory;
protected $fillable = [
'thread_id',
'user_id',
'business_id',
'last_read_at',
'is_active',
];
protected $casts = [
'last_read_at' => 'datetime',
'is_active' => 'boolean',
];
public function thread(): BelongsTo
{
return $this->belongsTo(\App\Models\Crm\CrmThread::class, 'thread_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function markAsRead(): void
{
$this->update(['last_read_at' => now()]);
}
public function hasUnread(): bool
{
if (! $this->last_read_at) {
return $this->thread->messages()->exists();
}
return $this->thread->messages()
->where('created_at', '>', $this->last_read_at)
->where('sender_id', '!=', $this->user_id)
->exists();
}
public function unreadCount(): int
{
if (! $this->last_read_at) {
return $this->thread->messages()
->where('sender_id', '!=', $this->user_id)
->count();
}
return $this->thread->messages()
->where('created_at', '>', $this->last_read_at)
->where('sender_id', '!=', $this->user_id)
->count();
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Prospect Import - Track CSV/bulk import jobs
*
* @property int $id
* @property int $business_id
* @property int $user_id
* @property string $filename
* @property string $status
* @property int $total_rows
* @property int $processed_rows
* @property int $created_count
* @property int $updated_count
* @property int $skipped_count
* @property int $error_count
* @property array|null $errors
* @property array|null $column_mapping
* @property \Carbon\Carbon|null $completed_at
*/
class ProspectImport extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'business_id',
'user_id',
'filename',
'status',
'total_rows',
'processed_rows',
'created_count',
'updated_count',
'skipped_count',
'error_count',
'errors',
'column_mapping',
'completed_at',
];
protected $casts = [
'total_rows' => 'integer',
'processed_rows' => 'integer',
'created_count' => 'integer',
'updated_count' => 'integer',
'skipped_count' => 'integer',
'error_count' => 'integer',
'errors' => 'array',
'column_mapping' => 'array',
'completed_at' => 'datetime',
];
// ==================== Relationships ====================
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function importer(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
// ==================== Scopes ====================
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
public function scopeProcessing($query)
{
return $query->where('status', self::STATUS_PROCESSING);
}
public function scopeCompleted($query)
{
return $query->where('status', self::STATUS_COMPLETED);
}
public function scopeFailed($query)
{
return $query->where('status', self::STATUS_FAILED);
}
// ==================== Helpers ====================
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isProcessing(): bool
{
return $this->status === self::STATUS_PROCESSING;
}
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
public function isFailed(): bool
{
return $this->status === self::STATUS_FAILED;
}
/**
* Get progress percentage
*/
public function getProgressPercent(): int
{
if ($this->total_rows === 0) {
return 0;
}
return (int) round(($this->processed_rows / $this->total_rows) * 100);
}
/**
* Get success rate percentage
*/
public function getSuccessRate(): int
{
$total = $this->created_count + $this->updated_count + $this->skipped_count + $this->error_count;
if ($total === 0) {
return 0;
}
return (int) round((($this->created_count + $this->updated_count) / $total) * 100);
}
/**
* Mark import as processing
*/
public function markProcessing(): void
{
$this->update(['status' => self::STATUS_PROCESSING]);
}
/**
* Mark import as completed
*/
public function markCompleted(): void
{
$this->update([
'status' => self::STATUS_COMPLETED,
'completed_at' => now(),
]);
}
/**
* Mark import as failed
*/
public function markFailed(?string $reason = null): void
{
$errors = $this->errors ?? [];
if ($reason) {
$errors[] = ['row' => 0, 'error' => $reason];
}
$this->update([
'status' => self::STATUS_FAILED,
'errors' => $errors,
'completed_at' => now(),
]);
}
/**
* Add error for a specific row
*/
public function addError(int $row, string $error): void
{
$errors = $this->errors ?? [];
$errors[] = ['row' => $row, 'error' => $error];
$this->update([
'errors' => $errors,
'error_count' => $this->error_count + 1,
]);
}
/**
* Increment processed count
*/
public function incrementProcessed(): void
{
$this->increment('processed_rows');
}
/**
* Increment created count
*/
public function incrementCreated(): void
{
$this->increment('created_count');
}
/**
* Increment updated count
*/
public function incrementUpdated(): void
{
$this->increment('updated_count');
}
/**
* Increment skipped count
*/
public function incrementSkipped(): void
{
$this->increment('skipped_count');
}
}

View File

@@ -0,0 +1,196 @@
<?php
namespace App\Models;
use App\Models\Crm\CrmLead;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Prospect Insight - Gap analysis and opportunity tracking
*
* @property int $id
* @property int $business_id
* @property int|null $lead_id
* @property int|null $account_id
* @property string $insight_type
* @property string|null $category
* @property string $description
* @property array|null $supporting_data
* @property int $created_by
*/
class ProspectInsight extends Model
{
// Insight types
public const TYPE_GAP = 'gap';
public const TYPE_PAIN_POINT = 'pain_point';
public const TYPE_OPPORTUNITY = 'opportunity';
public const TYPE_OBJECTION = 'objection';
public const TYPE_COMPETITOR_WEAKNESS = 'competitor_weakness';
public const TYPES = [
self::TYPE_GAP => 'Gap',
self::TYPE_PAIN_POINT => 'Pain Point',
self::TYPE_OPPORTUNITY => 'Opportunity',
self::TYPE_OBJECTION => 'Objection',
self::TYPE_COMPETITOR_WEAKNESS => 'Competitor Weakness',
];
// Categories
public const CATEGORY_PRICE_POINT = 'price_point';
public const CATEGORY_QUALITY = 'quality';
public const CATEGORY_CONSISTENCY = 'consistency';
public const CATEGORY_SERVICE = 'service';
public const CATEGORY_MARGIN = 'margin';
public const CATEGORY_RELIABILITY = 'reliability';
public const CATEGORY_SELECTION = 'selection';
public const CATEGORIES = [
self::CATEGORY_PRICE_POINT => 'Price Point',
self::CATEGORY_QUALITY => 'Quality',
self::CATEGORY_CONSISTENCY => 'Consistency',
self::CATEGORY_SERVICE => 'Service',
self::CATEGORY_MARGIN => 'Margin',
self::CATEGORY_RELIABILITY => 'Reliability',
self::CATEGORY_SELECTION => 'Selection',
];
protected $fillable = [
'business_id',
'lead_id',
'account_id',
'insight_type',
'category',
'description',
'supporting_data',
'created_by',
];
protected $casts = [
'supporting_data' => 'array',
];
// ==================== Relationships ====================
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function lead(): BelongsTo
{
return $this->belongsTo(CrmLead::class, 'lead_id');
}
public function account(): BelongsTo
{
return $this->belongsTo(Business::class, 'account_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// ==================== Scopes ====================
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForLead($query, int $leadId)
{
return $query->where('lead_id', $leadId);
}
public function scopeForAccount($query, int $accountId)
{
return $query->where('account_id', $accountId);
}
public function scopeOfType($query, string $type)
{
return $query->where('insight_type', $type);
}
public function scopeOfCategory($query, string $category)
{
return $query->where('category', $category);
}
public function scopeGaps($query)
{
return $query->where('insight_type', self::TYPE_GAP);
}
public function scopePainPoints($query)
{
return $query->where('insight_type', self::TYPE_PAIN_POINT);
}
public function scopeOpportunities($query)
{
return $query->where('insight_type', self::TYPE_OPPORTUNITY);
}
// ==================== Helpers ====================
public function getTypeLabel(): string
{
return self::TYPES[$this->insight_type] ?? ucfirst($this->insight_type);
}
public function getCategoryLabel(): string
{
if (! $this->category) {
return 'General';
}
return self::CATEGORIES[$this->category] ?? ucfirst($this->category);
}
/**
* Check if this insight is for a lead (prospect) vs existing account
*/
public function isForProspect(): bool
{
return ! is_null($this->lead_id);
}
/**
* Get the target entity (lead or account)
*/
public function getTarget(): CrmLead|Business|null
{
if ($this->lead_id) {
return $this->lead;
}
if ($this->account_id) {
return $this->account;
}
return null;
}
/**
* Add supporting data reference
*/
public function addSupportingData(string $key, mixed $value): void
{
$data = $this->supporting_data ?? [];
$data[$key] = $value;
$this->update(['supporting_data' => $data]);
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Sales Commission - Actual commission earned by sales rep on an order
*
* @property int $id
* @property int $business_id
* @property int $user_id
* @property int $order_id
* @property int|null $order_item_id
* @property int|null $commission_rate_id
* @property int $order_total
* @property float $commission_percent
* @property int $commission_amount
* @property string $status
* @property \Carbon\Carbon|null $approved_at
* @property int|null $approved_by
* @property \Carbon\Carbon|null $paid_at
* @property string|null $payment_reference
* @property string|null $notes
*/
class SalesCommission extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_PAID = 'paid';
protected $fillable = [
'business_id',
'user_id',
'order_id',
'order_item_id',
'commission_rate_id',
'order_total',
'commission_percent',
'commission_amount',
'status',
'approved_at',
'approved_by',
'paid_at',
'payment_reference',
'notes',
];
protected $casts = [
'order_total' => 'integer',
'commission_percent' => 'decimal:2',
'commission_amount' => 'integer',
'approved_at' => 'datetime',
'paid_at' => 'datetime',
];
// ==================== Relationships ====================
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function salesRep(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function orderItem(): BelongsTo
{
return $this->belongsTo(OrderItem::class);
}
public function commissionRate(): BelongsTo
{
return $this->belongsTo(SalesCommissionRate::class, 'commission_rate_id');
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by');
}
// ==================== Scopes ====================
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
public function scopeApproved($query)
{
return $query->where('status', self::STATUS_APPROVED);
}
public function scopePaid($query)
{
return $query->where('status', self::STATUS_PAID);
}
public function scopeUnpaid($query)
{
return $query->whereIn('status', [self::STATUS_PENDING, self::STATUS_APPROVED]);
}
// ==================== Helpers ====================
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isApproved(): bool
{
return $this->status === self::STATUS_APPROVED;
}
public function isPaid(): bool
{
return $this->status === self::STATUS_PAID;
}
/**
* Get commission amount in dollars
*/
public function getCommissionDollars(): float
{
return $this->commission_amount / 100;
}
/**
* Get order total in dollars
*/
public function getOrderDollars(): float
{
return $this->order_total / 100;
}
/**
* Approve this commission
*/
public function approve(User $approver): void
{
$this->update([
'status' => self::STATUS_APPROVED,
'approved_at' => now(),
'approved_by' => $approver->id,
]);
}
/**
* Mark as paid
*/
public function markPaid(?string $reference = null): void
{
$this->update([
'status' => self::STATUS_PAID,
'paid_at' => now(),
'payment_reference' => $reference,
]);
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Sales Commission Rate - Defines commission rates for sales reps
*
* @property int $id
* @property int $business_id
* @property int|null $user_id
* @property string $rate_type
* @property string|null $rateable_type
* @property int|null $rateable_id
* @property float $commission_percent
* @property \Carbon\Carbon $effective_from
* @property \Carbon\Carbon|null $effective_to
* @property bool $is_active
*/
class SalesCommissionRate extends Model
{
public const TYPE_DEFAULT = 'default';
public const TYPE_ACCOUNT = 'account';
public const TYPE_PRODUCT = 'product';
public const TYPE_BRAND = 'brand';
protected $fillable = [
'business_id',
'user_id',
'rate_type',
'rateable_type',
'rateable_id',
'commission_percent',
'effective_from',
'effective_to',
'is_active',
];
protected $casts = [
'commission_percent' => 'decimal:2',
'effective_from' => 'date',
'effective_to' => 'date',
'is_active' => 'boolean',
];
// ==================== Relationships ====================
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function rateable(): MorphTo
{
return $this->morphTo();
}
public function commissions(): HasMany
{
return $this->hasMany(SalesCommission::class, 'commission_rate_id');
}
// ==================== Scopes ====================
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForUser($query, ?int $userId)
{
return $query->where('user_id', $userId);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeEffective($query, ?\Carbon\Carbon $date = null)
{
$date = $date ?? now();
return $query
->where('effective_from', '<=', $date)
->where(function ($q) use ($date) {
$q->whereNull('effective_to')
->orWhere('effective_to', '>=', $date);
});
}
public function scopeDefault($query)
{
return $query->where('rate_type', self::TYPE_DEFAULT);
}
// ==================== Helpers ====================
/**
* Check if this rate is currently effective
*/
public function isEffective(?\Carbon\Carbon $date = null): bool
{
$date = $date ?? now();
if (! $this->is_active) {
return false;
}
if ($this->effective_from > $date) {
return false;
}
if ($this->effective_to && $this->effective_to < $date) {
return false;
}
return true;
}
/**
* Get display label for this rate
*/
public function getDisplayLabel(): string
{
$label = "{$this->commission_percent}%";
if ($this->user) {
$label .= " for {$this->user->name}";
}
return match ($this->rate_type) {
self::TYPE_DEFAULT => "Default: {$label}",
self::TYPE_ACCOUNT => "Account: {$label}",
self::TYPE_PRODUCT => "Product: {$label}",
self::TYPE_BRAND => "Brand: {$label}",
default => $label,
};
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Sales Rep Assignment - Links sales reps to accounts or stores
*
* @property int $id
* @property int $business_id
* @property int $user_id
* @property string $assignable_type
* @property int $assignable_id
* @property string $assignment_type
* @property float|null $commission_rate
* @property \Carbon\Carbon $assigned_at
* @property int|null $assigned_by
* @property string|null $notes
*/
class SalesRepAssignment extends Model
{
public const TYPE_PRIMARY = 'primary';
public const TYPE_SECONDARY = 'secondary';
protected $fillable = [
'business_id',
'user_id',
'assignable_type',
'assignable_id',
'assignment_type',
'commission_rate',
'assigned_at',
'assigned_by',
'notes',
];
protected $casts = [
'commission_rate' => 'decimal:2',
'assigned_at' => 'datetime',
];
// ==================== Relationships ====================
/**
* The seller business that owns this assignment
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
/**
* The sales rep user
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Alias for user - the sales rep
*/
public function salesRep(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* The assigned entity (Business account or Location store)
*/
public function assignable(): MorphTo
{
return $this->morphTo();
}
/**
* Who made this assignment
*/
public function assigner(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_by');
}
// ==================== Scopes ====================
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
public function scopePrimary($query)
{
return $query->where('assignment_type', self::TYPE_PRIMARY);
}
public function scopeSecondary($query)
{
return $query->where('assignment_type', self::TYPE_SECONDARY);
}
public function scopeAccounts($query)
{
return $query->where('assignable_type', Business::class);
}
public function scopeLocations($query)
{
return $query->where('assignable_type', Location::class);
}
// ==================== Helpers ====================
public function isPrimary(): bool
{
return $this->assignment_type === self::TYPE_PRIMARY;
}
public function isSecondary(): bool
{
return $this->assignment_type === self::TYPE_SECONDARY;
}
/**
* Get the effective commission rate (override or default)
*/
public function getEffectiveCommissionRate(?float $defaultRate = null): ?float
{
return $this->commission_rate ?? $defaultRate;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Sales Territory - Geographic region for sales rep assignment
*
* @property int $id
* @property int $business_id
* @property string $name
* @property string|null $description
* @property string $color
* @property bool $is_active
*/
class SalesTerritory extends Model
{
protected $fillable = [
'business_id',
'name',
'description',
'color',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
// ==================== Relationships ====================
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function areas(): HasMany
{
return $this->hasMany(SalesTerritoryArea::class, 'territory_id');
}
public function assignments(): HasMany
{
return $this->hasMany(SalesTerritoryAssignment::class, 'territory_id');
}
public function salesReps(): BelongsToMany
{
return $this->belongsToMany(User::class, 'sales_territory_assignments', 'territory_id', 'user_id')
->withPivot(['assignment_type', 'assigned_at', 'assigned_by'])
->withTimestamps();
}
// ==================== Scopes ====================
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
// ==================== Helpers ====================
/**
* Get the primary sales rep for this territory
*/
public function getPrimaryRep(): ?User
{
return $this->salesReps()
->wherePivot('assignment_type', 'primary')
->first();
}
/**
* Check if a location falls within this territory
*/
public function containsLocation(Location $location): bool
{
foreach ($this->areas as $area) {
if ($area->matchesLocation($location)) {
return true;
}
}
return false;
}
/**
* Get all zip codes in this territory
*/
public function getZipCodes(): array
{
return $this->areas()
->where('area_type', 'zip')
->pluck('area_value')
->toArray();
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Sales Territory Area - Geographic area definition (zip, city, state, county)
*
* @property int $id
* @property int $territory_id
* @property string $area_type
* @property string $area_value
*/
class SalesTerritoryArea extends Model
{
public const TYPE_ZIP = 'zip';
public const TYPE_CITY = 'city';
public const TYPE_STATE = 'state';
public const TYPE_COUNTY = 'county';
protected $fillable = [
'territory_id',
'area_type',
'area_value',
];
// ==================== Relationships ====================
public function territory(): BelongsTo
{
return $this->belongsTo(SalesTerritory::class, 'territory_id');
}
// ==================== Helpers ====================
/**
* Check if a location matches this area definition
*/
public function matchesLocation(Location $location): bool
{
$value = strtolower(trim($this->area_value));
return match ($this->area_type) {
self::TYPE_ZIP => strtolower(trim($location->zipcode ?? '')) === $value,
self::TYPE_CITY => strtolower(trim($location->city ?? '')) === $value,
self::TYPE_STATE => strtolower(trim($location->state ?? '')) === $value,
self::TYPE_COUNTY => strtolower(trim($location->county ?? '')) === $value,
default => false,
};
}
/**
* Get display label for this area
*/
public function getDisplayLabel(): string
{
$typeLabel = match ($this->area_type) {
self::TYPE_ZIP => 'ZIP',
self::TYPE_CITY => 'City',
self::TYPE_STATE => 'State',
self::TYPE_COUNTY => 'County',
default => ucfirst($this->area_type),
};
return "{$typeLabel}: {$this->area_value}";
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Sales Territory Assignment - Links sales reps to territories
*
* @property int $id
* @property int $territory_id
* @property int $user_id
* @property string $assignment_type
* @property \Carbon\Carbon $assigned_at
* @property int|null $assigned_by
*/
class SalesTerritoryAssignment extends Model
{
public const TYPE_PRIMARY = 'primary';
public const TYPE_SECONDARY = 'secondary';
protected $fillable = [
'territory_id',
'user_id',
'assignment_type',
'assigned_at',
'assigned_by',
];
protected $casts = [
'assigned_at' => 'datetime',
];
// ==================== Relationships ====================
public function territory(): BelongsTo
{
return $this->belongsTo(SalesTerritory::class, 'territory_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function salesRep(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function assigner(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_by');
}
// ==================== Scopes ====================
public function scopePrimary($query)
{
return $query->where('assignment_type', self::TYPE_PRIMARY);
}
public function scopeSecondary($query)
{
return $query->where('assignment_type', self::TYPE_SECONDARY);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
// ==================== Helpers ====================
public function isPrimary(): bool
{
return $this->assignment_type === self::TYPE_PRIMARY;
}
}

View File

@@ -15,12 +15,13 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Lab404\Impersonate\Models\Impersonate;
use NotificationChannels\WebPush\HasPushSubscriptions;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, HasRoles, HasUuids, Impersonate, Notifiable;
use HasFactory, HasPushSubscriptions, HasRoles, HasUuids, Impersonate, Notifiable;
/**
* User type constants

View File

@@ -109,6 +109,7 @@ class AppServiceProvider extends ServiceProvider
$versionData = cache()->remember('app.version_data', now()->addSeconds(5), function () {
$version = 'dev';
$commit = 'unknown';
$buildDate = null;
// For Docker: read from version.env (injected at build time)
$versionFile = base_path('version.env');
@@ -117,6 +118,7 @@ class AppServiceProvider extends ServiceProvider
$data = parse_ini_file($versionFile);
$version = $data['VERSION'] ?? 'dev';
$commit = $data['COMMIT'] ?? 'unknown';
$buildDate = $data['BUILD_DATE'] ?? null;
}
// For local dev: read from git directly (but cached for 5 seconds)
// Check for .git (directory for regular repos, file for worktrees)
@@ -128,6 +130,13 @@ class AppServiceProvider extends ServiceProvider
// Only proceed if we successfully got a commit SHA
if ($commit !== '' && $commit !== 'unknown') {
// Get commit date for local dev
$dateCommand = sprintf('cd %s && git log -1 --format=%%ci 2>/dev/null', escapeshellarg(base_path()));
$commitDate = trim(shell_exec($dateCommand) ?: '');
if ($commitDate) {
$buildDate = date('M j, g:ia', strtotime($commitDate));
}
// Check for uncommitted changes (dirty working directory)
$diffCommand = sprintf('cd %s && git diff --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
$cachedCommand = sprintf('cd %s && git diff --cached --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
@@ -147,17 +156,19 @@ class AppServiceProvider extends ServiceProvider
return [
'version' => $version,
'commit' => $commit,
'buildDate' => $buildDate,
];
});
} catch (\Exception $e) {
// If cache fails (e.g., Redis not ready), calculate version without caching
$versionData = ['version' => 'dev', 'commit' => 'unknown'];
$versionData = ['version' => 'dev', 'commit' => 'unknown', 'buildDate' => null];
$versionFile = base_path('version.env');
if (File::exists($versionFile)) {
$data = parse_ini_file($versionFile);
$versionData['version'] = $data['VERSION'] ?? 'dev';
$versionData['commit'] = $data['COMMIT'] ?? 'unknown';
$versionData['buildDate'] = $data['BUILD_DATE'] ?? null;
}
}
@@ -165,6 +176,7 @@ class AppServiceProvider extends ServiceProvider
$view->with([
'appVersion' => $versionData['version'],
'appCommit' => $versionData['commit'],
'appBuildDate' => $versionData['buildDate'],
'appVersionFull' => "{$versionData['version']} (sha-{$versionData['commit']})",
]);
});
@@ -299,5 +311,38 @@ class AppServiceProvider extends ServiceProvider
// Department/permission-based access
return $user->hasPermission('manage_bom');
});
// Team Management Gate - Manager-only access for team dashboards
Gate::define('manage-team', function (User $user, ?Business $business = null) {
// Get business from route if not provided
$business = $business ?? request()->route('business');
if (! $business) {
return false;
}
// Business Owner always has access
if ($user->id === $business->owner_user_id) {
return true;
}
// Super admin has access
if ($user->hasRole('super-admin')) {
return true;
}
// Check if user is a manager in any department for this business
$userDepartments = $user->departments ?? collect();
if ($userDepartments->where('pivot.role', 'manager')->isNotEmpty()) {
return true;
}
// Check role-based access
if (in_array($user->role, ['admin', 'manager'])) {
return true;
}
return false;
});
}
}

View File

@@ -260,10 +260,10 @@ class BrandVoicePrompt
* AI generation MUST respect these limits.
*/
public const CHARACTER_LIMITS = [
'tagline' => ['min' => 30, 'max' => 45, 'label' => 'Tagline'],
'short_description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'],
'description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'], // Alias
'long_description' => ['min' => 400, 'max' => 500, 'label' => 'Long Description'],
'tagline' => ['min' => null, 'max' => 255, 'label' => 'Tagline'],
'short_description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'],
'description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'], // Alias
'long_description' => ['min' => null, 'max' => 5000, 'label' => 'Long Description'],
'brand_announcement' => ['min' => 400, 'max' => 500, 'label' => 'Brand Announcement'],
'seo_title' => ['min' => 60, 'max' => 70, 'label' => 'SEO Title'],
'seo_description' => ['min' => 150, 'max' => 160, 'label' => 'SEO Description'],
@@ -803,6 +803,11 @@ class BrandVoicePrompt
return '';
}
// If no min is set, only enforce max
if ($limits['min'] === null) {
return "CHARACTER LIMIT: Output should not exceed {$limits['max']} characters.";
}
return "STRICT CHARACTER LIMIT: Output MUST be between {$limits['min']}-{$limits['max']} characters. Do NOT output fewer than {$limits['min']} or more than {$limits['max']} characters.";
}

View File

@@ -694,7 +694,7 @@ class AccountingReportingService
->active()
->where(function ($q) {
$q->where('account_subtype', 'cash')
->orWhere('name', 'like', '%Cash%');
->orWhere('name', 'ilike', '%Cash%');
})
->get();

View File

@@ -87,10 +87,10 @@ class CashFlowForecastService
->where(function ($q) {
$q->where('account_subtype', 'cash')
->orWhere('account_subtype', 'bank')
->orWhere('name', 'like', '%cash%')
->orWhere('name', 'like', '%bank%')
->orWhere('name', 'like', '%checking%')
->orWhere('name', 'like', '%savings%');
->orWhere('name', 'ilike', '%cash%')
->orWhere('name', 'ilike', '%bank%')
->orWhere('name', 'ilike', '%checking%')
->orWhere('name', 'ilike', '%savings%');
})
->active()
->postable()

View File

@@ -294,7 +294,7 @@ class ExpenseService
$month = now()->format('m');
$lastExpense = Expense::where('business_id', $business->id)
->where('expense_number', 'like', "{$prefix}-{$year}{$month}-%")
->where('expense_number', 'ilike', "{$prefix}-{$year}{$month}-%")
->orderByDesc('id')
->first();

View File

@@ -411,7 +411,7 @@ class RecurringSchedulerService
$month = now()->format('m');
$last = ArInvoice::where('business_id', $business->id)
->where('invoice_number', 'like', "{$prefix}-{$year}{$month}-%")
->where('invoice_number', 'ilike', "{$prefix}-{$year}{$month}-%")
->orderByDesc('id')
->first();
@@ -433,7 +433,7 @@ class RecurringSchedulerService
$month = now()->format('m');
$last = ApBill::where('business_id', $business->id)
->where('bill_number', 'like', "{$prefix}-{$year}{$month}-%")
->where('bill_number', 'ilike', "{$prefix}-{$year}{$month}-%")
->orderByDesc('id')
->first();
@@ -455,7 +455,7 @@ class RecurringSchedulerService
$month = now()->format('m');
$last = JournalEntry::where('business_id', $business->id)
->where('entry_number', 'like', "{$prefix}-{$year}{$month}-%")
->where('entry_number', 'ilike', "{$prefix}-{$year}{$month}-%")
->orderByDesc('id')
->first();

View File

@@ -75,7 +75,7 @@ class BuyerContextBuilder extends BaseContextBuilder
'name' => $contact->full_name,
'email' => $contact->email,
'phone' => $contact->phone,
'company' => $contact->company_name ?? $contact->business?->name,
'company' => $contact->business?->name,
'tags' => $contact->tags ?? [],
'lifecycle_stage' => $contact->lifecycle_stage ?? 'lead',
'created_at' => $contact->created_at->format('Y-m-d'),

View File

@@ -0,0 +1,533 @@
<?php
namespace App\Services\Cannaiq;
/**
* Advanced v4 Intelligence Data Transfer Object
*
* Contains advanced brand intelligence analytics including:
* - Brand positioning and differentiation scoring (v3)
* - Trend lead/lag analysis (predictive vs laggy behavior) (v3)
* - Cross-state market signals (v3)
* - Shelf displacement opportunities (v3)
* - Shelf value projections with capture scenarios (v4)
*
* v4.0 Additions:
* - shelfValueProjections: Revenue projections by scope (store/state/multi_state)
* - capture_scenarios: 10%, 25%, 50% market capture modeling
* - opportunity_label: "Big prize, low effort" etc.
* - consumerDemand: Consumer Demand Index + SKU lifecycle stages
* - elasticity: Price elasticity metrics per SKU
* - competitiveThreat: Competitive pressure scoring
* - portfolioBalance: Category mix, redundancy clusters, gaps
*
* All data is derived from existing CannaiQ + internal data; no new scrapes.
*/
class AdvancedV3IntelligenceDTO
{
public function __construct(
// v3.0 fields
public readonly ?array $brandPositioning = null,
public readonly ?array $trendLeadLag = null,
public readonly array $marketSignals = [],
public readonly array $shelfOpportunities = [],
// v4.0: Shelf value projections with capture scenarios
public readonly array $shelfValueProjections = [],
// v4.0: Consumer Demand Index + SKU lifecycle
public readonly ?array $consumerDemand = null,
// v4.0: Price elasticity metrics
public readonly ?array $elasticity = null,
// v4.0: Competitive threat scoring
public readonly ?array $competitiveThreat = null,
// v4.0: Portfolio balance analysis
public readonly ?array $portfolioBalance = null,
) {}
/**
* Create empty DTO when data is unavailable
*/
public static function empty(): self
{
return new self(
brandPositioning: null,
trendLeadLag: null,
marketSignals: [],
shelfOpportunities: [],
shelfValueProjections: [],
consumerDemand: null,
elasticity: null,
competitiveThreat: null,
portfolioBalance: null,
);
}
/**
* Create empty brand positioning structure
*
* Structure:
* - differentiation_score: 0-100 (how unique vs competitors)
* - positioning_label: 'more_of_the_same'|'value_disruptor'|'premium_standout'|'potency_leader'|'format_outlier'
* - comparables: Array of similar brands with distance scores
* - notes: Array of bullet explanations
*/
public static function emptyBrandPositioning(): array
{
return [
'differentiation_score' => null,
'positioning_label' => 'more_of_the_same',
'comparables' => [],
'notes' => [],
];
}
/**
* Create empty trend lead/lag structure
*
* Structure:
* - lead_lag_index: -100 (laggy) to +100 (predictive)
* - classification: 'strong_leader'|'emerging_leader'|'in_line'|'follower'|'laggy'
* - supporting_signals: Array of category-level signals
*/
public static function emptyTrendLeadLag(): array
{
return [
'lead_lag_index' => 0,
'classification' => 'in_line',
'supporting_signals' => [],
];
}
/**
* Create empty market signal structure
*
* Structure:
* - scope: 'multi_state'|'state'|'category'
* - state_code: optional state
* - category: optional category
* - description: human-readable summary
* - trend_strength: 0-100
* - relevant_to_brand: bool
* - brand_fit: 'strong_fit'|'partial_fit'|'gap'
* - example_brand: optional example
*/
public static function emptyMarketSignal(): array
{
return [
'scope' => 'category',
'state_code' => null,
'category' => null,
'description' => '',
'trend_strength' => 0,
'relevant_to_brand' => false,
'brand_fit' => 'gap',
'example_brand' => null,
];
}
/**
* Create empty shelf opportunity structure
*
* Structure:
* - store_id: CannaiQ store external ID
* - store_name: Store display name
* - state_code: State abbreviation
* - opportunity_type: 'whitespace'|'displacement'
* - competitor_brand: null for whitespace
* - competitor_product_name: null for whitespace
* - our_best_sku_id: our matching product ID
* - our_best_sku_name: our matching product name
* - est_monthly_units_current: competitor's current volume
* - est_monthly_units_if_we_win: projected volume if we win
* - est_monthly_revenue_if_we_win: projected revenue
* - quality_score_delta: -100 to +100 (positive = we're better)
* - value_score_delta: -100 to +100 (positive = better value)
* - displacement_difficulty: 'low'|'medium'|'high'
* - difficulty_score: 0-100 (100 = hardest)
* - rationale_tags: Array of reason strings
*/
public static function emptyShelfOpportunity(): array
{
return [
'store_id' => null,
'store_name' => 'Unknown',
'state_code' => null,
'opportunity_type' => 'whitespace',
'competitor_brand' => null,
'competitor_product_name' => null,
'our_best_sku_id' => null,
'our_best_sku_name' => null,
'est_monthly_units_current' => 0,
'est_monthly_units_if_we_win' => 0,
'est_monthly_revenue_if_we_win' => 0,
'quality_score_delta' => 0,
'value_score_delta' => 0,
'displacement_difficulty' => 'medium',
'difficulty_score' => 50,
'rationale_tags' => [],
];
}
/**
* Create empty shelf value projection structure (v4.0)
*
* Structure:
* - scope: 'store'|'state'|'multi_state' - geographic scope of projection
* - store_id: CannaiQ store ID (when scope='store')
* - store_name: Store display name (when scope='store')
* - state_code: State abbreviation (when scope='store' or 'state')
* - current_competitor_sales: Competitor revenue currently on shelf
* - category_total_sales: Total category sales at location
* - our_current_share: Our % of category sales (0.0-1.0)
* - our_current_shelf_value: Our current monthly revenue at location
* - avg_displacement_difficulty: 0-100 (aggregated from opportunities)
* - opportunity_label: 'Big prize, low effort'|'Low-hanging fruit'|'High potential, high difficulty'|'Grind zone'
* - capture_scenarios: Array of capture scenario projections
*/
public static function emptyShelfValueProjection(): array
{
return [
'scope' => 'store',
'store_id' => null,
'store_name' => null,
'state_code' => null,
'current_competitor_sales' => 0,
'category_total_sales' => 0,
'our_current_share' => 0,
'our_current_shelf_value' => 0,
'avg_displacement_difficulty' => 50,
'opportunity_label' => 'Grind zone',
'capture_scenarios' => [],
];
}
/**
* Create empty capture scenario structure (v4.0)
*
* Structure:
* - capture_percent: 10|25|50 - % of competitor shelf to capture
* - projected_monthly_revenue: Revenue if we achieve this capture
* - projected_units: Units if we achieve this capture
* - revenue_lift_from_current: Delta from our current revenue
* - effort_level: 'low'|'medium'|'high' - based on difficulty + capture %
*/
public static function emptyCaptureScenario(): array
{
return [
'capture_percent' => 10,
'projected_monthly_revenue' => 0,
'projected_units' => 0,
'revenue_lift_from_current' => 0,
'effort_level' => 'medium',
];
}
/**
* Get opportunity label based on value and difficulty
*
* @param float $value Estimated monthly revenue opportunity
* @param int $difficulty 0-100 difficulty score
*/
public static function getOpportunityLabel(float $value, int $difficulty): string
{
// High value threshold: $5,000/mo
// Low difficulty threshold: 40
$highValue = $value >= 5000;
$lowDifficulty = $difficulty <= 40;
return match (true) {
$highValue && $lowDifficulty => 'Big prize, low effort',
! $highValue && $lowDifficulty => 'Low-hanging fruit',
$highValue && ! $lowDifficulty => 'High potential, high difficulty',
default => 'Grind zone',
};
}
/**
* Convert to array for views
*/
public function toArray(): array
{
return [
'brandPositioning' => $this->brandPositioning,
'trendLeadLag' => $this->trendLeadLag,
'marketSignals' => $this->marketSignals,
'shelfOpportunities' => $this->shelfOpportunities,
'shelfValueProjections' => $this->shelfValueProjections,
'consumerDemand' => $this->consumerDemand,
'elasticity' => $this->elasticity,
'competitiveThreat' => $this->competitiveThreat,
'portfolioBalance' => $this->portfolioBalance,
];
}
/**
* Check if any v3/v4 intelligence data is available
*/
public function hasData(): bool
{
return $this->brandPositioning !== null
|| $this->trendLeadLag !== null
|| ! empty($this->marketSignals)
|| ! empty($this->shelfOpportunities)
|| ! empty($this->shelfValueProjections)
|| $this->consumerDemand !== null
|| $this->elasticity !== null
|| $this->competitiveThreat !== null
|| $this->portfolioBalance !== null;
}
/**
* Create empty consumer demand structure (v4.0)
*
* Structure:
* - consumer_demand_index: 0-100 overall brand demand score
* - sku_scores: Array of per-SKU demand metrics
*/
public static function emptyConsumerDemand(): array
{
return [
'consumer_demand_index' => null,
'sku_scores' => [],
];
}
/**
* Create empty SKU demand score structure (v4.0)
*
* Structure:
* - product_id: Internal product ID
* - product_name: Display name
* - demand_index: 0-100 demand score
* - promo_independence: 0-100 (higher = sells well without promos)
* - cross_store_consistency: 0-100 (higher = consistent across stores)
* - stage: 'launch'|'growth'|'peak'|'decline'|'terminal'|null
*/
public static function emptySkuDemandScore(): array
{
return [
'product_id' => null,
'product_name' => 'Unknown',
'demand_index' => null,
'promo_independence' => null,
'cross_store_consistency' => null,
'stage' => null,
];
}
/**
* Create empty elasticity structure (v4.0)
*
* Structure:
* - sku_elasticity: Array of per-SKU price elasticity metrics
*/
public static function emptyElasticity(): array
{
return [
'sku_elasticity' => [],
];
}
/**
* Create empty SKU elasticity structure (v4.0)
*
* Structure:
* - product_id: Internal product ID
* - product_name: Display name
* - current_price: Current average price
* - elasticity: Numeric elasticity coefficient (negative = price sensitive)
* - price_behavior: 'sensitive'|'stable'|'room_to_raise'|null
* - note: Human-readable recommendation
*/
public static function emptySkuElasticity(): array
{
return [
'product_id' => null,
'product_name' => 'Unknown',
'current_price' => null,
'elasticity' => null,
'price_behavior' => null,
'note' => null,
];
}
/**
* Create empty competitive threat structure (v4.0)
*
* Structure:
* - overall_threat_score: 0-100 aggregate threat level
* - threat_level: 'low'|'medium'|'high'
* - threats: Array of competitor threat details
*/
public static function emptyCompetitiveThreat(): array
{
return [
'overall_threat_score' => null,
'threat_level' => null,
'threats' => [],
];
}
/**
* Create empty competitor threat structure (v4.0)
*
* Structure:
* - brand_name: Competitor brand name
* - threat_score: 0-100 individual threat score
* - price_aggression: 0-100 (how aggressively they undercut)
* - velocity_trend: -100 to +100 (their growth vs decline)
* - overlap_score: 0-100 (category/store overlap)
* - notes: Array of threat reasons
*/
public static function emptyThreatBrand(): array
{
return [
'brand_name' => 'Unknown',
'threat_score' => null,
'price_aggression' => null,
'velocity_trend' => null,
'overlap_score' => null,
'notes' => [],
];
}
/**
* Create empty portfolio balance structure (v4.0)
*
* Structure:
* - category_mix: Array of category distribution
* - redundancy_clusters: Array of similar SKU groupings
* - gaps: Array of identified portfolio gaps
*/
public static function emptyPortfolioBalance(): array
{
return [
'category_mix' => [],
'redundancy_clusters' => [],
'gaps' => [],
];
}
/**
* Create empty category mix item structure (v4.0)
*/
public static function emptyCategoryMix(): array
{
return [
'category' => 'Unknown',
'sku_count' => 0,
'revenue_share_percent' => null,
];
}
/**
* Create empty redundancy cluster structure (v4.0)
*/
public static function emptyRedundancyCluster(): array
{
return [
'cluster_id' => null,
'label' => 'Unknown',
'product_ids' => [],
'note' => null,
];
}
/**
* Create empty portfolio gap structure (v4.0)
*/
public static function emptyPortfolioGap(): array
{
return [
'category' => 'Unknown',
'description' => null,
];
}
/**
* Get threat level label from score
*/
public static function getThreatLevel(float $score): string
{
return match (true) {
$score >= 70 => 'high',
$score >= 40 => 'medium',
default => 'low',
};
}
/**
* Get lifecycle stage from velocity metrics
*
* @param float $velocity Current daily velocity
* @param float|null $velocityTrend % change vs prior period (-100 to +100)
* @param float $categoryAvgVelocity Category average velocity
*/
public static function getLifecycleStage(float $velocity, ?float $velocityTrend, float $categoryAvgVelocity): string
{
$relativeVelocity = $categoryAvgVelocity > 0 ? $velocity / $categoryAvgVelocity : 0;
// Very low velocity with flat/declining trend = terminal
if ($relativeVelocity < 0.2 && ($velocityTrend === null || $velocityTrend <= 0)) {
return 'terminal';
}
// Low velocity but growing = launch
if ($relativeVelocity < 0.5 && $velocityTrend !== null && $velocityTrend > 20) {
return 'launch';
}
// Medium velocity with strong growth = growth
if ($velocityTrend !== null && $velocityTrend > 10) {
return 'growth';
}
// High velocity, stable = peak
if ($relativeVelocity >= 0.8 && ($velocityTrend === null || abs($velocityTrend) <= 10)) {
return 'peak';
}
// Declining = decline
if ($velocityTrend !== null && $velocityTrend < -10) {
return 'decline';
}
// Default to growth for healthy products
return 'growth';
}
/**
* Get positioning label for display
*/
public function getPositioningLabelDisplay(): string
{
if (! $this->brandPositioning) {
return 'Unknown';
}
return match ($this->brandPositioning['positioning_label'] ?? 'more_of_the_same') {
'value_disruptor' => 'Value Disruptor',
'premium_standout' => 'Premium Standout',
'potency_leader' => 'Potency Leader',
'format_outlier' => 'Format Outlier',
default => 'More of the Same',
};
}
/**
* Get trend classification for display
*/
public function getTrendClassificationDisplay(): string
{
if (! $this->trendLeadLag) {
return 'Unknown';
}
return match ($this->trendLeadLag['classification'] ?? 'in_line') {
'strong_leader' => 'Predictive (Leads Market)',
'emerging_leader' => 'Early Mover',
'follower' => 'Follower',
'laggy' => 'Laggy (Follows Late)',
default => 'In Line with Market',
};
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More