Compare commits

...

337 Commits

Author SHA1 Message Date
kelly
fc715c6022 feat: add DBA (Doing Business As) entity system for sellers
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
Implement complete DBA management allowing businesses to operate under
multiple trade names with separate addresses, licenses, banking info,
and invoice branding.

Database:
- Create business_dbas table with encrypted bank/tax fields
- Add dba_id foreign key to crm_invoices

Models:
- BusinessDba model with encrypted casts, auto-slug, single-default enforcement
- Business model: dbas(), activeDbas(), defaultDba(), getDbaForInvoice()
- CrmInvoice model: dba() relationship, getSellerDisplayInfo() method

Seller UI:
- DbaController with full CRUD + set-default + toggle-active
- Index/create/edit views using DaisyUI
- Trade Names card added to settings index

Admin:
- DbasRelationManager for BusinessResource in Filament

Migration:
- MigrateDbaData command to convert existing dba_name fields
2025-12-17 12:50:07 -07:00
kelly
32a00493f8 fix(ci): explicitly set DB_CONNECTION=pgsql in phpunit.xml
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
CI was falling back to sqlite because DB_CONNECTION wasn't set.
This caused all database-dependent tests to fail with 'no such table'.
2025-12-17 10:37:52 -07:00
kelly
1906a28347 fix: skip service worker registration on localhost
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Service worker interferes with Vite HMR during local development,
causing navigation issues (double-click required). Now only registers
on production domains.
2025-12-17 02:42:07 -07:00
kelly
5de445d1cf fix: remove redundant click listener from sidebar scroll save
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Simplified scroll position save - only save on scroll events, not link clicks.
2025-12-17 02:39:54 -07:00
kelly
9855d41869 fix: prevent click propagation on sidebar menu items
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Added @click.stop to sidebar-item links to ensure clicks don't
propagate to parent toggle buttons.
2025-12-17 02:39:25 -07:00
kelly
319c5cc4d5 feat: add chat icon to topbar with unread message indicator
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add chat/inbox icon between calendar and tasks
- Show green badge with unread thread count
- Links to CRM inbox page
2025-12-17 02:29:34 -07:00
kelly
44af326ebb fix: Conversations menu item now links to unified inbox
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Changed connect_conversations and crm_inbox routes from
seller.business.crm.threads.index to seller.business.crm.inbox
2025-12-17 02:29:12 -07:00
kelly
4f4e96dd84 style: make agent status widget more compact with badge style
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Use badge-sm instead of btn-xs
- Smaller avatar (w-6)
- Reduced padding (px-3 py-2)
- Removed online team members counter
- Smaller dropdown menu
2025-12-17 02:26:55 -07:00
kelly
fcc428b9f1 feat: add email engagement Activity logging and Brand Portal inbox filtering
- Add email.opened, email.clicked, email.bounced activity types
- Log Activity on first email open/click in EmailInteraction model
- Update BrandPortalController inbox to filter threads by linked brands
- Uses scopeForBrandPortal() on CrmThread for proper access control
2025-12-17 02:26:30 -07:00
kelly
fc9580470e feat: add logo/home link to inbox icon rail
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Added clickable logo at top of left icon rail
- Links to business dashboard
- Uses PWA icon (192x192 square)
2025-12-17 02:25:45 -07:00
kelly
bc9aaf745d feat: add Cannabrands logo to offline page
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Downloaded logo from cloud share
- Replaced icon with clickable logo linking to home
- Logo scales responsively (max 280px)
2025-12-17 02:23:10 -07:00
kelly
9df385e2e1 feat: complete omnichannel inbox real-time features
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Fix broadcast event names (.message.new, .typing, .thread.updated, .agent.status)
- Add / trigger for quick replies in composer
- Include agent status widget in inbox header
- Pass team member statuses from AgentStatus table
- Update useQuickReply to remove / trigger when inserting
2025-12-17 02:21:56 -07:00
kelly
102b2c1803 feat: add offline fallback page for PWA
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Created offline.html with connection status indicator
- Updated service worker to precache offline page
- Added fallback handler for navigation requests when offline
- Auto-reload when connection is restored
- Bumped cache version to v2
2025-12-17 02:18:43 -07:00
kelly
6705a8916a feat: use dispensary icon as platform favicon
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add SVG favicon with dispensary icon (cannabis leaf + storefront)
- Convert to ICO for browser compatibility
- Remove customizable favicon options from all layouts
- Standardize favicon across all layout files
2025-12-17 02:17:12 -07:00
kelly
3e71c35c9e feat: add omnichannel inbox with team chat and buyer context
- Redesign inbox with Chatwoot-style icon rail navigation
- Add team chat infrastructure (TeamConversation, TeamMessage models)
- Add buyer context tracking (current page, cart, recently viewed)
- Show live buyer context in conversation sidebar
- Add chat request status fields to CrmThread for buyer requests
- Add broadcast channels for real-time team messaging
2025-12-17 02:15:23 -07:00
kelly
c772431475 fix: PWA manifest icons - separate 'any' and 'maskable' purposes
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Chrome requires icons with separate purpose values, not combined.
Added distinct entries for both 'any' and 'maskable' purposes.
2025-12-17 02:14:28 -07:00
kelly
c6b8d76373 fix: sidebar menu items require single click (not double)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
DaisyUI v5 collapse checkbox was intercepting clicks on menu items.
Fixed by:
- Constraining checkbox height to title area only
- Adding z-index to collapse-title and collapse-content
- Ensuring clickable elements are above the checkbox overlay
2025-12-17 02:11:17 -07:00
kelly
6ce095c60a feat: redesign CRM inbox with Chatwoot-style dual sidebar layout
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add left icon rail for navigation (inbox views, channel filters, team)
- Move channel and status filters from top bar to sidebar icons
- Add sliding team panel showing online/offline coworkers
- Add presence tracking with online status indicators
- Improve thread list with channel badges on avatars
- Streamline conversation header with quick action buttons
- Add agent status selector in icon rail
- Integrate heartbeat for presence tracking
2025-12-17 01:50:32 -07:00
kelly
45ed3818fa fix: chat status buttons now work - prevent click propagation
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Added @click.stop to prevent dropdown from closing when clicking
status buttons, and type='button' to prevent form submission.
2025-12-17 01:13:26 -07:00
kelly
461b37ab30 fix: status filter on accounts page now filters by is_active
Some checks failed
ci/woodpecker/push/ci Pipeline failed
The dropdown offers active/inactive but controller was filtering by
status column (approval status). Now filters by is_active boolean
while still only showing approved accounts.
2025-12-17 01:11:57 -07:00
kelly
88167995b1 fix: use dispensary icon for customer column on orders page
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-17 01:09:58 -07:00
kelly
e8622765a2 fix: replace building SVG icons with dispensary.svg across all views
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Replaced all instances of building icon SVG path with dispensary.svg
- Updated All Brands icon to briefcase-in-circle design
- Files updated: sales accounts/dashboard, executive dashboard,
  manufacturing dashboard, business setup forms, buyer setup
2025-12-17 01:09:19 -07:00
kelly
983752a396 fix: use dispensary icon on sales accounts page, bookmark icon for brands
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Sales accounts page: use dispensary.svg instead of colored placeholder
- Brand switcher: use bookmark icon (matching sidebar Brands menu)
2025-12-17 01:05:29 -07:00
kelly
b338571fb9 style: match All Brands icon size to sidebar icons (size-4)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-17 01:00:56 -07:00
kelly
7990c2ab55 style: change All Brands icon to briefcase design
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Matches the same gray (#6b7280) as contacts icon
- Briefcase with handle and center clasp
2025-12-17 01:00:07 -07:00
kelly
e419c57072 style: lighten All Brands icon 15% and reduce size 10%
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Changed color from #6b7280 to #8b939e (15% lighter)
- Reduced button icon from size-6 to size-5
- Reduced dropdown icon from size-5 to size-4
2025-12-17 00:57:42 -07:00
kelly
05144fe04b feat: add folder-heart icon for All Brands in brand switcher
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Created all-brands.svg with folder + heart design
- Uses same gray color (#6b7280) as other default icons
- Transparent background for consistency
2025-12-17 00:56:21 -07:00
kelly
23aa144f85 fix: brand switcher - clicking 'All Brands' navigates to /brands
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- When no brand is selected, clicking 'All Brands' text navigates to brands index
- Chevron still opens dropdown to select a specific brand
- When a brand is selected, clicking the brand name opens dropdown
- Reverted to original grid icon style for 'All Brands'
2025-12-17 00:54:39 -07:00
kelly
bdf56d3d95 fix: show business name in brand switcher instead of 'All Brands'
Some checks failed
ci/woodpecker/push/ci Pipeline failed
When no brand is selected, show the business name (e.g., 'Cannabrands')
in the brand switcher button instead of 'All Brands' to avoid confusion
with the dropdown option.
2025-12-17 00:52:50 -07:00
kelly
de1574f920 fix: standardize default icons and rename Accounts to Customers
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Replace CDN URLs (cdn.spdy.io) with local /images/defaults/ paths
- Use dispensary.svg for accounts/stores (business entities)
- Use contact.svg for contacts and leads (people)
- Rename 'Accounts' to 'Customers' in sidebar and breadcrumbs
- Fix brand switcher to show Cannabrands logo when no brand selected
2025-12-17 00:48:49 -07:00
kelly
ff2f9ba64c fix: use default icons for contacts and accounts lists
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- contacts: use contact.svg icon
- accounts: use dispensary.svg icon
2025-12-17 00:41:15 -07:00
kelly
362cb8091b refactor: rename Accounts to Customers in sidebar menu
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-17 00:38:42 -07:00
kelly
0af81efac0 fix: use contact icon in contacts list view
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-17 00:37:35 -07:00
kelly
c705ef0cd0 feat: add contact icon for account avatars in sales dashboard
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Create contact.svg with same gray (#6b7280) as dispensary icon
- Use contact icon instead of dispensary for customer accounts
2025-12-17 00:35:14 -07:00
kelly
ae5d7bf47a fix: use default dispensary icon for account avatars in sales dashboard
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-17 00:30:20 -07:00
kelly
76ced26127 fix(k8s): use registry.spdy.io for base images
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Use registry.spdy.io/library/busybox for setup-storage init container
instead of Docker Hub to avoid rate limiting issues.

- Add setup-storage init container with busybox from local registry
- Registry is synced daily from Docker Hub via registry-sync-cronjob
2025-12-16 22:17:30 -07:00
kelly
e98ad5d611 chore: replace code.cannabrands.app with git.spdy.io
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Update all references to use the correct registry and git URLs:
- K8s deployment images: registry.spdy.io
- Documentation links: git.spdy.io
- CI/CD references: git.spdy.io

Also update CLAUDE.md:
- Mark PostgreSQL as external database (do not create on spdy.io)
- Update Docker Registry to registry.spdy.io (HTTPS)
2025-12-16 20:39:48 -07:00
kelly
37a6eb67b2 fix: resolve duplicate bom.update route name conflict
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Rename bom.update to bom.component.update for component updates
- Rename bom.detach to bom.component.detach for component removal
- Update view references to use new route names
- Fixes route:cache serialization error
2025-12-16 19:29:52 -07:00
kelly
2bf97fe4e1 config(k8s): use external PostgreSQL and Redis for dev
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- DB_HOST: 10.100.6.50 (external PostgreSQL)
- REDIS_HOST: 10.100.9.50 (external Redis)
- Remove in-cluster postgres and redis StatefulSets
- Removes postgres-patch.yaml and redis-patch.yaml from patches
2025-12-16 19:07:58 -07:00
kelly
f9130d1c67 feat: add plus addressing for user-specific email routing
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Reply-To can include user ID: inbox+u123@domain.com
- InboundEmailService parses plus address to auto-assign threads
- When user starts conversation, replies route back to them
- Verifies user belongs to business before assigning
2025-12-16 18:02:29 -07:00
kelly
a542112361 feat: add Reply-To headers to all transactional emails
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Create HasBusinessReplyTo trait for consistent Reply-To handling
- Apply trait to QuoteMail, InvoiceSentMail, and all Order emails
- Reply-To uses business's primary email identity when configured
- Enables email replies to route back to CRM inbox
2025-12-16 17:53:20 -07:00
kelly
b2108651d8 feat: enable email replies to route to CRM inbox
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add Reply-To header to QuoteMail using business's primary email identity
- Add emailIdentities() and primaryEmailIdentity() relationships to Business model
- Add notification to InboundEmailService for inbound email messages

When a user sends a quote (or invoice/order) email and the recipient
replies, the reply will:
1. Come in via webhook (Postmark, SendGrid, etc.)
2. Be processed by InboundEmailService
3. Threaded using In-Reply-To/References headers
4. Create a CrmChannelMessage in the CRM inbox
5. Send notification to assigned user or business users
2025-12-16 17:50:17 -07:00
kelly
b81b3ea8eb feat: remove chat widget from seller side, add message notifications
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Hide marketplace chat widget for sellers (only show for buyers)
- Sellers use CRM inbox instead of chat widget
- Create CrmNewMessageNotification for inbound messages
- Add 'message' style to NotificationStyleService
- Send notifications to assigned user or business users on new messages
2025-12-16 17:46:14 -07:00
kelly
3ae38ff5ee fix: resolve PDF generation and invoice location validation
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- QuoteController: Change pdf->inline() to pdf->stream() for DomPDF v3.x compatibility
- InvoiceController: Fix validation rule to use 'locations' table (not 'business_locations')

Fixes Issue #5 (PDF error) and Issue #8 (invoice submission error)
2025-12-16 17:45:55 -07:00
kelly
5218a44701 refactor: remove agent status from inbox, rely on user area
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Remove agent status dropdown from inbox top bar
- Remove presence channel subscription
- Remove agent status heartbeat
- Remove onlineUsers tracking
- Status is managed in user area settings instead
2025-12-16 17:43:52 -07:00
kelly
6234cea62c fix: remove character limit validation from product descriptions (Issue #7)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Long description fields (long_description, consumer_long_description,
buyer_long_description) no longer have max character limits, allowing
full product descriptions without truncation.
2025-12-16 17:39:48 -07:00
kelly
6e26a65f12 feat: add menu toggle button to inbox top bar
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Hamburger icon toggles sidebar expanded/collapsed state so users
can access navigation menu while in the inbox view.
2025-12-16 17:38:56 -07:00
kelly
86532e27fe fix: swap SMS and Chat icons
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- SMS: phone icon (device-phone-mobile)
- Chat: chat bubble icon (chat-bubble-left-right)
2025-12-16 17:37:22 -07:00
kelly
f2b8d04d03 feat: auto-collapse sidebar on inbox page for more space
User can hover to expand sidebar if needed.
2025-12-16 17:36:40 -07:00
kelly
615dbd3ee6 refactor: redesign unified inbox with Chatwoot-style layout
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add top bar with channel tabs (All, Email, SMS, WhatsApp, Instagram, Chat)
- Move status filters (All, Open, Pending, Closed) to top bar right side
- Move agent status dropdown to top bar
- Inline thread list with search and assignment filter
- Clean 3-column layout: thread list | messages | context sidebar
- Remove separate agent-status-widget and thread-filters partials
- Add null checks for Echo to prevent errors if WebSockets unavailable
2025-12-16 17:34:12 -07:00
kelly
fb6cf407d4 fix: make contacts.last_name nullable (Issue #9)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
The controller validation allows null last_name but the database
column was NOT NULL, causing insert failures.
2025-12-16 17:29:00 -07:00
kelly
9fcfc8b484 fix: correct users table column names in unified inbox query
Some checks failed
ci/woodpecker/push/ci Pipeline failed
The users table has first_name/last_name columns, not a name column.
Map the query results to include a computed 'name' field for view
compatibility since the Blade templates expect member.name.
2025-12-16 17:27:11 -07:00
kelly
60e1564c0f Merge branch 'feat/omnichannel-inbox' into develop
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-16 16:40:06 -07:00
kelly
a42d1bc3c8 fix(ci): remove --parallel flag (paratest not installed)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-16 16:19:43 -07:00
kelly
729234bd7f fix(ci): install dev dependencies for lint/test steps
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 16:18:23 -07:00
kelly
d718741cd3 fix: resolve invoice submission error (Issue #8)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add auto-generation of view_token on CrmInvoice creation
- Add migration to make account_id nullable (controller allows null)
2025-12-16 16:16:31 -07:00
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
5f0042e483 fix(ci): use explicit git clone plugin for auth
The default Woodpecker clone was failing with:
'could not read Username for https://code.cannabrands.app'

Using woodpeckerci/plugin-git explicitly should use the
configured repo credentials from Woodpecker's Gitea OAuth.
2025-12-10 17:08:12 -07: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
329c01523a Merge pull request 'fix: resolve Crystal issues #162, #163, #165, #166' (#171) from fix/crystal-issues-162-166 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/171
2025-12-10 22:29:15 +00:00
kelly
5fb26f901d Merge pull request 'fix: resolve Crystal issues #167, #172, #173' (#174) from fix/gitea-critical-issues into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/174
2025-12-10 22:29:02 +00:00
kelly
6baadf5744 fix: resolve Crystal issues #167, #172, #173
Issue #167: Invoice - No products populate for manual invoice
- Added scopeForBusiness() to Product model
- SearchController::invoiceProducts() now works correctly

Issue #172: Brand Overview Links Broken
- Eager load products relation in BrandController::dashboard()
- Fixes N+1 query and ensures metrics display correctly

Issue #173: Products Search only searching current page
- Changed filter-bar from Alpine-only to server-side form submission
- Search now queries entire database, not just current page
2025-12-10 15:14:14 -07:00
kelly
a3508c57a2 fix: resolve Crystal issues #162, #163, #165, #166
Issue #162: Edit Contact 404 error
- Contacts missing hashids caused 404 on edit (hashid-based routing)
- Migration backfills hashids for all 153 existing contacts

Issue #163: Batch creation error
- Added missing quantity_unit column to batches table
- Added hashid column to batches table
- Updated Batch model with HasHashid trait and quantity_unit in fillable

Issue #165: No save button on product edit
- Added dedicated mobile save button above tabs (sm:hidden)
- Fixed desktop button to hide on mobile (hidden sm:flex)
- Ensures save button is always visible on any screen size

Issue #166: Product validation error on create
- Changed category field from text input to select dropdown
- Now properly submits category_id matching validation rules
- Dropdown populated from business product_categories
2025-12-10 13:22:00 -07:00
kelly
38cba2cd72 Merge pull request 'fix: resolve multiple Gitea issues (#161-#167)' (#170) from fix/gitea-issues-161-167 into develop 2025-12-10 19:32:25 +00:00
kelly
735e09ab90 perf: optimize brands page and fix brand colors
- Move route generation from Blade to controller for brands index
- Remove mock random data that was slowing page render
- Update CSS with correct Cannabrands brand colors:
  - Primary: #4B6FA4 (muted blue)
  - Success: #4E8D71 (muted green)
  - Error: #E1524D (clean red)
- Add "DO NOT CHANGE" comments to prevent color changes
- Simplify brand cards and insights panel to use real data
2025-12-10 12:26:11 -07:00
kelly
05ef21cd71 fix: resolve multiple Gitea issues (#161-#167)
Issues fixed:
- #161: Quote submission - add tax_rate migration (already existed)
- #162: Contact edit 404 - change contact->id to contact->hashid in routes
- #163: Batch creation - expand batch_type constraint to include component/homogenized
- #164: Stock search - convert client-side to server-side search
- #165: Product edit save button - make always visible with different states
- #166: Product creation validation - make price_unit nullable with default
- #167: Invoice products - change stockFilter default from true to false

Additional fixes:
- Fix layouts.seller (non-existent) to layouts.app-with-sidebar in 14 views
- Fix CRM route names from seller.crm.* to seller.business.crm.*
2025-12-10 11:24:01 -07:00
kelly
65c65bf9cc fix: add missing tax_rate column to crm_quotes table 2025-12-09 17:36:10 -07:00
kelly
e33f0d0182 Merge pull request 'feat: CannaiQ Marketing Intelligence Engine (Phases 1-6)' (#160) from feature/cannaiq-marketing-intelligence into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/160
2025-12-10 00:18:55 +00:00
kelly
c8faf2f2d6 feat: add CRM account editing, cannaiq_enabled flag, and UI improvements
- Add cannaiq_enabled column to businesses table
- Add CRM account edit and contacts-edit views
- Enhance quote creation with improved contact selection
- Update search controller with additional functionality
- Add buyer businesses seeder for dev environment
- UI improvements to promotions, invoices, and sidebar
2025-12-09 16:45:31 -07:00
kelly
50bb3fce77 feat: add marketing automations / playbooks engine (Phase 6)
Adds the Marketing Automations system that allows sellers to create
automated marketing workflows triggered by CannaiQ intelligence data.

Features:
- Automation configuration with triggers (scheduled, manual)
- Condition evaluators (competitor OOS, slow mover, new store)
- Action executors (create promo, create campaign)
- Scheduled command and queued job execution
- Full CRUD UI with quick-start presets
- Run history tracking with detailed logs

Components:
- MarketingAutomation and MarketingAutomationRun models
- AutomationRunner service with extensible condition/action handlers
- RunDueMarketingAutomations command for cron scheduling
- RunMarketingAutomationJob for Horizon-backed execution
- Seller UI at /s/{business}/marketing/automations
2025-12-09 16:41:32 -07:00
kelly
c7fdc67060 feat: add dispensary marketing portal (Phase 5)
Add white-labeled marketing portal for dispensary partners:

- Business branding settings table and model for white-labeling
- Portal middleware (EnsureMarketingPortalAccess) with contact_type check
- Portal route group at /portal/{business}/*
- DashboardController with stats and CannaiQ recommendations
- PromoController for viewing recommended/existing promos
- CampaignController with create/send/schedule/cancel
- ListController for managing contact lists
- Policies for MarketingCampaign and MarketingPromo
- White-labeled portal layout with custom branding CSS
- Marketing Portal link in Filament admin for buyer businesses
- MarketingPortalUserSeeder for development testing
- PORTAL_ACCESS.md documentation
2025-12-09 15:59:40 -07:00
kelly
c7e2b0e4ac feat: add messaging layer for marketing campaigns (Phase 4)
Add contact & list management, email/SMS sending infrastructure, and
promo-to-campaign integration for the CannaiQ Marketing Intelligence Engine.

New models:
- MarketingContact: multi-type contacts (buyer, consumer, internal)
- MarketingList: static and smart lists for campaign targeting
- MarketingCampaign: direct email/SMS campaigns with list targeting
- MarketingMessageLog: send tracking per message

New services:
- EmailSender: uses business SMTP via MailSettingsResolver
- SmsSender: uses platform SMS via SmsProviderSettings (Twilio)

New features:
- Contacts CRUD with add-to-list functionality
- Lists CRUD with contact management
- Campaign creation pre-filled from promo copy
- Job-based sending with 100-message batches
- Scheduled campaign dispatch command

Updates sidebar with Contacts and Lists menu items under Growth.
2025-12-09 13:41:21 -07:00
kelly
0cf83744db chore: clarify comment in MarketingIntelligenceService 2025-12-09 12:40:07 -07:00
kelly
defeeffa07 docs: update CannaiQ API docs and client to use correct endpoints
- Changed endpoints from /dispensaries to /stores
- Changed auth from Bearer token to X-API-Key header
- Added new endpoint methods: getStoreProducts, getStoreProductMetrics,
  getStoreCompetitorSnapshot, getProductHistory, healthCheck, ping
- Updated documentation with all available endpoints and example responses
2025-12-09 12:39:47 -07:00
kelly
0fbf99c005 feat: add CannaiQ Marketing Intelligence Engine foundation
Phase 1 - Foundations:
- Add CannaiQ API client (CannaiqClient) with retry/backoff
- Add cannaiq config to services.php
- Create migrations for store_metrics, product_metrics, marketing_promos
- Create Cannaiq models (StoreMetric, ProductMetric)
- Create MarketingPromo model with promo types and statuses
- Add routes for /marketing/intelligence and /marketing/promos
- Add Intelligence and Promos to Growth menu section

Phase 2 - Intelligence Dashboard:
- Create MarketingIntelligenceService for data orchestration
- Wire IntelligenceController to service for data fetching
- Add intelligence views (index, store, product) with stub UI

Phase 3 - Promo Builder:
- Create PromoRecommendationService with AI recommendations
- Wire PromoController to service for recommendations/estimates
- Add SMS/email copy generation
- Add promo views (index, create, show, edit)

Also includes CannaiQ API documentation at docs/marketing/CANNAIQ_API.md
2025-12-09 12:35:00 -07:00
kelly
67eb679c7e fix: load contacts dynamically based on selected account in quote create
- Contact select now loads contacts for the selected customer account via AJAX
- Removed static $contacts loading from QuoteController (was loading seller contacts)
- Fixed security validation to verify contact belongs to account, not seller
- Shows 'Select account first' placeholder when no account selected
2025-12-09 12:28:06 -07:00
kelly
3b7f3acaa6 Merge pull request 'feat: CRM UI improvements and Alpine modal fixes' (#159) from fix/brand-hashid-fallback into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/159
2025-12-09 18:02:03 +00:00
kelly
3d1f3b1057 fix: use migrate only, never fresh or seed for deployments 2025-12-09 10:52:33 -07:00
kelly
7a2748e904 fix: remove postgres commands override causing root execution error 2025-12-09 10:49:59 -07:00
kelly
4f2061cd00 fix: add postgres health check wait in CI tests 2025-12-09 10:47:56 -07:00
kelly
8bb9044f2d ci: trigger rebuild 2025-12-09 10:44:55 -07:00
kelly
7da52677d5 fix: add parallel-lint as dev dependency to avoid CI network issues
Moves php-parallel-lint from global composer install during CI to a
project dev dependency. This uses the cached vendor folder instead of
requiring network access to Packagist during CI runs.
2025-12-09 10:31:04 -07:00
kelly
a049db38a9 fix: add eager loading for product images in BrandController
Fixes lazy loading violation on Product.images relation which was
causing BrandDashboardTest failures. Added `with('images')` eager
loading in both the dashboard method and calculateBrandInsights.
2025-12-09 09:50:43 -07:00
kelly
bb60a772f9 feat: CRM UI improvements and Alpine modal fixes
- Redesign Account detail page as "Account Command Center"
  - Compact metric tiles, financial snapshot strip
  - Quick actions row and tab navigation
  - Two-column layout with quotes, orders, invoices, tasks
- Fix Alpine.js modal initialization timing issues
  - send-menu-modal: register component and init if Alpine started
  - create-opportunity-modal: same fix
- Fix user dropdown not opening (remove style="display:none", use x-cloak)
- Add search routes and SearchController for seller context
- Various CRM view updates for consistency
2025-12-09 09:12:27 -07:00
kelly
95d92f27d3 refactor: update Inbox and Contacts screens for visual consistency
- Inbox: Add proper page header, card-wrapped 2-column layout
- Inbox: Improved sidebar with filters, search, and conversation list
- Inbox: Better empty state for conversation pane
- Contacts: Standardized header with description
- Contacts: Toolbar with search and type filter
- Contacts: Table-based layout wrapped in card
- Contacts: Improved empty state messaging
- Both: Aligned with Sales Dashboard, Accounts, Orders, Quotes styling
2025-12-09 01:00:08 -07:00
kelly
f08910bbf4 fix: add missing stage column to crm_deals table
The DealController queries by stage name but the table only had stage_id.
Added stage column with composite index for efficient kanban board queries.
2025-12-09 00:56:48 -07:00
kelly
e043137269 fix: add deleted_at column to crm_sla_policies and fix route names
- Add migration for SoftDeletes support in CrmSlaPolicy model
- Fix incorrect route names in CRM settings index view
  - templates → templates.index
  - roles → roles.index
2025-12-08 22:37:12 -07:00
kelly
de988d9abd fix: add missing period columns to crm_rep_metrics table 2025-12-08 22:22:08 -07:00
kelly
72df0cfe88 feat: add Quotes to Commerce menu in Sales Suite 2025-12-08 22:14:41 -07:00
kelly
65a752f4d8 Merge pull request 'feat: CRM leads system, customer creation, and various fixes' (#158) from fix/brand-hashid-fallback into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/158
2025-12-09 01:04:50 +00:00
kelly
7d0230be5f feat: add CRM leads system and customer creation
- Add CrmLead model for tracking prospects not yet in system
- Add LeadController with full CRUD operations
- Add leads views (index, create, show, edit)
- Add create/store methods to AccountController for direct customer creation
- Add customer create form view
- Add routes for leads and customer creation
- Update accounts index with Add Customer and Leads buttons
2025-12-08 17:51:59 -07:00
kelly
75305a01b0 fix: use ILIKE for case-insensitive search in orders and invoices 2025-12-08 17:48:13 -07:00
kelly
f2ce0dfee3 fix: batch creation and brand settings checkbox change detection 2025-12-08 17:38:36 -07:00
kelly
1222610080 fix: CRM accounts search, orders, and activity pages 2025-12-08 17:36:23 -07:00
kelly
c1d0cdf477 fix: use server-side search on products index 2025-12-08 17:29:57 -07:00
kelly
a55ea906ac fix: add hashid to brand eager load and defensive fallback
- Add hashid to brand eager loading in ProductController
- Add defensive fallback in Brand::getLogoUrl() and getBannerUrl()
- Falls back to direct Storage URL if hashid is missing
- Prevents route generation errors from incomplete eager loading
2025-12-08 17:29:57 -07:00
kelly
70e274415d Merge pull request 'fix: add hashid to brand eager load and defensive fallback' (#157) from fix/brand-hashid-fallback into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/157
2025-12-09 00:15:56 +00:00
kelly
fca89475cc fix: add hashid to brand eager load and defensive fallback
- Add hashid to brand eager loading in ProductController
- Add defensive fallback in Brand::getLogoUrl() and getBannerUrl()
- Falls back to direct Storage URL if hashid is missing
- Prevents route generation errors from incomplete eager loading
2025-12-08 17:10:29 -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
kelly
a88eeb7981 Merge pull request 'fix: make product search case-insensitive and optimize queries' (#156) from fix/product-search-case-insensitive into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/156
2025-12-08 23:27:05 +00:00
kelly
eed4df0c4a fix: make product search case-insensitive and optimize queries
- Use ILIKE instead of LIKE for PostgreSQL case-insensitive search
- Reduce eager loading in index() to only needed columns
- Remove images relation (use image_path column instead)
- Add pagination to listings() method
- Filter hashid at DB level instead of PHP collection
2025-12-08 16:21:58 -07:00
kelly
915b0407cf Merge pull request 'fix: pass $business to all CRM views and add sidebar menu ordering' (#155) from perf/controller-query-optimization into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/155
2025-12-08 21:49:52 +00:00
kelly
f173254700 perf: optimize controller queries with DB aggregations and pagination
- Replace collection-based aggregations with database-level SUM/COUNT queries
- Use CASE WHEN for consolidated stats queries (5+ queries -> 1 query)
- Add pagination to list pages (InvoiceController, DealController)
- Add query limits for profile pages and dropdowns (50-100 items max)
- Fix SiteSetting to handle missing table during migrations
- Fix CoreSchemaTest to exclude intercompany tables (valid accounting term)

Controllers optimized:
- Seller/InvoiceController - pagination + DB stats
- Seller/BrandController - refactored calculateBrandStats
- Seller/ContactController - added limits to show() queries
- Seller/WorkOrderController - consolidated 5 stats queries
- Crm/DealController - per-stage queries with limits
- Crm/CrmDashboardController - consolidated 8 queries to 2
- Crm/InvoiceController - DB aggregates for stats
- Crm/AccountController - DB aggregates for stats
- Crm/TaskController - consolidated 3 queries to 1
2025-12-08 14:14:06 -07:00
kelly
539cd0e4e1 fix: pass $business to all CRM views and add sidebar menu ordering
- Fix all CRM controllers to explicitly pass $business to views
- Add scopeOutstanding() to CrmInvoice model
- Add fallback in CRM layout to get $business from route
- Reorder sidebar menu sections (Overview, Inbox, Commerce, etc.)
- Add delivery location selector to quote create form
- Add migration for location_id on crm_quotes table
2025-12-08 12:59:21 -07:00
Jon
050a446ba0 Merge pull request 'feat: add Site Branding admin page' (#154) from feat/site-branding-admin into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/154
2025-12-08 18:49:48 +00:00
kelly
8fe4213178 feat: add Site Branding admin page for favicon and logo management
- Add SiteSetting model with Redis caching for key/value settings
- Add SiteBranding Filament page under Platform Settings
- Support upload for favicon, light logo, and dark logo
- Add inline current preview for each upload field
- Update layouts to use dynamic favicon from settings
2025-12-08 10:20:54 -07:00
kelly
d7413784ea Merge pull request 'fix: eliminate N+1 queries on stock page' (#153) from fix/stock-page-n-plus-1 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/153
2025-12-08 07:11:38 +00:00
kelly
b6b049e321 fix: eliminate N+1 queries on stock page
- Use withSum() to pre-compute batch quantities in a single query
  instead of calling $product->batches()->sum() for each row
- Add withCount('batches') to avoid COUNT query per product row
- Eager load Business parent relationship via resolveRouteBinding()
  to avoid extra query in sidebar layout
2025-12-08 00:02:25 -07:00
kelly
11509c4af0 Merge pull request 'feat: Processing Suite with compliance records and manufacturing updates' (#152) from feature/processing-suite into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/152
2025-12-08 06:58:40 +00:00
kelly
8651e5a9e6 feat: add compliance records and manufacturing dashboard updates
- Add compliance records views (create, index, show)
- Update manufacturing dashboard with enhanced metrics
- Update MfgPurchaseOrderController and MfgShipmentController
- Add package dependencies for manufacturing features
- Update suites config for processing module
2025-12-07 23:38:24 -07:00
kelly
e0d931d72c feat: add Processing Suite as standalone SaaS module
Phase 1 - Database Migrations (22 proc_* tables):
- proc_biomass_lots, proc_equipment, proc_extraction_runs
- proc_material_lots, proc_extraction_run_inputs/outputs
- proc_processing_runs, proc_processing_run_inputs/outputs
- proc_material_movements, proc_solvent_tanks/movements
- proc_equipment_maintenance, proc_qc_tests/results
- proc_compliance_records, proc_vendors, proc_customers
- proc_sales_orders, proc_sales_order_lines
- proc_shipments, proc_shipment_lines

Phase 2 - Models (app/Models/Processing/):
- ProcBiomassLot, ProcEquipment, ProcExtractionRun
- ProcMaterialLot, ProcExtractionRunInput/Output
- ProcProcessingRun, ProcProcessingRunInput/Output
- ProcMaterialMovement, ProcSolventTank/Movement
- ProcEquipmentMaintenance, ProcQcTest/Result
- ProcComplianceRecord, ProcVendor, ProcCustomer
- ProcSalesOrder/Line, ProcShipment/Line

Phase 3 - Suite Registration:
- Added 'processing' suite to config/suites.php
- Departments: extraction, processing, qa, compliance, sales

Phase 4 - Routes & Controllers (11 controllers):
- ProcessingDashboardController, BiomassController
- ExtractionRunController, ProcessingRunController
- MaterialLotController, SolventController
- EquipmentController, QcController
- ComplianceController, ProcessingSalesOrderController
- ProcessingShipmentController

Phase 5 - Blade Views (50+ views):
- Dashboard with KPIs and quick actions
- Full CRUD for all entities
- DaisyUI components throughout

Also includes Manufacturing Suite scaffolding (mfg_* tables).
2025-12-07 23:37:57 -07:00
kelly
6c7a0d2a35 Merge pull request 'perf: cache sidebar queries for faster page loads' (#150) from perf/sidebar-caching into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/150
2025-12-08 06:28:08 +00:00
kelly
95684ffae0 Merge pull request 'perf: cache suite checks in Redis to avoid N+1 queries' (#151) from perf/suite-redis-cache into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/151
2025-12-08 06:28:00 +00:00
kelly
b30f5db061 perf: increase dev web resources to match postgres (2Gi/2CPU) 2025-12-07 23:14:25 -07:00
kelly
266bb3ff9c perf: cache suite checks in Redis to avoid N+1 queries
- hasSuite() now caches all suite keys in Redis for 5 minutes
- hasSuiteFeature() now caches all features in Redis for 5 minutes
- Add clearSuiteCache() method to invalidate both caches

Reduces 5-6 DB queries per sidebar load to 0-2 cached queries.
2025-12-07 23:03:18 -07:00
kelly
f227a53ac1 perf: cache sidebar queries for faster page loads
- Cache accessibleBrands() for 5 minutes per user/business
- Cache getSelectedBrand() for 5 minutes per user/business/brand
- Cache SuiteMenuResolver::forBusiness() for 5 minutes per business/user

These sidebar queries were running on every page load, adding significant
latency. Now they're cached in Redis for instant subsequent page loads.
2025-12-07 22:59:22 -07:00
kelly
6d0adb0b02 Merge pull request 'fix: enable Horizon, Redis queue, and increase dev memory' (#149) from fix/dashboard-performance into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/149
2025-12-08 05:38:42 +00:00
kelly
61b2a2beb6 fix: dashboard view fixes and menu active state
- Fix timestamp display using Carbon::parse() in views
- Fix menu active state: Dashboard no longer highlights when on Analytics
- Add exact_match flag to prevent wildcard route matching
- Add brand_logo_path to Redis cached data for sales dashboard
- Transform Redis arrays to objects for view compatibility
2025-12-07 22:32:22 -07:00
kelly
fdfe132545 fix: increase dev memory limit to 1Gi to prevent OOM kills 2025-12-07 22:30:08 -07:00
kelly
c9e191ee7e fix: enable Redis queue connection for dev environment
- Add QUEUE_CONNECTION=redis to dev k8s overlay
- Fix DashboardController to cast Redis data to objects for Blade views
- Fix overview.blade.php to parse timestamp strings to Carbon
- Add brand_logo_path to sales metrics cache for view consistency
2025-12-07 22:14:56 -07:00
kelly
d42c964c30 Merge pull request 'feat: switch to Horizon for queue monitoring + dashboard fixes' (#148) from fix/dashboard-performance into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/148
2025-12-08 04:49:36 +00:00
kelly
b8e7ebc3ac feat: switch from database queue to Horizon for job monitoring
- Replace queue:work with php artisan horizon in supervisor
- Change QUEUE_CONNECTION from database to redis
- Enables /admin/horizon dashboard for Super Admins
- Provides real-time job monitoring, metrics, and retry capability

This completes the dashboard performance fix by ensuring the
CalculateDashboardMetrics job runs reliably via Horizon.
2025-12-07 20:50:33 -07:00
kelly
e156716002 Merge pull request 'fix: dashboard performance - Redis migration and schema fixes' (#147) from fix/dashboard-performance into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/147
2025-12-08 03:37:05 +00:00
kelly
b5c1d92397 Merge develop into fix/dashboard-performance 2025-12-07 20:32:17 -07:00
kelly
72e96b7e0e fix: complete dashboard Redis migration and schema fixes
- Fix schema mismatch: use 'metadata' column (not stage_1_metadata/stage_2_metadata)
- Fix Vehicle column: use 'is_active' boolean (not 'status' string)
- Migrate overview(), analytics(), sales() to read from Redis
- Remove dead code: getFleetMetrics() method
- Add hash wash metadata structure documentation
- Update CLAUDE.md with dashboard performance architecture rules

Reduces ~430 queries per dashboard request to 1 Redis read.
2025-12-07 20:03:24 -07:00
kelly
4489377762 Merge pull request 'fix: pre-calculate dashboard metrics via background job' (#146) from fix/dashboard-performance into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/146
2025-12-08 02:04:32 +00:00
kelly
eedd4c9cef fix: pre-calculate dashboard metrics via background job
Problem: Dashboard was causing 503s on first uncached request due to
dozens of expensive queries running sequentially (30+ seconds).

Solution: Pre-calculate all metrics in background via Horizon.

Changes:
- Add CalculateDashboardMetrics job that runs every 10 minutes
- Store all aggregations in Redis (dashboard:{business_id}:metrics)
- Update businessDashboard() to read from Redis (instant)
- Add dashboard:calculate-metrics command for manual runs
- Fallback: dispatches job if Redis empty, shows empty state

Metrics calculated:
- Core stats (revenue, orders, products, AOV)
- Invoice stats (total, pending, paid, overdue)
- Chart data (7 days, 30 days, 12 months)
- Top products by revenue
- Low stock alerts
- Processing metrics (washes, yields)
- Fleet metrics (drivers, vehicles)

Result: Dashboard loads in ~10ms (Redis read) instead of 30+ seconds.
2025-12-07 16:16:41 -07:00
kelly
2370f31a18 Merge pull request 'ci: trigger rebuild after Woodpecker repair' (#145) from ci/trigger-rebuild into develop 2025-12-07 22:15:36 +00:00
828 changed files with 102666 additions and 12124 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,399 +1,293 @@
# Woodpecker CI/CD Pipeline for Cannabrands Hub
# Documentation: https://woodpecker-ci.org/docs/intro
# Optimized for fast deploys (~8-10 min)
#
# 2-Environment Workflow (Optimized for small team):
# - develop branch → dev.cannabrands.app (integration/testing)
# - master branch → cannabrands.app (production)
# - tags (2025.X) → cannabrands.app (versioned production releases)
# Optimizations:
# - Parallel composer + frontend builds
# - Split tests (unit + feature run in parallel)
# - Dependency caching (npm + composer)
# - Single-stage Dockerfile.fast
# - Kaniko layer caching
#
# Pipeline Strategy:
# - PRs: Run tests (lint, style, phpunit)
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
# - Tags: Build versioned release
# External Services:
# - PostgreSQL: 10.100.6.50:5432 (cannabrands_dev)
# - Redis: 10.100.9.50:6379
# - MinIO: 10.100.9.80:9000
# - Docker Registry: git.spdy.io (for k8s pulls)
when:
- branch: [develop, master]
event: push
- event: [pull_request, tag]
# Install dependencies first (needed for php-lint to resolve traits/classes)
steps:
# Restore Composer cache
restore-composer-cache:
image: meltwater/drone-cache:dev
clone:
git:
image: woodpeckerci/plugin-git
settings:
backend: "filesystem"
restore: true
cache_key: "composer-{{ checksum \"composer.lock\" }}"
archive_format: "gzip"
mount:
- "vendor"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
depth: 50
lfs: false
partial: false
steps:
# ============================================
# PARALLEL: Composer + Frontend (with caching)
# ============================================
# Install dependencies (uses pre-built Laravel image with all extensions)
composer-install:
image: kirschbaumdevelopment/laravel-test-runner:8.3
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
commands:
- echo "Creating minimal .env for package discovery..."
- |
cat > .env << 'EOF'
APP_NAME="Cannabrands Hub"
APP_ENV=testing
APP_ENV=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-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
rebuild-composer-cache:
image: meltwater/drone-cache:dev
settings:
backend: "filesystem"
rebuild: true
cache_key: "composer-{{ checksum \"composer.lock\" }}"
archive_format: "gzip"
mount:
- "vendor"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
build-frontend:
image: 10.100.9.70:5000/library/node:22-alpine
environment:
VITE_REVERB_APP_KEY: 6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU=
VITE_REVERB_HOST: dev.cannabrands.app
VITE_REVERB_PORT: "443"
VITE_REVERB_SCHEME: https
npm_config_cache: .npm-cache
commands:
# Use cached node_modules if available
- npm ci --prefer-offline
- npm run build
- echo "✅ Frontend built"
# ============================================
# PR CHECKS (Parallel: lint, style, tests)
# ============================================
# PHP Syntax Check (PRs only - skipped on merge since tests already passed)
php-lint:
image: kirschbaumdevelopment/laravel-test-runner:8.3
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
commands:
- echo "Checking PHP syntax..."
- find app -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- find routes -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- find database -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- echo "✅ PHP syntax check complete!"
- ./vendor/bin/parallel-lint app routes database config --colors --blame
when:
event: pull_request
# Run Laravel Pint (PRs only - skipped on merge since tests already passed)
code-style:
image: kirschbaumdevelopment/laravel-test-runner:8.3
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
commands:
- echo "Checking code style with Laravel Pint..."
- ./vendor/bin/pint --test
- echo "✅ Code style check complete!"
when:
event: pull_request
# Run PHPUnit Tests (PRs only - skipped on merge since tests already passed)
# Note: Uses array cache/session for speed and isolation (Laravel convention)
# Redis + Reverb services used for real-time broadcasting tests
tests:
image: kirschbaumdevelopment/laravel-test-runner:8.3
# Split tests: Unit tests (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 "Starting Reverb server in background..."
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
- sleep 2
- echo "Running tests..."
- php artisan test --parallel
- echo "Tests complete!"
- php artisan test --testsuite=Unit
- echo "✅ Unit tests passed"
# Validate seeders that run in dev/staging environments
# This prevents deployment failures caused by seeder errors (e.g., fake() crashes)
# Uses APP_ENV=development to match K8s init container behavior
validate-seeders:
image: kirschbaumdevelopment/laravel-test-runner:8.3
# Split tests: Feature tests (with DB)
tests-feature:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
when:
event: pull_request
environment:
APP_ENV: development
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: testing
DB_USERNAME: testing
DB_PASSWORD: testing
APP_ENV: testing
CACHE_STORE: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: 10.100.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 seeders (matches K8s init container)..."
- cp .env.example .env
- php artisan key:generate
- echo "Running migrate:fresh --seed with APP_ENV=development..."
- php artisan migrate:fresh --seed --force
- echo "✅ Seeder validation complete!"
- php artisan test --testsuite=Feature
- echo "✅ Feature tests passed"
# ============================================
# BUILD & DEPLOY
# ============================================
# Create Docker config for registry auth (runs before Kaniko)
setup-registry-auth:
image: alpine
depends_on:
- composer-install
- build-frontend
environment:
REGISTRY_USER:
from_secret: registry_user
REGISTRY_PASSWORD:
from_secret: registry_password
commands:
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
- |
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
{"auths":{"registry.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
EOF
- echo "Auth config created"
when:
branch: [develop, master]
event: push
status: success
# Build and push Docker image for DEV environment (develop branch)
build-image-dev:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- dev # Latest dev build → dev.cannabrands.app
- dev-${CI_COMMIT_SHA:0:7} # Unique dev tag with SHA
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
- ${CI_COMMIT_BRANCH} # Branch name (develop)
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "dev"
VITE_REVERB_APP_KEY: "6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU="
VITE_REVERB_HOST: "dev.cannabrands.app"
VITE_REVERB_PORT: "443"
VITE_REVERB_SCHEME: "https"
cache_from:
- code.cannabrands.app/cannabrands/hub:buildcache-dev
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-dev
platforms: linux/amd64
# Disable provenance attestations - can cause Gitea registry 500 errors
provenance: false
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- setup-registry-auth
commands:
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
- |
/kaniko/executor \
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
--destination=registry.spdy.io/cannabrands/hub:dev \
--destination=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
--build-arg=APP_VERSION=dev \
--registry-mirror=10.100.9.70:5000 \
--insecure-registry=10.100.9.70:5000 \
--cache=true \
--cache-ttl=168h \
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache
when:
branch: develop
event: push
status: success
# Auto-deploy to dev.cannabrands.app (develop branch only)
deploy-dev:
image: bitnami/kubectl:latest
image: 10.100.9.70:5000/bitnami/kubectl:latest
depends_on:
- build-image-dev
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_dev
commands:
- echo "🚀 Auto-deploying to dev.cannabrands.app..."
- echo "Commit SHA${CI_COMMIT_SHA:0:7}"
- echo ""
# Setup kubeconfig
- mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
# Update deployment to use new SHA-tagged image (both app and init containers)
- |
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
migrate=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
app=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
migrate=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
-n cannabrands-dev
# Wait for rollout to complete (timeout 5 minutes)
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
# Verify deployment health
- |
echo ""
echo "✅ Deployment successful!"
echo "Pod status:"
kubectl get pods -n cannabrands-dev -l app=cannabrands-hub
echo ""
echo "Image deployed:"
kubectl get deployment cannabrands-hub -n cannabrands-dev -o jsonpath='{.spec.template.spec.containers[0].image}'
echo ""
- echo "✅ Deployed to dev.cannabrands.app"
when:
branch: develop
event: push
status: success
# Build and push Docker image for PRODUCTION (master branch)
build-image-production:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- latest # Latest production build
- prod-${CI_COMMIT_SHA:0:7} # Unique prod tag with SHA
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
- ${CI_COMMIT_BRANCH} # Branch name (master)
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "production"
cache_from:
- code.cannabrands.app/cannabrands/hub:buildcache-prod
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-prod
platforms: linux/amd64
# Disable provenance attestations - can cause Gitea registry 500 errors
provenance: false
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- setup-registry-auth
commands:
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
- |
/kaniko/executor \
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
--destination=git.spdy.io/cannabrands/hub:latest \
--destination=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
--build-arg=APP_VERSION=production \
--cache=true \
--cache-ttl=168h \
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
--insecure \
--insecure-pull \
--skip-tls-verify
when:
branch: master
event: push
status: success
# Deploy to production (master branch)
deploy-production:
image: bitnami/kubectl:latest
image: 10.100.9.70:5000/bitnami/kubectl:latest
depends_on:
- build-image-production
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_prod
commands:
- echo "🚀 Deploying to PRODUCTION (cannabrands.app)..."
- echo "Commit SHA ${CI_COMMIT_SHA:0:7}"
- mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
- |
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
migrate=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
app=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
migrate=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
-n cannabrands-prod
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
- |
echo ""
echo "✅ PRODUCTION deployment successful!"
echo "Pod status:"
kubectl get pods -n cannabrands-prod -l app=cannabrands-hub
- echo "✅ Deployed to cannabrands.app"
when:
branch: master
event: push
status: success
# Build and push Docker image for tagged releases (optional versioned releases)
build-image-release:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- ${CI_COMMIT_TAG} # CalVer tag (e.g., 2025.10.1)
- latest # Latest stable release
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "${CI_COMMIT_TAG}"
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-prod
platforms: linux/amd64
# Disable provenance attestations - can cause Gitea registry 500 errors
provenance: false
# For tags, setup auth first
setup-registry-auth-release:
image: alpine
depends_on:
- composer-install
- build-frontend
environment:
REGISTRY_USER:
from_secret: registry_user
REGISTRY_PASSWORD:
from_secret: registry_password
commands:
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
- |
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
{"auths":{"git.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
EOF
when:
event: tag
status: success
# Success notification
success:
image: alpine:latest
when:
- evaluate: 'CI_PIPELINE_STATUS == "success"'
build-image-release:
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- setup-registry-auth-release
commands:
- echo "✅ Pipeline completed successfully!"
- echo "All checks passed for commit ${CI_COMMIT_SHA:0:7}"
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
- |
if [ "${CI_PIPELINE_EVENT}" = "tag" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🎉 PRODUCTION RELEASE BUILD COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Version: ${CI_COMMIT_TAG}"
echo "Registry: code.cannabrands.app/cannabrands/hub"
echo ""
echo "Available as:"
echo " - code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
echo " - code.cannabrands.app/cannabrands/hub:latest"
echo ""
echo "🚀 Deploy to PRODUCTION (cannabrands.app):"
echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
echo " docker-compose -f docker-compose.production.yml up -d"
echo ""
echo "⚠️ This is a CUSTOMER-FACING release!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "master" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 PRODUCTION DEPLOYED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Site: https://cannabrands.app"
echo "Image: prod-${CI_COMMIT_SHA:0:7}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "develop" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 DEV BUILD + AUTO-DEPLOY COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Branch: develop"
echo "Commit: ${CI_COMMIT_SHA:0:7}"
echo ""
echo "✅ Built & Tagged:"
echo " - code.cannabrands.app/cannabrands/hub:dev"
echo " - code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7}"
echo " - code.cannabrands.app/cannabrands/hub:sha-${CI_COMMIT_SHA:0:7}"
echo ""
echo "✅ Auto-Deployed to Kubernetes:"
echo " - Environment: dev.cannabrands.app"
echo " - Namespace: cannabrands-dev"
echo " - Image: dev-${CI_COMMIT_SHA:0:7}"
echo ""
echo "🧪 Test your changes:"
echo " - Visit: https://dev.cannabrands.app"
echo " - Login: admin@example.com / password"
echo " - Check: https://dev.cannabrands.app/telescope"
echo ""
echo "Ready for production? Open PR: develop → master"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "pull_request" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ PR CHECKS PASSED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Ready to merge to master for production deployment."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
# Services for tests
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: testing
POSTGRES_PASSWORD: testing
POSTGRES_DB: testing
redis:
image: redis:7-alpine
commands:
- redis-server --bind 0.0.0.0
/kaniko/executor \
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
--destination=git.spdy.io/cannabrands/hub:${CI_COMMIT_TAG} \
--destination=git.spdy.io/cannabrands/hub:latest \
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
--build-arg=APP_VERSION=${CI_COMMIT_TAG} \
--cache=true \
--cache-ttl=168h \
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
--insecure \
--insecure-pull \
--skip-tls-verify
when:
event: tag

View File

@@ -69,14 +69,14 @@ git push origin develop
**Before (Mutable Tags - Problematic):**
```
code.cannabrands.app/cannabrands/hub:dev # Overwritten each build
git.spdy.io/cannabrands/hub:dev # Overwritten each build
```
**After (Immutable Tags - Best Practice):**
```
code.cannabrands.app/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
code.cannabrands.app/cannabrands/hub:dev # Latest dev (convenience)
code.cannabrands.app/cannabrands/hub:sha-a28d5b5 # Generic SHA
git.spdy.io/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
git.spdy.io/cannabrands/hub:dev # Latest dev (convenience)
git.spdy.io/cannabrands/hub:sha-a28d5b5 # Generic SHA
```
### Auto-Deploy Flow
@@ -109,14 +109,14 @@ If a deployment breaks dev, roll back to the previous version:
kubectl get deployment cannabrands-hub -n cannabrands-dev \
-o jsonpath='{.spec.template.spec.containers[0].image}'
# Output: code.cannabrands.app/cannabrands/hub:dev-a28d5b5
# Output: git.spdy.io/cannabrands/hub:dev-a28d5b5
# 2. Check git log for previous commit
git log --oneline develop | head -5
# 3. Rollback to previous SHA
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:dev-PREVIOUS_SHA \
app=git.spdy.io/cannabrands/hub:dev-PREVIOUS_SHA \
-n cannabrands-dev
# 4. Verify rollback
@@ -156,7 +156,7 @@ deploy-staging:
- chmod 600 ~/.kube/config
- |
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
app=git.spdy.io/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
-n cannabrands-staging
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-staging --timeout=300s
when:
@@ -207,7 +207,7 @@ kubectl logs -n cannabrands-dev deployment/cannabrands-hub --tail=100
cannabrands-hub-7d85986845-gnkbv 1/1 Running 0 45s
Image deployed:
code.cannabrands.app/cannabrands/hub:dev-a28d5b5
git.spdy.io/cannabrands/hub:dev-a28d5b5
```
---

View File

@@ -47,8 +47,8 @@ steps:
build-image:
image: plugins/docker
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
tags: [latest, ${CI_COMMIT_SHA:0:8}]
when:
branch: master
@@ -68,7 +68,7 @@ steps:
```bash
# On production server
ssh cannabrands-prod
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
docker pull git.spdy.io/cannabrands/hub:bef77df8
docker-compose up -d
# Or use deployment tool like Ansible, Deployer, etc.
```
@@ -108,7 +108,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
- docker exec cannabrands php artisan migrate --force
- docker exec cannabrands php artisan config:cache
@@ -160,7 +160,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
when:
branch: develop
@@ -176,7 +176,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
when:
branch: master
@@ -367,7 +367,7 @@ Production:
```bash
# Quick rollback (under 2 minutes)
ssh cannabrands-prod
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_COMMIT_SHA
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_COMMIT_SHA
docker-compose up -d
# Database rollback (if migrations ran)
@@ -536,8 +536,8 @@ steps:
build-image:
image: plugins/docker
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
tags:
- ${CI_COMMIT_BRANCH}
- ${CI_COMMIT_SHA:0:8}
@@ -559,7 +559,7 @@ steps:
from_secret: staging_ssh_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
- docker exec cannabrands php artisan migrate --force
- docker exec cannabrands php artisan config:cache
@@ -582,7 +582,7 @@ steps:
- echo "To deploy to production:"
- echo " ssh cannabrands-prod"
- echo " cd /var/www/cannabrands"
- echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo " docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo " docker-compose up -d"
- echo ""
- echo "⚠️ Remember: Check deployment checklist first!"

View File

@@ -102,7 +102,7 @@ Push to master → Woodpecker runs:
→ Build Docker image
→ Tag: cannabrands-hub:c165bf9 (commit SHA)
→ Tag: cannabrands-hub:latest
→ Push to code.cannabrands.app/cannabrands/hub
→ Push to git.spdy.io/cannabrands/hub
→ Image ready, no deployment yet
```
@@ -177,7 +177,7 @@ CMD ["php-fpm"]
### Staging Deployment:
```bash
# Pull the same image
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
docker pull git.spdy.io/cannabrands/hub:c165bf9
# Run with staging environment
docker run \
@@ -186,13 +186,13 @@ docker run \
-e DB_DATABASE=cannabrands_staging \
-e APP_DEBUG=true \
-e MAIL_MAILER=log \
code.cannabrands.app/cannabrands/hub:c165bf9
git.spdy.io/cannabrands/hub:c165bf9
```
### Production Deployment:
```bash
# Pull THE EXACT SAME IMAGE
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
docker pull git.spdy.io/cannabrands/hub:c165bf9
# Run with production environment
docker run \
@@ -201,7 +201,7 @@ docker run \
-e DB_DATABASE=cannabrands_production \
-e APP_DEBUG=false \
-e MAIL_MAILER=smtp \
code.cannabrands.app/cannabrands/hub:c165bf9
git.spdy.io/cannabrands/hub:c165bf9
```
**Key point**: Notice it's the **exact same image** (`c165bf9`), only environment variables differ.
@@ -218,7 +218,7 @@ docker run \
version: '3.8'
services:
app:
image: code.cannabrands.app/cannabrands/hub:latest
image: git.spdy.io/cannabrands/hub:latest
env_file:
- .env.staging # Staging-specific vars
ports:
@@ -253,7 +253,7 @@ secrets:
version: '3.8'
services:
app:
image: code.cannabrands.app/cannabrands/hub:c165bf9 # Specific SHA
image: git.spdy.io/cannabrands/hub:c165bf9 # Specific SHA
env_file:
- .env.production # Production-specific vars
ports:
@@ -301,7 +301,7 @@ spec:
spec:
containers:
- name: app
image: code.cannabrands.app/cannabrands/hub:c165bf9
image: git.spdy.io/cannabrands/hub:c165bf9
envFrom:
- configMapRef:
name: app-config-staging # Different per namespace
@@ -350,8 +350,8 @@ steps:
build-and-publish:
image: plugins/docker
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
tags:
- latest # Always overwrite
- ${CI_COMMIT_SHA:0:8} # Immutable SHA
@@ -384,7 +384,7 @@ Date: 2025-01-15 14:30:00 PST
Image: cannabrands-hub:c165bf9
Deployed by: jon@cannabrands.com
Approved by: compliance@cannabrands.com
Git commit: https://code.cannabrands.app/.../c165bf9
Git commit: https://git.spdy.io/.../c165bf9
Changes: Invoice picking workflow update
Tests passed: ✅ 28/28
Staging tested: ✅ 2 hours
@@ -424,7 +424,7 @@ Rollback image: cannabrands-hub:a1b2c3d
```bash
# On production server
ssh cannabrands-prod
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
docker pull git.spdy.io/cannabrands/hub:c165bf9
docker-compose -f docker-compose.production.yml up -d
```
@@ -487,14 +487,14 @@ steps:
security-scan:
image: aquasec/trivy
commands:
- trivy image code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- trivy image git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
```
### 4. Sign Images (Advanced)
Use Cosign to cryptographically sign images:
```bash
cosign sign code.cannabrands.app/cannabrands/hub:c165bf9
cosign sign git.spdy.io/cannabrands/hub:c165bf9
```
Compliance benefit: Prove image hasn't been tampered with.
@@ -507,10 +507,10 @@ Compliance benefit: Prove image hasn't been tampered with.
```bash
# List recent deployments
docker images code.cannabrands.app/cannabrands/hub
docker images git.spdy.io/cannabrands/hub
# Rollback to previous version
docker pull code.cannabrands.app/cannabrands/hub:a1b2c3d
docker pull git.spdy.io/cannabrands/hub:a1b2c3d
docker-compose -f docker-compose.production.yml up -d
```
@@ -531,7 +531,7 @@ deploy:
# Before risky deployment
git tag -a v1.5.2-stable -m "Last known good version"
docker tag cannabrands-hub:current cannabrands-hub:v1.5.2-stable
docker push code.cannabrands.app/cannabrands/hub:v1.5.2-stable
docker push git.spdy.io/cannabrands/hub:v1.5.2-stable
```
---

View File

@@ -254,25 +254,25 @@ WORKDIR /woodpecker/src
**Build and push to Gitea:**
```bash
docker build -f docker/ci-php.Dockerfile -t code.cannabrands.app/cannabrands/ci-php:8.3 .
docker push code.cannabrands.app/cannabrands/ci-php:8.3
docker build -f docker/ci-php.Dockerfile -t git.spdy.io/cannabrands/ci-php:8.3 .
docker push git.spdy.io/cannabrands/ci-php:8.3
```
**Update `.woodpecker/.ci.yml`:**
```yaml
steps:
php-lint:
image: code.cannabrands.app/cannabrands/ci-php:8.3
image: git.spdy.io/cannabrands/ci-php:8.3
commands:
- find app routes database -name "*.php" -exec php -l {} \;
composer-install:
image: code.cannabrands.app/cannabrands/ci-php:8.3
image: git.spdy.io/cannabrands/ci-php:8.3
commands:
- composer install --no-interaction --prefer-dist --optimize-autoloader
code-style:
image: code.cannabrands.app/cannabrands/ci-php:8.3
image: git.spdy.io/cannabrands/ci-php:8.3
commands:
- ./vendor/bin/pint --test
```

View File

@@ -107,7 +107,7 @@ version: '3.8'
services:
app:
image: code.cannabrands.app/cannabrands/hub:latest
image: git.spdy.io/cannabrands/hub:latest
container_name: cannabrands_app
restart: unless-stopped
ports:
@@ -204,8 +204,8 @@ steps:
build-image:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
username:
from_secret: gitea_username
password:
@@ -564,7 +564,7 @@ docker images | grep cannabrands
```bash
# Pull previous commit's image
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_SHA
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_SHA
# Update docker-compose.yml to use specific tag
docker compose up -d app

View File

@@ -11,10 +11,10 @@ Once you implement production deployments, Woodpecker will:
Your images will be available at:
```
code.cannabrands.app/cannabrands/hub
git.spdy.io/cannabrands/hub
```
**View packages**: https://code.cannabrands.app/Cannabrands/hub/-/packages
**View packages**: https://git.spdy.io/Cannabrands/hub/-/packages
## Step 1: Enable Gitea Package Registry
@@ -22,7 +22,7 @@ First, verify the registry is enabled on your Gitea instance:
1. **Check as admin**: Admin → Site Administration → Configuration
2. **Look for**: `[packages]` section with `ENABLED = true`
3. **Test**: Visit https://code.cannabrands.app/-/packages
3. **Test**: Visit https://git.spdy.io/-/packages
If not enabled, ask your Gitea admin to enable it in `app.ini`:
```ini
@@ -61,8 +61,8 @@ steps:
build-and-publish:
image: plugins/docker
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
@@ -136,15 +136,15 @@ Once images are published, you can pull them on your production servers:
```bash
# Login to Gitea registry
docker login code.cannabrands.app
docker login git.spdy.io
# Username: your-gitea-username
# Password: your-personal-access-token
# Pull latest image
docker pull code.cannabrands.app/cannabrands/hub:latest
docker pull git.spdy.io/cannabrands/hub:latest
# Or pull specific commit
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
docker pull git.spdy.io/cannabrands/hub:bef77df8
```
## Image Tagging Strategy
@@ -218,8 +218,8 @@ steps:
build-and-publish:
image: plugins/docker
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
@@ -236,7 +236,7 @@ steps:
notify-deploy:
image: alpine:latest
commands:
- echo "✅ New image published: code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo "✅ New image published: git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo "Ready for deployment to production!"
when:
- branch: master
@@ -271,8 +271,8 @@ services:
- Subsequent builds will work fine
**Images not appearing in Gitea packages**
- Check Gitea packages are enabled: https://code.cannabrands.app/-/packages
- Verify registry URL is `code.cannabrands.app` (not `ci.cannabrands.app`)
- Check Gitea packages are enabled: https://git.spdy.io/-/packages
- Verify registry URL is `git.spdy.io` (not `ci.cannabrands.app`)
## Next Steps

View File

@@ -85,7 +85,7 @@ git push origin 2025.11.3
### Step 3: Wait for CI Build (2-4 minutes)
Watch at: `code.cannabrands.app/cannabrands/hub/pipelines`
Watch at: `git.spdy.io/cannabrands/hub/pipelines`
CI will automatically:
- Run tests
@@ -113,7 +113,7 @@ git push origin master
```bash
# Deploy specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.3
app=git.spdy.io/cannabrands/hub:2025.11.3
# Watch deployment
kubectl rollout status deployment/cannabrands
@@ -131,7 +131,7 @@ kubectl get pods
```bash
# Option 1: Rollback to previous version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.2
app=git.spdy.io/cannabrands/hub:2025.11.2
# Option 2: Kubernetes automatic rollback
kubectl rollout undo deployment/cannabrands
@@ -154,7 +154,7 @@ git push origin 2025.11.4
# 4. Deploy when confident
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.4
app=git.spdy.io/cannabrands/hub:2025.11.4
```
---
@@ -170,7 +170,7 @@ master → Branch tracking
**Use in K3s dev/staging:**
```yaml
image: code.cannabrands.app/cannabrands/hub:latest-dev
image: git.spdy.io/cannabrands/hub:latest-dev
imagePullPolicy: Always
```
@@ -182,7 +182,7 @@ stable → Latest production release
**Use in K3s production:**
```yaml
image: code.cannabrands.app/cannabrands/hub:2025.11.3
image: git.spdy.io/cannabrands/hub:2025.11.3
imagePullPolicy: IfNotPresent
```
@@ -214,7 +214,7 @@ docker build -t cannabrands:test .
### View CI Status
```bash
# Visit Woodpecker
open https://code.cannabrands.app/cannabrands/hub/pipelines
open https://git.spdy.io/cannabrands/hub/pipelines
# Or check latest build
# (Visit Gitea → Repository → Pipelines)
@@ -227,7 +227,7 @@ open https://code.cannabrands.app/cannabrands/hub/pipelines
### CI Build Failing
```bash
# Check Woodpecker logs
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Visit: git.spdy.io/cannabrands/hub/pipelines
# Run tests locally first
./vendor/bin/sail artisan test
@@ -362,8 +362,8 @@ Before deploying:
- Pair with senior dev for first release
### CI/CD
- Woodpecker: `code.cannabrands.app/cannabrands/hub`
- Gitea: `code.cannabrands.app/cannabrands/hub`
- Woodpecker: `git.spdy.io/cannabrands/hub`
- Gitea: `git.spdy.io/cannabrands/hub`
- K3s Dashboard: (ask devops for link)
---
@@ -371,13 +371,13 @@ Before deploying:
## Important URLs
**Code Repository:**
https://code.cannabrands.app/cannabrands/hub
https://git.spdy.io/cannabrands/hub
**CI/CD Pipeline:**
https://code.cannabrands.app/cannabrands/hub/pipelines
https://git.spdy.io/cannabrands/hub/pipelines
**Container Registry:**
https://code.cannabrands.app/-/packages/container/cannabrands%2Fhub
https://git.spdy.io/-/packages/container/cannabrands%2Fhub
**Documentation:**
`.woodpecker/` directory in repository
@@ -430,7 +430,7 @@ Closes #42"
| Deploy | `kubectl set image deployment/cannabrands app=...:2025.11.1` |
| Rollback | `kubectl set image deployment/cannabrands app=...:2025.11.0` |
| Check version | `kubectl get deployment cannabrands -o jsonpath='{.spec.template.spec.containers[0].image}'` |
| View builds | Visit `code.cannabrands.app/cannabrands/hub/pipelines` |
| View builds | Visit `git.spdy.io/cannabrands/hub/pipelines` |
---

View File

@@ -33,7 +33,7 @@ git push origin master
2. Tests run (PHP lint, Pint, PHPUnit)
3. Docker image builds (if tests pass)
4. Tagged as: latest-dev, dev-c658193, master
5. Pushed to code.cannabrands.app/cannabrands/hub
5. Pushed to git.spdy.io/cannabrands/hub
6. Available in K3s dev namespace (manual or auto-pull)
```
@@ -47,7 +47,7 @@ git push origin master
**Use in K3s:**
```yaml
# dev/staging namespace
image: code.cannabrands.app/cannabrands/hub:latest-dev
image: git.spdy.io/cannabrands/hub:latest-dev
imagePullPolicy: Always # Always pull newest
```
@@ -81,7 +81,7 @@ git push origin 2025.11.1
**Use in K3s:**
```yaml
# production namespace
image: code.cannabrands.app/cannabrands/hub:2025.11.1
image: git.spdy.io/cannabrands/hub:2025.11.1
imagePullPolicy: IfNotPresent # Pin to specific version
```
@@ -212,7 +212,7 @@ git push origin master
./vendor/bin/sail artisan test
# Check CI is green
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Visit: git.spdy.io/cannabrands/hub/pipelines
# Test in staging/dev environment
# Verify key workflows work
@@ -264,12 +264,12 @@ git push origin 2025.11.3
```bash
# Watch Woodpecker build
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Visit: git.spdy.io/cannabrands/hub/pipelines
# Wait for success (2-4 minutes)
# CI will build and push:
# - code.cannabrands.app/cannabrands/hub:2025.11.3
# - code.cannabrands.app/cannabrands/hub:stable
# - git.spdy.io/cannabrands/hub:2025.11.3
# - git.spdy.io/cannabrands/hub:stable
```
#### 5. Deploy to Production (When Ready)
@@ -277,7 +277,7 @@ git push origin 2025.11.3
```bash
# Deploy new version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.3
app=git.spdy.io/cannabrands/hub:2025.11.3
# Watch rollout
kubectl rollout status deployment/cannabrands
@@ -328,11 +328,11 @@ git push origin master
```bash
# Option 1: Rollback to specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.2
app=git.spdy.io/cannabrands/hub:2025.11.2
# Option 2: Use previous stable
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:stable
app=git.spdy.io/cannabrands/hub:stable
# Note: 'stable' is updated on every release
# So if you just deployed 2025.11.3, 'stable' points to 2025.11.3
@@ -367,7 +367,7 @@ git push origin 2025.11.4
# Deploy
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.4
app=git.spdy.io/cannabrands/hub:2025.11.4
```
---

View File

@@ -4,9 +4,9 @@
**Current tagging strategy:**
```
code.cannabrands.app/cannabrands/hub:latest # Always changes
code.cannabrands.app/cannabrands/hub:c658193 # Commit SHA (meaningless)
code.cannabrands.app/cannabrands/hub:master # Branch name (changes)
git.spdy.io/cannabrands/hub:latest # Always changes
git.spdy.io/cannabrands/hub:c658193 # Commit SHA (meaningless)
git.spdy.io/cannabrands/hub:master # Branch name (changes)
```
**Issues:**
@@ -143,8 +143,8 @@ The CI pipeline now builds images with version metadata for both development and
build-image-dev:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
tags:
- dev # Latest dev build
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA
@@ -170,13 +170,13 @@ build-image-release:
**Result:**
```
# Development push to master
code.cannabrands.app/cannabrands/hub:dev
code.cannabrands.app/cannabrands/hub:sha-c658193
code.cannabrands.app/cannabrands/hub:master
git.spdy.io/cannabrands/hub:dev
git.spdy.io/cannabrands/hub:sha-c658193
git.spdy.io/cannabrands/hub:master
# Release (git tag 2025.10.1)
code.cannabrands.app/cannabrands/hub:2025.10.1 # Specific version
code.cannabrands.app/cannabrands/hub:latest # Latest stable
git.spdy.io/cannabrands/hub:2025.10.1 # Specific version
git.spdy.io/cannabrands/hub:latest # Latest stable
```
---
@@ -243,11 +243,11 @@ git checkout c658193
```bash
# Option 1: Rollback to specific version (recommended)
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:v1.2.2
app=git.spdy.io/cannabrands/hub:v1.2.2
# Option 2: Rollback to last stable
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:stable
app=git.spdy.io/cannabrands/hub:stable
# Option 3: Kubernetes rollback (uses previous deployment)
kubectl rollout undo deployment/cannabrands
@@ -281,7 +281,7 @@ cat CHANGELOG.md
# 5. Deploy specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:v1.2.1
app=git.spdy.io/cannabrands/hub:v1.2.1
```
---
@@ -357,7 +357,7 @@ audit-deployment:
```
Developer → Commit to master → CI tests → Build dev image
code.cannabrands.app/cannabrands/hub:dev-COMMIT
git.spdy.io/cannabrands/hub:dev-COMMIT
Deploy to dev/staging (optional)
```
@@ -486,7 +486,7 @@ spec:
spec:
containers:
- name: app
image: code.cannabrands.app/cannabrands/hub:v1.2.3
image: git.spdy.io/cannabrands/hub:v1.2.3
imagePullPolicy: IfNotPresent # Don't pull if tag exists
ports:
- containerPort: 80
@@ -535,7 +535,7 @@ git push origin master
# 5. Deploy to production (manual)
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:v1.3.0
app=git.spdy.io/cannabrands/hub:v1.3.0
```
### Emergency Rollback
@@ -546,7 +546,7 @@ kubectl rollout undo deployment/cannabrands
# Or specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:v1.2.3
app=git.spdy.io/cannabrands/hub:v1.2.3
# Verify
kubectl rollout status deployment/cannabrands

206
CLAUDE.md
View File

@@ -65,15 +65,74 @@ ALL routes need auth + user type middleware except public pages
**Creating PRs via Gitea API:**
```bash
# Requires GITEA_TOKEN environment variable
curl -X POST "https://code.cannabrands.app/api/v1/repos/Cannabrands/hub/pulls" \
curl -X POST "https://git.spdy.io/api/v1/repos/Cannabrands/hub/pulls" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "develop"}'
```
**Gitea Services:**
- **Gitea:** `https://code.cannabrands.app`
- **Woodpecker CI:** `https://ci.cannabrands.app`
**Infrastructure Services:**
| Service | Host | Notes |
|---------|------|-------|
| **Gitea** | `https://git.spdy.io` | Git repository |
| **Woodpecker CI** | `https://ci.spdy.io` | CI/CD pipelines |
| **Docker Registry** | `registry.spdy.io` | HTTPS registry with Let's Encrypt |
**PostgreSQL (Dev) - EXTERNAL DATABASE**
⚠️ **DO NOT create PostgreSQL databases on spdy.io infrastructure for cannabrands.**
Cannabrands uses an external managed PostgreSQL database.
```
Host: 10.100.6.50 (read replica)
Port: 5432
Database: cannabrands_dev
Username: cannabrands
Password: SpDyCannaBrands2024
URL: postgresql://cannabrands:SpDyCannaBrands2024@10.100.6.50:5432/cannabrands_dev
```
**PostgreSQL (CI)** - Ephemeral container for isolated tests
```
Host: postgres (service name)
Port: 5432
Database: testing
Username: testing
Password: testing
```
**Redis**
```
Host: 10.100.9.50
Port: 6379
Password: SpDyR3d1s2024!
URL: redis://:SpDyR3d1s2024!@10.100.9.50:6379
```
**MinIO (S3-Compatible Storage)**
```
Endpoint: 10.100.9.80:9000
Console: 10.100.9.80:9001
Region: us-east-1
Path Style: true
Bucket: cannabrands
Access Key: cannabrands-app
Secret Key: cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
```
**Gitea Container Registry** (for CI image pushes)
```
Registry: git.spdy.io
User: kelly@spdy.io
Token: c89fa0eeb417343b171f11de6b8e4292b2f50e2b
Scope: write:package
```
Woodpecker secrets: `registry_user`, `registry_password`
**CI/CD Notes:**
- Uses **Kaniko** for Docker builds (no Docker daemon, avoids DNS issues)
- Images pushed to `registry.spdy.io/cannabrands/hub`
- Base images pulled from `registry.spdy.io` (HTTPS with Let's Encrypt)
- Deploy: `develop` → dev.cannabrands.app, `master` → cannabrands.app
### 8. User-Business Relationship (Pivot Table)
Users connect to businesses via `business_user` pivot table (many-to-many).
@@ -191,6 +250,101 @@ if ($product->image_path) {
**This has caused multiple production outages - review docs before ANY storage changes!**
### 12. Dashboard & Metrics Performance (CRITICAL!)
**Production outages have occurred from violating these rules.**
#### The Golden Rule
**NEVER compute aggregations in HTTP controllers. Dashboard data comes from Redis, period.**
#### What Goes Where
| Location | Allowed | Not Allowed |
|----------|---------|-------------|
| Controller | `Redis::get()`, simple lookups by ID | `->sum()`, `->count()`, `->avg()`, loops with queries |
| Background Job | All aggregations, joins, complex queries | N/A |
#### ❌ BANNED Patterns in Controllers:
```php
// BANNED: Aggregation in controller
$revenue = Order::sum('total');
// BANNED: N+1 in loop
$items->map(fn($i) => Order::where('product_id', $i->id)->sum('qty'));
// BANNED: Query per day/iteration
for ($i = 0; $i < 30; $i++) {
$data[] = Order::whereDate('created_at', $date)->sum('total');
}
// BANNED: Selecting columns that don't exist
->select('id', 'stage_1_metadata') // Column doesn't exist!
```
#### ✅ REQUIRED Pattern:
```php
// Controller: Just read Redis
public function analytics(Business $business)
{
$data = Redis::get("dashboard:{$business->id}:analytics");
if (!$data) {
CalculateDashboardMetrics::dispatch($business->id);
return view('dashboard.analytics', ['data' => $this->emptyState()]);
}
return view('dashboard.analytics', ['data' => json_decode($data, true)]);
}
// Background Job: Do all the heavy lifting
public function handle()
{
// Batch query - ONE query for all products
$salesByProduct = OrderItem::whereIn('product_id', $productIds)
->groupBy('product_id')
->selectRaw('product_id, SUM(quantity) as total')
->pluck('total', 'product_id');
Redis::setex("dashboard:{$businessId}:analytics", 900, json_encode($data));
}
```
#### Before Merging Dashboard PRs:
1. Search for `->sum(`, `->count(`, `->avg(` in the controller
2. Search for `->map(function` with queries inside
3. If found → Move to background job
4. Query count must be < 20 for any dashboard page
#### The Architecture
```
BACKGROUND (every 10 min) HTTP REQUEST
======================== =============
┌─────────────────────┐ ┌─────────────────────┐
│ CalculateMetricsJob │ │ DashboardController │
│ │ │ │
│ - Heavy queries │ │ - Redis::get() only │
│ - Joins │──► Redis ──►│ - No aggregations │
│ - Aggregations │ │ - No loops+queries │
│ - Loops are OK here │ │ │
└─────────────────────┘ └─────────────────────┘
Takes 5-30 sec Takes 10ms
Runs in background User waits for this
```
#### Prevention Checklist for Future Dashboard Work
- [ ] All `->sum()`, `->count()`, `->avg()` are in background jobs, not controllers
- [ ] No `->map(function` with queries inside in controllers
- [ ] Redis keys exist after job runs (`redis-cli KEYS "dashboard:*"`)
- [ ] Job completes without errors (check `storage/logs/worker.log`)
- [ ] Controller only does `Redis::get()` for metrics
- [ ] Column names in `->select()` match actual database schema
---
## Tech Stack by Area
@@ -307,6 +461,48 @@ Product::where('is_active', true)->get(); // No business_id filter!
---
## Performance Requirements
**Database Queries:**
- NEVER write N+1 queries - always use eager loading (`with()`) for relationships
- NEVER run queries inside loops - batch them before the loop
- Avoid multiple queries when one JOIN or subquery works
- Dashboard/index pages should use MAX 5-10 queries total, not 50+
- Use `DB::enableQueryLog()` mentally - if a page would log 20+ queries, refactor
- Cache expensive aggregations (Redis, 5-min TTL) instead of recalculating every request
- Test with `DB::listen()` or Laravel Debugbar before committing controller code
**Before submitting controller code, verify:**
1. No queries inside foreach/map loops
2. All relationships eager loaded
3. Aggregations done in SQL, not PHP collections
4. Would this cause a 503 under load? If unsure, simplify.
**Examples:**
```php
// ❌ N+1 query - DON'T DO THIS
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // Query per iteration!
}
// ✅ Eager loaded - DO THIS
$orders = Order::with('customer')->get();
// ❌ Query in loop - DON'T DO THIS
foreach ($products as $product) {
$stock = Inventory::where('product_id', $product->id)->sum('quantity');
}
// ✅ Batch query - DO THIS
$stocks = Inventory::whereIn('product_id', $products->pluck('id'))
->groupBy('product_id')
->selectRaw('product_id, SUM(quantity) as total')
->pluck('total', 'product_id');
```
---
## What You Often Forget
✅ Scope by business_id BEFORE finding by ID
@@ -315,3 +511,5 @@ Product::where('is_active', true)->get(); // No business_id filter!
✅ DaisyUI for buyer/seller, Filament only for admin
✅ NO inline styles - use Tailwind/DaisyUI classes only
✅ Run tests before committing
✅ Eager load relationships to prevent N+1 queries
✅ No queries inside loops - batch before the loop

View File

@@ -44,7 +44,7 @@ Our workflow provides audit trails regulators love:
1. **Clone the repository**
```bash
git clone https://code.cannabrands.app/Cannabrands/hub.git
git clone https://git.spdy.io/Cannabrands/hub.git
cd hub
```
@@ -86,7 +86,7 @@ git commit -m "feat: add new feature"
git push origin feature/my-feature-name
# 4. Create Pull Request on Gitea
# - Navigate to https://code.cannabrands.app
# - Navigate to https://git.spdy.io
# - Create PR to merge your branch into develop
# - CI will run automatically
# - Request review from team
@@ -630,7 +630,7 @@ git push origin chore/changelog-2025.11.1
### Services
- **Woodpecker CI:** `https://ci.cannabrands.app`
- **Gitea:** `https://code.cannabrands.app`
- **Gitea:** `https://git.spdy.io`
- **Production:** `https://app.cannabrands.com` (future)
---

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,61 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CalculateDashboardMetrics;
use App\Models\Business;
use Illuminate\Console\Command;
class CalculateDashboardMetricsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'dashboard:calculate-metrics
{--business= : Specific business ID to calculate (optional)}
{--sync : Run synchronously instead of queuing}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Pre-calculate dashboard metrics and store in Redis';
/**
* Execute the console command.
*/
public function handle(): int
{
$businessId = $this->option('business');
$sync = $this->option('sync');
if ($businessId) {
$business = Business::find($businessId);
if (! $business) {
$this->error("Business {$businessId} not found");
return 1;
}
$this->info("Calculating metrics for business: {$business->name}");
} else {
$count = Business::where('type', 'seller')->where('status', 'approved')->count();
$this->info("Calculating metrics for {$count} businesses");
}
$job = new CalculateDashboardMetrics($businessId ? (int) $businessId : null);
if ($sync) {
$this->info('Running synchronously...');
$job->handle();
$this->info('Done!');
} else {
CalculateDashboardMetrics::dispatch($businessId ? (int) $businessId : null);
$this->info('Job dispatched to queue');
}
return 0;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SendMarketingCampaignJob;
use App\Models\Marketing\MarketingCampaign;
use Illuminate\Console\Command;
/**
* DispatchScheduledCampaigns - Dispatch scheduled marketing campaigns.
*
* Run via scheduler: Schedule::command('marketing:dispatch-scheduled-campaigns')->everyMinute();
*/
class DispatchScheduledCampaigns extends Command
{
protected $signature = 'marketing:dispatch-scheduled-campaigns';
protected $description = 'Dispatch scheduled marketing campaigns that are ready to send';
public function handle(): int
{
$campaigns = MarketingCampaign::readyToSend()->get();
if ($campaigns->isEmpty()) {
$this->info('No scheduled campaigns ready to send.');
return self::SUCCESS;
}
$this->info("Found {$campaigns->count()} campaign(s) ready to send.");
foreach ($campaigns as $campaign) {
$this->info("Dispatching campaign: {$campaign->name} (ID: {$campaign->id})");
SendMarketingCampaignJob::dispatch($campaign->id);
}
$this->info('Done.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Console\Commands;
use App\Models\Business;
use App\Models\BusinessDba;
use Illuminate\Console\Command;
/**
* Migrate existing business DBA data to the new business_dbas table.
*
* This command creates DBA records from existing business fields:
* - dba_name
* - invoice_payable_company_name, invoice_payable_address, etc.
* - ap_contact_* fields
* - primary_contact_* fields
*/
class MigrateDbaData extends Command
{
protected $signature = 'dba:migrate
{--dry-run : Show what would be created without actually creating records}
{--business= : Migrate only a specific business by ID or slug}
{--force : Skip confirmation prompt}';
protected $description = 'Migrate existing dba_name and invoice_payable_* fields to the business_dbas table';
public function handle(): int
{
$this->info('DBA Data Migration');
$this->line('==================');
$dryRun = $this->option('dry-run');
$specificBusiness = $this->option('business');
if ($dryRun) {
$this->warn('DRY RUN MODE - No records will be created');
}
// Build query
$query = Business::query()
->whereNotNull('dba_name')
->where('dba_name', '!=', '');
if ($specificBusiness) {
$query->where(function ($q) use ($specificBusiness) {
$q->where('id', $specificBusiness)
->orWhere('slug', $specificBusiness);
});
}
$businesses = $query->get();
$this->info("Found {$businesses->count()} businesses with dba_name set.");
if ($businesses->isEmpty()) {
$this->info('No businesses to migrate.');
return self::SUCCESS;
}
// Show preview
$this->newLine();
$this->table(
['ID', 'Business Name', 'DBA Name', 'Has Invoice Address', 'Already Has DBAs'],
$businesses->map(fn ($b) => [
$b->id,
\Illuminate\Support\Str::limit($b->name, 30),
\Illuminate\Support\Str::limit($b->dba_name, 30),
$b->invoice_payable_address ? 'Yes' : 'No',
$b->dbas()->exists() ? 'Yes' : 'No',
])
);
if (! $dryRun && ! $this->option('force')) {
if (! $this->confirm('Do you want to proceed with creating DBA records?')) {
$this->info('Aborted.');
return self::SUCCESS;
}
}
$created = 0;
$skipped = 0;
foreach ($businesses as $business) {
// Skip if business already has DBAs
if ($business->dbas()->exists()) {
$this->line(" Skipping {$business->name} - already has DBAs");
$skipped++;
continue;
}
if ($dryRun) {
$this->line(" Would create DBA for: {$business->name} -> {$business->dba_name}");
$created++;
continue;
}
// Create DBA from existing business fields
$dba = BusinessDba::create([
'business_id' => $business->id,
'trade_name' => $business->dba_name,
// Address - prefer invoice_payable fields, fall back to physical
'address' => $business->invoice_payable_address ?: $business->physical_address,
'city' => $business->invoice_payable_city ?: $business->physical_city,
'state' => $business->invoice_payable_state ?: $business->physical_state,
'zip' => $business->invoice_payable_zipcode ?: $business->physical_zipcode,
// License
'license_number' => $business->license_number,
'license_type' => $business->license_type,
// Contacts
'primary_contact_name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')) ?: null,
'primary_contact_email' => $business->primary_contact_email,
'primary_contact_phone' => $business->primary_contact_phone,
'ap_contact_name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')) ?: null,
'ap_contact_email' => $business->ap_contact_email,
'ap_contact_phone' => $business->ap_contact_phone,
// Invoice Settings
'invoice_footer' => $business->order_invoice_footer,
// Status
'is_default' => true,
'is_active' => true,
]);
$this->info(" Created DBA #{$dba->id} for {$business->name}: {$dba->trade_name}");
$created++;
}
$this->newLine();
$this->info("Summary: {$created} created, {$skipped} skipped");
if ($dryRun) {
$this->warn('Run without --dry-run to actually create records.');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Console\Commands;
use App\Jobs\RunMarketingAutomationJob;
use App\Models\Marketing\MarketingAutomation;
use Illuminate\Console\Command;
class RunDueMarketingAutomations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'marketing:run-due-automations
{--business= : Only process automations for a specific business ID}
{--dry-run : Show which automations would run without executing them}
{--sync : Run synchronously instead of dispatching to queue}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check and run all due marketing automations';
/**
* Execute the console command.
*/
public function handle(): int
{
$businessId = $this->option('business');
$dryRun = $this->option('dry-run');
$sync = $this->option('sync');
$this->info('Checking for due marketing automations...');
// Query active automations
$query = MarketingAutomation::where('is_active', true)
->whereIn('trigger_type', [
MarketingAutomation::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
MarketingAutomation::TRIGGER_SCHEDULED_STORE_CHECK,
]);
if ($businessId) {
$query->where('business_id', $businessId);
}
$automations = $query->get();
if ($automations->isEmpty()) {
$this->info('No active automations found.');
return Command::SUCCESS;
}
$this->info("Found {$automations->count()} active automation(s).");
$dueCount = 0;
foreach ($automations as $automation) {
if (! $automation->isDue()) {
$this->line(" - <comment>{$automation->name}</comment>: Not due yet");
continue;
}
$dueCount++;
if ($dryRun) {
$this->line(" - <info>{$automation->name}</info>: Would run (dry-run mode)");
$this->line(" Trigger: {$automation->trigger_type_label}");
$this->line(" Frequency: {$automation->frequency_label}");
$this->line(' Last run: '.($automation->last_run_at?->diffForHumans() ?? 'Never'));
continue;
}
$this->line(" - <info>{$automation->name}</info>: Dispatching...");
if ($sync) {
// Run synchronously
try {
$job = new RunMarketingAutomationJob($automation->id);
$job->handle(app(\App\Services\Marketing\AutomationRunner::class));
$this->line(' <info>Completed</info>');
} catch (\Exception $e) {
$this->error(" Failed: {$e->getMessage()}");
}
} else {
// Dispatch to queue
RunMarketingAutomationJob::dispatch($automation->id);
$this->line(' <info>Dispatched to queue</info>');
}
}
if ($dryRun) {
$this->newLine();
$this->info("Dry run complete. {$dueCount} automation(s) would have been executed.");
} else {
$this->newLine();
$this->info("Done. {$dueCount} automation(s) ".($sync ? 'executed' : 'dispatched').'.');
}
return Command::SUCCESS;
}
}

View File

@@ -120,6 +120,17 @@ class Kernel extends ConsoleKernel
->withoutOverlapping()
->runInBackground();
// ─────────────────────────────────────────────────────────────────────
// DASHBOARD METRICS PRE-CALCULATION
// ─────────────────────────────────────────────────────────────────────
// Pre-calculate dashboard metrics every 10 minutes
// Stores aggregations in Redis for instant page loads
$schedule->job(new \App\Jobs\CalculateDashboardMetrics)
->everyTenMinutes()
->withoutOverlapping()
->runInBackground();
// ─────────────────────────────────────────────────────────────────────
// HOUSEKEEPING & MAINTENANCE
// ─────────────────────────────────────────────────────────────────────

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,47 @@
<?php
namespace App\Events;
use App\Models\TeamMessage;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TeamMessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public TeamMessage $message
) {}
public function broadcastOn(): array
{
// Broadcast to the team conversation channel
return [
new PrivateChannel('team-conversation.'.$this->message->conversation_id),
];
}
public function broadcastAs(): string
{
return 'message.sent';
}
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'conversation_id' => $this->message->conversation_id,
'sender_id' => $this->message->sender_id,
'sender_name' => $this->message->getSenderName(),
'sender_initials' => $this->message->getSenderInitials(),
'body' => $this->message->body,
'type' => $this->message->type,
'metadata' => $this->message->metadata,
'created_at' => $this->message->created_at->toIso8601String(),
];
}
}

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

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\SiteSetting;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\HtmlString;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class SiteBranding extends Page implements HasForms
{
use InteractsWithForms;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-paint-brush';
protected static ?string $navigationLabel = 'Site Branding';
protected static ?string $title = 'Site Branding';
protected static string|\UnitEnum|null $navigationGroup = 'Platform Settings';
protected static ?int $navigationSort = 1;
protected string $view = 'filament.pages.site-branding';
public ?array $data = [];
public function mount(): void
{
$this->form->fill([
'site_name' => SiteSetting::get('site_name', 'Cannabrands Hub'),
'favicon' => SiteSetting::get('favicon_path') ? [SiteSetting::get('favicon_path')] : [],
'logo_light' => SiteSetting::get('logo_light_path') ? [SiteSetting::get('logo_light_path')] : [],
'logo_dark' => SiteSetting::get('logo_dark_path') ? [SiteSetting::get('logo_dark_path')] : [],
]);
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Site Identity')
->description('Configure the site name and branding assets.')
->schema([
TextInput::make('site_name')
->label('Site Name')
->required()
->maxLength(255)
->helperText('Displayed in browser tabs and emails.'),
]),
Section::make('Favicon')
->description('The small icon displayed in browser tabs. Recommended: 32x32 or 64x64 PNG/ICO.')
->columns(2)
->schema([
Placeholder::make('current_favicon')
->label('Current')
->content(function () {
$path = SiteSetting::get('favicon_path');
if (! $path) {
return new HtmlString(
'<div class="flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">'.
'<span class="text-gray-400 text-xs">Not set</span>'.
'</div>'
);
}
return new HtmlString(
'<div class="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-lg">'.
'<img src="'.SiteSetting::getFaviconUrl().'" alt="Favicon" class="w-8 h-8">'.
'</div>'
);
}),
FileUpload::make('favicon')
->label('Upload New')
->image()
->disk('public')
->directory('branding')
->visibility('public')
->acceptedFileTypes(['image/png', 'image/x-icon', 'image/ico', 'image/vnd.microsoft.icon'])
->maxSize(512)
->imagePreviewHeight('64')
->helperText('Upload a PNG or ICO file (max 512KB).'),
]),
Section::make('Logos')
->description('Upload logo variants for different backgrounds.')
->schema([
Section::make('Logo (Light/White)')
->description('For dark backgrounds (sidebar, etc.)')
->columns(2)
->schema([
Placeholder::make('current_logo_light')
->label('Current')
->content(function () {
$path = SiteSetting::get('logo_light_path');
if (! $path) {
return new HtmlString(
'<div class="flex items-center justify-center h-16 w-40 bg-gray-800 rounded-lg border-2 border-dashed border-gray-600">'.
'<span class="text-gray-400 text-xs">Not set</span>'.
'</div>'
);
}
return new HtmlString(
'<div class="inline-flex items-center justify-center h-16 px-4 bg-gray-800 rounded-lg">'.
'<img src="'.SiteSetting::getLogoLightUrl().'" alt="Logo Light" class="h-8 max-w-[150px] object-contain">'.
'</div>'
);
}),
FileUpload::make('logo_light')
->label('Upload New')
->image()
->disk('public')
->directory('branding')
->visibility('public')
->maxSize(2048)
->imagePreviewHeight('100'),
]),
Section::make('Logo (Dark)')
->description('For light backgrounds.')
->columns(2)
->schema([
Placeholder::make('current_logo_dark')
->label('Current')
->content(function () {
$path = SiteSetting::get('logo_dark_path');
if (! $path) {
return new HtmlString(
'<div class="flex items-center justify-center h-16 w-40 bg-gray-100 rounded-lg border-2 border-dashed border-gray-300">'.
'<span class="text-gray-400 text-xs">Not set</span>'.
'</div>'
);
}
return new HtmlString(
'<div class="inline-flex items-center justify-center h-16 px-4 bg-gray-100 rounded-lg">'.
'<img src="'.SiteSetting::getLogoDarkUrl().'" alt="Logo Dark" class="h-8 max-w-[150px] object-contain">'.
'</div>'
);
}),
FileUpload::make('logo_dark')
->label('Upload New')
->image()
->disk('public')
->directory('branding')
->visibility('public')
->maxSize(2048)
->imagePreviewHeight('100'),
]),
]),
])
->statePath('data');
}
public function save(): void
{
$data = $this->form->getState();
// Save site name
SiteSetting::set('site_name', $data['site_name']);
// Save file paths
$this->saveFileSetting('favicon_path', $data['favicon'] ?? []);
$this->saveFileSetting('logo_light_path', $data['logo_light'] ?? []);
$this->saveFileSetting('logo_dark_path', $data['logo_dark'] ?? []);
// Clear cache
SiteSetting::clearCache();
Notification::make()
->title('Branding settings saved')
->success()
->send();
}
protected function saveFileSetting(string $key, array $files): void
{
$path = ! empty($files) ? $files[0] : null;
// Handle TemporaryUploadedFile objects
if ($path instanceof TemporaryUploadedFile) {
$path = $path->store('branding', 'public');
}
SiteSetting::set($key, $path);
}
protected function getFormActions(): array
{
return [
Forms\Components\Actions\Action::make('save')
->label('Save Changes')
->submit('save'),
];
}
}

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

@@ -852,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.
@@ -1755,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')
@@ -1876,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}%");
});
});
})
@@ -1909,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')
@@ -2048,6 +2082,7 @@ class BusinessResource extends Resource
public static function getRelations(): array
{
return [
BusinessResource\RelationManagers\DbasRelationManager::class,
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
];
}

View File

@@ -28,6 +28,14 @@ class EditBusiness extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\Action::make('view_marketing_portal')
->label('Marketing Portal')
->icon('heroicon-o-megaphone')
->color('info')
->url(fn () => route('portal.dashboard', $this->record->slug))
->openUrlInNewTab()
->visible(fn () => $this->record->status === 'approved' && $this->record->business_type === 'buyer'),
Actions\Action::make('approve_application')
->label('Approve Application')
->icon('heroicon-o-check-circle')

View File

@@ -0,0 +1,235 @@
<?php
namespace App\Filament\Resources\BusinessResource\RelationManagers;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class DbasRelationManager extends RelationManager
{
protected static string $relationship = 'dbas';
protected static ?string $title = 'Trade Names (DBAs)';
protected static ?string $recordTitleAttribute = 'trade_name';
public function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Basic Information')
->schema([
TextInput::make('trade_name')
->label('Trade Name')
->required()
->maxLength(255),
TextInput::make('slug')
->label('Slug')
->disabled()
->dehydrated(false)
->helperText('Auto-generated from trade name'),
Toggle::make('is_default')
->label('Default DBA')
->helperText('Use for new invoices by default'),
Toggle::make('is_active')
->label('Active')
->default(true),
])
->columns(2),
Section::make('Address')
->schema([
TextInput::make('address')
->label('Street Address')
->maxLength(255),
TextInput::make('address_line_2')
->label('Address Line 2')
->maxLength(255),
Grid::make(3)
->schema([
TextInput::make('city')
->maxLength(255),
TextInput::make('state')
->maxLength(2)
->extraAttributes(['class' => 'uppercase']),
TextInput::make('zip')
->label('ZIP Code')
->maxLength(10),
]),
])
->collapsible(),
Section::make('License Information')
->schema([
TextInput::make('license_number')
->maxLength(255),
TextInput::make('license_type')
->maxLength(255),
DatePicker::make('license_expiration')
->label('Expiration Date'),
])
->columns(3)
->collapsible(),
Section::make('Banking Information')
->description('Sensitive data is encrypted at rest.')
->schema([
TextInput::make('bank_name')
->maxLength(255),
TextInput::make('bank_account_name')
->maxLength(255),
TextInput::make('bank_routing_number')
->maxLength(50)
->password()
->revealable(),
TextInput::make('bank_account_number')
->maxLength(50)
->password()
->revealable(),
Select::make('bank_account_type')
->options([
'checking' => 'Checking',
'savings' => 'Savings',
]),
])
->columns(2)
->collapsible()
->collapsed(),
Section::make('Tax Information')
->description('Sensitive data is encrypted at rest.')
->schema([
TextInput::make('tax_id')
->label('Tax ID')
->maxLength(50)
->password()
->revealable(),
Select::make('tax_id_type')
->label('Tax ID Type')
->options([
'ein' => 'EIN',
'ssn' => 'SSN',
]),
])
->columns(2)
->collapsible()
->collapsed(),
Section::make('Contacts')
->schema([
Grid::make(2)
->schema([
Section::make('Primary Contact')
->schema([
TextInput::make('primary_contact_name')
->label('Name')
->maxLength(255),
TextInput::make('primary_contact_email')
->label('Email')
->email()
->maxLength(255),
TextInput::make('primary_contact_phone')
->label('Phone')
->tel()
->maxLength(50),
]),
Section::make('AP Contact')
->schema([
TextInput::make('ap_contact_name')
->label('Name')
->maxLength(255),
TextInput::make('ap_contact_email')
->label('Email')
->email()
->maxLength(255),
TextInput::make('ap_contact_phone')
->label('Phone')
->tel()
->maxLength(50),
]),
]),
])
->collapsible()
->collapsed(),
Section::make('Invoice Settings')
->schema([
TextInput::make('payment_terms')
->maxLength(50)
->placeholder('Net 30'),
TextInput::make('invoice_prefix')
->maxLength(10)
->placeholder('INV-'),
Textarea::make('payment_instructions')
->rows(2)
->columnSpanFull(),
Textarea::make('invoice_footer')
->rows(2)
->columnSpanFull(),
])
->columns(2)
->collapsible()
->collapsed(),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('trade_name')
->label('Trade Name')
->searchable()
->sortable(),
TextColumn::make('city')
->label('Location')
->formatStateUsing(fn ($record) => $record->city && $record->state
? "{$record->city}, {$record->state}"
: ($record->city ?? $record->state ?? '-'))
->sortable(),
TextColumn::make('license_number')
->label('License')
->limit(15)
->tooltip(fn ($state) => $state),
IconColumn::make('is_default')
->label('Default')
->boolean()
->trueIcon('heroicon-o-star')
->falseIcon('heroicon-o-minus')
->trueColor('warning'),
IconColumn::make('is_active')
->label('Active')
->boolean(),
TextColumn::make('created_at')
->label('Created')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('is_default', 'desc')
->headerActions([
CreateAction::make(),
])
->actions([
EditAction::make(),
DeleteAction::make()
->requiresConfirmation(),
])
->emptyStateHeading('No Trade Names')
->emptyStateDescription('Add a DBA to manage different trade names for invoices and licenses.')
->emptyStateIcon('heroicon-o-building-office-2');
}
}

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

@@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\TeamConversation;
use App\Models\TeamMessage;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TeamChatController extends Controller
{
/**
* Get all team conversations for current user
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
]);
$user = $request->user();
// Verify user belongs to business
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$conversations = TeamConversation::forBusiness($validated['business_id'])
->forUser($user->id)
->with(['participants:id,first_name,last_name', 'messages' => fn ($q) => $q->latest()->limit(1)])
->orderByDesc('last_message_at')
->get()
->map(fn ($conv) => $this->formatConversation($conv, $user->id));
return response()->json(['conversations' => $conversations]);
}
/**
* Get or create a direct conversation with another user
*/
public function getOrCreateDirect(Request $request): JsonResponse
{
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
'user_id' => 'required|integer|exists:users,id',
]);
$user = $request->user();
// Verify current user belongs to business
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// Verify target user belongs to same business
$targetUser = User::find($validated['user_id']);
if (! $targetUser->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'User not in business'], 400);
}
// Can't chat with yourself
if ($validated['user_id'] === $user->id) {
return response()->json(['error' => 'Cannot chat with yourself'], 400);
}
$conversation = TeamConversation::getOrCreateDirect(
$validated['business_id'],
$user->id,
$validated['user_id']
);
$conversation->load('participants:id,first_name,last_name');
return response()->json([
'conversation' => $this->formatConversation($conversation, $user->id),
]);
}
/**
* Get messages for a conversation
*/
public function messages(Request $request, int $conversationId): JsonResponse
{
$user = $request->user();
$conversation = TeamConversation::with('participants')
->findOrFail($conversationId);
// Verify user is participant
if (! $conversation->participants->contains('id', $user->id)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$messages = $conversation->messages()
->with('sender:id,first_name,last_name')
->orderBy('created_at')
->limit(100)
->get()
->map(fn ($msg) => $this->formatMessage($msg));
// Mark conversation as read
$conversation->markReadFor($user->id);
return response()->json(['messages' => $messages]);
}
/**
* Send a message to a conversation
*/
public function send(Request $request, int $conversationId): JsonResponse
{
$validated = $request->validate([
'body' => 'required|string|max:10000',
'type' => 'sometimes|string|in:text,file,image',
'metadata' => 'sometimes|array',
]);
$user = $request->user();
$conversation = TeamConversation::with('participants')
->findOrFail($conversationId);
// Verify user is participant
if (! $conversation->participants->contains('id', $user->id)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$message = TeamMessage::create([
'conversation_id' => $conversationId,
'sender_id' => $user->id,
'body' => $validated['body'],
'type' => $validated['type'] ?? TeamMessage::TYPE_TEXT,
'metadata' => $validated['metadata'] ?? null,
'read_by' => [$user->id], // Sender has read it
]);
$message->load('sender:id,first_name,last_name');
return response()->json([
'message' => $this->formatMessage($message),
]);
}
/**
* Mark conversation as read
*/
public function markRead(Request $request, int $conversationId): JsonResponse
{
$user = $request->user();
$conversation = TeamConversation::with('participants')
->findOrFail($conversationId);
// Verify user is participant
if (! $conversation->participants->contains('id', $user->id)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$conversation->markReadFor($user->id);
return response()->json(['success' => true]);
}
/**
* Get team members available for chat
*/
public function teamMembers(Request $request): JsonResponse
{
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
]);
$user = $request->user();
// Verify user belongs to business
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// Get all users in the business except current user
$members = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $validated['business_id']))
->where('id', '!=', $user->id)
->select('id', 'first_name', 'last_name')
->orderBy('first_name')
->get()
->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'initials' => strtoupper(substr($u->first_name ?? '', 0, 1).substr($u->last_name ?? '', 0, 1)),
]);
return response()->json(['members' => $members]);
}
/**
* Format conversation for API response
*/
private function formatConversation(TeamConversation $conversation, int $currentUserId): array
{
$other = $conversation->getOtherParticipant($currentUserId);
return [
'id' => $conversation->id,
'type' => $conversation->type,
'name' => $conversation->getDisplayName($currentUserId),
'other_user' => $other ? [
'id' => $other->id,
'name' => $other->name,
'initials' => strtoupper(substr($other->first_name ?? '', 0, 1).substr($other->last_name ?? '', 0, 1)),
] : null,
'last_message_preview' => $conversation->last_message_preview,
'last_message_at' => $conversation->last_message_at?->toIso8601String(),
'unread_count' => $conversation->getUnreadCountFor($currentUserId),
'is_pinned' => $conversation->participants->firstWhere('id', $currentUserId)?->pivot?->is_pinned ?? false,
'is_muted' => $conversation->participants->firstWhere('id', $currentUserId)?->pivot?->is_muted ?? false,
];
}
/**
* Format message for API response
*/
private function formatMessage(TeamMessage $message): array
{
return [
'id' => $message->id,
'sender_id' => $message->sender_id,
'sender_name' => $message->getSenderName(),
'sender_initials' => $message->getSenderInitials(),
'body' => $message->body,
'type' => $message->type,
'metadata' => $message->metadata,
'created_at' => $message->created_at->toIso8601String(),
];
}
}

File diff suppressed because it is too large Load Diff

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

@@ -73,15 +73,27 @@ class OrderController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('order_number', 'like', "%{$search}%")
$q->where('order_number', 'ILIKE', "%{$search}%")
->orWhereHas('business', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
$q->where('name', 'ILIKE', "%{$search}%");
});
});
}
$orders = $query->paginate(20)->withQueryString();
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $orders->map(fn ($o) => [
'order_number' => $o->order_number,
'name' => $o->order_number.' - '.$o->business->name,
'customer' => $o->business->name,
'status' => $o->status,
])->values()->toArray(),
]);
}
return view('seller.orders.index', compact('orders', 'business'));
}

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Jobs\SendMarketingCampaignJob;
use App\Models\Branding\BusinessBrandingSetting;
use App\Models\Business;
use App\Models\Marketing\MarketingCampaign;
use App\Models\Marketing\MarketingList;
use App\Models\Marketing\MarketingPromo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class CampaignController extends Controller
{
public function index(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
$campaigns = MarketingCampaign::where('business_id', $business->id)
->with('list')
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->when($request->channel, fn ($q, $channel) => $q->where('channel', $channel))
->latest()
->paginate(15);
$statuses = [
'draft' => 'Draft',
'scheduled' => 'Scheduled',
'sending' => 'Sending',
'sent' => 'Sent',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
'failed' => 'Failed',
];
$channels = MarketingCampaign::CHANNELS;
return view('portal.campaigns.index', compact(
'business',
'branding',
'campaigns',
'statuses',
'channels'
));
}
public function create(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
// Get lists for this business
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->get();
// Pre-populate from promo if provided
$promo = null;
if ($request->query('promo_id')) {
$promo = MarketingPromo::where('business_id', $business->id)
->find($request->query('promo_id'));
}
// Pre-select channel if provided
$preselectedChannel = $request->query('channel', 'email');
$channels = MarketingCampaign::CHANNELS;
return view('portal.campaigns.create', compact(
'business',
'branding',
'lists',
'promo',
'preselectedChannel',
'channels'
));
}
public function store(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'channel' => 'required|in:email,sms',
'list_id' => 'required|exists:marketing_lists,id',
'subject' => 'required_if:channel,email|nullable|string|max:255',
'body' => 'required|string',
'send_at' => 'nullable|date|after:now',
'promo_id' => 'nullable|exists:marketing_promos,id',
]);
// Verify list belongs to this business
$list = MarketingList::where('business_id', $business->id)
->findOrFail($validated['list_id']);
// Build campaign data
$campaignData = [
'business_id' => $business->id,
'name' => $validated['name'],
'channel' => $validated['channel'],
'list_id' => $list->id,
'subject' => $validated['subject'] ?? null,
'body' => $validated['body'],
'status' => 'draft',
'created_by' => Auth::id(),
// Use branding defaults for from fields
'from_name' => $branding->effective_from_name,
'from_email' => $branding->effective_from_email,
];
// Link to promo if provided
if (! empty($validated['promo_id'])) {
$promo = MarketingPromo::where('business_id', $business->id)
->find($validated['promo_id']);
if ($promo) {
$campaignData['source_type'] = 'promo';
$campaignData['source_id'] = $promo->id;
}
}
// Set schedule if provided
if (! empty($validated['send_at'])) {
$campaignData['send_at'] = $validated['send_at'];
$campaignData['status'] = 'scheduled';
}
$campaign = MarketingCampaign::create($campaignData);
if ($campaign->status === 'scheduled') {
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', 'Campaign scheduled successfully.');
}
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', 'Campaign created as draft. Review and send when ready.');
}
public function show(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
$branding = BusinessBrandingSetting::forBusiness($business);
$campaign->load(['list', 'logs']);
// Get stats
$stats = [
'total_recipients' => $campaign->total_recipients,
'sent' => $campaign->total_sent,
'delivered' => $campaign->total_delivered,
'opened' => $campaign->total_opened,
'clicked' => $campaign->total_clicked,
'failed' => $campaign->total_failed,
];
return view('portal.campaigns.show', compact(
'business',
'branding',
'campaign',
'stats'
));
}
public function sendNow(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
return back()->with('error', 'This campaign cannot be sent.');
}
// Count recipients
$recipientCount = $campaign->list?->contacts()->count() ?? 0;
if ($recipientCount === 0) {
return back()->with('error', 'No recipients in the selected list.');
}
// Update campaign
$campaign->update([
'status' => 'sending',
'total_recipients' => $recipientCount,
'sent_at' => now(),
]);
// Dispatch job
SendMarketingCampaignJob::dispatch($campaign);
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', "Campaign is now sending to {$recipientCount} recipients.");
}
public function schedule(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
if ($campaign->status !== 'draft') {
return back()->with('error', 'Only draft campaigns can be scheduled.');
}
$validated = $request->validate([
'send_at' => 'required|date|after:now',
]);
$campaign->update([
'status' => 'scheduled',
'send_at' => $validated['send_at'],
]);
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', 'Campaign scheduled for '.$campaign->send_at->format('M j, Y g:i A'));
}
public function cancel(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
return back()->with('error', 'This campaign cannot be cancelled.');
}
$campaign->update([
'status' => 'cancelled',
]);
return redirect()
->route('portal.campaigns.index', $business->slug)
->with('success', 'Campaign cancelled.');
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\Branding\BusinessBrandingSetting;
use App\Models\Business;
use App\Models\Marketing\MarketingCampaign;
use App\Models\Marketing\MarketingPromo;
use App\Services\Marketing\PromoRecommendationService;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function __construct(
protected PromoRecommendationService $promoService
) {}
public function index(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
// Get recommended promos for this business
$recommendedPromos = collect();
try {
// Get store external IDs for this business if available
$storeExternalIds = $business->cannaiqStores()
->pluck('external_id')
->toArray();
if (! empty($storeExternalIds)) {
$recommendations = $this->promoService->getRecommendations(
$business,
$storeExternalIds[0] ?? null,
limit: 5
);
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
}
} catch (\Exception $e) {
// CannaiQ not configured or error - that's fine, show empty
}
// Get recent campaigns for this business
$recentCampaigns = MarketingCampaign::where('business_id', $business->id)
->with('list')
->latest()
->limit(5)
->get();
// Get active promos
$activePromos = MarketingPromo::forBusiness($business->id)
->currentlyActive()
->with('brand')
->limit(5)
->get();
// Get campaign stats
$campaignStats = [
'total' => MarketingCampaign::where('business_id', $business->id)->count(),
'sent' => MarketingCampaign::where('business_id', $business->id)
->whereIn('status', ['sent', 'completed'])
->count(),
'draft' => MarketingCampaign::where('business_id', $business->id)
->where('status', 'draft')
->count(),
'scheduled' => MarketingCampaign::where('business_id', $business->id)
->where('status', 'scheduled')
->count(),
];
return view('portal.dashboard', compact(
'business',
'branding',
'recommendedPromos',
'recentCampaigns',
'activePromos',
'campaignStats'
));
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\Branding\BusinessBrandingSetting;
use App\Models\Business;
use App\Models\Marketing\MarketingList;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ListController extends Controller
{
public function index(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->paginate(15);
return view('portal.lists.index', compact(
'business',
'branding',
'lists'
));
}
public function create(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
$types = MarketingList::getTypes();
return view('portal.lists.create', compact(
'business',
'branding',
'types'
));
}
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'type' => 'required|in:static,smart',
]);
$list = MarketingList::create([
'business_id' => $business->id,
'name' => $validated['name'],
'description' => $validated['description'],
'type' => $validated['type'],
'created_by' => Auth::id(),
]);
return redirect()
->route('portal.lists.show', [$business->slug, $list])
->with('success', 'List created successfully.');
}
public function show(Request $request, Business $business, MarketingList $list)
{
// Ensure list belongs to this business
if ($list->business_id !== $business->id) {
abort(404);
}
$branding = BusinessBrandingSetting::forBusiness($business);
$contacts = $list->contacts()
->orderBy('created_at', 'desc')
->paginate(25);
return view('portal.lists.show', compact(
'business',
'branding',
'list',
'contacts'
));
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\Branding\BusinessBrandingSetting;
use App\Models\Business;
use App\Models\Marketing\MarketingPromo;
use App\Services\Marketing\PromoRecommendationService;
use Illuminate\Http\Request;
class PromoController extends Controller
{
public function __construct(
protected PromoRecommendationService $promoService
) {}
public function index(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
// Get recommended promos from CannaiQ
$recommendedPromos = collect();
try {
$storeExternalIds = $business->cannaiqStores()
->pluck('external_id')
->toArray();
if (! empty($storeExternalIds)) {
$recommendations = $this->promoService->getRecommendations(
$business,
$storeExternalIds[0] ?? null,
limit: 20
);
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
}
} catch (\Exception $e) {
// CannaiQ not available
}
// Get existing promos for this business
$existingPromos = MarketingPromo::forBusiness($business->id)
->with('brand')
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->latest()
->paginate(12);
$statuses = MarketingPromo::getStatuses();
return view('portal.promos.index', compact(
'business',
'branding',
'recommendedPromos',
'existingPromos',
'statuses'
));
}
public function show(Request $request, Business $business, MarketingPromo $promo)
{
// Ensure promo belongs to this business
if ($promo->business_id !== $business->id) {
abort(404);
}
$branding = BusinessBrandingSetting::forBusiness($business);
$promo->load('brand');
return view('portal.promos.show', compact(
'business',
'branding',
'promo'
));
}
}

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) {
@@ -102,17 +102,28 @@ class BatchController extends Controller
$maxValue = ($request->cannabinoid_unit ?? '%') === '%' ? 100 : 1000;
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
// Accept either product_id or component_id (form sends component_id)
'product_id' => 'required_without:component_id|exists:products,id',
'component_id' => 'required_without:product_id|exists:products,id',
'batch_type' => 'nullable|string|in:component,homogenized',
'cannabinoid_unit' => 'nullable|string|in:%,MG/ML,MG/G,MG/UNIT',
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
'quantity_produced' => 'nullable|integer|min:0',
'batch_number' => 'required|string|max:100|unique:batches,batch_number',
'internal_code' => 'nullable|string|max:100',
// Accept either quantity_produced or quantity_total (form sends quantity_total)
'quantity_produced' => 'nullable|numeric|min:0',
'quantity_total' => 'nullable|numeric|min:0',
'quantity_remaining' => 'nullable|numeric|min:0',
'quantity_unit' => 'nullable|string|max:50',
'quantity_allocated' => 'nullable|integer|min:0',
'expiration_date' => 'nullable|date',
'is_active' => 'nullable|boolean',
'is_active' => 'nullable',
'production_date' => 'nullable|date',
'harvest_date' => 'nullable|date',
'package_date' => 'nullable|date',
'test_date' => 'nullable|date',
'test_id' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'license_number' => 'nullable|string|max:255',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
@@ -126,10 +137,18 @@ class BatchController extends Controller
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Map component_id to product_id if provided
$productId = $validated['product_id'] ?? $validated['component_id'];
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
})->findOrFail($productId);
// Map form fields to model fields
$validated['product_id'] = $productId;
$validated['quantity_produced'] = $validated['quantity_total'] ?? $validated['quantity_produced'] ?? 0;
$validated['quantity_available'] = $validated['quantity_remaining'] ?? $validated['quantity_produced'];
// Set business_id and defaults
$validated['business_id'] = $business->id;

View File

@@ -9,12 +9,14 @@ use App\Http\Requests\UpdateBrandRequest;
use App\Models\Brand;
use App\Models\BrandOrchestratorProfile;
use App\Models\Business;
use App\Models\Crm\CrmChannel;
use App\Models\Menu;
use App\Models\OrchestratorTask;
use App\Models\PromoRecommendation;
use App\Models\Promotion;
use App\Services\Promo\InBrandPromoHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -42,7 +44,32 @@ class BrandController extends Controller
->orderBy('name')
->get();
return view('seller.brands.index', compact('business', 'brands'));
// Pre-compute expensive operations for Alpine.js (prevents N+1 route() calls in Blade)
$brandsJson = $brands->filter(fn ($brand) => $brand->hashid)->map(function ($brand) use ($business) {
return [
'id' => $brand->id,
'hashid' => $brand->hashid,
'name' => $brand->name,
'tagline' => $brand->tagline,
'logo_url' => $brand->hasLogo() ? $brand->getLogoUrl(160) : null,
'is_active' => $brand->is_active,
'is_public' => $brand->is_public,
'is_featured' => $brand->is_featured,
'products_count' => $brand->products_count ?? 0,
'updated_at' => $brand->updated_at?->diffForHumans(),
'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();
return view('seller.brands.index', compact('business', 'brands', 'brandsJson'));
}
/**
@@ -145,121 +172,179 @@ class BrandController extends Controller
{
$this->authorize('view', [$brand, $business]);
// Load relationships
// Determine active tab - only load data for that tab
$activeTab = $request->input('tab', 'overview');
// Load minimal brand data with products for metrics display
$brand->load(['business', 'products']);
// Get stats data for Analytics tab (default to this month)
$preset = $request->input('preset', 'this_month');
$startDate = null;
$endDate = null;
switch ($preset) {
case 'this_week':
$startDate = now()->startOfWeek();
$endDate = now()->endOfWeek();
break;
case 'last_week':
$startDate = now()->subWeek()->startOfWeek();
$endDate = now()->subWeek()->endOfWeek();
break;
case 'this_month':
$startDate = now()->startOfMonth();
$endDate = now()->endOfMonth();
break;
case 'last_month':
$startDate = now()->subMonth()->startOfMonth();
$endDate = now()->subMonth()->endOfMonth();
break;
case 'this_year':
$startDate = now()->startOfYear();
$endDate = now()->endOfYear();
break;
case 'custom':
$startDate = $request->input('start_date') ? \Carbon\Carbon::parse($request->input('start_date'))->startOfDay() : now()->startOfMonth();
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
break;
case 'all_time':
default:
// Query from earliest order for this brand, or default to brand creation date if no orders
$earliestOrder = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
})->oldest('created_at')->first();
// If no orders, use the brand's creation date as the starting point
$startDate = $earliestOrder
? $earliestOrder->created_at->startOfDay()
: ($brand->created_at ? $brand->created_at->startOfDay() : now()->subYears(3)->startOfDay());
$endDate = now()->endOfDay();
break;
}
// Calculate stats for analytics tab
$stats = $this->calculateBrandStats($brand, $startDate, $endDate);
// Load promotions filtered by brand
$promotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->withCount('products')
->orderBy('created_at', 'desc')
->get();
// Load upcoming promotions (scheduled within next 7 days)
$upcomingPromotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->upcomingWithinDays(7)
->withCount('products')
->orderBy('starts_at', 'asc')
->get();
// Load active promotions for quick display
$activePromotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->active()
->withCount('products')
->orderBy('ends_at', 'asc')
->get();
// Load menus filtered by brand
$menus = Menu::where('business_id', $business->id)
->where('brand_id', $brand->id)
->withCount('products')
->orderBy('created_at', 'desc')
->get();
// Load promo recommendations for this brand
$recommendations = PromoRecommendation::where('business_id', $business->id)
->where('brand_id', $brand->id)
->pending()
->notExpired()
->with(['product'])
->orderByRaw("
CASE
WHEN priority = 'high' THEN 1
WHEN priority = 'medium' THEN 2
WHEN priority = 'low' THEN 3
ELSE 4
END
")
->orderByDesc('confidence')
->get();
// Load all brands for the brand selector dropdown
// Load all brands for the brand selector dropdown (lightweight, always needed)
$brands = $business->brands()
->where('is_active', true)
->withCount('products')
->orderBy('name')
->get();
// Load products for this brand (newest first) with pagination
// Get date range for stats (used by overview and analytics)
$preset = $request->input('preset', 'this_month');
[$startDate, $endDate] = $this->getDateRangeForPreset($preset, $request, $brand);
// Initialize empty data - will be populated based on active tab
$viewData = [
'business' => $business,
'brand' => $brand,
'brands' => $brands,
'preset' => $preset,
'startDate' => $startDate,
'endDate' => $endDate,
'activeTab' => $activeTab,
// Empty defaults for all tab data
'promotions' => collect(),
'activePromotions' => collect(),
'upcomingPromotions' => collect(),
'recommendations' => collect(),
'menus' => collect(),
'products' => collect(),
'productsPagination' => [],
'productsPaginator' => null,
'collections' => collect(),
'brandInsights' => [],
// Empty stats defaults
'totalOrders' => 0,
'totalRevenue' => 0,
'totalUnits' => 0,
'avgOrderValue' => 0,
'totalProducts' => 0,
'activeProducts' => 0,
'revenueChange' => 0,
'ordersChange' => 0,
'revenueByDay' => collect(),
'productStats' => collect(),
'bestSellingSku' => null,
'topBuyers' => collect(),
];
// Load data based on active tab
switch ($activeTab) {
case 'overview':
$viewData = array_merge($viewData, $this->loadOverviewTabData($brand, $business, $startDate, $endDate));
break;
case 'products':
$viewData = array_merge($viewData, $this->loadProductsTabData($brand, $business, $request));
break;
case 'promotions':
$viewData = array_merge($viewData, $this->loadPromotionsTabData($brand, $business));
break;
case 'menus':
$viewData = array_merge($viewData, $this->loadMenusTabData($brand, $business));
break;
case 'analytics':
$viewData = array_merge($viewData, $this->loadAnalyticsTabData($brand, $business, $startDate, $endDate, $preset));
break;
case 'settings':
case 'storefront':
case 'collections':
// These tabs don't need additional data loading
break;
}
return view('seller.brands.dashboard', $viewData);
}
/**
* Get date range based on preset selection.
*/
private function getDateRangeForPreset(string $preset, Request $request, Brand $brand): array
{
switch ($preset) {
case 'this_week':
return [now()->startOfWeek(), now()->endOfWeek()];
case 'last_week':
return [now()->subWeek()->startOfWeek(), now()->subWeek()->endOfWeek()];
case 'this_month':
return [now()->startOfMonth(), now()->endOfMonth()];
case 'last_month':
return [now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()];
case 'this_year':
return [now()->startOfYear(), now()->endOfYear()];
case 'custom':
$startDate = $request->input('start_date') ? \Carbon\Carbon::parse($request->input('start_date'))->startOfDay() : now()->startOfMonth();
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
return [$startDate, $endDate];
case 'all_time':
default:
$earliestOrder = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
})->oldest('created_at')->first();
$startDate = $earliestOrder
? $earliestOrder->created_at->startOfDay()
: ($brand->created_at ? $brand->created_at->startOfDay() : now()->subYears(3)->startOfDay());
return [$startDate, now()->endOfDay()];
}
}
/**
* Load data for Overview tab (lightweight stats + insights).
*/
private function loadOverviewTabData(Brand $brand, Business $business, $startDate, $endDate): array
{
// Cache brand insights for 15 minutes
$cacheKey = "brand:{$brand->id}:insights:{$startDate->format('Y-m-d')}:{$endDate->format('Y-m-d')}";
$brandInsights = Cache::remember($cacheKey, 900, fn () => $this->calculateBrandInsights($brand, $business, $startDate, $endDate));
// Load active promotions for quick display (lightweight)
$activePromotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->active()
->withCount('products')
->orderBy('ends_at', 'asc')
->limit(5)
->get();
// Load recommendations (lightweight - limit to 5)
$recommendations = PromoRecommendation::where('business_id', $business->id)
->where('brand_id', $brand->id)
->pending()
->notExpired()
->with(['product'])
->orderByRaw("CASE WHEN priority = 'high' THEN 1 WHEN priority = 'medium' THEN 2 WHEN priority = 'low' THEN 3 ELSE 4 END")
->orderByDesc('confidence')
->limit(5)
->get();
// Get basic counts (very fast single query)
$productCounts = $brand->products()
->selectRaw('COUNT(*) as total, SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active')
->first();
return [
'brandInsights' => $brandInsights,
'activePromotions' => $activePromotions,
'recommendations' => $recommendations,
'totalProducts' => $productCounts->total ?? 0,
'activeProducts' => $productCounts->active ?? 0,
];
}
/**
* Load data for Products tab.
*/
private function loadProductsTabData(Brand $brand, Business $business, Request $request): array
{
$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) {
// Set brand relationship so getImageUrl() can fall back to brand logo
$product->setRelation('brand', $brand);
return [
@@ -275,35 +360,101 @@ 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();
// Pagination info for the view
$productsPagination = [
'current_page' => $productsPaginator->currentPage(),
'last_page' => $productsPaginator->lastPage(),
'per_page' => $productsPaginator->perPage(),
'total' => $productsPaginator->total(),
'from' => $productsPaginator->firstItem(),
'to' => $productsPaginator->lastItem(),
];
return view('seller.brands.dashboard', array_merge($stats, [
'business' => $business,
'brand' => $brand,
'brands' => $brands,
'preset' => $preset,
'startDate' => $startDate,
'endDate' => $endDate,
'promotions' => $promotions,
'activePromotions' => $activePromotions,
'upcomingPromotions' => $upcomingPromotions,
'recommendations' => $recommendations,
'menus' => $menus,
return [
'products' => $products,
'productsPagination' => $productsPagination,
'productsPagination' => [
'current_page' => $productsPaginator->currentPage(),
'last_page' => $productsPaginator->lastPage(),
'per_page' => $productsPaginator->perPage(),
'total' => $productsPaginator->total(),
'from' => $productsPaginator->firstItem(),
'to' => $productsPaginator->lastItem(),
],
'productsPaginator' => $productsPaginator,
'collections' => collect(), // Placeholder for future collections feature
]));
];
}
/**
* Load data for Promotions tab.
*/
private function loadPromotionsTabData(Brand $brand, Business $business): array
{
$promotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->withCount('products')
->orderBy('created_at', 'desc')
->get();
$upcomingPromotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->upcomingWithinDays(7)
->withCount('products')
->orderBy('starts_at', 'asc')
->get();
$activePromotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->active()
->withCount('products')
->orderBy('ends_at', 'asc')
->get();
return [
'promotions' => $promotions,
'upcomingPromotions' => $upcomingPromotions,
'activePromotions' => $activePromotions,
];
}
/**
* Load data for Menus tab.
*/
private function loadMenusTabData(Brand $brand, Business $business): array
{
$menus = Menu::where('business_id', $business->id)
->where('brand_id', $brand->id)
->withCount('products')
->orderBy('created_at', 'desc')
->get();
return ['menus' => $menus];
}
/**
* Load data for Analytics tab (cached for 15 minutes).
*/
private function loadAnalyticsTabData(Brand $brand, Business $business, $startDate, $endDate, string $preset): array
{
// Cache stats for 15 minutes (keyed by brand + date range)
$cacheKey = "brand:{$brand->id}:stats:{$preset}:{$startDate->format('Y-m-d')}:{$endDate->format('Y-m-d')}";
return Cache::remember($cacheKey, 900, fn () => $this->calculateBrandStats($brand, $startDate, $endDate));
}
/**
* API endpoint for lazy-loading tab data via AJAX.
*/
public function tabData(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
$tab = $request->input('tab', 'overview');
$preset = $request->input('preset', 'this_month');
[$startDate, $endDate] = $this->getDateRangeForPreset($preset, $request, $brand);
$data = match ($tab) {
'overview' => $this->loadOverviewTabData($brand, $business, $startDate, $endDate),
'products' => $this->loadProductsTabData($brand, $business, $request),
'promotions' => $this->loadPromotionsTabData($brand, $business),
'menus' => $this->loadMenusTabData($brand, $business),
'analytics' => $this->loadAnalyticsTabData($brand, $business, $startDate, $endDate, $preset),
default => [],
};
return response()->json($data);
}
/**
@@ -359,7 +510,14 @@ class BrandController extends Controller
{
$this->authorize('update', [$brand, $business]);
return view('seller.brands.edit', compact('business', 'brand'));
// Get available email channels for CRM inbound routing
$emailChannels = CrmChannel::forBusiness($business->id)
->where('type', CrmChannel::TYPE_EMAIL)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.brands.edit', compact('business', 'brand', 'emailChannels'));
}
/**
@@ -456,6 +614,19 @@ class BrandController extends Controller
$brand->inbound_email = $request->input('inbound_email');
$brand->sms_number = $request->input('sms_number');
// CRM Channel Assignment (validate channel belongs to this business)
if ($request->has('inbound_email_channel_id')) {
$channelId = $request->input('inbound_email_channel_id');
if ($channelId) {
$channel = CrmChannel::where('business_id', $business->id)
->where('id', $channelId)
->first();
$validated['inbound_email_channel_id'] = $channel ? $channel->id : null;
} else {
$validated['inbound_email_channel_id'] = null;
}
}
// Update brand
$brand->update($validated);
@@ -599,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
// ═══════════════════════════════════════════════════════════════
@@ -712,6 +888,7 @@ class BrandController extends Controller
'isBrandManager' => $isBrandManager,
// Core stats
'salesStats' => $salesStats,
'storeStats' => $storeStats,
'productCategories' => $productCategories,
'productVelocity' => $productVelocity,
// Product states
@@ -1274,48 +1451,49 @@ class BrandController extends Controller
*/
private function calculateBrandStats(Brand $brand, $startDate, $endDate): array
{
// Eager load products with their varieties
$brand->load([
'products' => function ($query) {
$query->with('varieties');
},
]);
// Calculate product counts with efficient queries (not loading all products)
$productCounts = $brand->products()
->selectRaw('COUNT(*) as total, SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active')
->first();
$totalProducts = $productCounts->total ?? 0;
$activeProducts = $productCounts->active ?? 0;
// Calculate overall brand metrics
$totalProducts = $brand->products->count();
$activeProducts = $brand->products->where('is_active', true)->count();
// Get product IDs for this brand (for use in subqueries)
$brandProductIds = $brand->products()->pluck('id');
// Get all order items for this brand's products in the selected date range
// WITH eager loading to prevent N+1 queries
$orderItems = \App\Models\OrderItem::whereHas('product', function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
})
// Calculate current period metrics with single efficient query
$currentStats = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
->whereHas('order', function ($query) use ($startDate, $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
})
->with('order.business', 'product')
->get();
->selectRaw('
COUNT(DISTINCT order_id) as total_orders,
COALESCE(SUM(line_total), 0) as total_revenue,
COALESCE(SUM(quantity), 0) as total_units
')
->first();
// Calculate metrics
$totalOrders = $orderItems->pluck('order_id')->unique()->count();
$totalRevenue = $orderItems->sum('line_total');
$totalUnits = $orderItems->sum('quantity');
$totalOrders = $currentStats->total_orders ?? 0;
$totalRevenue = $currentStats->total_revenue ?? 0;
$totalUnits = $currentStats->total_units ?? 0;
// Previous period comparison (same duration before start date)
$daysDiff = $startDate->diffInDays($endDate);
$previousStartDate = $startDate->copy()->subDays($daysDiff + 1);
$previousEndDate = $startDate->copy()->subDay();
$previousOrderItems = \App\Models\OrderItem::whereHas('product', function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
})
$previousStats = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
->whereHas('order', function ($query) use ($previousStartDate, $previousEndDate) {
$query->whereBetween('created_at', [$previousStartDate, $previousEndDate]);
})
->get();
->selectRaw('
COUNT(DISTINCT order_id) as total_orders,
COALESCE(SUM(line_total), 0) as total_revenue
')
->first();
$previousRevenue = $previousOrderItems->sum('line_total');
$previousOrders = $previousOrderItems->pluck('order_id')->unique()->count();
$previousRevenue = $previousStats->total_revenue ?? 0;
$previousOrders = $previousStats->total_orders ?? 0;
// Calculate percent changes
$revenueChange = $previousRevenue > 0 ? (($totalRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
@@ -1324,71 +1502,106 @@ class BrandController extends Controller
// Average order value
$avgOrderValue = $totalOrders > 0 ? $totalRevenue / $totalOrders : 0;
// Revenue by day
$revenueByDay = $orderItems->groupBy(function ($item) {
return $item->order->created_at->format('Y-m-d');
})->map(function ($items) {
return $items->sum('line_total');
})->sortKeys();
// Revenue by day - using database aggregation
$revenueByDay = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
->join('orders', 'order_items.order_id', '=', 'orders.id')
->whereBetween('orders.created_at', [$startDate, $endDate])
->selectRaw('DATE(orders.created_at) as date, SUM(order_items.line_total) as revenue')
->groupBy('date')
->orderBy('date')
->pluck('revenue', 'date');
// Build a map of product_id => order items for efficient lookup
$productOrderItemsMap = $orderItems->groupBy('product_id');
// Top products by revenue (with varieties nested under parents)
// Filter to only show parent products (exclude varieties from top level)
$productStats = $brand->products
->filter(function ($product) {
return is_null($product->parent_product_id); // Only parent products
// Top products by revenue - using database aggregation (limit to top 20)
$topProductsData = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
->whereHas('order', function ($query) use ($startDate, $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
})
->map(function ($product) use ($productOrderItemsMap) {
// Get order items for this product from the map (no additional query!)
$items = $productOrderItemsMap->get($product->id, collect());
->selectRaw('
product_id,
SUM(line_total) as revenue,
SUM(quantity) as units,
COUNT(DISTINCT order_id) as orders
')
->groupBy('product_id')
->orderByDesc('revenue')
->limit(20)
->get()
->keyBy('product_id');
$revenue = $items->sum('line_total');
$units = $items->sum('quantity');
$orders = $items->pluck('order_id')->unique()->count();
// Load only the products we need for display
$topProductIds = $topProductsData->keys();
$products = \App\Models\Product::whereIn('id', $topProductIds)
->whereNull('parent_product_id')
->with(['varieties' => function ($q) use ($topProductsData) {
$q->whereIn('id', $topProductsData->keys());
}])
->get()
->keyBy('id');
// Always get variety breakdown if product has varieties
$varietyStats = [];
if ($product->has_varieties) {
$varietyStats = $product->varieties->map(function ($variety) use ($productOrderItemsMap) {
// Get order items for this variety from the map (no additional query!)
$varietyItems = $productOrderItemsMap->get($variety->id, collect());
// Build product stats with preloaded data
$productStats = $topProductsData
->filter(function ($data) use ($products) {
return $products->has($data->product_id);
})
->map(function ($data) use ($products, $topProductsData) {
$product = $products->get($data->product_id);
$varietyStats = collect();
if ($product && $product->has_varieties) {
$varietyStats = $product->varieties->map(function ($variety) use ($topProductsData) {
$varietyData = $topProductsData->get($variety->id);
return [
'product' => $variety,
'revenue' => $varietyItems->sum('line_total'),
'units' => $varietyItems->sum('quantity'),
'orders' => $varietyItems->pluck('order_id')->unique()->count(),
'revenue' => $varietyData->revenue ?? 0,
'units' => $varietyData->units ?? 0,
'orders' => $varietyData->orders ?? 0,
];
})->sortByDesc('revenue');
}
return [
'product' => $product,
'revenue' => $revenue,
'units' => $units,
'orders' => $orders,
'revenue' => $data->revenue,
'units' => $data->units,
'orders' => $data->orders,
'varieties' => $varietyStats,
];
})->sortByDesc('revenue');
})
->sortByDesc('revenue');
// Get best selling SKU
$bestSellingSku = $productStats->first();
// Top buyers by revenue
$topBuyers = $orderItems->groupBy(function ($item) {
return $item->order->business_id;
})->map(function ($items) {
$business = $items->first()->order->business;
// Top buyers by revenue - using database aggregation
$topBuyersData = \App\Models\OrderItem::whereIn('product_id', $brandProductIds)
->join('orders', 'order_items.order_id', '=', 'orders.id')
->whereBetween('orders.created_at', [$startDate, $endDate])
->selectRaw('
orders.business_id,
SUM(order_items.line_total) as revenue,
COUNT(DISTINCT orders.id) as orders,
SUM(order_items.quantity) as units
')
->groupBy('orders.business_id')
->orderByDesc('revenue')
->limit(5)
->get();
// Load buyer businesses in single query
$buyerBusinesses = \App\Models\Business::whereIn('id', $topBuyersData->pluck('business_id'))
->select('id', 'name')
->get()
->keyBy('id');
$topBuyers = $topBuyersData->map(function ($data) use ($buyerBusinesses) {
return [
'business' => $business,
'revenue' => $items->sum('line_total'),
'orders' => $items->pluck('order_id')->unique()->count(),
'units' => $items->sum('quantity'),
'business' => $buyerBusinesses->get($data->business_id),
'revenue' => $data->revenue,
'orders' => $data->orders,
'units' => $data->units,
];
})->sortByDesc('revenue')->take(5);
});
return [
'totalProducts' => $totalProducts,
@@ -1675,4 +1888,255 @@ class BrandController extends Controller
->route('seller.business.brands.index', $business->slug)
->with('success', 'Brand deleted successfully!');
}
/**
* Calculate lightweight brand insights for the dashboard
*/
private function calculateBrandInsights(Brand $brand, Business $business, $startDate, $endDate): array
{
// Eager load images to avoid N+1 and lazy loading errors
$products = $brand->products()->with('images')->get();
// Top Performer - product with highest revenue in date range
$topPerformer = null;
$topPerformerData = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
})
->whereBetween('created_at', [$startDate, $endDate])
->whereIn('status', ['confirmed', 'completed', 'shipped', 'delivered'])
->with(['items.product' => function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
}])
->get()
->flatMap(function ($order) use ($brand) {
return $order->items->filter(function ($item) use ($brand) {
return $item->product && $item->product->brand_id === $brand->id;
});
})
->groupBy('product_id')
->map(function ($items) {
$product = $items->first()->product;
return [
'product' => $product,
'revenue' => $items->sum(function ($item) {
return $item->quantity * $item->price;
}),
'orders' => $items->count(),
];
})
->sortByDesc('revenue')
->first();
if ($topPerformerData) {
$topPerformer = [
'name' => $topPerformerData['product']->name,
'hashid' => $topPerformerData['product']->hashid,
'revenue' => $topPerformerData['revenue'],
'orders' => $topPerformerData['orders'],
];
}
// Needs Attention - aggregate counts for quick issues
$missingImages = $products->filter(fn ($p) => empty($p->image_path) && $p->images->isEmpty())->count();
$hiddenProducts = $products->filter(fn ($p) => ! $p->is_active)->count();
$draftProducts = $products->filter(fn ($p) => $p->status === 'draft')->count();
// Note: Out of stock would require inventory data - hardcoded to 0 for now
$outOfStock = 0;
$totalIssues = $missingImages + $hiddenProducts + $draftProducts + $outOfStock;
// Visibility Issues - hidden + draft count
$visibilityIssues = $hiddenProducts + $draftProducts;
return [
'topPerformer' => $topPerformer,
'needsAttention' => [
'total' => $totalIssues,
'missingImages' => $missingImages,
'hiddenProducts' => $hiddenProducts,
'draftProducts' => $draftProducts,
'outOfStock' => $outOfStock,
],
'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

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Crm\CrmThread;
use App\Models\Order;
use App\Models\Product;
use App\Models\Promotion;
@@ -268,19 +269,26 @@ class BrandPortalController extends Controller
/**
* Inbox - Conversations (messaging).
*
* Shows conversations related to the business.
* Uses existing messaging infrastructure but scoped to Brand Portal context.
* Shows CRM threads related to the user's linked brands only.
* Uses CrmThread scoped by brand_id for filtering.
*/
public function inbox(Request $request, Business $business)
{
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// For inbox, we show conversations but in a limited Brand Portal context
// This integrates with existing messaging system
// Get threads filtered to only those related to linked brands
$threads = CrmThread::forBusiness($business->id)
->forBrandPortal($brandIds)
->with(['contact', 'assignee', 'brand'])
->withCount('messages')
->orderByDesc('last_message_at')
->paginate(25);
return view('seller.brand-portal.inbox', compact(
'business',
'brands'
'brands',
'threads'
));
}

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

@@ -18,12 +18,22 @@ class BrandSwitcherController extends Controller
{
$brandId = $request->input('brand_id');
$brandHashid = $request->input('brand_hashid');
$redirectTo = $request->input('redirect_to');
// If both are empty, clear the session (show all brands)
if (empty($brandId) && empty($brandHashid)) {
// Clear cache for current user before removing session
$user = auth()->user();
$business = $user?->primaryBusiness();
$oldBrandId = session('selected_brand_id');
if ($user && $business && $oldBrandId) {
\Illuminate\Support\Facades\Cache::forget("selected_brand:{$user->id}:{$business->id}:{$oldBrandId}");
}
session()->forget('selected_brand_id');
return back();
return $redirectTo ? redirect($redirectTo) : back();
}
// Verify the brand exists and belongs to user's business
@@ -56,6 +66,7 @@ class BrandSwitcherController extends Controller
/**
* Get the currently selected brand (helper method).
* Cached for 5 minutes to avoid repeated queries on every page load.
*/
public static function getSelectedBrand(): ?Brand
{
@@ -72,9 +83,14 @@ class BrandSwitcherController extends Controller
return null;
}
return Brand::forBusiness($business)
->where('id', $brandId)
->first();
// Cache by user + business + brand to avoid repeated queries
$cacheKey = "selected_brand:{$user->id}:{$business->id}:{$brandId}";
return \Illuminate\Support\Facades\Cache::remember($cacheKey, 300, function () use ($business, $brandId) {
return Brand::forBusiness($business)
->where('id', $brandId)
->first();
});
}
/**

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

@@ -14,12 +14,17 @@ class ContactController extends Controller
{
/**
* Display a listing of contacts (CRM Core)
* Shows all contacts who have interacted with this seller business
* Shows all contacts from buyer businesses (accounts)
*/
public function index(Request $request, Business $business)
{
// Get all contact IDs that have interacted with this business
// through orders, conversations, or messages
// Get all contacts from buyer businesses (accounts)
// This gives a complete view of all contacts in the CRM
$query = Contact::whereHas('business', function ($q) {
$q->where('type', 'buyer');
})->with(['business', 'user']);
// Also track which contacts have engaged for stats
$orderContactIds = Order::whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})->whereNotNull('contact_id')->pluck('contact_id');
@@ -28,11 +33,7 @@ class ContactController extends Controller
->whereNotNull('primary_contact_id')
->pluck('primary_contact_id');
$contactIds = $orderContactIds->merge($conversationContactIds)->unique();
// Build query
$query = Contact::whereIn('id', $contactIds)
->with(['business', 'user']);
$engagedContactIds = $orderContactIds->merge($conversationContactIds)->unique();
// Search filter
if ($request->filled('search')) {
@@ -60,6 +61,8 @@ class ContactController extends Controller
$query->whereIn('id', $orderContactIds);
} elseif ($request->activity === 'has_conversations') {
$query->whereIn('id', $conversationContactIds);
} elseif ($request->activity === 'engaged') {
$query->whereIn('id', $engagedContactIds);
}
}
@@ -75,12 +78,14 @@ class ContactController extends Controller
$contacts = $query->paginate(20)->withQueryString();
// Get stats
// Get stats - count all buyer contacts and engaged contacts
$allBuyerContactsQuery = Contact::whereHas('business', fn ($q) => $q->where('type', 'buyer'));
$stats = [
'total' => Contact::whereIn('id', $contactIds)->count(),
'active' => Contact::whereIn('id', $contactIds)->where('is_active', true)->count(),
'with_orders' => Contact::whereIn('id', $orderContactIds)->count(),
'with_conversations' => Contact::whereIn('id', $conversationContactIds)->count(),
'total' => (clone $allBuyerContactsQuery)->count(),
'active' => (clone $allBuyerContactsQuery)->where('is_active', true)->count(),
'with_orders' => $orderContactIds->count(),
'with_conversations' => $conversationContactIds->count(),
'engaged' => $engagedContactIds->count(),
];
return view('seller.contacts.index', compact('business', 'contacts', 'stats'));
@@ -107,41 +112,45 @@ class ContactController extends Controller
// Load contact relationships
$contact->load(['business', 'user']);
// Get conversations
// Get conversations (limit for profile view)
$conversations = Conversation::where('business_id', $business->id)
->where('primary_contact_id', $contact->id)
->with('latestMessage')
->orderBy('last_message_at', 'desc')
->limit(20)
->get();
// Get orders
// Get orders (limit for profile view, select only needed columns)
$orders = Order::whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->where('contact_id', $contact->id)
->with(['business', 'items.product'])
->with(['business:id,name', 'items:id,order_id,product_id,quantity,unit_price', 'items.product:id,name,sku'])
->latest()
->limit(20)
->get();
// Get invoices
// Get invoices (limit for profile view)
$invoices = Invoice::whereHas('order', function ($q) use ($contact) {
$q->where('contact_id', $contact->id);
})
->whereHas('order.items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->with('order')
->with('order:id,order_number')
->latest()
->limit(20)
->get();
// Get backorders (orders with status 'backorder')
// Get backorders (limit for profile view)
$backorders = Order::whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->where('contact_id', $contact->id)
->where('status', 'backorder')
->with(['business', 'items.product'])
->with(['business:id,name', 'items:id,order_id,product_id,quantity', 'items.product:id,name,sku'])
->latest()
->limit(10)
->get();
// Premium features (gated by has_marketing)
@@ -172,14 +181,18 @@ class ContactController extends Controller
/**
* Build unified activity timeline (Premium feature)
* Limited to most recent 30 items for performance
*/
private function buildTimeline(Contact $contact, Business $business): array
{
$timeline = [];
// Get all related activities
// Get recent conversations (limit for performance)
$conversations = Conversation::where('business_id', $business->id)
->where('primary_contact_id', $contact->id)
->select('id', 'subject', 'created_at')
->latest()
->limit(10)
->get();
foreach ($conversations as $conversation) {
@@ -193,10 +206,14 @@ class ContactController extends Controller
];
}
// Get recent orders (limit for performance)
$orders = Order::whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->where('contact_id', $contact->id)
->select('id', 'order_number', 'total', 'created_at')
->latest()
->limit(10)
->get();
foreach ($orders as $order) {
@@ -210,12 +227,16 @@ class ContactController extends Controller
];
}
// Get recent invoices (limit for performance)
$invoices = Invoice::whereHas('order', function ($q) use ($contact) {
$q->where('contact_id', $contact->id);
})
->whereHas('order.items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->select('id', 'invoice_number', 'payment_status', 'created_at')
->latest()
->limit(10)
->get();
foreach ($invoices as $invoice) {
@@ -229,11 +250,11 @@ class ContactController extends Controller
];
}
// Sort by date descending
// Sort by date descending and limit total items
usort($timeline, function ($a, $b) {
return $b['date'] <=> $a['date'];
});
return $timeline;
return array_slice($timeline, 0, 30);
}
}

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

@@ -5,28 +5,180 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Crm\CrmEvent;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmTask;
use App\Models\Invoice;
use App\Models\Location;
use App\Models\SalesOpportunity;
use App\Models\SendMenuLog;
use App\Services\Cannaiq\CannaiqClient;
use Illuminate\Http\Request;
class AccountController extends Controller
{
/**
* Display accounts listing
* Display accounts listing - only buyers who have ordered from this seller
*/
public function index(Request $request, Business $business)
{
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->with(['contacts'])
->orderBy('name')
->paginate(25);
$query = Business::where('type', 'buyer')
->whereHas('orders', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})
->with(['contacts']);
// Search filter
if ($request->filled('q')) {
$search = $request->q;
$query->where(function ($q) use ($search) {
$q->where('name', 'ILIKE', "%{$search}%")
->orWhere('dba_name', 'ILIKE', "%{$search}%")
->orWhere('business_email', 'ILIKE', "%{$search}%");
});
}
// Only show approved accounts (approved buyers)
$query->where('status', 'approved');
// Active/Inactive filter
if ($request->filled('status')) {
if ($request->status === 'active') {
$query->where('is_active', true);
} elseif ($request->status === 'inactive') {
$query->where('is_active', false);
}
}
$accounts = $query->orderBy('name')->paginate(25);
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $accounts->map(fn ($a) => [
'slug' => $a->slug,
'name' => $a->name,
'email' => $a->business_email,
'status' => $a->status,
])->values()->toArray(),
]);
}
return view('seller.crm.accounts.index', compact('business', 'accounts'));
}
/**
* Show create customer form
*/
public function create(Request $request, Business $business)
{
return view('seller.crm.accounts.create', compact('business'));
}
/**
* Store a new customer (buyer business)
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'business_email' => 'nullable|email|max:255',
'business_phone' => 'nullable|string|max:50',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:50',
'physical_zipcode' => 'nullable|string|max:20',
'contact_name' => 'nullable|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'contact_title' => 'nullable|string|max:100',
]);
// Create the buyer business
$account = Business::create([
'name' => $validated['name'],
'dba_name' => $validated['dba_name'] ?? null,
'license_number' => $validated['license_number'] ?? null,
'business_email' => $validated['business_email'] ?? null,
'business_phone' => $validated['business_phone'] ?? null,
'physical_address' => $validated['physical_address'] ?? null,
'physical_city' => $validated['physical_city'] ?? null,
'physical_state' => $validated['physical_state'] ?? null,
'physical_zipcode' => $validated['physical_zipcode'] ?? null,
'type' => 'buyer',
'status' => 'approved', // Auto-approve customers created by sellers
]);
// Create contact if provided
if (! empty($validated['contact_name'])) {
$account->contacts()->create([
'first_name' => explode(' ', $validated['contact_name'])[0],
'last_name' => implode(' ', array_slice(explode(' ', $validated['contact_name']), 1)) ?: null,
'email' => $validated['contact_email'] ?? null,
'phone' => $validated['contact_phone'] ?? null,
'title' => $validated['contact_title'] ?? null,
]);
}
// Log the creation event
CrmEvent::log(
sellerBusinessId: $business->id,
eventType: 'account_created',
summary: "Customer {$account->name} created",
buyerBusinessId: $account->id,
userId: auth()->id(),
channel: 'system'
);
// Return JSON for AJAX requests
if ($request->expectsJson()) {
return response()->json([
'id' => $account->id,
'name' => $account->name,
'slug' => $account->slug,
]);
}
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Customer created successfully.');
}
/**
* Show edit customer form
*/
public function edit(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.edit', compact('business', 'account'));
}
/**
* Update a customer (buyer business)
*/
public function update(Request $request, Business $business, Business $account)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'business_email' => 'nullable|email|max:255',
'business_phone' => 'nullable|string|max:50',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:50',
'physical_zipcode' => 'nullable|string|max:20',
]);
$account->update($validated);
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Customer updated successfully.');
}
/**
* Show account details
*/
@@ -34,17 +186,57 @@ class AccountController extends Controller
{
$account->load(['contacts']);
// Get orders for this account from this seller
$orders = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->latest()
->limit(10)
// Location filtering
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
// Load all locations for this account with contacts pivot
$locations = $account->locations()
->with(['contacts' => function ($q) {
$q->wherePivot('role', 'buyer');
}])
->orderBy('name')
->get();
// Base order query for this seller
$baseOrderQuery = fn () => $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
});
// Get orders (filtered by location if selected)
$ordersQuery = $baseOrderQuery();
if ($selectedLocation) {
$ordersQuery->where('location_id', $selectedLocation->id);
}
$orders = $ordersQuery->with(['invoice', 'location'])->latest()->limit(10)->get();
// Get quotes for this account (filtered by location if selected)
$quotesQuery = CrmQuote::where('business_id', $business->id)
->where('account_id', $account->id);
if ($selectedLocation) {
$quotesQuery->where('location_id', $selectedLocation->id);
}
$quotes = $quotesQuery->with(['contact', 'items'])->latest()->limit(10)->get();
// Base invoice query
$baseInvoiceQuery = fn () => Invoice::whereHas('order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
});
// Get invoices (filtered by location if selected)
$invoicesQuery = $baseInvoiceQuery();
if ($selectedLocation) {
$invoicesQuery->whereHas('order', function ($q) use ($selectedLocation) {
$q->where('location_id', $selectedLocation->id);
});
}
$invoices = $invoicesQuery->with(['order', 'payments'])->latest()->limit(10)->get();
// Get opportunities for this account from this seller
// SalesOpportunity uses business_id for the buyer
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['stage', 'brand'])
@@ -52,7 +244,6 @@ class AccountController extends Controller
->get();
// Get tasks related to this account
// CrmTask uses business_id for the buyer
$tasks = CrmTask::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->whereNull('completed_at')
@@ -84,31 +275,142 @@ class AccountController extends Controller
->limit(20)
->get();
// Compute stats for this account (orders from this seller)
$ordersQuery = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
});
// Compute stats - if location selected, show location-specific stats
if ($selectedLocation) {
$orderStats = $baseOrderQuery()
->where('location_id', $selectedLocation->id)
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
->first();
} else {
$orderStats = $baseOrderQuery()
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
->first();
}
$pipelineValue = $opportunities->where('status', 'open')->sum('value');
$opportunityStats = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->where('status', 'open')
->selectRaw('COUNT(*) as open_count, COALESCE(SUM(value), 0) as pipeline_value')
->first();
// Financial stats from invoices (location-filtered if applicable)
$financialStatsQuery = $baseInvoiceQuery();
if ($selectedLocation) {
$financialStatsQuery->whereHas('order', function ($q) use ($selectedLocation) {
$q->where('location_id', $selectedLocation->id);
});
}
$financialStats = $financialStatsQuery->selectRaw('
COALESCE(SUM(amount_due), 0) as outstanding_balance,
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due_amount,
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoice_count,
MIN(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN due_date END) as oldest_past_due_date
')
->first();
// Get last payment info
$lastPayment = \App\Models\InvoicePayment::whereHas('invoice.order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->latest('payment_date')
->first();
$stats = [
'total_orders' => $ordersQuery->count(),
'total_revenue' => $ordersQuery->sum('total') ?? 0,
'open_opportunities' => $opportunities->where('status', 'open')->count(),
'pipeline_value' => $pipelineValue ?? 0,
'total_orders' => $orderStats->total_orders ?? 0,
'total_revenue' => $orderStats->total_revenue ?? 0,
'open_opportunities' => $opportunityStats->open_count ?? 0,
'pipeline_value' => $opportunityStats->pipeline_value ?? 0,
];
$financials = [
'outstanding_balance' => $financialStats->outstanding_balance ?? 0,
'past_due_amount' => $financialStats->past_due_amount ?? 0,
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
'oldest_past_due_days' => $financialStats->oldest_past_due_date
? (int) ceil(abs(now()->diffInDays($financialStats->oldest_past_due_date)))
: null,
'last_payment_amount' => $lastPayment->amount ?? null,
'last_payment_date' => $lastPayment->payment_date ?? null,
];
// Calculate unattributed orders/invoices (those without location_id)
$unattributedOrdersCount = $baseOrderQuery()->whereNull('location_id')->count();
$unattributedInvoicesCount = $baseInvoiceQuery()
->whereHas('order', function ($q) {
$q->whereNull('location_id');
})
->count();
// Calculate per-location stats for location tiles
$locationStats = [];
if ($locations->count() > 0) {
$locationIds = $locations->pluck('id')->toArray();
// Order stats by location
$ordersByLocation = $baseOrderQuery()
->whereIn('location_id', $locationIds)
->selectRaw('location_id, COUNT(*) as orders_count, COALESCE(SUM(total), 0) as revenue')
->groupBy('location_id')
->get()
->keyBy('location_id');
// Invoice stats by location
$invoicesByLocation = Invoice::whereHas('order', function ($q) use ($business, $account, $locationIds) {
$q->where('business_id', $account->id)
->whereIn('location_id', $locationIds)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->selectRaw('
(SELECT location_id FROM orders WHERE orders.id = invoices.order_id) as location_id,
COALESCE(SUM(amount_due), 0) as outstanding,
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due,
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoices
')
->groupByRaw('(SELECT location_id FROM orders WHERE orders.id = invoices.order_id)')
->get()
->keyBy('location_id');
foreach ($locations as $location) {
$orderData = $ordersByLocation->get($location->id);
$invoiceData = $invoicesByLocation->get($location->id);
$ordersCount = $orderData->orders_count ?? 0;
$openInvoices = $invoiceData->open_invoices ?? 0;
$locationStats[$location->id] = [
'orders' => $ordersCount,
'revenue' => $orderData->revenue ?? 0,
'outstanding' => $invoiceData->outstanding ?? 0,
'past_due' => $invoiceData->past_due ?? 0,
'open_invoices' => $openInvoices,
'has_attributed_data' => ($ordersCount + $openInvoices) > 0,
];
}
}
return view('seller.crm.accounts.show', compact(
'business',
'account',
'stats',
'financials',
'orders',
'quotes',
'invoices',
'opportunities',
'tasks',
'conversationEvents',
'sendHistory',
'activities'
'activities',
'locations',
'selectedLocation',
'locationStats',
'unattributedOrdersCount',
'unattributedInvoicesCount'
));
}
@@ -117,9 +419,26 @@ class AccountController extends Controller
*/
public function contacts(Request $request, Business $business, Business $account)
{
$contacts = $account->contacts()->paginate(25);
// Location filtering
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
return view('seller.crm.accounts.contacts', compact('business', 'account', 'contacts'));
// Base query for contacts
$contactsQuery = $account->contacts();
// If location selected, filter to contacts assigned to that location
if ($selectedLocation) {
$contactsQuery->whereHas('locations', function ($q) use ($selectedLocation) {
$q->where('locations.id', $selectedLocation->id);
});
}
$contacts = $contactsQuery->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.contacts', compact('business', 'account', 'contacts', 'locations', 'selectedLocation'));
}
/**
@@ -127,7 +446,21 @@ class AccountController extends Controller
*/
public function opportunities(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.opportunities', compact('business', 'account'));
// Location filtering (note: opportunities don't have location_id yet, so we just pass the context)
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
// Load opportunities for this account
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['stage', 'brand', 'owner'])
->latest()
->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.opportunities', compact('business', 'account', 'opportunities', 'locations', 'selectedLocation'));
}
/**
@@ -135,7 +468,28 @@ class AccountController extends Controller
*/
public function orders(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.orders', compact('business', 'account'));
// Location filtering
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
$ordersQuery = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
});
// Filter by location if selected
if ($selectedLocation) {
$ordersQuery->where('location_id', $selectedLocation->id);
}
$orders = $ordersQuery->with(['items.product.brand', 'location'])
->latest()
->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders', 'locations', 'selectedLocation'));
}
/**
@@ -143,7 +497,20 @@ class AccountController extends Controller
*/
public function activity(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.activity', compact('business', 'account'));
// Location filtering (note: activities don't have location_id yet, so we just pass the context)
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
$activities = Activity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['causer'])
->latest()
->paginate(50);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities', 'locations', 'selectedLocation'));
}
/**
@@ -151,7 +518,22 @@ class AccountController extends Controller
*/
public function tasks(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.tasks', compact('business', 'account'));
// Location filtering (note: tasks don't have location_id yet, so we just pass the context)
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
// Load tasks for this account
$tasks = CrmTask::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['assignee', 'opportunity'])
->orderByRaw('completed_at IS NOT NULL')
->orderBy('due_at')
->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.tasks', compact('business', 'account', 'tasks', 'locations', 'selectedLocation'));
}
/**
@@ -176,4 +558,258 @@ class AccountController extends Controller
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Note added successfully.');
}
/**
* Store a new contact for an account
*/
public function storeContact(Request $request, Business $business, Business $account)
{
$validated = $request->validate([
'first_name' => 'required|string|max:100',
'last_name' => 'nullable|string|max:100',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'title' => 'nullable|string|max:100',
]);
$contact = $account->contacts()->create($validated);
// Return JSON for AJAX requests
if ($request->expectsJson()) {
return response()->json([
'id' => $contact->id,
'first_name' => $contact->first_name,
'last_name' => $contact->last_name,
'email' => $contact->email,
'phone' => $contact->phone,
'title' => $contact->title,
]);
}
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact added successfully.');
}
/**
* Show edit contact form
*/
public function editContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
return view('seller.crm.accounts.contacts-edit', compact('business', 'account', 'contact'));
}
/**
* Update a contact
*/
public function updateContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
$validated = $request->validate([
'first_name' => 'required|string|max:100',
'last_name' => 'nullable|string|max:100',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'title' => 'nullable|string|max:100',
'is_active' => 'boolean',
]);
// Handle checkbox - if not sent, default to false
$validated['is_active'] = $request->boolean('is_active');
$contact->update($validated);
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact updated successfully.');
}
/**
* Delete a contact
*/
public function destroyContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
$contact->delete();
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact deleted successfully.');
}
/**
* Show location edit form
*/
public function editLocation(Request $request, Business $business, Business $account, Location $location)
{
// Verify location belongs to this account
if ($location->business_id !== $account->id) {
abort(404);
}
// Load contacts that can be assigned to this location
$contacts = $account->contacts()->orderBy('first_name')->get();
// Load currently assigned contacts with their roles
$locationContacts = $location->contacts()->get();
// Available roles for location contacts
$contactRoles = [
'buyer' => 'Buyer',
'ap' => 'Accounts Payable',
'marketing' => 'Marketing',
'gm' => 'General Manager',
'inventory' => 'Inventory Manager',
'other' => 'Other',
];
// CannaiQ platforms
$cannaiqPlatforms = [
'dutchie' => 'Dutchie',
'jane' => 'Jane',
'weedmaps' => 'Weedmaps',
'leafly' => 'Leafly',
'iheartjane' => 'iHeartJane',
'other' => 'Other',
];
return view('seller.crm.accounts.locations-edit', compact(
'business',
'account',
'location',
'contacts',
'locationContacts',
'contactRoles',
'cannaiqPlatforms'
));
}
/**
* Update location
*/
public function updateLocation(Request $request, Business $business, Business $account, Location $location)
{
// Verify location belongs to this account
if ($location->business_id !== $account->id) {
abort(404);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'address' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:50',
'zipcode' => 'nullable|string|max:20',
'phone' => 'nullable|string|max:50',
'email' => 'nullable|email|max:255',
'is_active' => 'boolean',
'cannaiq_platform' => 'nullable|string|max:50',
'cannaiq_store_slug' => 'nullable|string|max:255',
'cannaiq_store_id' => 'nullable|string|max:100',
'cannaiq_store_name' => 'nullable|string|max:255',
'contact_roles' => 'nullable|array',
'contact_roles.*.contact_id' => 'required|exists:contacts,id',
'contact_roles.*.role' => 'required|string|max:50',
'contact_roles.*.is_primary' => 'boolean',
]);
// Handle checkbox
$validated['is_active'] = $request->boolean('is_active');
// Clear CannaiQ fields if platform is cleared
if (empty($validated['cannaiq_platform'])) {
$validated['cannaiq_store_slug'] = null;
$validated['cannaiq_store_id'] = null;
$validated['cannaiq_store_name'] = null;
}
// Update location
$location->update([
'name' => $validated['name'],
'address' => $validated['address'] ?? null,
'city' => $validated['city'] ?? null,
'state' => $validated['state'] ?? null,
'zipcode' => $validated['zipcode'] ?? null,
'phone' => $validated['phone'] ?? null,
'email' => $validated['email'] ?? null,
'is_active' => $validated['is_active'],
'cannaiq_platform' => $validated['cannaiq_platform'] ?? null,
'cannaiq_store_slug' => $validated['cannaiq_store_slug'] ?? null,
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
]);
// Sync location contacts
if (isset($validated['contact_roles'])) {
$syncData = [];
foreach ($validated['contact_roles'] as $contactRole) {
// Verify contact belongs to this account
$contact = Contact::where('business_id', $account->id)
->where('id', $contactRole['contact_id'])
->first();
if ($contact) {
$syncData[$contact->id] = [
'role' => $contactRole['role'],
'is_primary' => $contactRole['is_primary'] ?? false,
];
}
}
$location->contacts()->sync($syncData);
} else {
$location->contacts()->detach();
}
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Location updated successfully.');
}
/**
* Search CannaiQ stores for linking
*/
public function searchCannaiqStores(Request $request, Business $business, Business $account, Location $location)
{
// Verify location belongs to this account
if ($location->business_id !== $account->id) {
abort(404);
}
$request->validate([
'platform' => 'required|string|max:50',
'query' => 'required|string|min:2|max:100',
]);
try {
$client = app(CannaiqClient::class);
$results = $client->searchStores(
platform: $request->input('platform'),
query: $request->input('query')
);
return response()->json([
'success' => true,
'stores' => $results,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to search stores: '.$e->getMessage(),
'stores' => [],
], 500);
}
}
}

View File

@@ -22,19 +22,19 @@ class AutomationController extends Controller
->orderByDesc('created_at')
->paginate(25);
return view('seller.crm.automations.index', compact('automations'));
return view('seller.crm.automations.index', compact('automations', 'business'));
}
/**
* Show automation builder
*/
public function create(Request $request)
public function create(Request $request, Business $business)
{
$triggers = CrmAutomation::TRIGGERS;
$operators = CrmAutomationCondition::OPERATORS;
$actionTypes = CrmAutomationAction::TYPES;
return view('seller.crm.automations.create', compact('triggers', 'operators', 'actionTypes'));
return view('seller.crm.automations.create', compact('triggers', 'operators', 'actionTypes', 'business'));
}
/**
@@ -97,7 +97,7 @@ class AutomationController extends Controller
]);
}
return redirect()->route('seller.crm.automations.show', $automation)
return redirect()->route('seller.business.crm.automations.show', [$business, $automation])
->with('success', 'Automation created successfully.');
}
@@ -112,7 +112,7 @@ class AutomationController extends Controller
$automation->load(['conditions', 'actions', 'logs' => fn ($q) => $q->latest()->limit(50)]);
return view('seller.crm.automations.show', compact('automation'));
return view('seller.crm.automations.show', compact('automation', 'business'));
}
/**
@@ -130,7 +130,7 @@ class AutomationController extends Controller
$operators = CrmAutomationCondition::OPERATORS;
$actionTypes = CrmAutomationAction::TYPES;
return view('seller.crm.automations.edit', compact('automation', 'triggers', 'operators', 'actionTypes'));
return view('seller.crm.automations.edit', compact('automation', 'triggers', 'operators', 'actionTypes', 'business'));
}
/**
@@ -202,7 +202,7 @@ class AutomationController extends Controller
]);
}
return redirect()->route('seller.crm.automations.show', $automation)
return redirect()->route('seller.business.crm.automations.show', [$business, $automation])
->with('success', 'Automation updated successfully.');
}
@@ -235,7 +235,7 @@ class AutomationController extends Controller
$copy = $automation->duplicate();
return redirect()->route('seller.crm.automations.edit', $copy)
return redirect()->route('seller.business.crm.automations.edit', [$business, $copy])
->with('success', 'Automation duplicated. Make your changes and activate when ready.');
}
@@ -250,7 +250,7 @@ class AutomationController extends Controller
$automation->delete();
return redirect()->route('seller.crm.automations.index')
return redirect()->route('seller.business.crm.automations.index', $business)
->with('success', 'Automation deleted.');
}
}

View File

@@ -0,0 +1,196 @@
<?php
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\BusinessEmailIdentity;
use App\Models\Crm\CrmChannel;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ChannelController extends Controller
{
/**
* List all CRM channels for a business.
*/
public function index(Business $business)
{
$channels = CrmChannel::forBusiness($business->id)
->orderBy('type')
->orderBy('name')
->get();
return view('seller.crm.channels.index', compact('business', 'channels'));
}
/**
* Show the create channel form.
*/
public function create(Business $business)
{
// Get available email identities
$emailIdentities = BusinessEmailIdentity::forBusiness($business->id)
->active()
->with('mailSettings')
->get();
return view('seller.crm.channels.create', [
'business' => $business,
'channel' => null,
'emailIdentities' => $emailIdentities,
'types' => [
CrmChannel::TYPE_EMAIL => 'Email',
CrmChannel::TYPE_SMS => 'SMS',
],
'departments' => CrmChannel::DEPARTMENTS,
]);
}
/**
* Store a new channel.
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'type' => ['required', 'string', Rule::in([CrmChannel::TYPE_EMAIL, CrmChannel::TYPE_SMS])],
'department' => ['required', 'string', Rule::in(array_keys(CrmChannel::DEPARTMENTS))],
'is_active' => ['boolean'],
// Email-specific
'identity_id' => ['nullable', 'required_if:type,email', 'exists:business_email_identities,id'],
// SMS-specific
'phone_number' => ['nullable', 'required_if:type,sms', 'string', 'max:20'],
]);
// Build config based on type
$config = ['department' => $validated['department']];
$identifier = null;
if ($validated['type'] === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
$identity = BusinessEmailIdentity::where('business_id', $business->id)
->findOrFail($validated['identity_id']);
$config['identity_id'] = $identity->id;
$config['mail_settings_id'] = $identity->mail_settings_id;
$identifier = $identity->email;
}
if ($validated['type'] === CrmChannel::TYPE_SMS && ! empty($validated['phone_number'])) {
$config['phone_number'] = $validated['phone_number'];
$identifier = $validated['phone_number'];
}
$channel = CrmChannel::create([
'business_id' => $business->id,
'type' => $validated['type'],
'name' => $validated['name'],
'department' => $validated['department'],
'identifier' => $identifier,
'config' => $config,
'is_active' => $request->boolean('is_active', true),
'can_send' => true,
'can_receive' => true,
]);
// Link the email identity to this channel
if ($validated['type'] === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
BusinessEmailIdentity::where('id', $validated['identity_id'])
->update(['crm_channel_id' => $channel->id]);
}
return redirect()
->route('seller.business.crm.channels.index', $business)
->with('success', 'Channel created successfully.');
}
/**
* Show the edit channel form.
*/
public function edit(Business $business, CrmChannel $channel)
{
// Security: ensure channel belongs to business
if ($channel->business_id !== $business->id) {
abort(404);
}
// Get available email identities
$emailIdentities = BusinessEmailIdentity::forBusiness($business->id)
->active()
->with('mailSettings')
->get();
return view('seller.crm.channels.edit', [
'business' => $business,
'channel' => $channel,
'emailIdentities' => $emailIdentities,
'types' => [
CrmChannel::TYPE_EMAIL => 'Email',
CrmChannel::TYPE_SMS => 'SMS',
],
'departments' => CrmChannel::DEPARTMENTS,
]);
}
/**
* Update an existing channel.
*/
public function update(Request $request, Business $business, CrmChannel $channel)
{
// Security: ensure channel belongs to business
if ($channel->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'department' => ['required', 'string', Rule::in(array_keys(CrmChannel::DEPARTMENTS))],
'is_active' => ['boolean'],
// Email-specific
'identity_id' => ['nullable', 'exists:business_email_identities,id'],
// SMS-specific
'phone_number' => ['nullable', 'string', 'max:20'],
]);
// Build config based on type
$config = $channel->config ?? [];
$config['department'] = $validated['department'];
$identifier = $channel->identifier;
if ($channel->type === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
$identity = BusinessEmailIdentity::where('business_id', $business->id)
->findOrFail($validated['identity_id']);
// Unlink old identity if different
$oldIdentityId = $config['identity_id'] ?? null;
if ($oldIdentityId && $oldIdentityId != $identity->id) {
BusinessEmailIdentity::where('id', $oldIdentityId)
->update(['crm_channel_id' => null]);
}
$config['identity_id'] = $identity->id;
$config['mail_settings_id'] = $identity->mail_settings_id;
$identifier = $identity->email;
// Link new identity
$identity->update(['crm_channel_id' => $channel->id]);
}
if ($channel->type === CrmChannel::TYPE_SMS && ! empty($validated['phone_number'])) {
$config['phone_number'] = $validated['phone_number'];
$identifier = $validated['phone_number'];
}
$channel->update([
'name' => $validated['name'],
'department' => $validated['department'],
'identifier' => $identifier,
'config' => $config,
'is_active' => $request->boolean('is_active', true),
]);
return redirect()
->route('seller.business.crm.channels.index', $business)
->with('success', 'Channel updated successfully.');
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Contact;
use Illuminate\Http\Request;
class ContactController extends Controller
{
/**
* Display all CRM contacts (contacts from buyer businesses).
*/
public function index(Request $request, Business $business)
{
$query = Contact::query()
->whereHas('business', function ($q) {
$q->where('type', 'buyer');
})
->with(['business', 'location']);
// Search filter
if ($request->filled('q')) {
$search = $request->q;
$query->where(function ($q) use ($search) {
$q->where('first_name', 'ILIKE', "%{$search}%")
->orWhere('last_name', 'ILIKE', "%{$search}%")
->orWhere('email', 'ILIKE', "%{$search}%")
->orWhere('phone', 'ILIKE', "%{$search}%")
->orWhere('position', 'ILIKE', "%{$search}%")
->orWhereHas('business', function ($q) use ($search) {
$q->where('name', 'ILIKE', "%{$search}%")
->orWhere('dba_name', 'ILIKE', "%{$search}%");
});
});
}
// Account filter
if ($request->filled('account')) {
$query->where('business_id', $request->account);
}
// Contact type filter
if ($request->filled('type')) {
$query->where('contact_type', $request->type);
}
// Active filter - default to active
if ($request->filled('status')) {
if ($request->status === 'inactive') {
$query->where('is_active', false);
} elseif ($request->status === 'all') {
// Show all
} else {
$query->where('is_active', true);
}
} else {
$query->where('is_active', true);
}
$contacts = $query
->orderBy('last_name')
->orderBy('first_name')
->paginate(25)
->withQueryString();
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $contacts->map(fn ($c) => [
'hashid' => $c->hashid,
'name' => $c->getFullName(),
'email' => $c->email,
'account' => $c->business?->name,
])->values()->toArray(),
]);
}
// Get accounts for filter dropdown
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->orderBy('name')
->get(['id', 'name', 'dba_name']);
return view('seller.crm.contacts.index', compact('business', 'contacts', 'accounts'));
}
/**
* Show the form for creating a new contact.
*/
public function create(Request $request, Business $business)
{
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->orderBy('name')
->get(['id', 'name', 'dba_name']);
$selectedAccount = $request->filled('account')
? Business::find($request->account)
: null;
return view('seller.crm.contacts.create', compact('business', 'accounts', 'selectedAccount'));
}
/**
* Store a newly created contact.
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'business_id' => 'required|exists:businesses,id',
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'phone' => 'nullable|string|max:50',
'mobile' => 'nullable|string|max:50',
'position' => 'nullable|string|max:255',
'contact_type' => 'nullable|string|in:'.implode(',', array_keys(Contact::CONTACT_TYPES)),
'preferred_contact_method' => 'nullable|string|in:'.implode(',', array_keys(Contact::COMMUNICATION_METHODS)),
'is_primary' => 'nullable|boolean',
'notes' => 'nullable|string|max:1000',
]);
// Verify the target business is a buyer
$targetBusiness = Business::findOrFail($validated['business_id']);
if ($targetBusiness->type !== 'buyer') {
return redirect()->back()->with('error', 'Contacts can only be added to customer accounts.');
}
// If setting as primary, remove primary from other contacts
if ($request->boolean('is_primary')) {
Contact::where('business_id', $validated['business_id'])->update(['is_primary' => false]);
}
$contact = Contact::create([
'business_id' => $validated['business_id'],
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
'mobile' => $validated['mobile'] ?? null,
'position' => $validated['position'] ?? null,
'contact_type' => $validated['contact_type'] ?? 'general',
'preferred_contact_method' => $validated['preferred_contact_method'] ?? 'email',
'is_primary' => $request->boolean('is_primary', false),
'is_active' => true,
'notes' => $validated['notes'] ?? null,
'created_by' => auth()->id(),
]);
return redirect()
->route('seller.business.crm.contacts.index', $business)
->with('success', "Contact '{$contact->getFullName()}' created successfully.");
}
/**
* Show the form for editing a contact.
*/
public function edit(Business $business, Contact $contact)
{
// Verify contact belongs to a buyer business
if ($contact->business->type !== 'buyer') {
abort(404);
}
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->orderBy('name')
->get(['id', 'name', 'dba_name']);
return view('seller.crm.contacts.edit', compact('business', 'contact', 'accounts'));
}
/**
* Update a contact.
*/
public function update(Request $request, Business $business, Contact $contact)
{
// Verify contact belongs to a buyer business
if ($contact->business->type !== 'buyer') {
abort(404);
}
$validated = $request->validate([
'business_id' => 'required|exists:businesses,id',
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'phone' => 'nullable|string|max:50',
'mobile' => 'nullable|string|max:50',
'position' => 'nullable|string|max:255',
'contact_type' => 'nullable|string|in:'.implode(',', array_keys(Contact::CONTACT_TYPES)),
'preferred_contact_method' => 'nullable|string|in:'.implode(',', array_keys(Contact::COMMUNICATION_METHODS)),
'is_primary' => 'nullable|boolean',
'notes' => 'nullable|string|max:1000',
]);
// Verify the target business is a buyer
$targetBusiness = Business::findOrFail($validated['business_id']);
if ($targetBusiness->type !== 'buyer') {
return redirect()->back()->with('error', 'Contacts can only belong to customer accounts.');
}
// If setting as primary, remove primary from other contacts
if ($request->boolean('is_primary') && ! $contact->is_primary) {
Contact::where('business_id', $validated['business_id'])
->where('id', '!=', $contact->id)
->update(['is_primary' => false]);
}
$contact->update([
'business_id' => $validated['business_id'],
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
'mobile' => $validated['mobile'] ?? null,
'position' => $validated['position'] ?? null,
'contact_type' => $validated['contact_type'] ?? 'general',
'preferred_contact_method' => $validated['preferred_contact_method'] ?? 'email',
'is_primary' => $request->boolean('is_primary', false),
'notes' => $validated['notes'] ?? null,
'updated_by' => auth()->id(),
]);
return redirect()
->route('seller.business.crm.contacts.index', $business)
->with('success', "Contact '{$contact->getFullName()}' updated successfully.");
}
/**
* Archive/delete a contact.
*/
public function destroy(Business $business, Contact $contact)
{
// Verify contact belongs to a buyer business
if ($contact->business->type !== 'buyer') {
abort(404);
}
$name = $contact->getFullName();
$contact->archive('Deleted via CRM', auth()->user());
return redirect()
->route('seller.business.crm.contacts.index', $business)
->with('success', "Contact '{$name}' has been archived.");
}
}

View File

@@ -5,8 +5,13 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Jobs\Crm\SyncCalendarJob;
use App\Models\Business;
use App\Models\CalendarEvent;
use App\Models\Contact;
use App\Models\Crm\CrmCalendarConnection;
use App\Models\Crm\CrmMeetingBooking;
use App\Models\Crm\CrmSyncedEvent;
use App\Models\Crm\CrmTask;
use App\Models\User;
use App\Services\Crm\CrmCalendarService;
use Illuminate\Http\Request;
@@ -17,7 +22,7 @@ class CrmCalendarController extends Controller
) {}
/**
* Calendar view
* Calendar view - unified activity calendar
*/
public function index(Request $request, Business $business)
{
@@ -28,52 +33,403 @@ class CrmCalendarController extends Controller
->where('user_id', $user->id)
->get();
// Get events for calendar view
$startDate = $request->input('start', now()->startOfMonth());
$endDate = $request->input('end', now()->endOfMonth());
// Get team members for assignment dropdown
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->select('id', 'first_name', 'last_name', 'email')
->get();
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections->pluck('id'))
->whereBetween('start_at', [$startDate, $endDate])
// Get contacts for event creation
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
$contacts = Contact::whereIn('business_id', $customerBusinessIds)
->with('business:id,name')
->orderBy('first_name')
->limit(200)
->get();
// Get event types and colors for legend/forms
$eventTypes = CalendarEvent::TYPES;
$eventColors = CalendarEvent::TYPE_COLORS;
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
return view('seller.crm.calendar.index', compact(
'business',
'connections',
'teamMembers',
'contacts',
'eventTypes',
'eventColors'
));
}
/**
* API: Get all events for date range (unified: internal + synced + bookings + tasks)
*/
public function events(Request $request, Business $business)
{
$user = $request->user();
$validated = $request->validate([
'start' => 'required|date',
'end' => 'required|date|after:start',
]);
$startDate = $validated['start'];
$endDate = $validated['end'];
$allEvents = collect();
// 1. Internal CalendarEvents
$internalEvents = CalendarEvent::forSellerBusiness($business->id)
->inDateRange($startDate, $endDate)
->with(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name'])
->get()
->map(fn ($e) => [
'id' => $e->id,
'id' => 'event_'.$e->id,
'title' => $e->title,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at?->toIso8601String(),
'allDay' => $e->all_day,
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
'color' => $e->getColor(),
'classNames' => ['calendar-event-internal', 'event-type-'.$e->type],
'extendedProps' => [
'source' => 'internal',
'event_id' => $e->id,
'type' => $e->type,
'type_label' => $e->getTypeLabel(),
'status' => $e->status,
'location' => $e->location,
'description' => $e->description,
'attendees' => $e->attendees,
'contact_id' => $e->contact_id,
'contact_name' => $e->contact ? $e->contact->first_name.' '.$e->contact->last_name : null,
'assigned_to' => $e->assigned_to,
'assignee_name' => $e->assignee ? $e->assignee->first_name.' '.$e->assignee->last_name : null,
'editable' => true,
],
]);
$allEvents = $allEvents->merge($internalEvents);
// Get meeting bookings
$bookings = \App\Models\Crm\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
// 2. Synced external events (Google/Outlook)
$connections = CrmCalendarConnection::where('business_id', $business->id)
->where('user_id', $user->id)
->where('sync_enabled', true)
->pluck('id');
if ($connections->isNotEmpty()) {
$syncedEvents = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
->whereBetween('start_at', [$startDate, $endDate])
->with('connection:id,provider')
->get()
->map(fn ($e) => [
'id' => 'synced_'.$e->id,
'title' => $e->title,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at?->toIso8601String(),
'allDay' => $e->all_day,
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
'classNames' => ['calendar-event-synced', 'provider-'.$e->connection->provider],
'extendedProps' => [
'source' => 'synced',
'provider' => $e->connection->provider,
'location' => $e->location,
'description' => $e->description,
'attendees' => $e->attendees,
'external_link' => $e->external_link,
'editable' => false,
],
]);
$allEvents = $allEvents->merge($syncedEvents);
}
// 3. Meeting bookings
$bookings = CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
$q->where('business_id', $business->id)
->where('user_id', $user->id);
})
->whereBetween('start_at', [$startDate, $endDate])
->with(['meetingLink', 'contact'])
->where('status', '!=', 'cancelled')
->with(['meetingLink:id,name', 'contact:id,first_name,last_name'])
->get()
->map(fn ($b) => [
'id' => 'booking_'.$b->id,
'title' => $b->meetingLink->name.' - '.$b->booker_name,
'title' => ($b->meetingLink->name ?? 'Meeting').' - '.$b->booker_name,
'start' => $b->start_at->toIso8601String(),
'end' => $b->end_at->toIso8601String(),
'color' => '#10b981',
'classNames' => ['calendar-event-booking'],
'extendedProps' => [
'type' => 'booking',
'contact_id' => $b->contact_id,
'source' => 'booking',
'booking_id' => $b->id,
'status' => $b->status,
'booker_name' => $b->booker_name,
'booker_email' => $b->booker_email,
'contact_id' => $b->contact_id,
'contact_name' => $b->contact ? $b->contact->first_name.' '.$b->contact->last_name : null,
'location' => $b->location,
'editable' => false,
],
]);
$allEvents = $allEvents->merge($bookings);
$allEvents = $events->merge($bookings);
// 4. CRM Tasks with due dates (shown as all-day markers) - only show user's assigned tasks
$tasks = CrmTask::forSellerBusiness($business->id)
->where('assigned_to', $user->id)
->incomplete()
->whereNotNull('due_at')
->whereBetween('due_at', [$startDate, $endDate])
->with(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name'])
->get()
->map(fn ($t) => [
'id' => 'task_'.$t->id,
'title' => '📋 '.$t->title,
'start' => $t->due_at->toDateString(),
'allDay' => true,
'color' => $t->isOverdue() ? '#EF4444' : '#F59E0B',
'classNames' => ['calendar-event-task', $t->isOverdue() ? 'task-overdue' : ''],
'extendedProps' => [
'source' => 'task',
'task_id' => $t->id,
'type' => $t->type,
'priority' => $t->priority,
'contact_id' => $t->contact_id,
'contact_name' => $t->contact ? $t->contact->first_name.' '.$t->contact->last_name : null,
'assigned_to' => $t->assigned_to,
'assignee_name' => $t->assignee ? $t->assignee->first_name.' '.$t->assignee->last_name : null,
'is_overdue' => $t->isOverdue(),
'editable' => false,
],
]);
$allEvents = $allEvents->merge($tasks);
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
return view('seller.crm.calendar.index', compact('business', 'connections', 'allEvents'));
return response()->json($allEvents->values());
}
/**
* Store a new calendar event
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string|max:5000',
'location' => 'nullable|string|max:255',
'start_at' => 'required|date',
'end_at' => 'nullable|date|after:start_at',
'all_day' => 'boolean',
'type' => 'required|string|in:'.implode(',', array_keys(CalendarEvent::TYPES)),
'contact_id' => 'nullable|exists:contacts,id',
'assigned_to' => 'nullable|exists:users,id',
'reminder_minutes' => 'nullable|integer|min:0',
]);
// Security: verify contact belongs to a customer business
if (! empty($validated['contact_id'])) {
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
Contact::whereIn('business_id', $customerBusinessIds)
->findOrFail($validated['contact_id']);
}
// Security: verify assignee belongs to business
if (! empty($validated['assigned_to'])) {
User::where('id', $validated['assigned_to'])
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->firstOrFail();
}
$event = CalendarEvent::create([
'seller_business_id' => $business->id,
'created_by' => $request->user()->id,
'assigned_to' => $validated['assigned_to'] ?? $request->user()->id,
'title' => $validated['title'],
'description' => $validated['description'] ?? null,
'location' => $validated['location'] ?? null,
'start_at' => $validated['start_at'],
'end_at' => $validated['end_at'] ?? null,
'all_day' => $validated['all_day'] ?? false,
'type' => $validated['type'],
'status' => 'scheduled',
'contact_id' => $validated['contact_id'] ?? null,
'reminder_at' => isset($validated['reminder_minutes']) && $validated['reminder_minutes'] > 0
? now()->parse($validated['start_at'])->subMinutes($validated['reminder_minutes'])
: null,
]);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'event' => $event->load(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name']),
]);
}
return back()->with('success', 'Event created successfully.');
}
/**
* Update a calendar event
*/
public function update(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'title' => 'sometimes|required|string|max:255',
'description' => 'nullable|string|max:5000',
'location' => 'nullable|string|max:255',
'start_at' => 'sometimes|required|date',
'end_at' => 'nullable|date|after:start_at',
'all_day' => 'boolean',
'type' => 'sometimes|required|string|in:'.implode(',', array_keys(CalendarEvent::TYPES)),
'status' => 'sometimes|required|string|in:scheduled,completed,cancelled',
'contact_id' => 'nullable|exists:contacts,id',
'assigned_to' => 'nullable|exists:users,id',
'reminder_minutes' => 'nullable|integer|min:0',
]);
// Security checks for contact and assignee
if (isset($validated['contact_id']) && $validated['contact_id']) {
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
Contact::whereIn('business_id', $customerBusinessIds)
->findOrFail($validated['contact_id']);
}
if (isset($validated['assigned_to']) && $validated['assigned_to']) {
User::where('id', $validated['assigned_to'])
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->firstOrFail();
}
// Handle reminder
if (isset($validated['reminder_minutes'])) {
$validated['reminder_at'] = $validated['reminder_minutes'] > 0
? now()->parse($validated['start_at'] ?? $event->start_at)->subMinutes($validated['reminder_minutes'])
: null;
$validated['reminder_sent'] = false;
unset($validated['reminder_minutes']);
}
$event->update($validated);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'event' => $event->fresh()->load(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name']),
]);
}
return back()->with('success', 'Event updated successfully.');
}
/**
* Quick reschedule via drag-and-drop
*/
public function reschedule(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$validated = $request->validate([
'start_at' => 'required|date',
'end_at' => 'nullable|date|after:start_at',
'all_day' => 'boolean',
]);
$event->reschedule(
$validated['start_at'],
$validated['end_at'] ?? null,
$request->user()
);
if (isset($validated['all_day'])) {
$event->update(['all_day' => $validated['all_day']]);
}
return response()->json([
'success' => true,
'event' => $event->fresh(),
]);
}
/**
* Mark event as complete
*/
public function complete(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
abort(404);
}
$event->markComplete($request->user());
if ($request->wantsJson()) {
return response()->json(['success' => true, 'event' => $event->fresh()]);
}
return back()->with('success', 'Event marked as complete.');
}
/**
* Cancel an event
*/
public function cancel(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
abort(404);
}
$event->cancel($request->user());
if ($request->wantsJson()) {
return response()->json(['success' => true, 'event' => $event->fresh()]);
}
return back()->with('success', 'Event cancelled.');
}
/**
* Delete an event
*/
public function destroy(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
abort(404);
}
$event->delete();
if ($request->wantsJson()) {
return response()->json(['success' => true]);
}
return back()->with('success', 'Event deleted.');
}
/**
* Get single event details (for modal)
*/
public function show(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
abort(404);
}
$event->load([
'contact:id,first_name,last_name,email,phone',
'business:id,name',
'assignee:id,first_name,last_name,email',
'creator:id,first_name,last_name',
]);
return response()->json($event);
}
/**
@@ -104,7 +460,7 @@ class CrmCalendarController extends Controller
$params = http_build_query([
'client_id' => config('services.google.client_id'),
'redirect_uri' => route('seller.crm.calendar.callback'),
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
'response_type' => 'code',
'scope' => 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events',
'access_type' => 'offline',
@@ -128,7 +484,7 @@ class CrmCalendarController extends Controller
$params = http_build_query([
'client_id' => config('services.microsoft.client_id'),
'redirect_uri' => route('seller.crm.calendar.callback'),
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
'response_type' => 'code',
'scope' => 'offline_access Calendars.ReadWrite',
'state' => $state,
@@ -140,17 +496,17 @@ class CrmCalendarController extends Controller
/**
* OAuth callback
*/
public function callback(Request $request)
public function callback(Request $request, Business $business)
{
if ($request->has('error')) {
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->withErrors(['error' => 'Authorization failed: '.$request->input('error_description')]);
}
try {
$state = decrypt($request->input('state'));
} catch (\Exception $e) {
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->withErrors(['error' => 'Invalid state parameter.']);
}
@@ -161,7 +517,7 @@ class CrmCalendarController extends Controller
$tokens = $this->calendarService->exchangeCodeForTokens($provider, $code);
if (! $tokens) {
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->withErrors(['error' => 'Failed to obtain access token.']);
}
@@ -189,7 +545,7 @@ class CrmCalendarController extends Controller
// Queue initial sync
SyncCalendarJob::dispatch($state['user_id'], $provider);
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->with('success', ucfirst($provider).' Calendar connected successfully.');
}
@@ -238,34 +594,4 @@ class CrmCalendarController extends Controller
return back()->with('success', 'Calendar sync started. Events will appear shortly.');
}
/**
* API: Get events for date range (for calendar JS)
*/
public function events(Request $request, Business $business)
{
$user = $request->user();
$validated = $request->validate([
'start' => 'required|date',
'end' => 'required|date|after:start',
]);
$connections = CrmCalendarConnection::where('business_id', $business->id)
->where('user_id', $user->id)
->pluck('id');
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
->whereBetween('start_at', [$validated['start'], $validated['end']])
->get()
->map(fn ($e) => [
'id' => $e->id,
'title' => $e->title,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at?->toIso8601String(),
'allDay' => $e->all_day,
]);
return response()->json($events);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmPipeline;
use App\Models\Crm\CrmRepMetric;
use App\Models\Crm\CrmSlaTimer;
use App\Models\Crm\CrmThread;
@@ -43,13 +44,26 @@ class CrmDashboardController extends Controller
*/
public function sales(Request $request, Business $business)
{
// Get the default pipeline for stage name mapping
$defaultPipeline = CrmPipeline::where('business_id', $business->id)
->where('is_default', true)
->first();
// Pipeline summary
$stageMap = collect($defaultPipeline?->stages ?? [])->mapWithKeys(function ($stage, $index) {
return [$index => $stage['name'] ?? "Stage {$index}"];
})->all();
// Pipeline summary - group by stage_id (index into pipeline stages JSON array)
$pipelineSummary = CrmDeal::forBusiness($business->id)
->open()
->selectRaw('stage, count(*) as count, sum(value) as total_value, sum(weighted_value) as weighted_value')
->groupBy('stage')
->get();
->selectRaw('stage_id, count(*) as count, sum(value) as total_value, sum(weighted_value) as weighted_value')
->groupBy('stage_id')
->get()
->map(function ($item) use ($stageMap) {
$item->stage_name = $stageMap[$item->stage_id] ?? "Stage {$item->stage_id}";
return $item;
});
// Won/Lost this month
$monthlyStats = [
@@ -86,7 +100,8 @@ class CrmDashboardController extends Controller
'monthlyStats',
'closingThisMonth',
'atRiskDeals',
'leaderboard'
'leaderboard',
'business'
));
}
@@ -126,7 +141,8 @@ class CrmDashboardController extends Controller
'slaMetrics',
'repMetrics',
'threadDistribution',
'dealDistribution'
'dealDistribution',
'business'
));
}
@@ -166,19 +182,33 @@ class CrmDashboardController extends Controller
->with('thread.contact')
->get();
// Quick stats
// Quick stats - consolidated into efficient queries
$threadStats = CrmThread::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_threads,
SUM(CASE WHEN is_read = false AND status = 'open' THEN 1 ELSE 0 END) as unread_threads
")
->first();
$dealStats = CrmDeal::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_deals,
SUM(CASE WHEN status = 'open' AND owner_id = ? THEN 1 ELSE 0 END) as my_deals,
SUM(CASE WHEN status = 'open' THEN value ELSE 0 END) as pipeline_value,
SUM(CASE WHEN status = 'open' THEN weighted_value ELSE 0 END) as weighted_pipeline,
SUM(CASE WHEN status = 'won' AND EXTRACT(MONTH FROM actual_close_date) = ? AND EXTRACT(YEAR FROM actual_close_date) = ? THEN value ELSE 0 END) as won_this_month
", [$user->id, now()->month, now()->year])
->first();
$stats = [
'open_threads' => CrmThread::forBusiness($business->id)->open()->count(),
'open_threads' => $threadStats->open_threads ?? 0,
'my_threads' => $myThreads->count(),
'unread_threads' => CrmThread::forBusiness($business->id)->unread()->count(),
'open_deals' => CrmDeal::forBusiness($business->id)->open()->count(),
'my_deals' => CrmDeal::forBusiness($business->id)->ownedBy($user->id)->open()->count(),
'pipeline_value' => CrmDeal::forBusiness($business->id)->open()->sum('value'),
'weighted_pipeline' => CrmDeal::forBusiness($business->id)->open()->sum('weighted_value'),
'won_this_month' => CrmDeal::forBusiness($business->id)
->won()
->whereMonth('actual_close_date', now()->month)
->sum('value'),
'unread_threads' => $threadStats->unread_threads ?? 0,
'open_deals' => $dealStats->open_deals ?? 0,
'my_deals' => $dealStats->my_deals ?? 0,
'pipeline_value' => $dealStats->pipeline_value ?? 0,
'weighted_pipeline' => $dealStats->weighted_pipeline ?? 0,
'won_this_month' => $dealStats->won_this_month ?? 0,
'sla_compliance' => $this->slaService->getMetrics($business->id, 30)['compliance_rate'] ?? 100,
];

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;
@@ -28,7 +29,7 @@ class CrmSettingsController extends Controller
'tags' => CrmTag::where('business_id', $business->id)->count(),
];
return view('seller.crm.settings.index', compact('stats'));
return view('seller.crm.settings.index', compact('stats', 'business'));
}
// ================== CHANNELS ==================
@@ -44,17 +45,17 @@ class CrmSettingsController extends Controller
->get()
->groupBy('type');
return view('seller.crm.settings.channels.index', compact('channels'));
return view('seller.crm.settings.channels.index', compact('channels', 'business'));
}
/**
* Create channel form
*/
public function createChannel(Request $request)
public function createChannel(Request $request, Business $business)
{
$types = CrmChannel::TYPES;
return view('seller.crm.settings.channels.create', compact('types'));
return view('seller.crm.settings.channels.create', compact('types', 'business'));
}
/**
@@ -86,7 +87,7 @@ class CrmSettingsController extends Controller
'is_default' => $validated['is_default'] ?? false,
]);
return redirect()->route('seller.crm.settings.channels')
return redirect()->route('seller.business.crm.settings.channels', $business)
->with('success', 'Channel created successfully.');
}
@@ -102,7 +103,7 @@ class CrmSettingsController extends Controller
$types = CrmChannel::TYPES;
return view('seller.crm.settings.channels.edit', compact('channel', 'types'));
return view('seller.crm.settings.channels.edit', compact('channel', 'types', 'business'));
}
/**
@@ -132,7 +133,7 @@ class CrmSettingsController extends Controller
$channel->update($validated);
return redirect()->route('seller.crm.settings.channels')
return redirect()->route('seller.business.crm.settings.channels', $business)
->with('success', 'Channel updated.');
}
@@ -164,15 +165,15 @@ class CrmSettingsController extends Controller
->orderBy('name')
->get();
return view('seller.crm.settings.pipelines.index', compact('pipelines'));
return view('seller.crm.settings.pipelines.index', compact('pipelines', 'business'));
}
/**
* Create pipeline form
*/
public function createPipeline()
public function createPipeline(Request $request, Business $business)
{
return view('seller.crm.settings.pipelines.create');
return view('seller.crm.settings.pipelines.create', compact('business'));
}
/**
@@ -206,7 +207,7 @@ class CrmSettingsController extends Controller
'is_default' => $validated['is_default'] ?? false,
]);
return redirect()->route('seller.crm.settings.pipelines')
return redirect()->route('seller.business.crm.settings.pipelines', $business)
->with('success', 'Pipeline created.');
}
@@ -220,7 +221,7 @@ class CrmSettingsController extends Controller
abort(404);
}
return view('seller.crm.settings.pipelines.edit', compact('pipeline'));
return view('seller.crm.settings.pipelines.edit', compact('pipeline', 'business'));
}
/**
@@ -253,7 +254,7 @@ class CrmSettingsController extends Controller
$pipeline->update($validated);
return redirect()->route('seller.crm.settings.pipelines')
return redirect()->route('seller.business.crm.settings.pipelines', $business)
->with('success', 'Pipeline updated.');
}
@@ -288,15 +289,15 @@ class CrmSettingsController extends Controller
->orderBy('priority')
->get();
return view('seller.crm.settings.sla.index', compact('policies'));
return view('seller.crm.settings.sla.index', compact('policies', 'business'));
}
/**
* Create SLA policy form
*/
public function createSlaPolicy()
public function createSlaPolicy(Request $request, Business $business)
{
return view('seller.crm.settings.sla.create');
return view('seller.crm.settings.sla.create', compact('business'));
}
/**
@@ -330,7 +331,7 @@ class CrmSettingsController extends Controller
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()->route('seller.crm.settings.sla')
return redirect()->route('seller.business.crm.settings.sla', $business)
->with('success', 'SLA policy created.');
}
@@ -344,7 +345,7 @@ class CrmSettingsController extends Controller
abort(404);
}
return view('seller.crm.settings.sla.edit', compact('policy'));
return view('seller.crm.settings.sla.edit', compact('policy', 'business'));
}
/**
@@ -371,7 +372,7 @@ class CrmSettingsController extends Controller
$policy->update($validated);
return redirect()->route('seller.crm.settings.sla')
return redirect()->route('seller.business.crm.settings.sla', $business)
->with('success', 'SLA policy updated.');
}
@@ -403,7 +404,7 @@ class CrmSettingsController extends Controller
->orderBy('name')
->get();
return view('seller.crm.settings.tags.index', compact('tags'));
return view('seller.crm.settings.tags.index', compact('tags', 'business'));
}
/**
@@ -478,7 +479,7 @@ class CrmSettingsController extends Controller
->get()
->groupBy('category');
return view('seller.crm.settings.templates.index', compact('templates'));
return view('seller.crm.settings.templates.index', compact('templates', 'business'));
}
/**
@@ -489,7 +490,7 @@ class CrmSettingsController extends Controller
$categories = CrmMessageTemplate::CATEGORIES;
$channels = CrmChannel::TYPES;
return view('seller.crm.settings.templates.create', compact('categories', 'channels'));
return view('seller.crm.settings.templates.create', compact('categories', 'channels', 'business'));
}
/**
@@ -519,7 +520,7 @@ class CrmSettingsController extends Controller
'is_active' => true,
]);
return redirect()->route('seller.crm.settings.templates')
return redirect()->route('seller.business.crm.settings.templates', $business)
->with('success', 'Template created.');
}
@@ -536,7 +537,7 @@ class CrmSettingsController extends Controller
$categories = CrmMessageTemplate::CATEGORIES;
$channels = CrmChannel::TYPES;
return view('seller.crm.settings.templates.edit', compact('template', 'categories', 'channels'));
return view('seller.crm.settings.templates.edit', compact('template', 'categories', 'channels', 'business'));
}
/**
@@ -560,7 +561,7 @@ class CrmSettingsController extends Controller
$template->update($validated);
return redirect()->route('seller.crm.settings.templates')
return redirect()->route('seller.business.crm.settings.templates', $business)
->with('success', 'Template updated.');
}
@@ -592,7 +593,7 @@ class CrmSettingsController extends Controller
->orderBy('name')
->get();
return view('seller.crm.settings.roles.index', compact('roles'));
return view('seller.crm.settings.roles.index', compact('roles', 'business'));
}
/**
@@ -649,4 +650,81 @@ class CrmSettingsController extends Controller
return back()->with('success', 'Role deleted.');
}
/**
* Quick replies list
*/
public function quickReplies(Request $request, Business $business)
{
$quickReplies = ChatQuickReply::where('business_id', $business->id)
->orderBy('sort_order')
->orderBy('label')
->get();
$categories = $quickReplies->pluck('category')->filter()->unique()->values();
return view('seller.crm.settings.quick-replies.index', [
'business' => $business,
'quickReplies' => $quickReplies,
'categories' => $categories,
]);
}
/**
* Store new quick reply
*/
public function storeQuickReply(Request $request, Business $business)
{
$validated = $request->validate([
'label' => 'required|string|max:100',
'message' => 'required|string|max:2000',
'category' => 'nullable|string|max:50',
'is_active' => 'boolean',
]);
$validated['business_id'] = $business->id;
$validated['is_active'] = $request->boolean('is_active', true);
$validated['sort_order'] = ChatQuickReply::where('business_id', $business->id)->max('sort_order') + 1;
ChatQuickReply::create($validated);
return back()->with('success', 'Quick reply created.');
}
/**
* Update quick reply
*/
public function updateQuickReply(Request $request, Business $business, ChatQuickReply $quickReply)
{
if ($quickReply->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'label' => 'required|string|max:100',
'message' => 'required|string|max:2000',
'category' => 'nullable|string|max:50',
'is_active' => 'boolean',
]);
$validated['is_active'] = $request->boolean('is_active', true);
$quickReply->update($validated);
return back()->with('success', 'Quick reply updated.');
}
/**
* Delete quick reply
*/
public function destroyQuickReply(Request $request, Business $business, ChatQuickReply $quickReply)
{
if ($quickReply->business_id !== $business->id) {
abort(404);
}
$quickReply->delete();
return back()->with('success', 'Quick reply deleted.');
}
}

View File

@@ -31,10 +31,10 @@ class DealController extends Controller
?? CrmPipeline::forBusiness($business->id)->default()->first()
?? CrmPipeline::createDefault($business->id);
// Get deals grouped by stage
// Build base query for deals
$dealsQuery = CrmDeal::forBusiness($business->id)
->where('pipeline_id', $pipeline->id)
->with(['contact', 'account', 'owner']);
->with(['contact:id,first_name,last_name,email', 'account:id,name', 'owner:id,first_name,last_name,email']);
// Filters
if ($request->filled('owner_id')) {
@@ -52,23 +52,47 @@ class DealController extends Controller
$dealsQuery->open();
}
$deals = $dealsQuery->get()->groupBy('stage');
// Get deals grouped by stage using database grouping for efficiency
// Limit to reasonable number per stage for board view
$stages = $pipeline->stages ?? [];
$deals = collect();
foreach ($stages as $stage) {
$stageDeals = (clone $dealsQuery)
->where('stage', $stage['name'] ?? $stage)
->orderByDesc('value')
->limit(50)
->get();
$deals[$stage['name'] ?? $stage] = $stageDeals;
}
// Get pipelines for selector
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
// Get pipelines for selector (limited fields)
$pipelines = CrmPipeline::forBusiness($business->id)
->active()
->select('id', 'name', 'stages', 'is_default')
->get();
// Get team members
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
// Get team members (limited fields)
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->select('id', 'first_name', 'last_name', 'email')
->get();
// Calculate stats with single efficient query using selectRaw
$statsResult = CrmDeal::forBusiness($business->id)
->open()
->selectRaw('SUM(value) as total_value, SUM(weighted_value) as weighted_value, COUNT(*) as deals_count')
->first();
$wonThisMonth = CrmDeal::forBusiness($business->id)
->won()
->whereMonth('actual_close_date', now()->month)
->whereYear('actual_close_date', now()->year)
->sum('value');
// Calculate stats
$stats = [
'total_value' => CrmDeal::forBusiness($business->id)->open()->sum('value'),
'weighted_value' => CrmDeal::forBusiness($business->id)->open()->sum('weighted_value'),
'deals_count' => CrmDeal::forBusiness($business->id)->open()->count(),
'won_this_month' => CrmDeal::forBusiness($business->id)
->won()
->whereMonth('actual_close_date', now()->month)
->sum('value'),
'total_value' => $statsResult->total_value ?? 0,
'weighted_value' => $statsResult->weighted_value ?? 0,
'deals_count' => $statsResult->deals_count ?? 0,
'won_this_month' => $wonThisMonth,
];
return view('seller.crm.deals.index', compact('business', 'pipeline', 'deals', 'pipelines', 'teamMembers', 'stats'));
@@ -79,15 +103,37 @@ class DealController extends Controller
*/
public function create(Request $request, Business $business)
{
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
$contacts = Contact::where('business_id', $business->id)->get();
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})->get();
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
$brands = Brand::where('business_id', $business->id)->get();
$pipelines = CrmPipeline::forBusiness($business->id)
->active()
->select('id', 'name', 'stages', 'is_default')
->get();
return view('seller.crm.deals.create', compact('pipelines', 'contacts', 'accounts', 'teamMembers', 'brands'));
// Limit contacts for dropdown - most recent 100
$contacts = Contact::where('business_id', $business->id)
->select('id', 'first_name', 'last_name', 'email')
->orderByDesc('updated_at')
->limit(100)
->get();
// Limit accounts for dropdown - most recent 100
// Get businesses that have placed orders containing this seller's products
$accounts = Business::whereHas('orders', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})
->select('id', 'name')
->orderByDesc('updated_at')
->limit(100)
->get();
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->select('id', 'first_name', 'last_name', 'email')
->get();
$brands = Brand::where('business_id', $business->id)
->select('id', 'name')
->get();
return view('seller.crm.deals.create', compact('pipelines', 'contacts', 'accounts', 'teamMembers', 'brands', 'business'));
}
/**
@@ -155,7 +201,7 @@ class DealController extends Controller
'status' => CrmDeal::STATUS_OPEN,
]);
return redirect()->route('seller.crm.deals.show', $deal)
return redirect()->route('seller.business.crm.deals.show', [$business, $deal])
->with('success', 'Deal created successfully.');
}
@@ -191,7 +237,7 @@ class DealController extends Controller
$deal->refresh();
}
return view('seller.crm.deals.show', compact('deal', 'suggestions'));
return view('seller.crm.deals.show', compact('deal', 'suggestions', 'business'));
}
/**
@@ -328,7 +374,7 @@ class DealController extends Controller
$deal->delete();
return redirect()->route('seller.crm.deals.index')
return redirect()->route('seller.business.crm.deals.index', $business)
->with('success', 'Deal deleted.');
}
}

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
{
@@ -32,24 +36,33 @@ class InvoiceController extends Controller
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('invoice_number', 'like', "%{$request->search}%")
->orWhere('title', 'like', "%{$request->search}%");
$q->where('invoice_number', 'ILIKE', "%{$request->search}%")
->orWhere('title', 'ILIKE', "%{$request->search}%");
});
}
$invoices = $query->orderByDesc('created_at')->paginate(25);
// Stats
// Stats - single efficient query with conditional aggregation
$invoiceStats = CrmInvoice::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN balance_due ELSE 0 END) as outstanding,
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN balance_due ELSE 0 END) as overdue
")
->first();
$paidThisMonth = CrmInvoicePayment::whereHas('invoice', fn ($q) => $q->where('business_id', $business->id))
->whereMonth('payment_date', now()->month)
->whereYear('payment_date', now()->year)
->sum('amount');
$stats = [
'outstanding' => CrmInvoice::forBusiness($business->id)->outstanding()->sum('amount_due'),
'overdue' => CrmInvoice::forBusiness($business->id)->overdue()->sum('amount_due'),
'paid_this_month' => CrmInvoicePayment::whereHas('invoice', fn ($q) => $q->where('business_id', $business->id))
->whereMonth('payment_date', now()->month)
->whereYear('payment_date', now()->year)
->sum('amount'),
'outstanding' => $invoiceStats->outstanding ?? 0,
'overdue' => $invoiceStats->overdue ?? 0,
'paid_this_month' => $paidThisMonth,
];
return view('seller.crm.invoices.index', compact('invoices', 'stats'));
return view('seller.crm.invoices.index', compact('invoices', 'stats', 'business'));
}
/**
@@ -61,9 +74,9 @@ class InvoiceController extends Controller
abort(404);
}
$invoice->load(['contact', 'account', 'quote', 'creator', 'items.product', 'payments']);
$invoice->load(['contact', 'account', 'quote', 'creator', 'items.product', 'payments.recordedBy']);
return view('seller.crm.invoices.show', compact('invoice'));
return view('seller.crm.invoices.show', compact('invoice', 'business'));
}
/**
@@ -71,13 +84,76 @@ class InvoiceController extends Controller
*/
public function create(Request $request, Business $business)
{
$contacts = \App\Models\Contact::where('business_id', $business->id)->get();
// Get all approved buyer businesses as potential customers (matching quotes)
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->with('locations:id,business_id,name,is_primary')
->orderBy('name')
->select(['id', 'name', 'slug'])
->get();
// Get open deals for linking
$deals = CrmDeal::forBusiness($business->id)->open()->get();
// Limit quotes to accepted without invoices
$quotes = CrmQuote::forBusiness($business->id)
->where('status', CrmQuote::STATUS_ACCEPTED)
->whereDoesntHave('invoice')
->select('id', 'quote_number', 'title', 'total', 'contact_id', 'account_id', 'location_id')
->with(['contact:id,first_name,last_name', 'items.product'])
->limit(50)
->get();
return view('seller.crm.invoices.create', compact('contacts', 'quotes'));
// Transform quotes for Alpine.js (avoid complex closures in Blade @json)
$quotesForJs = $quotes->map(fn ($q) => [
'id' => $q->id,
'account_id' => $q->account_id,
'contact_id' => $q->contact_id,
'location_id' => $q->location_id,
'items' => $q->items->map(fn ($i) => [
'product_id' => $i->product_id,
'description' => $i->description,
'quantity' => $i->quantity,
'unit_price' => $i->unit_price,
'discount_percent' => $i->discount_percent ?? 0,
])->values(),
])->values();
// Pre-fill from URL parameters
$selectedAccount = null;
$selectedLocation = null;
$selectedContact = null;
$locationContacts = collect();
if ($request->filled('account_id')) {
$selectedAccount = $accounts->firstWhere('id', $request->account_id);
}
if ($request->filled('location_id') && $selectedAccount) {
$selectedLocation = $selectedAccount->locations->firstWhere('id', $request->location_id);
}
// Pre-fill from quote if provided
$quote = null;
if ($request->filled('quote_id')) {
$quote = $quotes->firstWhere('id', $request->quote_id);
if ($quote) {
$selectedAccount = $accounts->firstWhere('id', $quote->account_id);
}
}
return view('seller.crm.invoices.create', compact(
'accounts',
'deals',
'quotes',
'quotesForJs',
'business',
'selectedAccount',
'selectedLocation',
'selectedContact',
'locationContacts',
'quote'
));
}
/**
@@ -89,21 +165,28 @@ class InvoiceController extends Controller
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
'account_id' => 'nullable|exists:businesses,id',
'location_id' => 'nullable|exists:locations,id',
'quote_id' => 'nullable|exists:crm_quotes,id',
'deal_id' => 'nullable|exists:crm_deals,id',
'due_date' => 'required|date|after_or_equal:today',
'tax_rate' => 'nullable|numeric|min:0|max:100',
'discount_type' => 'nullable|in:fixed,percentage',
'discount_value' => 'nullable|numeric|min:0',
'notes' => 'nullable|string|max:2000',
'payment_terms' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.product_id' => 'nullable|exists:products,id',
'items.*.description' => 'required|string|max:500',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
]);
// SECURITY: Verify contact belongs to business
\App\Models\Contact::where('id', $validated['contact_id'])
->where('business_id', $business->id)
->firstOrFail();
// SECURITY: Verify contact belongs to the account if account is provided
$contact = \App\Models\Contact::findOrFail($validated['contact_id']);
if (! empty($validated['account_id']) && $contact->business_id !== (int) $validated['account_id']) {
return back()->withErrors(['contact_id' => 'Contact must belong to the selected account.']);
}
// SECURITY: Verify quote belongs to business if provided
if (! empty($validated['quote_id'])) {
@@ -112,22 +195,33 @@ class InvoiceController extends Controller
->firstOrFail();
}
// SECURITY: Verify deal belongs to business if provided
if (! empty($validated['deal_id'])) {
CrmDeal::where('id', $validated['deal_id'])
->where('business_id', $business->id)
->firstOrFail();
}
$invoiceNumber = CrmInvoice::generateInvoiceNumber($business->id);
$invoice = CrmInvoice::create([
'business_id' => $business->id,
'contact_id' => $validated['contact_id'],
'account_id' => $validated['account_id'],
'quote_id' => $validated['quote_id'],
'location_id' => $validated['location_id'] ?? null,
'quote_id' => $validated['quote_id'] ?? null,
'deal_id' => $validated['deal_id'] ?? null,
'created_by' => $request->user()->id,
'invoice_number' => $invoiceNumber,
'title' => $validated['title'],
'status' => CrmInvoice::STATUS_DRAFT,
'issue_date' => now(),
'invoice_date' => now(),
'due_date' => $validated['due_date'],
'tax_rate' => $validated['tax_rate'] ?? 0,
'discount_type' => $validated['discount_type'],
'discount_value' => $validated['discount_value'] ?? 0,
'notes' => $validated['notes'],
'payment_terms' => $validated['payment_terms'],
'terms' => $validated['payment_terms'],
'currency' => 'USD',
]);
@@ -135,19 +229,150 @@ class InvoiceController extends Controller
foreach ($validated['items'] as $index => $item) {
CrmInvoiceItem::create([
'invoice_id' => $invoice->id,
'product_id' => $item['product_id'] ?? null,
'description' => $item['description'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'sort_order' => $index,
'discount_percent' => $item['discount_percent'] ?? 0,
'position' => $index,
]);
}
$invoice->calculateTotals();
return redirect()->route('seller.crm.invoices.show', $invoice)
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created successfully.');
}
/**
* Edit invoice form
*/
public function edit(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
if (! $invoice->canBeEdited()) {
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->withErrors(['error' => 'This invoice cannot be edited.']);
}
$invoice->load(['contact', 'account', 'items.product']);
// Get all approved buyer businesses
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->with('locations:id,business_id,name,is_primary')
->orderBy('name')
->select(['id', 'name', 'slug'])
->get();
// Get open deals for linking
$deals = CrmDeal::forBusiness($business->id)->open()->get();
// No quotes dropdown in edit - already linked
$quotes = collect();
$selectedAccount = $invoice->account;
$selectedLocation = $invoice->location ?? null;
$selectedContact = $invoice->contact;
$locationContacts = collect();
return view('seller.crm.invoices.edit', compact(
'invoice',
'accounts',
'deals',
'quotes',
'business',
'selectedAccount',
'selectedLocation',
'selectedContact',
'locationContacts'
));
}
/**
* Update invoice
*/
public function update(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
if (! $invoice->canBeEdited()) {
return back()->withErrors(['error' => 'This invoice cannot be edited.']);
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
'account_id' => 'nullable|exists:businesses,id',
'location_id' => 'nullable|exists:locations,id',
'deal_id' => 'nullable|exists:crm_deals,id',
'due_date' => 'required|date',
'tax_rate' => 'nullable|numeric|min:0|max:100',
'discount_type' => 'nullable|in:fixed,percentage',
'discount_value' => 'nullable|numeric|min:0',
'notes' => 'nullable|string|max:2000',
'payment_terms' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.product_id' => 'nullable|exists:products,id',
'items.*.description' => 'required|string|max:500',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
]);
// SECURITY: Verify contact belongs to the account if account is provided
$contact = \App\Models\Contact::findOrFail($validated['contact_id']);
if (! empty($validated['account_id']) && $contact->business_id !== (int) $validated['account_id']) {
return back()->withErrors(['contact_id' => 'Contact must belong to the selected account.']);
}
// SECURITY: Verify deal belongs to business if provided
if (! empty($validated['deal_id'])) {
CrmDeal::where('id', $validated['deal_id'])
->where('business_id', $business->id)
->firstOrFail();
}
$invoice->update([
'contact_id' => $validated['contact_id'],
'account_id' => $validated['account_id'],
'location_id' => $validated['location_id'] ?? null,
'deal_id' => $validated['deal_id'] ?? null,
'title' => $validated['title'],
'due_date' => $validated['due_date'],
'tax_rate' => $validated['tax_rate'] ?? 0,
'discount_type' => $validated['discount_type'],
'discount_value' => $validated['discount_value'] ?? 0,
'notes' => $validated['notes'],
'terms' => $validated['payment_terms'],
]);
// Delete existing items and recreate
$invoice->items()->delete();
foreach ($validated['items'] as $index => $item) {
CrmInvoiceItem::create([
'invoice_id' => $invoice->id,
'product_id' => $item['product_id'] ?? null,
'description' => $item['description'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'discount_percent' => $item['discount_percent'] ?? 0,
'position' => $index,
]);
}
$invoice->calculateTotals();
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice updated successfully.');
}
/**
* Send invoice to contact
*/
@@ -161,9 +386,31 @@ class InvoiceController extends Controller
return back()->withErrors(['error' => 'This invoice cannot be sent.']);
}
$invoice->send($request->user());
$validated = $request->validate([
'to' => 'required|email',
'cc' => 'nullable|string',
'message' => 'nullable|string|max:2000',
]);
// TODO: Send email notification to contact
// Generate PDF
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
$pdf = Pdf::loadView('pdfs.crm-invoice', [
'invoice' => $invoice,
'business' => $business,
]);
// Send email
$ccEmails = [];
if (! empty($validated['cc'])) {
$ccEmails = array_filter(array_map('trim', explode(',', $validated['cc'])));
}
Mail::to($validated['to'])
->cc($ccEmails)
->send(new InvoiceMail($invoice, $business, $validated['message'] ?? null, $pdf->output()));
// Update status
$invoice->send($request->user());
return back()->with('success', 'Invoice sent successfully.');
}
@@ -240,8 +487,33 @@ class InvoiceController extends Controller
abort(404);
}
// TODO: Generate PDF
return back()->with('info', 'PDF generation coming soon.');
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
$pdf = Pdf::loadView('pdfs.crm-invoice', [
'invoice' => $invoice,
'business' => $business,
]);
return $pdf->download($invoice->invoice_number.'.pdf');
}
/**
* View invoice PDF inline
*/
public function pdf(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
$pdf = Pdf::loadView('pdfs.crm-invoice', [
'invoice' => $invoice,
'business' => $business,
]);
return $pdf->stream($invoice->invoice_number.'.pdf');
}
/**
@@ -259,7 +531,7 @@ class InvoiceController extends Controller
$invoice->delete();
return redirect()->route('seller.crm.invoices.index')
return redirect()->route('seller.business.crm.invoices.index', $business)
->with('success', 'Invoice deleted.');
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmLead;
use Illuminate\Http\Request;
class LeadController extends Controller
{
/**
* Display leads listing
*/
public function index(Request $request, Business $business)
{
$query = CrmLead::forSeller($business)
->with('assignee')
->notConverted();
// Search filter
if ($request->filled('q')) {
$search = $request->q;
$query->where(function ($q) use ($search) {
$q->where('company_name', 'ILIKE', "%{$search}%")
->orWhere('contact_name', 'ILIKE', "%{$search}%")
->orWhere('contact_email', 'ILIKE', "%{$search}%");
});
}
// Status filter
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
}
$leads = $query->latest()->paginate(25);
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $leads->map(fn ($l) => [
'hashid' => $l->hashid,
'name' => $l->company_name,
'contact' => $l->contact_name,
'email' => $l->contact_email,
'status' => $l->status,
])->values()->toArray(),
]);
}
return view('seller.crm.leads.index', compact('business', 'leads'));
}
/**
* Show create lead form
*/
public function create(Request $request, Business $business)
{
return view('seller.crm.leads.create', compact('business'));
}
/**
* Store a new lead
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'company_name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'contact_name' => 'required|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'contact_title' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:50',
'address' => 'nullable|string|max:255',
'zip_code' => 'nullable|string|max:20',
'source' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::SOURCES)),
'notes' => 'nullable|string|max:5000',
]);
$validated['seller_business_id'] = $business->id;
$validated['status'] = 'new';
$lead = CrmLead::create($validated);
return redirect()
->route('seller.business.crm.leads.show', [$business->slug, $lead->hashid])
->with('success', 'Lead created successfully.');
}
/**
* Show lead details
*/
public function show(Request $request, Business $business, CrmLead $lead)
{
// Ensure lead belongs to this seller
if ($lead->seller_business_id !== $business->id) {
abort(404);
}
$lead->load('assignee');
return view('seller.crm.leads.show', compact('business', 'lead'));
}
/**
* Show edit lead form
*/
public function edit(Request $request, Business $business, CrmLead $lead)
{
// Ensure lead belongs to this seller
if ($lead->seller_business_id !== $business->id) {
abort(404);
}
return view('seller.crm.leads.edit', compact('business', 'lead'));
}
/**
* Update a lead
*/
public function update(Request $request, Business $business, CrmLead $lead)
{
// Ensure lead belongs to this seller
if ($lead->seller_business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'company_name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'contact_name' => 'required|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'contact_title' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:50',
'address' => 'nullable|string|max:255',
'zip_code' => 'nullable|string|max:20',
'source' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::SOURCES)),
'status' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::STATUSES)),
'notes' => 'nullable|string|max:5000',
]);
$lead->update($validated);
return redirect()
->route('seller.business.crm.leads.show', [$business->slug, $lead->hashid])
->with('success', 'Lead updated successfully.');
}
/**
* Delete a lead
*/
public function destroy(Request $request, Business $business, CrmLead $lead)
{
// Ensure lead belongs to this seller
if ($lead->seller_business_id !== $business->id) {
abort(404);
}
$lead->delete();
return redirect()
->route('seller.business.crm.leads.index', $business->slug)
->with('success', 'Lead deleted.');
}
}

View File

@@ -28,15 +28,15 @@ class MeetingLinkController extends Controller
->orderByDesc('created_at')
->get();
return view('seller.crm.meetings.links.index', compact('meetingLinks'));
return view('seller.crm.meetings.links.index', compact('meetingLinks', 'business'));
}
/**
* Create meeting link form
*/
public function create()
public function create(Request $request, Business $business)
{
return view('seller.crm.meetings.links.create');
return view('seller.crm.meetings.links.create', compact('business'));
}
/**
@@ -81,7 +81,7 @@ class MeetingLinkController extends Controller
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
->with('success', 'Meeting link created. Share the booking URL with contacts.');
}
@@ -96,7 +96,7 @@ class MeetingLinkController extends Controller
$meetingLink->load(['bookings' => fn ($q) => $q->upcoming()->with('contact')]);
return view('seller.crm.meetings.links.show', compact('meetingLink'));
return view('seller.crm.meetings.links.show', compact('meetingLink', 'business'));
}
/**
@@ -108,7 +108,7 @@ class MeetingLinkController extends Controller
abort(404);
}
return view('seller.crm.meetings.links.edit', compact('meetingLink'));
return view('seller.crm.meetings.links.edit', compact('meetingLink', 'business'));
}
/**
@@ -136,7 +136,7 @@ class MeetingLinkController extends Controller
$meetingLink->update($validated);
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
->with('success', 'Meeting link updated.');
}
@@ -165,7 +165,7 @@ class MeetingLinkController extends Controller
$meetingLink->delete();
return redirect()->route('seller.crm.meetings.links.index')
return redirect()->route('seller.business.crm.meetings.links.index', $business)
->with('success', 'Meeting link deleted.');
}
@@ -252,7 +252,7 @@ class MeetingLinkController extends Controller
->orderBy('start_time')
->paginate(25);
return view('seller.crm.meetings.bookings.index', compact('bookings'));
return view('seller.crm.meetings.bookings.index', compact('bookings', 'business'));
}
/**

View File

@@ -3,14 +3,21 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Mail\QuoteMail;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmQuoteItem;
use App\Models\Product;
use App\Models\Location;
use App\Models\Order;
use App\Models\OrderItem;
use App\Services\Accounting\ArService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
class QuoteController extends Controller
{
@@ -30,14 +37,27 @@ class QuoteController extends Controller
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('quote_number', 'like', "%{$request->search}%")
->orWhere('title', 'like', "%{$request->search}%");
$q->where('quote_number', 'ilike', "%{$request->search}%")
->orWhere('title', 'ilike', "%{$request->search}%");
});
}
$quotes = $query->orderByDesc('created_at')->paginate(25);
return view('seller.crm.quotes.index', compact('quotes'));
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $quotes->map(fn ($q) => [
'id' => $q->id,
'name' => $q->quote_number.' - '.($q->title ?? 'Untitled'),
'contact' => $q->contact?->name ?? '-',
'status' => $q->status,
'total' => '$'.number_format($q->total, 2),
])->values()->toArray(),
]);
}
return view('seller.crm.quotes.index', compact('quotes', 'business'));
}
/**
@@ -45,21 +65,96 @@ class QuoteController extends Controller
*/
public function create(Request $request, Business $business)
{
$contacts = Contact::where('business_id', $business->id)->get();
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})->get();
$deals = CrmDeal::forBusiness($business->id)->open()->get();
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->where('is_active', true)
// Get all approved buyer businesses as potential customers
// Contacts are loaded dynamically via /search/contacts?customer_id={account_id}
// Include locations for delivery address selection
// Note: We don't filter by whereHas('contacts') because newly created customers
// may not have contacts yet - contacts can be added after selecting the account
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->with('locations:id,business_id,name,is_primary')
->orderBy('name')
->select(['id', 'name', 'slug'])
->get();
$deals = CrmDeal::forBusiness($business->id)->open()->get();
// Products are loaded via AJAX search (/search/products) for better performance
// Pre-fill from deal if provided
$deal = $request->filled('deal_id')
? CrmDeal::forBusiness($business->id)->find($request->deal_id)
: null;
return view('seller.crm.quotes.create', compact('contacts', 'accounts', 'deals', 'products', 'deal'));
// Pre-fill from URL parameters (coming from customer dashboard)
$selectedAccount = null;
$selectedLocation = null;
$selectedContact = null;
$locationContacts = collect();
// Handle clear actions
if ($request->has('clearAccount')) {
// Redirect without any prefills
return redirect()->route('seller.business.crm.quotes.create', $business);
}
if ($request->has('clearLocation')) {
// Keep account but clear location
return redirect()->route('seller.business.crm.quotes.create', [$business, 'account_id' => $request->account_id]);
}
if ($request->has('clearContact')) {
// Keep account and location but clear contact
$params = ['account_id' => $request->account_id];
if ($request->location_id) {
$params['location_id'] = $request->location_id;
}
return redirect()->route('seller.business.crm.quotes.create', array_merge([$business], $params));
}
// Pre-fill account
if ($request->filled('account_id')) {
$selectedAccount = $accounts->firstWhere('id', $request->account_id);
}
// Pre-fill location (must belong to selected account)
if ($selectedAccount && $request->filled('location_id')) {
$selectedLocation = $selectedAccount->locations->firstWhere('id', $request->location_id);
}
// If location selected, get contacts assigned to that location
if ($selectedLocation) {
$locationContacts = $selectedLocation->contacts()
->with('pivot')
->get()
->map(fn ($c) => [
'value' => $c->id,
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
'is_primary' => $c->pivot->is_primary ?? false,
'role' => $c->pivot->role ?? 'buyer',
]);
// Try to find primary buyer for this location
$primaryBuyer = $locationContacts->firstWhere('is_primary', true)
?? $locationContacts->firstWhere('role', 'buyer');
if ($primaryBuyer && ! $request->filled('contact_id')) {
$selectedContact = Contact::find($primaryBuyer['value']);
}
}
// Pre-fill contact if explicitly provided
if ($request->filled('contact_id')) {
$selectedContact = Contact::find($request->contact_id);
}
return view('seller.crm.quotes.create', compact(
'accounts',
'deals',
'deal',
'business',
'selectedAccount',
'selectedLocation',
'selectedContact',
'locationContacts'
));
}
/**
@@ -78,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',
@@ -87,10 +181,13 @@ class QuoteController extends Controller
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
]);
// SECURITY: Verify contact belongs to business
Contact::where('id', $validated['contact_id'])
->where('business_id', $business->id)
->firstOrFail();
// SECURITY: Verify contact belongs to the selected account (customer business)
// Contacts are associated with buyer businesses, not the seller
if (! empty($validated['account_id'])) {
Contact::where('id', $validated['contact_id'])
->where('business_id', $validated['account_id'])
->firstOrFail();
}
// SECURITY: Verify deal belongs to business if provided
if (! empty($validated['deal_id'])) {
@@ -111,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',
]);
@@ -136,7 +233,7 @@ class QuoteController extends Controller
$quote->calculateTotals();
return redirect()->route('seller.crm.quotes.show', $quote)
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
->with('success', 'Quote created successfully.');
}
@@ -151,7 +248,7 @@ class QuoteController extends Controller
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product', 'invoice', 'files']);
return view('seller.crm.quotes.show', compact('quote'));
return view('seller.crm.quotes.show', compact('quote', 'business'));
}
/**
@@ -167,16 +264,9 @@ class QuoteController extends Controller
return back()->withErrors(['error' => 'This quote cannot be edited.']);
}
$quote->load('items');
$quote->load(['items.product', 'contact', 'account', 'deal']);
$contacts = Contact::where('business_id', $business->id)->get();
$accounts = Business::whereHas('ordersAsCustomer')->get();
$deals = CrmDeal::forBusiness($business->id)->open()->get();
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->where('is_active', true)
->get();
return view('seller.crm.quotes.edit', compact('quote', 'contacts', 'accounts', 'deals', 'products'));
return view('seller.crm.quotes.edit', compact('quote', 'business'));
}
/**
@@ -235,12 +325,12 @@ class QuoteController extends Controller
$quote->calculateTotals();
return redirect()->route('seller.crm.quotes.show', $quote)
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
->with('success', 'Quote updated successfully.');
}
/**
* Send quote to contact
* Send quote via email
*/
public function send(Request $request, Business $business, CrmQuote $quote)
{
@@ -248,17 +338,252 @@ class QuoteController extends Controller
abort(404);
}
if (! $quote->canBeSent()) {
return back()->withErrors(['error' => 'This quote cannot be sent.']);
$validated = $request->validate([
'to' => 'required|email',
'cc' => 'nullable|string',
'message' => 'nullable|string|max:2000',
'attach_pdf' => 'boolean',
]);
// Generate PDF if needed
$pdfPath = null;
if ($validated['attach_pdf'] ?? true) {
$pdfPath = $this->generateQuotePdf($quote, $business);
}
$quote->send($request->user());
// Send email
$ccEmails = [];
if (! empty($validated['cc'])) {
$ccEmails = array_map('trim', explode(',', $validated['cc']));
}
// TODO: Send email notification to contact
Mail::to($validated['to'])
->cc($ccEmails)
->send(new QuoteMail($quote, $business, $validated['message'] ?? null, $pdfPath));
// Update quote status if draft
if ($quote->status === CrmQuote::STATUS_DRAFT) {
$quote->send($request->user());
}
// Log activity
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.emailed',
description: "Quote {$quote->quote_number} emailed to {$validated['to']}",
causer: $request->user(),
contactId: $quote->contact_id,
);
return back()->with('success', 'Quote sent successfully.');
}
/**
* Update quote status (accept/decline/expire)
*/
public function updateStatus(Request $request, Business $business, CrmQuote $quote)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'status' => 'required|in:accepted,rejected,expired',
'note' => 'nullable|string|max:1000',
]);
$oldStatus = $quote->status;
if ($validated['status'] === 'accepted') {
$quote->accept();
} elseif ($validated['status'] === 'rejected') {
$quote->reject($validated['note'] ?? 'Declined by seller');
} else {
$quote->update([
'status' => CrmQuote::STATUS_EXPIRED,
]);
}
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.status_changed',
description: "Quote {$quote->quote_number} status changed from {$oldStatus} to {$validated['status']}",
causer: $request->user(),
contactId: $quote->contact_id,
);
return back()->with('success', 'Quote status updated.');
}
/**
* Convert quote to order
*/
public function convertToOrder(Request $request, Business $business, CrmQuote $quote)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
if ($quote->order_id) {
return back()->withErrors(['error' => 'This quote already has an order.']);
}
$validated = $request->validate([
'also_create_invoice' => 'boolean',
]);
// Create order from quote
$orderNumber = 'ORD-'.strtoupper(uniqid());
$order = Order::create([
'order_number' => $orderNumber,
'business_id' => $quote->account_id, // Buyer business
'seller_business_id' => $business->id,
'contact_id' => $quote->contact_id,
'user_id' => $request->user()->id,
'subtotal' => $quote->subtotal,
'surcharge' => 0,
'tax' => $quote->tax_amount,
'total' => $quote->total,
'status' => 'new',
'created_by' => 'seller',
'payment_terms' => 'net_30',
'notes' => $quote->notes,
]);
// Copy line items
foreach ($quote->items as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
'line_total' => $item->line_total,
'product_name' => $item->product?->name ?? $item->description,
'product_sku' => $item->product?->sku ?? '',
'brand_name' => $item->product?->brand?->name ?? '',
]);
}
// Link quote to order and update status
$quote->update([
'order_id' => $order->id,
'status' => CrmQuote::STATUS_ACCEPTED,
'accepted_at' => now(),
]);
// Log activity
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.converted_to_order',
description: "Quote {$quote->quote_number} converted to Order {$orderNumber}",
causer: $request->user(),
contactId: $quote->contact_id,
);
// Optionally create invoice
if ($validated['also_create_invoice'] ?? false) {
$invoice = $quote->convertToInvoice();
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Order and invoice created from quote.');
}
return redirect()->route('seller.business.orders.show', [$business, $order])
->with('success', 'Order created from quote.');
}
/**
* Generate invoice from quote (or its order)
*/
public function generateInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
if ($quote->invoice) {
return back()->withErrors(['error' => 'This quote already has an invoice.']);
}
// Credit check if there's a buyer account
if ($quote->account_id) {
$buyerBusiness = Business::find($quote->account_id);
if ($buyerBusiness) {
$creditCheck = $arService->checkCreditForAccount(
$business,
$buyerBusiness,
(float) $quote->total
);
if (! $creditCheck['can_extend']) {
return back()->withErrors([
'error' => 'Cannot create invoice: '.$creditCheck['reason'],
]);
}
}
}
$invoice = $quote->convertToInvoice();
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.invoice_generated',
description: "Invoice {$invoice->invoice_number} generated from Quote {$quote->quote_number}",
causer: $request->user(),
contactId: $quote->contact_id,
);
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created from quote.');
}
/**
* Generate and store quote PDF
*/
protected function generateQuotePdf(CrmQuote $quote, Business $business): ?string
{
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
$pdf = Pdf::loadView('pdfs.crm-quote', [
'quote' => $quote,
'business' => $business,
'sellerBusiness' => $business,
]);
$filename = "quotes/{$quote->quote_number}.pdf";
Storage::put($filename, $pdf->output());
$quote->update(['pdf_path' => $filename]);
return $filename;
}
/**
* View quote PDF
*/
public function pdf(Request $request, Business $business, CrmQuote $quote)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
$pdf = Pdf::loadView('pdfs.crm-quote', [
'quote' => $quote,
'business' => $business,
'sellerBusiness' => $business,
]);
return $pdf->stream("{$quote->quote_number}.pdf");
}
/**
* Convert quote to invoice
*/
@@ -302,7 +627,7 @@ class QuoteController extends Controller
$invoice = $quote->convertToInvoice();
return redirect()->route('seller.crm.invoices.show', $invoice)
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created from quote.');
}
@@ -330,7 +655,7 @@ class QuoteController extends Controller
$quote->delete();
return redirect()->route('seller.crm.quotes.index')
return redirect()->route('seller.business.crm.quotes.index', $business)
->with('success', 'Quote deleted.');
}
}

View File

@@ -40,22 +40,43 @@ class TaskController extends Controller
$tasksQuery->where('type', $request->type);
}
// Search filter
if ($request->filled('q')) {
$search = $request->q;
$tasksQuery->where(function ($q) use ($search) {
$q->where('title', 'ILIKE', "%{$search}%")
->orWhere('details', 'ILIKE', "%{$search}%");
});
}
$tasks = $tasksQuery->paginate(25);
// Get stats
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $tasks->map(fn ($t) => [
'id' => $t->id,
'name' => $t->title,
'type' => $t->type,
'assignee' => $t->assignee?->name ?? 'Unassigned',
'due_at' => $t->due_at?->format('M j, Y'),
])->values()->toArray(),
]);
}
// Get stats with single efficient query
$statsQuery = CrmTask::where('seller_business_id', $business->id)
->selectRaw('
SUM(CASE WHEN assigned_to = ? AND completed_at IS NULL THEN 1 ELSE 0 END) as my_tasks,
SUM(CASE WHEN completed_at IS NULL AND due_at < NOW() THEN 1 ELSE 0 END) as overdue,
SUM(CASE WHEN completed_at IS NULL AND DATE(due_at) = CURRENT_DATE THEN 1 ELSE 0 END) as due_today
', [$user->id])
->first();
$stats = [
'my_tasks' => CrmTask::where('seller_business_id', $business->id)
->where('assigned_to', $user->id)
->whereNull('completed_at')
->count(),
'overdue' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '<', now())
->count(),
'due_today' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->whereDate('due_at', today())
->count(),
'my_tasks' => $statsQuery->my_tasks ?? 0,
'overdue' => $statsQuery->overdue ?? 0,
'due_today' => $statsQuery->due_today ?? 0,
];
$counts = $stats; // View expects $counts
@@ -63,7 +84,12 @@ class TaskController extends Controller
// Get team members for assignment filter
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers'));
// Get buyer businesses (accounts) for filtering
$buyerBusinesses = Business::where('type', 'buyer')
->orderBy('name')
->get(['id', 'name']);
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers', 'buyerBusinesses'));
}
/**
@@ -71,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
{
@@ -22,13 +29,113 @@ class ThreadController extends Controller
protected CrmAiService $aiService
) {}
/**
* Show compose form for new thread
*/
public function create(Request $request, Business $business)
{
// Get customer business IDs (businesses that have ordered from this seller)
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
// Get contacts from customer businesses (accounts)
$contacts = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
->with('business:id,name')
->orderBy('first_name')
->limit(200)
->get();
// Get available channels
$channels = $this->channelService->getAvailableChannels($business->id);
// Pre-select contact if provided
$selectedContact = null;
if ($request->filled('contact_id')) {
$selectedContact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
->find($request->contact_id);
}
return view('seller.crm.threads.create', compact('business', 'contacts', 'channels', 'selectedContact'));
}
/**
* Store a new thread and send initial message
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'contact_id' => 'required|exists:contacts,id',
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
'subject' => 'nullable|string|max:255',
'body' => 'required|string|max:10000',
'attachments.*' => 'nullable|file|max:10240',
]);
// Get customer business IDs (businesses that have ordered from this seller)
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
// SECURITY: Verify contact belongs to a customer business
$contact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
->findOrFail($validated['contact_id']);
// Determine recipient address
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
? $contact->email
: $contact->phone;
if (! $to) {
return back()->withInput()->withErrors([
'channel_type' => 'Contact does not have the required contact info for this channel.',
]);
}
// Create thread first
$thread = CrmThread::create([
'business_id' => $business->id,
'contact_id' => $contact->id,
'account_id' => $contact->account_id,
'subject' => $validated['subject'],
'status' => 'open',
'priority' => 'normal',
'last_channel_type' => $validated['channel_type'],
'assigned_to' => $request->user()->id,
]);
// Send the message
$success = $this->channelService->sendMessage(
businessId: $business->id,
channelType: $validated['channel_type'],
to: $to,
body: $validated['body'],
subject: $validated['subject'] ?? null,
threadId: $thread->id,
contactId: $contact->id,
userId: $request->user()->id,
attachments: $request->file('attachments', [])
);
if (! $success) {
// Delete the thread if message failed
$thread->delete();
return back()->withInput()->withErrors(['body' => 'Failed to send message.']);
}
return redirect()
->route('seller.business.crm.threads.show', [$business, $thread])
->with('success', 'Conversation started successfully.');
}
/**
* Display unified inbox
*/
public function index(Request $request, Business $business)
{
$query = CrmThread::forBusiness($business->id)
->with(['contact', 'assignee', 'messages' => fn ($q) => $q->latest()->limit(1)])
->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)])
->withCount('messages');
// Filters
@@ -52,11 +159,21 @@ class ThreadController extends Controller
$query->withPriority($request->priority);
}
// Department filter
if ($request->filled('department')) {
$query->forDepartment($request->department);
}
// Brand filter
if ($request->filled('brand_id')) {
$query->forBrand($request->brand_id);
}
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('subject', 'like', "%{$request->search}%")
->orWhere('last_message_preview', 'like', "%{$request->search}%")
->orWhereHas('contact', fn ($c) => $c->where('name', 'like', "%{$request->search}%"));
$q->where('subject', 'ilike', "%{$request->search}%")
->orWhere('last_message_preview', 'ilike', "%{$request->search}%")
->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%"));
});
}
@@ -70,7 +187,16 @@ class ThreadController extends Controller
// Get available channels
$channels = $this->channelService->getAvailableChannels($business->id);
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels'));
// Get brands for filter dropdown
$brands = \App\Models\Brand::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
// Get departments for filter dropdown
$departments = CrmChannel::DEPARTMENTS;
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels', 'brands', 'departments'));
}
/**
@@ -88,6 +214,8 @@ class ThreadController extends Controller
'contact',
'account',
'assignee',
'brand',
'channel',
'messages.attachments',
'messages.user',
'deals',
@@ -168,6 +296,12 @@ class ThreadController extends Controller
return back()->withErrors(['body' => 'Failed to send message.']);
}
// Auto-assign thread to sender if unassigned
if ($thread->assigned_to === null) {
$thread->assigned_to = $request->user()->id;
$thread->save();
}
// Handle SLA
$this->slaService->handleOutboundMessage($thread);
@@ -319,4 +453,363 @@ class ThreadController extends Controller
]),
]);
}
// ========================================
// API Endpoints for Real-Time Inbox
// ========================================
/**
* API: Get threads list for real-time updates
*/
public function apiIndex(Request $request, Business $business): JsonResponse
{
$user = $request->user();
$query = CrmThread::forBusiness($business->id)
->with(['contact:id,first_name,last_name,email,phone', 'assignee:id,name', 'channel:id,type,name', 'account:id,name'])
->withCount('messages');
// Apply "my accounts" filter for sales reps
if ($request->boolean('my_accounts')) {
$query->forSalesRep($business->id, $user->id);
}
// Status filter
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
}
// Channel filter
if ($request->filled('channel') && $request->channel !== 'all') {
$query->where('last_channel_type', $request->channel);
}
// Assigned filter
if ($request->filled('assigned')) {
if ($request->assigned === 'me') {
$query->where('assigned_to', $user->id);
} elseif ($request->assigned === 'unassigned') {
$query->whereNull('assigned_to');
} elseif (is_numeric($request->assigned)) {
$query->where('assigned_to', $request->assigned);
}
}
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('subject', 'ilike', "%{$search}%")
->orWhere('last_message_preview', 'ilike', "%{$search}%")
->orWhereHas('contact', fn ($c) => $c->whereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", ["%{$search}%"]))
->orWhereHas('account', fn ($a) => $a->where('name', 'ilike', "%{$search}%"));
});
}
$threads = $query->orderByDesc('last_message_at')
->limit($request->input('limit', 50))
->get();
return response()->json([
'threads' => $threads->map(fn ($t) => [
'id' => $t->id,
'subject' => $t->subject,
'status' => $t->status,
'priority' => $t->priority,
'is_read' => $t->is_read,
'last_message_at' => $t->last_message_at?->toIso8601String(),
'last_message_preview' => $t->last_message_preview,
'last_message_direction' => $t->last_message_direction,
'last_channel_type' => $t->last_channel_type,
'contact' => $t->contact ? [
'id' => $t->contact->id,
'name' => $t->contact->getFullName(),
'email' => $t->contact->email,
'phone' => $t->contact->phone,
] : null,
'account' => $t->account ? [
'id' => $t->account->id,
'name' => $t->account->name,
] : null,
'assignee' => $t->assignee ? [
'id' => $t->assignee->id,
'name' => $t->assignee->name,
] : null,
'messages_count' => $t->messages_count,
]),
]);
}
/**
* API: Get messages for a thread
*/
public function apiMessages(Request $request, Business $business, CrmThread $thread): JsonResponse
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$query = $thread->messages()
->with(['user:id,name', 'attachments'])
->orderBy('created_at', 'asc');
// Pagination for infinite scroll
if ($request->filled('before_id')) {
$query->where('id', '<', $request->before_id);
}
$messages = $query->limit($request->input('limit', 50))->get();
// Mark thread as read
if ($messages->isNotEmpty()) {
$thread->markAsRead($request->user());
}
return response()->json([
'messages' => $messages->map(fn ($m) => [
'id' => $m->id,
'body' => $m->body,
'body_html' => $m->body_html,
'direction' => $m->direction,
'channel_type' => $m->channel_type,
'sender_id' => $m->user_id,
'sender_name' => $m->user?->name ?? ($m->direction === 'inbound' ? $thread->contact?->getFullName() : 'System'),
'status' => $m->status,
'created_at' => $m->created_at->toIso8601String(),
'attachments' => $m->attachments->map(fn ($a) => [
'id' => $a->id,
'filename' => $a->original_filename ?? $a->filename,
'mime_type' => $a->mime_type,
'size' => $a->size,
'url' => Storage::disk($a->disk ?? 'minio')->url($a->path),
]),
]),
'has_more' => $messages->count() === $request->input('limit', 50),
'thread' => [
'id' => $thread->id,
'subject' => $thread->subject,
'status' => $thread->status,
'contact' => $thread->contact ? [
'id' => $thread->contact->id,
'name' => $thread->contact->getFullName(),
'email' => $thread->contact->email,
'phone' => $thread->contact->phone,
] : null,
],
]);
}
/**
* API: Send typing indicator
*/
public function typing(Request $request, Business $business, CrmThread $thread): JsonResponse
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$validated = $request->validate([
'is_typing' => 'required|boolean',
]);
broadcast(new CrmTypingIndicator(
threadId: $thread->id,
userId: $request->user()->id,
userName: $request->user()->name,
isTyping: $validated['is_typing']
))->toOthers();
// Update active view type
CrmActiveView::startViewing(
$thread,
$request->user(),
$validated['is_typing'] ? CrmActiveView::VIEW_TYPE_TYPING : CrmActiveView::VIEW_TYPE_VIEWING
);
return response()->json(['success' => true]);
}
/**
* API: Get quick replies
*/
public function quickReplies(Request $request, Business $business): JsonResponse
{
$quickReplies = ChatQuickReply::where('business_id', $business->id)
->where('is_active', true)
->orderByDesc('usage_count')
->orderBy('sort_order')
->get()
->groupBy('category');
return response()->json([
'quick_replies' => $quickReplies,
]);
}
/**
* API: Use a quick reply (increment usage count)
*/
public function useQuickReply(Request $request, Business $business, ChatQuickReply $quickReply): JsonResponse
{
if ($quickReply->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// Increment usage count
$quickReply->increment('usage_count');
// Process template variables
$message = $quickReply->message;
if ($request->filled('contact_id')) {
$contact = Contact::find($request->contact_id);
if ($contact) {
$message = str_replace(
['{{name}}', '{{first_name}}', '{{last_name}}', '{{company}}'],
[$contact->getFullName(), $contact->first_name, $contact->last_name, $contact->business?->name ?? ''],
$message
);
}
}
return response()->json([
'message' => $message,
'label' => $quickReply->label,
]);
}
/**
* API: Get contact details with email engagement
*/
public function apiContact(Request $request, Business $business, CrmThread $thread): JsonResponse
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$contact = $thread->contact;
if (! $contact) {
return response()->json(['contact' => null]);
}
// Get recent email engagement
$emailEngagement = [];
if (class_exists(\App\Models\Analytics\EmailInteraction::class)) {
$emailEngagement = \App\Models\Analytics\EmailInteraction::where(function ($q) use ($contact) {
$q->where('recipient_email', $contact->email);
if ($contact->user_id) {
$q->orWhere('recipient_user_id', $contact->user_id);
}
})
->whereNotNull('first_opened_at')
->with('emailCampaign:id,subject')
->orderByDesc('first_opened_at')
->limit(10)
->get()
->map(fn ($i) => [
'id' => $i->id,
'campaign_subject' => $i->emailCampaign?->subject ?? 'Unknown Campaign',
'opened_at' => $i->first_opened_at?->toIso8601String(),
'open_count' => $i->open_count,
'clicked_at' => $i->first_clicked_at?->toIso8601String(),
'click_count' => $i->click_count,
]);
}
// Get recent orders from this contact's account
$recentOrders = [];
if ($thread->account_id) {
$recentOrders = \App\Models\Order::where('business_id', $thread->account_id)
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->orderByDesc('created_at')
->limit(5)
->get()
->map(fn ($o) => [
'id' => $o->id,
'hashid' => $o->hashid,
'total' => $o->total,
'status' => $o->status,
'created_at' => $o->created_at->toIso8601String(),
]);
}
return response()->json([
'contact' => [
'id' => $contact->id,
'name' => $contact->getFullName(),
'email' => $contact->email,
'phone' => $contact->phone,
'title' => $contact->title,
'contact_type' => $contact->contact_type,
],
'account' => $thread->account ? [
'id' => $thread->account->id,
'name' => $thread->account->name,
'address' => $thread->account->full_address ?? null,
] : null,
'email_engagement' => $emailEngagement,
'recent_orders' => $recentOrders,
]);
}
/**
* Unified inbox view (Chatwoot-style)
*/
public function unified(Request $request, Business $business)
{
$user = $request->user();
// Get initial threads
$query = CrmThread::forBusiness($business->id)
->with(['contact:id,first_name,last_name,email,phone', 'assignee:id,name', 'account:id,name'])
->withCount('messages')
->orderByDesc('last_message_at')
->limit(50);
$threads = $query->get();
// Get team members with their status
$teamMemberStatuses = AgentStatus::where('business_id', $business->id)
->where('last_seen_at', '>=', now()->subMinutes(5))
->pluck('status', 'user_id');
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->select('id', 'first_name', 'last_name')
->get()
->map(fn ($member) => [
'id' => $member->id,
'name' => trim($member->first_name.' '.$member->last_name),
'status' => $teamMemberStatuses[$member->id] ?? 'offline',
]);
// Get agent status
$agentStatus = AgentStatus::where('business_id', $business->id)
->where('user_id', $user->id)
->first();
// Get quick replies
$quickReplies = ChatQuickReply::where('business_id', $business->id)
->where('is_active', true)
->orderByDesc('usage_count')
->get()
->groupBy('category');
// Get channels
$channels = $this->channelService->getAvailableChannels($business->id);
// Check if user has sales rep assignments (for "My Accounts" filter)
$hasSalesRepAssignments = SalesRepAssignment::where('business_id', $business->id)
->where('user_id', $user->id)
->exists();
return view('seller.crm.inbox.unified', compact(
'business',
'threads',
'teamMembers',
'agentStatus',
'quickReplies',
'channels',
'hasSalesRepAssignments'
));
}
}

View File

@@ -22,6 +22,7 @@ class EmailSettingsController extends Controller
'business' => $business,
'settings' => $settings,
'drivers' => BusinessMailSettings::DRIVERS,
'providers' => BusinessMailSettings::PROVIDERS,
'encryptions' => BusinessMailSettings::ENCRYPTIONS,
'commonPorts' => BusinessMailSettings::COMMON_PORTS,
]);
@@ -34,6 +35,7 @@ class EmailSettingsController extends Controller
{
$validated = $request->validate([
'driver' => ['required', 'string', Rule::in(array_keys(BusinessMailSettings::DRIVERS))],
'provider' => ['required', 'string', Rule::in(array_keys(BusinessMailSettings::PROVIDERS))],
'host' => ['nullable', 'string', 'max:255'],
'port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'encryption' => ['nullable', 'string', Rule::in(['tls', 'ssl', ''])],
@@ -43,6 +45,9 @@ class EmailSettingsController extends Controller
'from_email' => ['nullable', 'email', 'max:255'],
'reply_to_email' => ['nullable', 'email', 'max:255'],
'is_active' => ['boolean'],
// Postal-specific config fields
'postal_server_url' => ['nullable', 'url', 'max:255'],
'postal_webhook_secret' => ['nullable', 'string', 'max:255'],
]);
// Handle empty encryption value
@@ -55,6 +60,21 @@ class EmailSettingsController extends Controller
unset($validated['password']);
}
// Build provider_config from provider-specific fields
$providerConfig = [];
if ($validated['provider'] === BusinessMailSettings::PROVIDER_POSTAL) {
if (! empty($validated['postal_server_url'])) {
$providerConfig['server_url'] = $validated['postal_server_url'];
}
if (! empty($validated['postal_webhook_secret'])) {
$providerConfig['webhook_secret'] = $validated['postal_webhook_secret'];
}
}
$validated['provider_config'] = ! empty($providerConfig) ? $providerConfig : null;
// Remove provider-specific fields from main validated array
unset($validated['postal_server_url'], $validated['postal_webhook_secret']);
$settings = BusinessMailSettings::getOrCreate($business);
$settings->update($validated);

View File

@@ -3,10 +3,13 @@
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Mail\Invoices\InvoiceSentMail;
use App\Models\Business;
use App\Models\Invoice;
use App\Models\InvoicePayment;
use App\Services\InvoiceService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
@@ -25,64 +28,7 @@ class InvoiceController extends Controller
->orderBy('name')
->get();
// Get all products from brands owned by this business with images, stock levels, and batches
$products = \App\Models\Product::forBusiness($business)
->where('is_active', true)
->with(['brand', 'images', 'availableBatches.labs'])
->select('id', 'brand_id', 'name', 'sku', 'description', 'wholesale_price', 'msrp_price',
'quantity_on_hand', 'quantity_allocated', 'type', 'image_path')
->orderBy('name')
->get()
->map(function ($product) use ($business) {
// Map batches with their COA data
$batches = $product->availableBatches->map(function ($batch) {
$latestLab = $batch->getLatestLab();
return [
'id' => $batch->id,
'batch_number' => $batch->batch_number,
'quantity_available' => $batch->quantity_available,
'production_date' => $batch->production_date?->format('M j, Y'),
'expiration_date' => $batch->expiration_date?->format('M j, Y'),
'is_expiring_soon' => $batch->isExpiringSoon(),
'lab' => $latestLab ? [
'total_thc' => $latestLab->total_thc,
'total_cbd' => $latestLab->total_cbd,
'test_date' => $latestLab->test_date->format('M j, Y'),
'lab_name' => $latestLab->lab_name,
'compliance_pass' => $latestLab->compliance_pass,
'terpene_profile' => $latestLab->terpene_profile,
] : null,
];
});
// Calculate inventory from InventoryItem model
$totalOnHand = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_on_hand');
$totalAllocated = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_allocated');
return [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
'description' => $product->description,
'brand_name' => $product->brand?->name,
'wholesale_price' => $product->wholesale_price,
'msrp_price' => $product->msrp_price,
'quantity_on_hand' => $totalOnHand,
'quantity_allocated' => $totalAllocated,
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
'type' => $product->type,
'image_url' => $product->images->first()?->path
? \Storage::url($product->images->first()->path)
: ($product->image_path ? \Storage::url($product->image_path) : null),
'batches' => $batches,
'has_batches' => $batches->count() > 0,
];
});
// Products are loaded via API search (/search/invoice-products) for better performance
// Get recently invoiced products (last 30 days, top 10 most common)
$recentProducts = \App\Models\Product::forBusiness($business)
@@ -118,7 +64,7 @@ class InvoiceController extends Controller
];
});
return view('seller.invoices.create', compact('business', 'buyers', 'products', 'recentProducts'));
return view('seller.invoices.create', compact('business', 'buyers', 'recentProducts'));
}
/**
@@ -172,24 +118,68 @@ class InvoiceController extends Controller
/**
* Display a listing of invoices for the business.
*/
public function index(Business $business)
public function index(Business $business, Request $request)
{
// Get invoices where orders contain items from brands under this business
$invoices = Invoice::with(['order.items.product.brand', 'order.contact', 'order.user', 'business'])
->whereHas('order.items.product', function ($query) use ($business) {
$query->forBusiness($business);
})
->latest()
->get();
// Get brand IDs for this business (single query, reused for filtering)
$brandIds = $business->brands()->pluck('id');
// Base query: invoices where orders contain items from this business's brands
$baseQuery = Invoice::whereHas('order.items.product', function ($query) use ($brandIds) {
$query->whereIn('brand_id', $brandIds);
});
// Calculate stats with efficient database aggregates (not in-memory iteration)
$stats = [
'total' => $invoices->count(),
'unpaid' => $invoices->where('payment_status', 'unpaid')->count(),
'partially_paid' => $invoices->where('payment_status', 'partially_paid')->count(),
'paid' => $invoices->where('payment_status', 'paid')->count(),
'overdue' => $invoices->filter(fn ($inv) => $inv->isOverdue())->count(),
'total' => (clone $baseQuery)->count(),
'unpaid' => (clone $baseQuery)->where('payment_status', 'unpaid')->count(),
'partially_paid' => (clone $baseQuery)->where('payment_status', 'partially_paid')->count(),
'paid' => (clone $baseQuery)->where('payment_status', 'paid')->count(),
'overdue' => (clone $baseQuery)->where('payment_status', '!=', 'paid')
->where('due_date', '<', now())->count(),
];
// Apply search filter - search by customer business name or invoice number
$search = $request->input('search');
if ($search) {
$baseQuery->where(function ($query) use ($search) {
$query->where('invoice_number', 'ilike', "%{$search}%")
->orWhereHas('business', function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%");
});
});
}
// Apply status filter
$status = $request->input('status');
if ($status === 'unpaid') {
$baseQuery->where('payment_status', 'unpaid');
} elseif ($status === 'paid') {
$baseQuery->where('payment_status', 'paid');
} elseif ($status === 'overdue') {
$baseQuery->where('payment_status', '!=', 'paid')
->where('due_date', '<', now());
}
// Paginate with only the relations needed for display
$invoices = (clone $baseQuery)
->with(['business:id,name,primary_contact_email,business_email', 'order:id,contact_id,user_id', 'order.contact:id,first_name,last_name,email', 'order.user:id,email'])
->latest()
->paginate(25)
->withQueryString();
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $invoices->map(fn ($i) => [
'hashid' => $i->hashid,
'name' => $i->invoice_number.' - '.$i->business->name,
'invoice_number' => $i->invoice_number,
'customer' => $i->business->name,
'status' => $i->payment_status,
])->values()->toArray(),
]);
}
return view('seller.invoices.index', compact('business', 'invoices', 'stats'));
}
@@ -199,7 +189,13 @@ class InvoiceController extends Controller
public function show(Business $business, Invoice $invoice)
{
// Verify invoice belongs to this business through order items
$invoice->load(['order.items.product.brand', 'business']);
$invoice->load([
'order.items.product.brand',
'order.contact',
'order.user',
'business',
'payments.recordedByUser',
]);
// Check if any of the order's items belong to brands owned by this business
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
@@ -289,4 +285,102 @@ class InvoiceController extends Controller
'contacts' => $contacts,
]);
}
/**
* Send invoice by email.
*/
public function send(Business $business, Invoice $invoice, Request $request, InvoiceService $invoiceService): Response
{
// Verify invoice belongs to this business through order items
$invoice->load('order.items.product.brand');
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
abort(403, 'This invoice does not belong to your business');
}
$validated = $request->validate([
'to' => ['required', 'email'],
'cc' => ['nullable', 'email'],
'message' => ['nullable', 'string', 'max:2000'],
'attach_pdf' => ['sometimes', 'boolean'],
]);
// Generate PDF if requested
$pdfContent = null;
if ($validated['attach_pdf'] ?? false) {
// Regenerate PDF if it doesn't exist
if (! $invoice->pdf_path || ! Storage::disk('local')->exists($invoice->pdf_path)) {
$invoiceService->regeneratePdf($invoice);
$invoice->refresh();
}
if ($invoice->pdf_path && Storage::disk('local')->exists($invoice->pdf_path)) {
$pdfContent = Storage::disk('local')->get($invoice->pdf_path);
}
}
// Send email
$mail = Mail::to($validated['to']);
if (! empty($validated['cc'])) {
$mail->cc($validated['cc']);
}
$mail->send(new InvoiceSentMail(
$invoice,
$validated['message'] ?? null,
$pdfContent
));
return back()->with('success', 'Invoice sent successfully to '.$validated['to']);
}
/**
* Record a payment for an invoice.
*/
public function recordPayment(Business $business, Invoice $invoice, Request $request): Response
{
// Verify invoice belongs to this business through order items
$invoice->load('order.items.product.brand');
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
abort(403, 'This invoice does not belong to your business');
}
if ($invoice->payment_status === 'paid') {
return back()->withErrors(['error' => 'This invoice is already fully paid.']);
}
$validated = $request->validate([
'amount' => ['required', 'numeric', 'min:0.01', 'max:'.$invoice->amount_due],
'payment_date' => ['required', 'date'],
'payment_method' => ['required', 'string', 'in:cash,check,wire,ach,credit_card,bank_transfer,other'],
'reference' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string', 'max:500'],
]);
InvoicePayment::create([
'invoice_id' => $invoice->id,
'amount' => $validated['amount'],
'payment_date' => $validated['payment_date'],
'payment_method' => $validated['payment_method'],
'reference' => $validated['reference'],
'notes' => $validated['notes'],
'recorded_by' => $request->user()->id,
]);
$statusMessage = $invoice->fresh()->payment_status === 'paid'
? 'Payment recorded. Invoice is now fully paid.'
: 'Payment recorded successfully.';
return back()->with('success', $statusMessage);
}
}

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

@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgBatch;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MfgBatchController extends Controller
{
public function index(Business $business, Request $request): View
{
$query = MfgBatch::forBusiness($business->id)
->with(['product', 'workOrder']);
// Filter by status
if ($request->filled('status')) {
$query->status($request->status);
}
$batches = $query->orderBy('created_at', 'desc')->paginate(20);
$stats = [
'open' => MfgBatch::forBusiness($business->id)->status('open')->count(),
'under_qc' => MfgBatch::forBusiness($business->id)->status('under_qc')->count(),
'released' => MfgBatch::forBusiness($business->id)->status('released')->count(),
'rejected' => MfgBatch::forBusiness($business->id)->status('rejected')->count(),
];
return view('seller.manufacturing.batches.index', [
'business' => $business,
'batches' => $batches,
'stats' => $stats,
'currentStatus' => $request->status,
]);
}
public function show(Business $business, MfgBatch $batch): View
{
if ($batch->business_id !== $business->id) {
abort(403);
}
$batch->load(['product', 'workOrder.recipe', 'inputs.inputProduct']);
return view('seller.manufacturing.batches.show', [
'business' => $business,
'batch' => $batch,
]);
}
/**
* Send batch to QC.
*/
public function sendToQc(Business $business, MfgBatch $batch): RedirectResponse
{
if ($batch->business_id !== $business->id) {
abort(403);
}
if ($batch->status !== 'open') {
return back()->with('error', 'Only open batches can be sent to QC.');
}
$batch->update(['status' => 'under_qc']);
return back()->with('success', 'Batch sent to QC.');
}
/**
* Release batch.
*/
public function release(Business $business, MfgBatch $batch): RedirectResponse
{
if ($batch->business_id !== $business->id) {
abort(403);
}
if (! $batch->release()) {
return back()->with('error', 'Cannot release this batch. Must be under QC first.');
}
return back()->with('success', 'Batch released.');
}
/**
* Reject batch.
*/
public function reject(Business $business, MfgBatch $batch, Request $request): RedirectResponse
{
if ($batch->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'reason' => 'nullable|string|max:500',
]);
$batch->reject($validated['reason'] ?? null);
return back()->with('success', 'Batch rejected.');
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgBatch;
use App\Models\Manufacturing\MfgComplianceRecord;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class MfgComplianceController extends Controller
{
public function index(Business $business, Request $request): View
{
$query = MfgComplianceRecord::forBusiness($business->id)
->with('batch');
if ($request->filled('type')) {
$query->where('record_type', $request->type);
}
$records = $query->orderBy('created_at', 'desc')->paginate(20);
$recordTypes = MfgComplianceRecord::forBusiness($business->id)
->distinct()
->pluck('record_type')
->filter();
return view('seller.manufacturing.compliance-records.index', [
'business' => $business,
'records' => $records,
'recordTypes' => $recordTypes,
'currentType' => $request->type,
]);
}
public function create(Business $business): View
{
$batches = MfgBatch::forBusiness($business->id)
->orderBy('batch_number')
->get(['id', 'batch_number']);
return view('seller.manufacturing.compliance-records.create', [
'business' => $business,
'batches' => $batches,
]);
}
public function store(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'record_type' => 'required|string|max:100',
'title' => 'required|string|max:255',
'mfg_batch_id' => 'nullable|exists:mfg_batches,id',
'description' => 'nullable|string',
'document' => 'nullable|file|max:10240|mimes:pdf,doc,docx,jpg,jpeg,png',
'issued_at' => 'nullable|date',
'expires_at' => 'nullable|date|after:issued_at',
'external_reference' => 'nullable|string|max:255',
]);
$documentPath = null;
if ($request->hasFile('document')) {
$documentPath = $request->file('document')->store(
"businesses/{$business->id}/mfg-compliance",
'private'
);
}
MfgComplianceRecord::create([
'business_id' => $business->id,
'record_type' => $validated['record_type'],
'title' => $validated['title'],
'mfg_batch_id' => $validated['mfg_batch_id'] ?? null,
'description' => $validated['description'] ?? null,
'document_path' => $documentPath,
'issued_at' => $validated['issued_at'] ?? null,
'expires_at' => $validated['expires_at'] ?? null,
'external_reference' => $validated['external_reference'] ?? null,
]);
return redirect()
->route('seller.business.mfg.compliance-records.index', $business->slug)
->with('success', 'Compliance record created.');
}
public function show(Business $business, MfgComplianceRecord $complianceRecord): View
{
if ($complianceRecord->business_id !== $business->id) {
abort(403);
}
$complianceRecord->load('batch');
return view('seller.manufacturing.compliance-records.show', [
'business' => $business,
'record' => $complianceRecord,
]);
}
/**
* Download the compliance document.
*/
public function download(Business $business, MfgComplianceRecord $complianceRecord)
{
if ($complianceRecord->business_id !== $business->id) {
abort(403);
}
if (! $complianceRecord->document_path) {
abort(404, 'No document attached.');
}
return Storage::disk('private')->download($complianceRecord->document_path);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgCustomer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MfgCustomerController extends Controller
{
public function index(Business $business): View
{
$customers = MfgCustomer::forBusiness($business->id)
->withCount('salesOrders')
->orderBy('name')
->paginate(20);
return view('seller.manufacturing.customers.index', [
'business' => $business,
'customers' => $customers,
]);
}
public function create(Business $business): View
{
return view('seller.manufacturing.customers.create', [
'business' => $business,
]);
}
public function store(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:50',
'contact_name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'notes' => 'nullable|string',
'is_active' => 'boolean',
]);
MfgCustomer::create([
'business_id' => $business->id,
...$validated,
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()
->route('seller.business.mfg.customers.index', $business->slug)
->with('success', 'Customer created.');
}
public function show(Business $business, MfgCustomer $customer): View
{
if ($customer->business_id !== $business->id) {
abort(403);
}
$customer->load(['salesOrders' => fn ($q) => $q->latest()->limit(10)]);
return view('seller.manufacturing.customers.show', [
'business' => $business,
'customer' => $customer,
]);
}
public function edit(Business $business, MfgCustomer $customer): View
{
if ($customer->business_id !== $business->id) {
abort(403);
}
return view('seller.manufacturing.customers.edit', [
'business' => $business,
'customer' => $customer,
]);
}
public function update(Business $business, MfgCustomer $customer, Request $request): RedirectResponse
{
if ($customer->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:50',
'contact_name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'notes' => 'nullable|string',
'is_active' => 'boolean',
]);
$customer->update($validated);
return redirect()
->route('seller.business.mfg.customers.index', $business->slug)
->with('success', 'Customer updated.');
}
public function destroy(Business $business, MfgCustomer $customer): RedirectResponse
{
if ($customer->business_id !== $business->id) {
abort(403);
}
$customer->delete();
return redirect()
->route('seller.business.mfg.customers.index', $business->slug)
->with('success', 'Customer deleted.');
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgBatch;
use App\Models\Manufacturing\MfgWorkOrder;
use Illuminate\View\View;
class MfgDashboardController extends Controller
{
public function index(Business $business): View
{
// Today's work orders
$todaysWorkOrders = MfgWorkOrder::forBusiness($business->id)
->whereDate('scheduled_start_at', today())
->with(['product', 'recipe'])
->orderBy('scheduled_start_at')
->limit(10)
->get();
// Open batches (not released or rejected)
$openBatches = MfgBatch::forBusiness($business->id)
->whereIn('status', ['open', 'under_qc'])
->with(['product', 'workOrder'])
->orderBy('created_at', 'desc')
->limit(10)
->get();
// Work order stats
$workOrderStats = [
'planned' => MfgWorkOrder::forBusiness($business->id)->status('planned')->count(),
'in_progress' => MfgWorkOrder::forBusiness($business->id)->status('in_progress')->count(),
'completed_today' => MfgWorkOrder::forBusiness($business->id)
->status('completed')
->whereDate('actual_end_at', today())
->count(),
];
// Batch stats
$batchStats = [
'open' => MfgBatch::forBusiness($business->id)->status('open')->count(),
'under_qc' => MfgBatch::forBusiness($business->id)->status('under_qc')->count(),
'released_today' => MfgBatch::forBusiness($business->id)
->status('released')
->whereDate('updated_at', today())
->count(),
];
return view('seller.manufacturing.dashboard', [
'business' => $business,
'todaysWorkOrders' => $todaysWorkOrders,
'openBatches' => $openBatches,
'workOrderStats' => $workOrderStats,
'batchStats' => $batchStats,
]);
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgInventoryItem;
use App\Models\Manufacturing\MfgInventoryMovement;
use App\Models\Manufacturing\MfgWarehouse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MfgInventoryController extends Controller
{
public function index(Business $business, Request $request): View
{
$query = MfgInventoryItem::forBusiness($business->id)
->with(['product', 'warehouse', 'location']);
// Filter by warehouse
if ($request->filled('warehouse_id')) {
$query->where('mfg_warehouse_id', $request->warehouse_id);
}
$items = $query->orderBy('product_id')->paginate(50);
$warehouses = MfgWarehouse::forBusiness($business->id)
->active()
->orderBy('name')
->get();
// Summary stats
$totalItems = MfgInventoryItem::forBusiness($business->id)->count();
$lowStockItems = MfgInventoryItem::forBusiness($business->id)
->whereColumn('quantity_on_hand', '<', 'quantity_reserved')
->count();
return view('seller.manufacturing.inventory.index', [
'business' => $business,
'items' => $items,
'warehouses' => $warehouses,
'currentWarehouseId' => $request->warehouse_id,
'totalItems' => $totalItems,
'lowStockItems' => $lowStockItems,
]);
}
public function show(Business $business, MfgInventoryItem $item): View
{
if ($item->business_id !== $business->id) {
abort(403);
}
$item->load(['product', 'warehouse', 'location']);
// Recent movements for this item
$movements = MfgInventoryMovement::forBusiness($business->id)
->where('product_id', $item->product_id)
->orderBy('created_at', 'desc')
->limit(20)
->get();
return view('seller.manufacturing.inventory.show', [
'business' => $business,
'item' => $item,
'movements' => $movements,
]);
}
/**
* Show inventory movements (ledger).
*/
public function movements(Business $business, Request $request): View
{
$query = MfgInventoryMovement::forBusiness($business->id)
->with(['product', 'sourceWarehouse', 'targetWarehouse']);
if ($request->filled('type')) {
$query->type($request->type);
}
$movements = $query->orderBy('created_at', 'desc')->paginate(50);
return view('seller.manufacturing.inventory.movements', [
'business' => $business,
'movements' => $movements,
'currentType' => $request->type,
]);
}
}

View File

@@ -0,0 +1,291 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgInventoryMovement;
use App\Models\Manufacturing\MfgPurchaseOrder;
use App\Models\Manufacturing\MfgPurchaseOrderLine;
use App\Models\Manufacturing\MfgVendor;
use App\Models\Manufacturing\MfgWarehouse;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class MfgPurchaseOrderController extends Controller
{
public function index(Business $business, Request $request): View
{
$query = MfgPurchaseOrder::forBusiness($business->id)
->with(['vendor', 'lines']);
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$purchaseOrders = $query->orderBy('created_at', 'desc')->paginate(20);
$stats = [
'draft' => MfgPurchaseOrder::forBusiness($business->id)->where('status', 'draft')->count(),
'submitted' => MfgPurchaseOrder::forBusiness($business->id)->where('status', 'submitted')->count(),
'received' => MfgPurchaseOrder::forBusiness($business->id)->where('status', 'received')->count(),
];
return view('seller.manufacturing.purchase-orders.index', [
'business' => $business,
'purchaseOrders' => $purchaseOrders,
'stats' => $stats,
'currentStatus' => $request->status,
]);
}
public function create(Business $business): View
{
$vendors = MfgVendor::forBusiness($business->id)->active()->orderBy('name')->get();
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))->orderBy('name')->get();
$warehouses = MfgWarehouse::forBusiness($business->id)->active()->orderBy('name')->get();
return view('seller.manufacturing.purchase-orders.create', [
'business' => $business,
'vendors' => $vendors,
'products' => $products,
'warehouses' => $warehouses,
]);
}
public function store(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'mfg_vendor_id' => 'required|exists:mfg_vendors,id',
'mfg_warehouse_id' => 'required|exists:mfg_warehouses,id',
'expected_delivery_at' => 'nullable|date',
'notes' => 'nullable|string',
'lines' => 'required|array|min:1',
'lines.*.product_id' => 'required|exists:products,id',
'lines.*.quantity' => 'required|numeric|min:0.0001',
'lines.*.unit_price' => 'nullable|numeric|min:0',
'lines.*.uom' => 'nullable|string|max:50',
]);
DB::transaction(function () use ($business, $validated) {
$po = MfgPurchaseOrder::create([
'business_id' => $business->id,
'mfg_vendor_id' => $validated['mfg_vendor_id'],
'mfg_warehouse_id' => $validated['mfg_warehouse_id'],
'po_number' => MfgPurchaseOrder::generatePoNumber($business->id),
'status' => 'draft',
'expected_delivery_at' => $validated['expected_delivery_at'] ?? null,
'notes' => $validated['notes'] ?? null,
]);
foreach ($validated['lines'] as $index => $line) {
MfgPurchaseOrderLine::create([
'mfg_purchase_order_id' => $po->id,
'product_id' => $line['product_id'],
'quantity_ordered' => $line['quantity'],
'quantity_received' => 0,
'unit_price' => $line['unit_price'] ?? 0,
'uom' => $line['uom'] ?? 'unit',
'line_number' => $index + 1,
]);
}
});
return redirect()
->route('seller.business.mfg.purchase-orders.index', $business->slug)
->with('success', 'Purchase order created.');
}
public function show(Business $business, MfgPurchaseOrder $purchaseOrder): View
{
if ($purchaseOrder->business_id !== $business->id) {
abort(403);
}
$purchaseOrder->load(['vendor', 'warehouse', 'lines.product']);
return view('seller.manufacturing.purchase-orders.show', [
'business' => $business,
'purchaseOrder' => $purchaseOrder,
]);
}
public function edit(Business $business, MfgPurchaseOrder $purchaseOrder): View
{
if ($purchaseOrder->business_id !== $business->id) {
abort(403);
}
if ($purchaseOrder->status === 'received') {
return redirect()
->route('seller.business.mfg.purchase-orders.show', [$business->slug, $purchaseOrder->id])
->with('error', 'Cannot edit a received purchase order.');
}
$purchaseOrder->load(['lines.product']);
$vendors = MfgVendor::forBusiness($business->id)->active()->orderBy('name')->get();
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))->orderBy('name')->get();
$warehouses = MfgWarehouse::forBusiness($business->id)->active()->orderBy('name')->get();
return view('seller.manufacturing.purchase-orders.edit', [
'business' => $business,
'purchaseOrder' => $purchaseOrder,
'vendors' => $vendors,
'products' => $products,
'warehouses' => $warehouses,
]);
}
public function update(Business $business, MfgPurchaseOrder $purchaseOrder, Request $request): RedirectResponse
{
if ($purchaseOrder->business_id !== $business->id) {
abort(403);
}
if ($purchaseOrder->status === 'received') {
return back()->with('error', 'Cannot edit a received purchase order.');
}
$validated = $request->validate([
'mfg_vendor_id' => 'required|exists:mfg_vendors,id',
'mfg_warehouse_id' => 'required|exists:mfg_warehouses,id',
'expected_delivery_at' => 'nullable|date',
'notes' => 'nullable|string',
'lines' => 'required|array|min:1',
'lines.*.id' => 'nullable|exists:mfg_purchase_order_lines,id',
'lines.*.product_id' => 'required|exists:products,id',
'lines.*.quantity' => 'required|numeric|min:0.0001',
'lines.*.unit_price' => 'nullable|numeric|min:0',
'lines.*.uom' => 'nullable|string|max:50',
]);
DB::transaction(function () use ($purchaseOrder, $validated) {
$purchaseOrder->update([
'mfg_vendor_id' => $validated['mfg_vendor_id'],
'mfg_warehouse_id' => $validated['mfg_warehouse_id'],
'expected_delivery_at' => $validated['expected_delivery_at'] ?? null,
'notes' => $validated['notes'] ?? null,
]);
// Delete existing lines and recreate
$purchaseOrder->lines()->delete();
foreach ($validated['lines'] as $index => $line) {
MfgPurchaseOrderLine::create([
'mfg_purchase_order_id' => $purchaseOrder->id,
'product_id' => $line['product_id'],
'quantity_ordered' => $line['quantity'],
'quantity_received' => 0,
'unit_price' => $line['unit_price'] ?? 0,
'uom' => $line['uom'] ?? 'unit',
'line_number' => $index + 1,
]);
}
});
return redirect()
->route('seller.business.mfg.purchase-orders.show', [$business->slug, $purchaseOrder->id])
->with('success', 'Purchase order updated.');
}
public function destroy(Business $business, MfgPurchaseOrder $purchaseOrder): RedirectResponse
{
if ($purchaseOrder->business_id !== $business->id) {
abort(403);
}
if ($purchaseOrder->status === 'received') {
return back()->with('error', 'Cannot delete a received purchase order.');
}
$purchaseOrder->delete();
return redirect()
->route('seller.business.mfg.purchase-orders.index', $business->slug)
->with('success', 'Purchase order deleted.');
}
/**
* Submit the PO to the vendor.
*/
public function submit(Business $business, MfgPurchaseOrder $purchaseOrder): RedirectResponse
{
if ($purchaseOrder->business_id !== $business->id) {
abort(403);
}
if ($purchaseOrder->status !== 'draft') {
return back()->with('error', 'Only draft purchase orders can be submitted.');
}
$purchaseOrder->update([
'status' => 'submitted',
'submitted_at' => now(),
]);
return back()->with('success', 'Purchase order submitted.');
}
/**
* Mark the PO as received and create inventory movements.
*/
public function receive(Business $business, MfgPurchaseOrder $purchaseOrder, Request $request): RedirectResponse
{
if ($purchaseOrder->business_id !== $business->id) {
abort(403);
}
if ($purchaseOrder->status === 'received') {
return back()->with('error', 'Purchase order already received.');
}
$validated = $request->validate([
'lines' => 'required|array',
'lines.*.id' => 'required|exists:mfg_purchase_order_lines,id',
'lines.*.quantity_received' => 'required|numeric|min:0',
]);
DB::transaction(function () use ($purchaseOrder, $validated, $business) {
// Batch load all lines upfront to avoid N+1
$lineIds = collect($validated['lines'])->pluck('id');
$lines = MfgPurchaseOrderLine::whereIn('id', $lineIds)
->where('mfg_purchase_order_id', $purchaseOrder->id)
->get()
->keyBy('id');
$lineDataById = collect($validated['lines'])->keyBy('id');
foreach ($lines as $line) {
$lineData = $lineDataById[$line->id];
$line->update([
'quantity_received' => $lineData['quantity_received'],
]);
// Create inventory movement for received quantity
if ($lineData['quantity_received'] > 0) {
MfgInventoryMovement::create([
'business_id' => $business->id,
'product_id' => $line->product_id,
'target_warehouse_id' => $purchaseOrder->mfg_warehouse_id,
'quantity' => $lineData['quantity_received'],
'uom' => $line->uom,
'movement_type' => 'receive',
'reference_type' => 'purchase_order',
'reference_id' => $purchaseOrder->id,
'reason' => 'PO Receipt: '.$purchaseOrder->po_number,
]);
}
}
$purchaseOrder->update([
'status' => 'received',
'received_at' => now(),
]);
});
return back()->with('success', 'Purchase order marked as received. Inventory updated.');
}
}

View File

@@ -0,0 +1,221 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgBatch;
use App\Models\Manufacturing\MfgQcResult;
use App\Models\Manufacturing\MfgQcTest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MfgQcController extends Controller
{
/**
* List QC test definitions.
*/
public function index(Business $business): View
{
$tests = MfgQcTest::forBusiness($business->id)
->withCount('results')
->orderBy('name')
->paginate(20);
return view('seller.manufacturing.qc-tests.index', [
'business' => $business,
'tests' => $tests,
]);
}
/**
* Show form to create a QC test definition.
*/
public function create(Business $business): View
{
return view('seller.manufacturing.qc-tests.create', [
'business' => $business,
]);
}
/**
* Store a new QC test definition.
*/
public function store(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:50',
'category' => 'nullable|string|max:100',
'description' => 'nullable|string',
'min_value' => 'nullable|numeric',
'max_value' => 'nullable|numeric',
'target_value' => 'nullable|numeric',
'uom' => 'nullable|string|max:50',
'is_active' => 'boolean',
]);
MfgQcTest::create([
'business_id' => $business->id,
...$validated,
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()
->route('seller.business.mfg.qc-tests.index', $business->slug)
->with('success', 'QC test created.');
}
/**
* Show a QC test definition.
*/
public function show(Business $business, MfgQcTest $qcTest): View
{
if ($qcTest->business_id !== $business->id) {
abort(403);
}
$qcTest->load(['results' => fn ($q) => $q->latest()->limit(20)]);
return view('seller.manufacturing.qc-tests.show', [
'business' => $business,
'test' => $qcTest,
]);
}
/**
* Show form to edit a QC test definition.
*/
public function edit(Business $business, MfgQcTest $qcTest): View
{
if ($qcTest->business_id !== $business->id) {
abort(403);
}
return view('seller.manufacturing.qc-tests.edit', [
'business' => $business,
'test' => $qcTest,
]);
}
/**
* Update a QC test definition.
*/
public function update(Business $business, MfgQcTest $qcTest, Request $request): RedirectResponse
{
if ($qcTest->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:50',
'category' => 'nullable|string|max:100',
'description' => 'nullable|string',
'min_value' => 'nullable|numeric',
'max_value' => 'nullable|numeric',
'target_value' => 'nullable|numeric',
'uom' => 'nullable|string|max:50',
'is_active' => 'boolean',
]);
$qcTest->update($validated);
return redirect()
->route('seller.business.mfg.qc-tests.index', $business->slug)
->with('success', 'QC test updated.');
}
/**
* List QC results.
*/
public function results(Business $business, Request $request): View
{
$query = MfgQcResult::forBusiness($business->id)
->with(['test', 'batch']);
if ($request->filled('batch_id')) {
$query->where('mfg_batch_id', $request->batch_id);
}
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$results = $query->orderBy('tested_at', 'desc')->paginate(20);
$batches = MfgBatch::forBusiness($business->id)
->orderBy('batch_number')
->get(['id', 'batch_number']);
return view('seller.manufacturing.qc-results.index', [
'business' => $business,
'results' => $results,
'batches' => $batches,
'currentBatchId' => $request->batch_id,
'currentStatus' => $request->status,
]);
}
/**
* Show form to record a QC result.
*/
public function createResult(Business $business): View
{
$tests = MfgQcTest::forBusiness($business->id)->active()->orderBy('name')->get();
$batches = MfgBatch::forBusiness($business->id)
->whereIn('status', ['open', 'under_qc'])
->orderBy('batch_number')
->get();
return view('seller.manufacturing.qc-results.create', [
'business' => $business,
'tests' => $tests,
'batches' => $batches,
]);
}
/**
* Store a QC result.
*/
public function storeResult(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'mfg_qc_test_id' => 'required|exists:mfg_qc_tests,id',
'mfg_batch_id' => 'required|exists:mfg_batches,id',
'tested_at' => 'required|date',
'result_value' => 'nullable|numeric',
'result_text' => 'nullable|string|max:255',
'status' => 'required|in:pass,fail,pending',
'tested_by' => 'nullable|string|max:255',
'notes' => 'nullable|string',
]);
MfgQcResult::create([
'business_id' => $business->id,
...$validated,
]);
return redirect()
->route('seller.business.mfg.qc-results.index', $business->slug)
->with('success', 'QC result recorded.');
}
/**
* Show a QC result.
*/
public function showResult(Business $business, MfgQcResult $qcResult): View
{
if ($qcResult->business_id !== $business->id) {
abort(403);
}
$qcResult->load(['test', 'batch']);
return view('seller.manufacturing.qc-results.show', [
'business' => $business,
'result' => $qcResult,
]);
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgRecipe;
use App\Models\Manufacturing\MfgRecipeComponent;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MfgRecipeController extends Controller
{
public function index(Business $business): View
{
$recipes = MfgRecipe::forBusiness($business->id)
->with(['product', 'components.componentProduct'])
->orderBy('created_at', 'desc')
->paginate(20);
return view('seller.manufacturing.recipes.index', [
'business' => $business,
'recipes' => $recipes,
]);
}
public function create(Business $business): View
{
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->orderBy('name')
->get();
// Components can be any product (raw materials, packaging, etc.)
$componentProducts = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->orderBy('name')
->get();
return view('seller.manufacturing.recipes.create', [
'business' => $business,
'products' => $products,
'componentProducts' => $componentProducts,
]);
}
public function store(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'name' => 'nullable|string|max:255',
'version' => 'integer|min:1',
'status' => 'required|in:draft,active,archived',
'yield_target_percent' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string',
'components' => 'array',
'components.*.component_product_id' => 'required|exists:products,id',
'components.*.quantity_per_unit' => 'required|numeric|min:0',
'components.*.uom' => 'required|string|max:50',
'components.*.is_primary' => 'boolean',
'components.*.wastage_percent' => 'nullable|numeric|min:0|max:100',
'components.*.notes' => 'nullable|string',
]);
$recipe = MfgRecipe::create([
'business_id' => $business->id,
'product_id' => $validated['product_id'],
'name' => $validated['name'] ?? null,
'version' => $validated['version'] ?? 1,
'status' => $validated['status'],
'yield_target_percent' => $validated['yield_target_percent'] ?? null,
'notes' => $validated['notes'] ?? null,
]);
// Create components
if (! empty($validated['components'])) {
foreach ($validated['components'] as $component) {
MfgRecipeComponent::create([
'mfg_recipe_id' => $recipe->id,
'component_product_id' => $component['component_product_id'],
'quantity_per_unit' => $component['quantity_per_unit'],
'uom' => $component['uom'],
'is_primary' => $component['is_primary'] ?? true,
'wastage_percent' => $component['wastage_percent'] ?? null,
'notes' => $component['notes'] ?? null,
]);
}
}
return redirect()
->route('seller.business.manufacturing.recipes.show', [$business->slug, $recipe->id])
->with('success', 'Recipe created successfully.');
}
public function show(Business $business, MfgRecipe $recipe): View
{
// Ensure recipe belongs to business
if ($recipe->business_id !== $business->id) {
abort(403);
}
$recipe->load(['product', 'components.componentProduct', 'workOrders']);
return view('seller.manufacturing.recipes.show', [
'business' => $business,
'recipe' => $recipe,
]);
}
public function edit(Business $business, MfgRecipe $recipe): View
{
if ($recipe->business_id !== $business->id) {
abort(403);
}
$recipe->load(['components']);
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->orderBy('name')
->get();
$componentProducts = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->orderBy('name')
->get();
return view('seller.manufacturing.recipes.edit', [
'business' => $business,
'recipe' => $recipe,
'products' => $products,
'componentProducts' => $componentProducts,
]);
}
public function update(Business $business, MfgRecipe $recipe, Request $request): RedirectResponse
{
if ($recipe->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'name' => 'nullable|string|max:255',
'version' => 'integer|min:1',
'status' => 'required|in:draft,active,archived',
'yield_target_percent' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string',
'components' => 'array',
'components.*.component_product_id' => 'required|exists:products,id',
'components.*.quantity_per_unit' => 'required|numeric|min:0',
'components.*.uom' => 'required|string|max:50',
'components.*.is_primary' => 'boolean',
'components.*.wastage_percent' => 'nullable|numeric|min:0|max:100',
'components.*.notes' => 'nullable|string',
]);
$recipe->update([
'product_id' => $validated['product_id'],
'name' => $validated['name'] ?? null,
'version' => $validated['version'] ?? $recipe->version,
'status' => $validated['status'],
'yield_target_percent' => $validated['yield_target_percent'] ?? null,
'notes' => $validated['notes'] ?? null,
]);
// Replace components
$recipe->components()->delete();
if (! empty($validated['components'])) {
foreach ($validated['components'] as $component) {
MfgRecipeComponent::create([
'mfg_recipe_id' => $recipe->id,
'component_product_id' => $component['component_product_id'],
'quantity_per_unit' => $component['quantity_per_unit'],
'uom' => $component['uom'],
'is_primary' => $component['is_primary'] ?? true,
'wastage_percent' => $component['wastage_percent'] ?? null,
'notes' => $component['notes'] ?? null,
]);
}
}
return redirect()
->route('seller.business.manufacturing.recipes.show', [$business->slug, $recipe->id])
->with('success', 'Recipe updated successfully.');
}
public function destroy(Business $business, MfgRecipe $recipe): RedirectResponse
{
if ($recipe->business_id !== $business->id) {
abort(403);
}
$recipe->delete();
return redirect()
->route('seller.business.manufacturing.recipes.index', $business->slug)
->with('success', 'Recipe deleted successfully.');
}
}

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgCustomer;
use App\Models\Manufacturing\MfgSalesOrder;
use App\Models\Manufacturing\MfgSalesOrderLine;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class MfgSalesOrderController extends Controller
{
public function index(Business $business, Request $request): View
{
$query = MfgSalesOrder::forBusiness($business->id)
->with(['customer', 'lines']);
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$salesOrders = $query->orderBy('created_at', 'desc')->paginate(20);
$stats = [
'draft' => MfgSalesOrder::forBusiness($business->id)->where('status', 'draft')->count(),
'confirmed' => MfgSalesOrder::forBusiness($business->id)->where('status', 'confirmed')->count(),
'shipped' => MfgSalesOrder::forBusiness($business->id)->where('status', 'shipped')->count(),
'completed' => MfgSalesOrder::forBusiness($business->id)->where('status', 'completed')->count(),
];
return view('seller.manufacturing.sales-orders.index', [
'business' => $business,
'salesOrders' => $salesOrders,
'stats' => $stats,
'currentStatus' => $request->status,
]);
}
public function create(Business $business): View
{
$customers = MfgCustomer::forBusiness($business->id)->active()->orderBy('name')->get();
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))->orderBy('name')->get();
return view('seller.manufacturing.sales-orders.create', [
'business' => $business,
'customers' => $customers,
'products' => $products,
]);
}
public function store(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'mfg_customer_id' => 'required|exists:mfg_customers,id',
'requested_delivery_at' => 'nullable|date',
'shipping_address' => 'nullable|string',
'notes' => 'nullable|string',
'lines' => 'required|array|min:1',
'lines.*.product_id' => 'required|exists:products,id',
'lines.*.quantity' => 'required|numeric|min:0.0001',
'lines.*.unit_price' => 'nullable|numeric|min:0',
'lines.*.uom' => 'nullable|string|max:50',
]);
DB::transaction(function () use ($business, $validated) {
$so = MfgSalesOrder::create([
'business_id' => $business->id,
'mfg_customer_id' => $validated['mfg_customer_id'],
'so_number' => MfgSalesOrder::generateSoNumber($business->id),
'status' => 'draft',
'requested_delivery_at' => $validated['requested_delivery_at'] ?? null,
'shipping_address' => $validated['shipping_address'] ?? null,
'notes' => $validated['notes'] ?? null,
]);
foreach ($validated['lines'] as $index => $line) {
MfgSalesOrderLine::create([
'mfg_sales_order_id' => $so->id,
'product_id' => $line['product_id'],
'quantity_ordered' => $line['quantity'],
'quantity_shipped' => 0,
'unit_price' => $line['unit_price'] ?? 0,
'uom' => $line['uom'] ?? 'unit',
'line_number' => $index + 1,
]);
}
});
return redirect()
->route('seller.business.mfg.sales-orders.index', $business->slug)
->with('success', 'Sales order created.');
}
public function show(Business $business, MfgSalesOrder $salesOrder): View
{
if ($salesOrder->business_id !== $business->id) {
abort(403);
}
$salesOrder->load(['customer', 'lines.product', 'shipments']);
return view('seller.manufacturing.sales-orders.show', [
'business' => $business,
'salesOrder' => $salesOrder,
]);
}
public function edit(Business $business, MfgSalesOrder $salesOrder): View
{
if ($salesOrder->business_id !== $business->id) {
abort(403);
}
if (in_array($salesOrder->status, ['shipped', 'completed'])) {
return redirect()
->route('seller.business.mfg.sales-orders.show', [$business->slug, $salesOrder->id])
->with('error', 'Cannot edit a shipped or completed sales order.');
}
$salesOrder->load(['lines.product']);
$customers = MfgCustomer::forBusiness($business->id)->active()->orderBy('name')->get();
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))->orderBy('name')->get();
return view('seller.manufacturing.sales-orders.edit', [
'business' => $business,
'salesOrder' => $salesOrder,
'customers' => $customers,
'products' => $products,
]);
}
public function update(Business $business, MfgSalesOrder $salesOrder, Request $request): RedirectResponse
{
if ($salesOrder->business_id !== $business->id) {
abort(403);
}
if (in_array($salesOrder->status, ['shipped', 'completed'])) {
return back()->with('error', 'Cannot edit a shipped or completed sales order.');
}
$validated = $request->validate([
'mfg_customer_id' => 'required|exists:mfg_customers,id',
'requested_delivery_at' => 'nullable|date',
'shipping_address' => 'nullable|string',
'notes' => 'nullable|string',
'lines' => 'required|array|min:1',
'lines.*.id' => 'nullable|exists:mfg_sales_order_lines,id',
'lines.*.product_id' => 'required|exists:products,id',
'lines.*.quantity' => 'required|numeric|min:0.0001',
'lines.*.unit_price' => 'nullable|numeric|min:0',
'lines.*.uom' => 'nullable|string|max:50',
]);
DB::transaction(function () use ($salesOrder, $validated) {
$salesOrder->update([
'mfg_customer_id' => $validated['mfg_customer_id'],
'requested_delivery_at' => $validated['requested_delivery_at'] ?? null,
'shipping_address' => $validated['shipping_address'] ?? null,
'notes' => $validated['notes'] ?? null,
]);
// Delete existing lines and recreate
$salesOrder->lines()->delete();
foreach ($validated['lines'] as $index => $line) {
MfgSalesOrderLine::create([
'mfg_sales_order_id' => $salesOrder->id,
'product_id' => $line['product_id'],
'quantity_ordered' => $line['quantity'],
'quantity_shipped' => 0,
'unit_price' => $line['unit_price'] ?? 0,
'uom' => $line['uom'] ?? 'unit',
'line_number' => $index + 1,
]);
}
});
return redirect()
->route('seller.business.mfg.sales-orders.show', [$business->slug, $salesOrder->id])
->with('success', 'Sales order updated.');
}
public function destroy(Business $business, MfgSalesOrder $salesOrder): RedirectResponse
{
if ($salesOrder->business_id !== $business->id) {
abort(403);
}
if ($salesOrder->status !== 'draft') {
return back()->with('error', 'Only draft sales orders can be deleted.');
}
$salesOrder->delete();
return redirect()
->route('seller.business.mfg.sales-orders.index', $business->slug)
->with('success', 'Sales order deleted.');
}
/**
* Confirm the sales order.
*/
public function confirm(Business $business, MfgSalesOrder $salesOrder): RedirectResponse
{
if ($salesOrder->business_id !== $business->id) {
abort(403);
}
if ($salesOrder->status !== 'draft') {
return back()->with('error', 'Only draft sales orders can be confirmed.');
}
$salesOrder->update([
'status' => 'confirmed',
'confirmed_at' => now(),
]);
return back()->with('success', 'Sales order confirmed.');
}
/**
* Cancel the sales order.
*/
public function cancel(Business $business, MfgSalesOrder $salesOrder): RedirectResponse
{
if ($salesOrder->business_id !== $business->id) {
abort(403);
}
if (in_array($salesOrder->status, ['shipped', 'completed'])) {
return back()->with('error', 'Cannot cancel a shipped or completed sales order.');
}
$salesOrder->update([
'status' => 'cancelled',
'cancelled_at' => now(),
]);
return back()->with('success', 'Sales order cancelled.');
}
}

View File

@@ -0,0 +1,286 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgInventoryMovement;
use App\Models\Manufacturing\MfgSalesOrder;
use App\Models\Manufacturing\MfgShipment;
use App\Models\Manufacturing\MfgShipmentLine;
use App\Models\Manufacturing\MfgWarehouse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class MfgShipmentController extends Controller
{
public function index(Business $business, Request $request): View
{
$query = MfgShipment::forBusiness($business->id)
->with(['salesOrder.customer', 'warehouse', 'lines']);
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$shipments = $query->orderBy('created_at', 'desc')->paginate(20);
$stats = [
'pending' => MfgShipment::forBusiness($business->id)->where('status', 'pending')->count(),
'packed' => MfgShipment::forBusiness($business->id)->where('status', 'packed')->count(),
'shipped' => MfgShipment::forBusiness($business->id)->where('status', 'shipped')->count(),
'delivered' => MfgShipment::forBusiness($business->id)->where('status', 'delivered')->count(),
];
return view('seller.manufacturing.shipments.index', [
'business' => $business,
'shipments' => $shipments,
'stats' => $stats,
'currentStatus' => $request->status,
]);
}
public function create(Business $business): View
{
$salesOrders = MfgSalesOrder::forBusiness($business->id)
->whereIn('status', ['confirmed'])
->with(['customer', 'lines.product'])
->orderBy('so_number')
->get();
$warehouses = MfgWarehouse::forBusiness($business->id)->active()->orderBy('name')->get();
return view('seller.manufacturing.shipments.create', [
'business' => $business,
'salesOrders' => $salesOrders,
'warehouses' => $warehouses,
]);
}
public function store(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'mfg_sales_order_id' => 'required|exists:mfg_sales_orders,id',
'mfg_warehouse_id' => 'required|exists:mfg_warehouses,id',
'carrier' => 'nullable|string|max:100',
'tracking_number' => 'nullable|string|max:255',
'notes' => 'nullable|string',
'lines' => 'required|array|min:1',
'lines.*.mfg_sales_order_line_id' => 'required|exists:mfg_sales_order_lines,id',
'lines.*.quantity' => 'required|numeric|min:0.0001',
]);
DB::transaction(function () use ($business, $validated) {
$shipment = MfgShipment::create([
'business_id' => $business->id,
'mfg_sales_order_id' => $validated['mfg_sales_order_id'],
'mfg_warehouse_id' => $validated['mfg_warehouse_id'],
'shipment_number' => MfgShipment::generateShipmentNumber($business->id),
'status' => 'pending',
'carrier' => $validated['carrier'] ?? null,
'tracking_number' => $validated['tracking_number'] ?? null,
'notes' => $validated['notes'] ?? null,
]);
foreach ($validated['lines'] as $line) {
MfgShipmentLine::create([
'mfg_shipment_id' => $shipment->id,
'mfg_sales_order_line_id' => $line['mfg_sales_order_line_id'],
'quantity_shipped' => $line['quantity'],
]);
}
});
return redirect()
->route('seller.business.mfg.shipments.index', $business->slug)
->with('success', 'Shipment created.');
}
public function show(Business $business, MfgShipment $shipment): View
{
if ($shipment->business_id !== $business->id) {
abort(403);
}
$shipment->load(['salesOrder.customer', 'warehouse', 'lines.salesOrderLine.product']);
return view('seller.manufacturing.shipments.show', [
'business' => $business,
'shipment' => $shipment,
]);
}
public function edit(Business $business, MfgShipment $shipment): View
{
if ($shipment->business_id !== $business->id) {
abort(403);
}
if (in_array($shipment->status, ['shipped', 'delivered'])) {
return redirect()
->route('seller.business.mfg.shipments.show', [$business->slug, $shipment->id])
->with('error', 'Cannot edit a shipped or delivered shipment.');
}
$shipment->load(['salesOrder.lines.product', 'lines']);
$warehouses = MfgWarehouse::forBusiness($business->id)->active()->orderBy('name')->get();
return view('seller.manufacturing.shipments.edit', [
'business' => $business,
'shipment' => $shipment,
'warehouses' => $warehouses,
]);
}
public function update(Business $business, MfgShipment $shipment, Request $request): RedirectResponse
{
if ($shipment->business_id !== $business->id) {
abort(403);
}
if (in_array($shipment->status, ['shipped', 'delivered'])) {
return back()->with('error', 'Cannot edit a shipped or delivered shipment.');
}
$validated = $request->validate([
'mfg_warehouse_id' => 'required|exists:mfg_warehouses,id',
'carrier' => 'nullable|string|max:100',
'tracking_number' => 'nullable|string|max:255',
'notes' => 'nullable|string',
]);
$shipment->update($validated);
return redirect()
->route('seller.business.mfg.shipments.show', [$business->slug, $shipment->id])
->with('success', 'Shipment updated.');
}
public function destroy(Business $business, MfgShipment $shipment): RedirectResponse
{
if ($shipment->business_id !== $business->id) {
abort(403);
}
if ($shipment->status !== 'pending') {
return back()->with('error', 'Only pending shipments can be deleted.');
}
$shipment->delete();
return redirect()
->route('seller.business.mfg.shipments.index', $business->slug)
->with('success', 'Shipment deleted.');
}
/**
* Mark shipment as packed.
*/
public function pack(Business $business, MfgShipment $shipment): RedirectResponse
{
if ($shipment->business_id !== $business->id) {
abort(403);
}
if ($shipment->status !== 'pending') {
return back()->with('error', 'Only pending shipments can be packed.');
}
$shipment->update([
'status' => 'packed',
'packed_at' => now(),
]);
return back()->with('success', 'Shipment marked as packed.');
}
/**
* Mark shipment as shipped and create inventory movements.
*/
public function ship(Business $business, MfgShipment $shipment): RedirectResponse
{
if ($shipment->business_id !== $business->id) {
abort(403);
}
if (! in_array($shipment->status, ['pending', 'packed'])) {
return back()->with('error', 'Only pending or packed shipments can be shipped.');
}
DB::transaction(function () use ($shipment, $business) {
$shipment->load(['lines.salesOrderLine', 'salesOrder.lines']);
// Create inventory movements for each line
foreach ($shipment->lines as $line) {
if ($line->salesOrderLine) {
MfgInventoryMovement::create([
'business_id' => $business->id,
'product_id' => $line->salesOrderLine->product_id,
'source_warehouse_id' => $shipment->mfg_warehouse_id,
'quantity' => -$line->quantity_shipped, // Negative for outgoing
'uom' => $line->salesOrderLine->uom ?? 'unit',
'movement_type' => 'ship',
'reference_type' => 'shipment',
'reference_id' => $shipment->id,
'reason' => 'Shipment: '.$shipment->shipment_number,
]);
// Update sales order line shipped quantity
$line->salesOrderLine->increment('quantity_shipped', $line->quantity_shipped);
}
}
$shipment->update([
'status' => 'shipped',
'shipped_at' => now(),
]);
// Update sales order status if all lines are shipped
$salesOrder = $shipment->salesOrder;
if ($salesOrder) {
$allShipped = $salesOrder->lines->every(fn ($l) => $l->quantity_shipped >= $l->quantity_ordered);
if ($allShipped) {
$salesOrder->update(['status' => 'shipped']);
}
}
});
return back()->with('success', 'Shipment marked as shipped. Inventory updated.');
}
/**
* Mark shipment as delivered.
*/
public function deliver(Business $business, MfgShipment $shipment): RedirectResponse
{
if ($shipment->business_id !== $business->id) {
abort(403);
}
if ($shipment->status !== 'shipped') {
return back()->with('error', 'Only shipped shipments can be delivered.');
}
DB::transaction(function () use ($shipment) {
$shipment->load('salesOrder.shipments');
$shipment->update([
'status' => 'delivered',
'delivered_at' => now(),
]);
// Update sales order status if all shipments delivered
$salesOrder = $shipment->salesOrder;
if ($salesOrder) {
$allDelivered = $salesOrder->shipments->every(fn ($s) => $s->id === $shipment->id || $s->status === 'delivered');
if ($allDelivered) {
$salesOrder->update(['status' => 'completed']);
}
}
});
return back()->with('success', 'Shipment marked as delivered.');
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgVendor;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MfgVendorController extends Controller
{
public function index(Business $business): View
{
$vendors = MfgVendor::forBusiness($business->id)
->withCount('purchaseOrders')
->orderBy('name')
->paginate(20);
return view('seller.manufacturing.vendors.index', [
'business' => $business,
'vendors' => $vendors,
]);
}
public function create(Business $business): View
{
return view('seller.manufacturing.vendors.create', [
'business' => $business,
]);
}
public function store(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:50',
'contact_name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'notes' => 'nullable|string',
'is_active' => 'boolean',
]);
MfgVendor::create([
'business_id' => $business->id,
...$validated,
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()
->route('seller.business.mfg.vendors.index', $business->slug)
->with('success', 'Vendor created.');
}
public function show(Business $business, MfgVendor $vendor): View
{
if ($vendor->business_id !== $business->id) {
abort(403);
}
$vendor->load(['purchaseOrders' => fn ($q) => $q->latest()->limit(10)]);
return view('seller.manufacturing.vendors.show', [
'business' => $business,
'vendor' => $vendor,
]);
}
public function edit(Business $business, MfgVendor $vendor): View
{
if ($vendor->business_id !== $business->id) {
abort(403);
}
return view('seller.manufacturing.vendors.edit', [
'business' => $business,
'vendor' => $vendor,
]);
}
public function update(Business $business, MfgVendor $vendor, Request $request): RedirectResponse
{
if ($vendor->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:50',
'contact_name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'notes' => 'nullable|string',
'is_active' => 'boolean',
]);
$vendor->update($validated);
return redirect()
->route('seller.business.mfg.vendors.index', $business->slug)
->with('success', 'Vendor updated.');
}
public function destroy(Business $business, MfgVendor $vendor): RedirectResponse
{
if ($vendor->business_id !== $business->id) {
abort(403);
}
$vendor->delete();
return redirect()
->route('seller.business.mfg.vendors.index', $business->slug)
->with('success', 'Vendor deleted.');
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgWorkCenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MfgWorkCenterController extends Controller
{
public function index(Business $business): View
{
$workCenters = MfgWorkCenter::forBusiness($business->id)
->withCount('operations')
->orderBy('name')
->paginate(20);
return view('seller.manufacturing.work-centers.index', [
'business' => $business,
'workCenters' => $workCenters,
]);
}
public function create(Business $business): View
{
return view('seller.manufacturing.work-centers.create', [
'business' => $business,
]);
}
public function store(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:50',
'type' => 'nullable|string|max:50',
'capacity_units_per_hour' => 'nullable|integer|min:0',
'is_active' => 'boolean',
]);
MfgWorkCenter::create([
'business_id' => $business->id,
...$validated,
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()
->route('seller.business.manufacturing.work-centers.index', $business->slug)
->with('success', 'Work center created.');
}
public function edit(Business $business, MfgWorkCenter $workCenter): View
{
if ($workCenter->business_id !== $business->id) {
abort(403);
}
return view('seller.manufacturing.work-centers.edit', [
'business' => $business,
'workCenter' => $workCenter,
]);
}
public function update(Business $business, MfgWorkCenter $workCenter, Request $request): RedirectResponse
{
if ($workCenter->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'code' => 'nullable|string|max:50',
'type' => 'nullable|string|max:50',
'capacity_units_per_hour' => 'nullable|integer|min:0',
'is_active' => 'boolean',
]);
$workCenter->update($validated);
return redirect()
->route('seller.business.manufacturing.work-centers.index', $business->slug)
->with('success', 'Work center updated.');
}
public function destroy(Business $business, MfgWorkCenter $workCenter): RedirectResponse
{
if ($workCenter->business_id !== $business->id) {
abort(403);
}
$workCenter->delete();
return redirect()
->route('seller.business.manufacturing.work-centers.index', $business->slug)
->with('success', 'Work center deleted.');
}
}

View File

@@ -0,0 +1,255 @@
<?php
namespace App\Http\Controllers\Seller\Manufacturing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Manufacturing\MfgBatch;
use App\Models\Manufacturing\MfgRecipe;
use App\Models\Manufacturing\MfgWorkCenter;
use App\Models\Manufacturing\MfgWorkOrder;
use App\Models\Manufacturing\MfgWorkOrderOperation;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MfgWorkOrderController extends Controller
{
public function index(Business $business, Request $request): View
{
$query = MfgWorkOrder::forBusiness($business->id)
->with(['product', 'recipe']);
// Filter by status
if ($request->filled('status')) {
$query->status($request->status);
}
$workOrders = $query->orderBy('created_at', 'desc')->paginate(20);
$stats = [
'planned' => MfgWorkOrder::forBusiness($business->id)->status('planned')->count(),
'in_progress' => MfgWorkOrder::forBusiness($business->id)->status('in_progress')->count(),
'completed' => MfgWorkOrder::forBusiness($business->id)->status('completed')->count(),
];
return view('seller.manufacturing.work-orders.index', [
'business' => $business,
'workOrders' => $workOrders,
'stats' => $stats,
'currentStatus' => $request->status,
]);
}
public function create(Business $business): View
{
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->orderBy('name')
->get();
$recipes = MfgRecipe::forBusiness($business->id)
->active()
->with('product')
->orderBy('created_at', 'desc')
->get();
$workCenters = MfgWorkCenter::forBusiness($business->id)
->active()
->orderBy('name')
->get();
return view('seller.manufacturing.work-orders.create', [
'business' => $business,
'products' => $products,
'recipes' => $recipes,
'workCenters' => $workCenters,
]);
}
public function store(Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'mfg_recipe_id' => 'nullable|exists:mfg_recipes,id',
'quantity_planned' => 'required|numeric|min:0.0001',
'uom' => 'required|string|max:50',
'scheduled_start_at' => 'nullable|date',
'scheduled_end_at' => 'nullable|date|after_or_equal:scheduled_start_at',
'notes' => 'nullable|string',
'operations' => 'array',
'operations.*.sequence' => 'required|integer|min:1',
'operations.*.operation_code' => 'required|string|max:50',
'operations.*.mfg_work_center_id' => 'nullable|exists:mfg_work_centers,id',
]);
$workOrder = MfgWorkOrder::create([
'business_id' => $business->id,
'product_id' => $validated['product_id'],
'mfg_recipe_id' => $validated['mfg_recipe_id'] ?? null,
'work_order_number' => MfgWorkOrder::generateWorkOrderNumber($business->id),
'status' => 'planned',
'quantity_planned' => $validated['quantity_planned'],
'uom' => $validated['uom'],
'scheduled_start_at' => $validated['scheduled_start_at'] ?? null,
'scheduled_end_at' => $validated['scheduled_end_at'] ?? null,
'notes' => $validated['notes'] ?? null,
]);
// Create operations
if (! empty($validated['operations'])) {
foreach ($validated['operations'] as $operation) {
MfgWorkOrderOperation::create([
'mfg_work_order_id' => $workOrder->id,
'sequence' => $operation['sequence'],
'operation_code' => $operation['operation_code'],
'mfg_work_center_id' => $operation['mfg_work_center_id'] ?? null,
'status' => 'pending',
]);
}
}
return redirect()
->route('seller.business.manufacturing.work-orders.show', [$business->slug, $workOrder->id])
->with('success', 'Work order created: '.$workOrder->work_order_number);
}
public function show(Business $business, MfgWorkOrder $workOrder): View
{
if ($workOrder->business_id !== $business->id) {
abort(403);
}
$workOrder->load(['product', 'recipe.components.componentProduct', 'operations.workCenter', 'batches']);
return view('seller.manufacturing.work-orders.show', [
'business' => $business,
'workOrder' => $workOrder,
]);
}
public function edit(Business $business, MfgWorkOrder $workOrder): View
{
if ($workOrder->business_id !== $business->id) {
abort(403);
}
$workOrder->load(['operations']);
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->orderBy('name')
->get();
$recipes = MfgRecipe::forBusiness($business->id)
->active()
->with('product')
->orderBy('created_at', 'desc')
->get();
$workCenters = MfgWorkCenter::forBusiness($business->id)
->active()
->orderBy('name')
->get();
return view('seller.manufacturing.work-orders.edit', [
'business' => $business,
'workOrder' => $workOrder,
'products' => $products,
'recipes' => $recipes,
'workCenters' => $workCenters,
]);
}
public function update(Business $business, MfgWorkOrder $workOrder, Request $request): RedirectResponse
{
if ($workOrder->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'mfg_recipe_id' => 'nullable|exists:mfg_recipes,id',
'quantity_planned' => 'required|numeric|min:0.0001',
'uom' => 'required|string|max:50',
'scheduled_start_at' => 'nullable|date',
'scheduled_end_at' => 'nullable|date|after_or_equal:scheduled_start_at',
'notes' => 'nullable|string',
]);
$workOrder->update($validated);
return redirect()
->route('seller.business.manufacturing.work-orders.show', [$business->slug, $workOrder->id])
->with('success', 'Work order updated.');
}
/**
* Start a work order.
*/
public function start(Business $business, MfgWorkOrder $workOrder): RedirectResponse
{
if ($workOrder->business_id !== $business->id) {
abort(403);
}
if (! $workOrder->start()) {
return back()->with('error', 'Cannot start this work order.');
}
return back()->with('success', 'Work order started.');
}
/**
* Complete a work order and create batch.
*/
public function complete(Business $business, MfgWorkOrder $workOrder, Request $request): RedirectResponse
{
if ($workOrder->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'quantity_completed' => 'required|numeric|min:0',
]);
if (! $workOrder->complete($validated['quantity_completed'])) {
return back()->with('error', 'Cannot complete this work order.');
}
// Create a batch for the completed work order
$batch = MfgBatch::create([
'business_id' => $business->id,
'batch_number' => MfgBatch::generateBatchNumber($business->id),
'product_id' => $workOrder->product_id,
'mfg_work_order_id' => $workOrder->id,
'status' => 'open',
'quantity_produced' => $validated['quantity_completed'],
'uom' => $workOrder->uom,
'manufactured_at' => now(),
]);
// Link batch to work order
$workOrder->update(['mfg_batch_id' => $batch->id]);
return redirect()
->route('seller.business.manufacturing.batches.show', [$business->slug, $batch->id])
->with('success', 'Work order completed. Batch '.$batch->batch_number.' created.');
}
public function destroy(Business $business, MfgWorkOrder $workOrder): RedirectResponse
{
if ($workOrder->business_id !== $business->id) {
abort(403);
}
if ($workOrder->status !== 'planned') {
return back()->with('error', 'Can only delete planned work orders.');
}
$workOrder->delete();
return redirect()
->route('seller.business.manufacturing.work-orders.index', $business->slug)
->with('success', 'Work order deleted.');
}
}

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Marketing\Campaign;
use App\Models\Marketing\MarketingChannel;
use App\Models\Marketing\MarketingPromo;
use App\Models\Marketing\MarketingTemplate;
use App\Services\AI\TemplatePromptBuilder;
use App\Services\Marketing\AIContentService;
@@ -45,13 +46,21 @@ class CampaignController extends Controller
$preselectedSegment = $request->query('segment');
$preselectedBrand = $request->query('brand_id');
// Pre-populate from Promo if promo_id provided
$promo = null;
if ($request->query('promo_id')) {
$promo = MarketingPromo::where('business_id', $business->id)
->find($request->query('promo_id'));
}
return view('seller.marketing.campaigns.create', compact(
'business',
'brands',
'channels',
'templates',
'preselectedSegment',
'preselectedBrand'
'preselectedBrand',
'promo'
));
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Services\Marketing\MarketingIntelligenceService;
use Illuminate\Http\Request;
/**
* Marketing Intelligence Controller
*
* Displays market intelligence data from CannaiQ including:
* - Store-level metrics (pricing position, market share, trends)
* - Product metrics (velocity, pricing history, competitor positioning)
* - Competitor snapshots (out-of-stock, pricing, promotions)
*/
class IntelligenceController extends Controller
{
protected MarketingIntelligenceService $intelligence;
public function __construct(MarketingIntelligenceService $intelligence)
{
$this->intelligence = $intelligence;
}
/**
* Display the marketing intelligence dashboard
*/
public function index(Request $request)
{
$business = currentBusiness();
// Get brands for filtering
$brands = Brand::where('business_id', $business->id)
->orderBy('name')
->get();
// Get store external ID from business settings or request
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
// Fetch intelligence data if store is configured
$storeMetrics = [];
$productMetrics = [];
$competitorSnapshot = [];
$trends = [];
if ($storeExternalId) {
$storeMetrics = $this->intelligence->getStoreIntelligence($business->id, $storeExternalId);
$productMetrics = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 20);
$competitorSnapshot = $this->intelligence->getCompetitorSnapshot($business->id, $storeExternalId);
$trends = $this->intelligence->getMarketTrends($business->id, $storeExternalId);
}
return view('seller.marketing.intelligence.index', compact(
'business',
'brands',
'storeExternalId',
'storeMetrics',
'productMetrics',
'competitorSnapshot',
'trends'
));
}
/**
* Display store-level intelligence details
*/
public function store(Request $request, $businessSlug, string $storeExternalId)
{
$business = currentBusiness();
$storeData = $this->intelligence->getStoreIntelligence($business->id, $storeExternalId);
$productMetrics = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 50);
$trends = $this->intelligence->getMarketTrends($business->id, $storeExternalId);
return view('seller.marketing.intelligence.store', compact(
'business',
'storeExternalId',
'storeData',
'productMetrics',
'trends'
));
}
/**
* Display product-level intelligence details
*/
public function product(Request $request, $businessSlug, string $productExternalId)
{
$business = currentBusiness();
// Get store context
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
$productData = [];
$priceHistory = [];
$competitorPricing = [];
if ($storeExternalId) {
// Get product data from cached metrics
$allProducts = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 100);
$products = $allProducts['products'] ?? [];
// Find the specific product
$productData = collect($products)->firstWhere('product_id', $productExternalId) ?? [];
// Price history would come from historical snapshots
// For now, placeholder
$priceHistory = [];
$competitorPricing = [];
}
return view('seller.marketing.intelligence.product', compact(
'business',
'productExternalId',
'productData',
'priceHistory',
'competitorPricing'
));
}
/**
* Refresh intelligence data from CannaiQ
*/
public function refresh(Request $request, $businessSlug)
{
$business = currentBusiness();
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
if (! $storeExternalId) {
return redirect()
->route('seller.business.marketing.intelligence.index', $business->slug)
->with('error', 'No store configured for intelligence data.');
}
$results = $this->intelligence->refreshIntelligence($business->id, $storeExternalId);
$successCount = count(array_filter($results));
$message = $successCount > 0
? "Intelligence data refreshed ({$successCount}/3 data sources updated)."
: 'Failed to refresh intelligence data. Please try again later.';
return redirect()
->route('seller.business.marketing.intelligence.index', $business->slug)
->with($successCount > 0 ? 'success' : 'error', $message);
}
}

View File

@@ -0,0 +1,338 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Jobs\SendMarketingCampaignJob;
use App\Models\Business;
use App\Models\Marketing\MarketingCampaign;
use App\Models\Marketing\MarketingList;
use App\Models\Marketing\MarketingPromo;
use App\Services\Messaging\EmailSender;
use App\Services\Messaging\SmsSender;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MarketingCampaignController extends Controller
{
public function index(Request $request, Business $business): View
{
$query = MarketingCampaign::forBusiness($business->id)
->with('list')
->orderBy('created_at', 'desc');
if ($request->filled('status')) {
$query->where('status', $request->status);
}
if ($request->filled('channel')) {
$query->channel($request->channel);
}
$campaigns = $query->paginate(25)->withQueryString();
return view('seller.marketing.campaigns.index', [
'business' => $business,
'campaigns' => $campaigns,
'statuses' => MarketingCampaign::STATUSES,
'channels' => MarketingCampaign::CHANNELS,
'filters' => $request->only(['status', 'channel']),
]);
}
public function create(Request $request, Business $business): View
{
$lists = MarketingList::forBusiness($business->id)->get();
// Pre-fill from promo if source=promo
$prefill = [];
if ($request->source === 'promo' && $request->promo_id) {
$promo = MarketingPromo::forBusiness($business->id)->find($request->promo_id);
if ($promo) {
$prefill = $this->prefillFromPromo($promo, $request->channel ?? 'email');
}
}
return view('seller.marketing.campaigns.create', [
'business' => $business,
'lists' => $lists,
'channels' => MarketingCampaign::CHANNELS,
'prefill' => $prefill,
'source' => $request->source,
'sourceId' => $request->promo_id,
]);
}
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'channel' => 'required|in:email,sms,multi',
'marketing_list_id' => 'nullable|exists:marketing_lists,id',
'subject' => 'nullable|string|max:255',
'email_preview_text' => 'nullable|string|max:255',
'sms_body' => 'nullable|string|max:1600',
'email_body_html' => 'nullable|string',
'from_name' => 'nullable|string|max:255',
'from_email' => 'nullable|email|max:255',
'source_type' => 'nullable|string|in:manual,promo,automation',
'source_id' => 'nullable|integer',
]);
// Verify list belongs to business
if ($validated['marketing_list_id']) {
$list = MarketingList::where('business_id', $business->id)
->find($validated['marketing_list_id']);
if (! $list) {
return back()->withErrors(['marketing_list_id' => 'Invalid list selected.'])->withInput();
}
}
$campaign = MarketingCampaign::create([
'business_id' => $business->id,
'name' => $validated['name'],
'channel' => $validated['channel'],
'status' => MarketingCampaign::STATUS_DRAFT,
'marketing_list_id' => $validated['marketing_list_id'] ?? null,
'subject' => $validated['subject'] ?? null,
'email_preview_text' => $validated['email_preview_text'] ?? null,
'sms_body' => $validated['sms_body'] ?? null,
'email_body_html' => $validated['email_body_html'] ?? null,
'from_name' => $validated['from_name'] ?? null,
'from_email' => $validated['from_email'] ?? null,
'source_type' => $validated['source_type'] ?? MarketingCampaign::SOURCE_MANUAL,
'source_id' => $validated['source_id'] ?? null,
]);
return redirect()
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
->with('success', 'Campaign created successfully.');
}
public function show(Business $business, MarketingCampaign $campaign): View
{
$this->authorizeCampaign($business, $campaign);
$campaign->load('list', 'messageLogs');
return view('seller.marketing.campaigns.show', [
'business' => $business,
'campaign' => $campaign,
]);
}
public function edit(Business $business, MarketingCampaign $campaign): View
{
$this->authorizeCampaign($business, $campaign);
if (! $campaign->canEdit()) {
return redirect()
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
->with('error', 'Cannot edit a campaign that is sending or sent.');
}
$lists = MarketingList::forBusiness($business->id)->get();
return view('seller.marketing.campaigns.edit', [
'business' => $business,
'campaign' => $campaign,
'lists' => $lists,
'channels' => MarketingCampaign::CHANNELS,
]);
}
public function update(Request $request, Business $business, MarketingCampaign $campaign): RedirectResponse
{
$this->authorizeCampaign($business, $campaign);
if (! $campaign->canEdit()) {
return back()->with('error', 'Cannot edit a campaign that is sending or sent.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'channel' => 'required|in:email,sms,multi',
'marketing_list_id' => 'nullable|exists:marketing_lists,id',
'subject' => 'nullable|string|max:255',
'email_preview_text' => 'nullable|string|max:255',
'sms_body' => 'nullable|string|max:1600',
'email_body_html' => 'nullable|string',
'from_name' => 'nullable|string|max:255',
'from_email' => 'nullable|email|max:255',
]);
// Verify list belongs to business
if ($validated['marketing_list_id']) {
$list = MarketingList::where('business_id', $business->id)
->find($validated['marketing_list_id']);
if (! $list) {
return back()->withErrors(['marketing_list_id' => 'Invalid list selected.'])->withInput();
}
}
$campaign->update([
'name' => $validated['name'],
'channel' => $validated['channel'],
'marketing_list_id' => $validated['marketing_list_id'] ?? null,
'subject' => $validated['subject'] ?? null,
'email_preview_text' => $validated['email_preview_text'] ?? null,
'sms_body' => $validated['sms_body'] ?? null,
'email_body_html' => $validated['email_body_html'] ?? null,
'from_name' => $validated['from_name'] ?? null,
'from_email' => $validated['from_email'] ?? null,
]);
return redirect()
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
->with('success', 'Campaign updated successfully.');
}
public function schedule(Request $request, Business $business, MarketingCampaign $campaign): RedirectResponse
{
$this->authorizeCampaign($business, $campaign);
if (! $campaign->canSchedule()) {
return back()->with('error', 'Cannot schedule this campaign.');
}
$validated = $request->validate([
'send_at' => 'required|date|after:now',
]);
$campaign->schedule(new \DateTime($validated['send_at']));
return back()->with('success', 'Campaign scheduled for '.date('M j, Y g:i A', strtotime($validated['send_at'])));
}
public function sendNow(Business $business, MarketingCampaign $campaign): RedirectResponse
{
$this->authorizeCampaign($business, $campaign);
if (! $campaign->canSend()) {
return back()->with('error', 'Cannot send this campaign. Make sure a list is selected and campaign is in draft status.');
}
SendMarketingCampaignJob::dispatch($campaign->id);
return back()->with('success', 'Campaign is now being sent.');
}
public function cancel(Business $business, MarketingCampaign $campaign): RedirectResponse
{
$this->authorizeCampaign($business, $campaign);
if (! $campaign->canCancel()) {
return back()->with('error', 'Cannot cancel this campaign.');
}
$campaign->cancel();
return back()->with('success', 'Campaign cancelled.');
}
public function testEmail(Request $request, Business $business, MarketingCampaign $campaign, EmailSender $emailSender): JsonResponse
{
$this->authorizeCampaign($business, $campaign);
$validated = $request->validate([
'email' => 'required|email',
]);
if (! $campaign->hasEmailContent()) {
return response()->json(['success' => false, 'message' => 'Campaign has no email content.']);
}
$result = $emailSender->sendTestEmail($campaign, $validated['email']);
return response()->json($result);
}
public function testSms(Request $request, Business $business, MarketingCampaign $campaign, SmsSender $smsSender): JsonResponse
{
$this->authorizeCampaign($business, $campaign);
$validated = $request->validate([
'phone' => 'required|string',
]);
if (! $campaign->hasSmsContent()) {
return response()->json(['success' => false, 'message' => 'Campaign has no SMS content.']);
}
$result = $smsSender->sendTestSms($campaign, $validated['phone']);
return response()->json($result);
}
public function destroy(Business $business, MarketingCampaign $campaign): RedirectResponse
{
$this->authorizeCampaign($business, $campaign);
if ($campaign->status === MarketingCampaign::STATUS_SENDING) {
return back()->with('error', 'Cannot delete a campaign that is currently sending.');
}
$campaign->delete();
return redirect()
->route('seller.business.marketing.campaigns.index', $business)
->with('success', 'Campaign deleted successfully.');
}
protected function authorizeCampaign(Business $business, MarketingCampaign $campaign): void
{
if ($campaign->business_id !== $business->id) {
abort(404);
}
}
protected function prefillFromPromo(MarketingPromo $promo, string $channel): array
{
$name = $promo->name.' - '.($channel === 'sms' ? 'SMS' : 'Email').' Blast';
$subject = $promo->name;
// Build simple description from promo
$description = $promo->description ?? '';
$dateRange = '';
if ($promo->start_date && $promo->end_date) {
$dateRange = 'Valid '.$promo->start_date->format('M j').' - '.$promo->end_date->format('M j');
}
// Simple email template
$emailHtml = <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{$promo->name}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #1a1a1a;">{$promo->name}</h1>
<p>{$description}</p>
<p style="font-weight: bold; color: #059669;">{$dateRange}</p>
<p>Don't miss out on this limited time offer!</p>
</body>
</html>
HTML;
// Simple SMS
$smsBody = $promo->name;
if ($description) {
$smsBody .= ' - '.substr($description, 0, 100);
}
if ($dateRange) {
$smsBody .= '. '.$dateRange;
}
return [
'name' => $name,
'subject' => $subject,
'email_body_html' => $emailHtml,
'sms_body' => $smsBody,
'source_type' => MarketingCampaign::SOURCE_PROMO,
'source_id' => $promo->id,
];
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Marketing\MarketingContact;
use App\Models\Marketing\MarketingList;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MarketingContactController extends Controller
{
public function index(Request $request, Business $business): View
{
$query = MarketingContact::forBusiness($business->id)
->orderBy('created_at', 'desc');
if ($request->filled('type')) {
$query->ofType($request->type);
}
if ($request->filled('subscribed')) {
if ($request->subscribed === 'email') {
$query->subscribedEmail();
} elseif ($request->subscribed === 'sms') {
$query->subscribedSms();
}
}
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('email', 'ILIKE', "%{$search}%")
->orWhere('phone', 'ILIKE', "%{$search}%")
->orWhere('first_name', 'ILIKE', "%{$search}%")
->orWhere('last_name', 'ILIKE', "%{$search}%");
});
}
$contacts = $query->paginate(25)->withQueryString();
$lists = MarketingList::forBusiness($business->id)->get();
return view('seller.marketing.contacts.index', [
'business' => $business,
'contacts' => $contacts,
'lists' => $lists,
'types' => MarketingContact::TYPES,
'filters' => $request->only(['type', 'subscribed', 'search']),
]);
}
public function create(Business $business): View
{
return view('seller.marketing.contacts.create', [
'business' => $business,
'types' => MarketingContact::TYPES,
'sources' => MarketingContact::SOURCES,
]);
}
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'type' => 'required|in:buyer,consumer,internal',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:20',
'first_name' => 'nullable|string|max:100',
'last_name' => 'nullable|string|max:100',
'tags' => 'nullable|array',
'is_subscribed_email' => 'boolean',
'is_subscribed_sms' => 'boolean',
]);
if (empty($validated['email']) && empty($validated['phone'])) {
return back()->withErrors(['email' => 'Either email or phone is required.'])->withInput();
}
$contact = MarketingContact::create([
'business_id' => $business->id,
'type' => $validated['type'],
'email' => $validated['email'] ?? null,
'phone' => $validated['phone'] ?? null,
'first_name' => $validated['first_name'] ?? null,
'last_name' => $validated['last_name'] ?? null,
'tags' => $validated['tags'] ?? [],
'source' => MarketingContact::SOURCE_MANUAL,
'is_subscribed_email' => $validated['is_subscribed_email'] ?? true,
'is_subscribed_sms' => $validated['is_subscribed_sms'] ?? true,
]);
return redirect()
->route('seller.business.marketing.contacts.index', $business)
->with('success', 'Contact created successfully.');
}
public function edit(Business $business, MarketingContact $contact): View
{
$this->authorizeContact($business, $contact);
return view('seller.marketing.contacts.edit', [
'business' => $business,
'contact' => $contact,
'types' => MarketingContact::TYPES,
'sources' => MarketingContact::SOURCES,
]);
}
public function update(Request $request, Business $business, MarketingContact $contact): RedirectResponse
{
$this->authorizeContact($business, $contact);
$validated = $request->validate([
'type' => 'required|in:buyer,consumer,internal',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:20',
'first_name' => 'nullable|string|max:100',
'last_name' => 'nullable|string|max:100',
'tags' => 'nullable|array',
'is_subscribed_email' => 'boolean',
'is_subscribed_sms' => 'boolean',
]);
if (empty($validated['email']) && empty($validated['phone'])) {
return back()->withErrors(['email' => 'Either email or phone is required.'])->withInput();
}
$contact->update([
'type' => $validated['type'],
'email' => $validated['email'] ?? null,
'phone' => $validated['phone'] ?? null,
'first_name' => $validated['first_name'] ?? null,
'last_name' => $validated['last_name'] ?? null,
'tags' => $validated['tags'] ?? [],
'is_subscribed_email' => $validated['is_subscribed_email'] ?? true,
'is_subscribed_sms' => $validated['is_subscribed_sms'] ?? true,
]);
return redirect()
->route('seller.business.marketing.contacts.index', $business)
->with('success', 'Contact updated successfully.');
}
public function destroy(Business $business, MarketingContact $contact): RedirectResponse
{
$this->authorizeContact($business, $contact);
$contact->delete();
return redirect()
->route('seller.business.marketing.contacts.index', $business)
->with('success', 'Contact deleted successfully.');
}
public function addToList(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'contact_ids' => 'required|array',
'contact_ids.*' => 'integer|exists:marketing_contacts,id',
'list_id' => 'required|integer|exists:marketing_lists,id',
]);
$list = MarketingList::where('business_id', $business->id)
->findOrFail($validated['list_id']);
$contacts = MarketingContact::forBusiness($business->id)
->whereIn('id', $validated['contact_ids'])
->pluck('id');
$list->addContacts($contacts->toArray());
return back()->with('success', count($contacts).' contact(s) added to list.');
}
protected function authorizeContact(Business $business, MarketingContact $contact): void
{
if ($contact->business_id !== $business->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Marketing\MarketingList;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MarketingListController extends Controller
{
public function index(Business $business): View
{
$lists = MarketingList::forBusiness($business->id)
->withCount('contacts')
->orderBy('created_at', 'desc')
->paginate(25);
return view('seller.marketing.lists.index', [
'business' => $business,
'lists' => $lists,
]);
}
public function create(Business $business): View
{
return view('seller.marketing.lists.create', [
'business' => $business,
'types' => MarketingList::TYPES,
]);
}
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'type' => 'required|in:static,smart',
'filters' => 'nullable|array',
]);
MarketingList::create([
'business_id' => $business->id,
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'type' => $validated['type'],
'filters' => $validated['filters'] ?? null,
]);
return redirect()
->route('seller.business.marketing.lists.index', $business)
->with('success', 'List created successfully.');
}
public function show(Business $business, MarketingList $list): View
{
$this->authorizeList($business, $list);
$contacts = $list->getContacts()->paginate(25);
return view('seller.marketing.lists.show', [
'business' => $business,
'list' => $list,
'contacts' => $contacts,
]);
}
public function edit(Business $business, MarketingList $list): View
{
$this->authorizeList($business, $list);
return view('seller.marketing.lists.edit', [
'business' => $business,
'list' => $list,
'types' => MarketingList::TYPES,
]);
}
public function update(Request $request, Business $business, MarketingList $list): RedirectResponse
{
$this->authorizeList($business, $list);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'filters' => 'nullable|array',
]);
$list->update([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'filters' => $validated['filters'] ?? null,
]);
return redirect()
->route('seller.business.marketing.lists.index', $business)
->with('success', 'List updated successfully.');
}
public function destroy(Business $business, MarketingList $list): RedirectResponse
{
$this->authorizeList($business, $list);
$list->delete();
return redirect()
->route('seller.business.marketing.lists.index', $business)
->with('success', 'List deleted successfully.');
}
public function removeContact(Business $business, MarketingList $list, int $contactId): RedirectResponse
{
$this->authorizeList($business, $list);
$list->removeContacts([$contactId]);
return back()->with('success', 'Contact removed from list.');
}
protected function authorizeList(Business $business, MarketingList $list): void
{
if ($list->business_id !== $business->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,382 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Marketing\MarketingPromo;
use App\Services\Marketing\PromoRecommendationService;
use Illuminate\Http\Request;
/**
* Promo Builder Controller
*
* Manages promotional offers including:
* - Creating promos with AI recommendations
* - Targeting stores, brands, or categories
* - Estimating lift and margin impact
* - Generating SMS/email copy
*/
class PromoController extends Controller
{
protected PromoRecommendationService $recommendations;
public function __construct(PromoRecommendationService $recommendations)
{
$this->recommendations = $recommendations;
}
/**
* Display list of all promos
*/
public function index(Request $request)
{
$business = currentBusiness();
$promos = MarketingPromo::forBusiness($business->id)
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->when($request->type, fn ($q, $type) => $q->where('type', $type))
->when($request->brand_id, fn ($q, $brandId) => $q->where('brand_id', $brandId))
->with(['brand', 'creator'])
->orderByDesc('created_at')
->paginate(20);
$brands = Brand::where('business_id', $business->id)
->orderBy('name')
->get();
$promoTypes = MarketingPromo::getTypes();
$statuses = MarketingPromo::getStatuses();
return view('seller.marketing.promos.index', compact(
'business',
'promos',
'brands',
'promoTypes',
'statuses'
));
}
/**
* Show create promo form (Promo Builder wizard)
*/
public function create(Request $request)
{
$business = currentBusiness();
$brands = Brand::where('business_id', $business->id)
->orderBy('name')
->get();
$promoTypes = MarketingPromo::getTypes();
// Get AI recommendations
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
$recommendations = $this->recommendations->getRecommendations($business->id, $storeExternalId);
return view('seller.marketing.promos.create', compact(
'business',
'brands',
'promoTypes',
'recommendations'
));
}
/**
* Store a new promo
*/
public function store(Request $request)
{
$business = currentBusiness();
$validated = $request->validate([
'name' => 'required|string|max:255',
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
'brand_id' => 'nullable|exists:brands,id',
'store_external_id' => 'nullable|string|max:100',
'config' => 'required|array',
'expected_lift' => 'nullable|numeric|min:0|max:100',
'expected_margin_brand' => 'nullable|numeric',
'expected_margin_store' => 'nullable|numeric',
'starts_at' => 'nullable|date',
'ends_at' => 'nullable|date|after_or_equal:starts_at',
'description' => 'nullable|string|max:1000',
'sms_copy' => 'nullable|string|max:160',
'email_copy' => 'nullable|string|max:5000',
]);
// Verify brand belongs to business if provided
if ($validated['brand_id']) {
$brand = Brand::where('business_id', $business->id)
->where('id', $validated['brand_id'])
->first();
if (! $brand) {
abort(404, 'Brand not found');
}
}
$promo = MarketingPromo::create([
'business_id' => $business->id,
'name' => $validated['name'],
'type' => $validated['type'],
'brand_id' => $validated['brand_id'] ?? null,
'store_external_id' => $validated['store_external_id'] ?? null,
'config' => $validated['config'],
'expected_lift' => $validated['expected_lift'] ?? null,
'expected_margin_brand' => $validated['expected_margin_brand'] ?? null,
'expected_margin_store' => $validated['expected_margin_store'] ?? null,
'starts_at' => $validated['starts_at'] ?? null,
'ends_at' => $validated['ends_at'] ?? null,
'description' => $validated['description'] ?? null,
'sms_copy' => $validated['sms_copy'] ?? null,
'email_copy' => $validated['email_copy'] ?? null,
'status' => MarketingPromo::STATUS_DRAFT,
'created_by' => auth()->id(),
]);
return redirect()
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
->with('success', 'Promo created successfully.');
}
/**
* Display a single promo
*/
public function show(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$promo->load(['brand', 'creator']);
return view('seller.marketing.promos.show', compact('business', 'promo'));
}
/**
* Show edit form for a promo
*/
public function edit(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$brands = Brand::where('business_id', $business->id)
->orderBy('name')
->get();
$promoTypes = MarketingPromo::getTypes();
return view('seller.marketing.promos.edit', compact(
'business',
'promo',
'brands',
'promoTypes'
));
}
/**
* Update a promo
*/
public function update(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'brand_id' => 'nullable|exists:brands,id',
'store_external_id' => 'nullable|string|max:100',
'config' => 'nullable|array',
'expected_lift' => 'nullable|numeric|min:0|max:100',
'expected_margin_brand' => 'nullable|numeric',
'expected_margin_store' => 'nullable|numeric',
'starts_at' => 'nullable|date',
'ends_at' => 'nullable|date|after_or_equal:starts_at',
'description' => 'nullable|string|max:1000',
'sms_copy' => 'nullable|string|max:160',
'email_copy' => 'nullable|string|max:5000',
]);
// Verify brand belongs to business if provided
if ($validated['brand_id']) {
$brand = Brand::where('business_id', $business->id)
->where('id', $validated['brand_id'])
->first();
if (! $brand) {
abort(404, 'Brand not found');
}
}
$promo->update([
'name' => $validated['name'],
'brand_id' => $validated['brand_id'] ?? null,
'store_external_id' => $validated['store_external_id'] ?? null,
'config' => $validated['config'] ?? $promo->config,
'expected_lift' => $validated['expected_lift'] ?? $promo->expected_lift,
'expected_margin_brand' => $validated['expected_margin_brand'] ?? $promo->expected_margin_brand,
'expected_margin_store' => $validated['expected_margin_store'] ?? $promo->expected_margin_store,
'starts_at' => $validated['starts_at'] ?? $promo->starts_at,
'ends_at' => $validated['ends_at'] ?? $promo->ends_at,
'description' => $validated['description'] ?? $promo->description,
'sms_copy' => $validated['sms_copy'] ?? $promo->sms_copy,
'email_copy' => $validated['email_copy'] ?? $promo->email_copy,
]);
return redirect()
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
->with('success', 'Promo updated successfully.');
}
/**
* Delete a promo
*/
public function destroy(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$promo->delete();
return redirect()
->route('seller.business.marketing.promos.index', $business->slug)
->with('success', 'Promo deleted successfully.');
}
/**
* Activate a promo
*/
public function activate(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$promo->activate();
return redirect()
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
->with('success', 'Promo activated successfully.');
}
/**
* Cancel a promo
*/
public function cancel(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$promo->cancel();
return redirect()
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
->with('success', 'Promo cancelled.');
}
/**
* Duplicate a promo
*/
public function duplicate(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$newPromo = $promo->replicate();
$newPromo->name = $promo->name.' (Copy)';
$newPromo->status = MarketingPromo::STATUS_DRAFT;
$newPromo->created_by = auth()->id();
$newPromo->save();
return redirect()
->route('seller.business.marketing.promos.edit', [$business->slug, $newPromo])
->with('success', 'Promo duplicated. Make your changes and save.');
}
/**
* Get AI recommendations for promo
*/
public function recommend(Request $request, $businessSlug)
{
$business = currentBusiness();
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
$recommendations = $this->recommendations->getRecommendations($business->id, $storeExternalId);
return response()->json([
'success' => true,
'recommendations' => $recommendations,
]);
}
/**
* Estimate promo impact
*/
public function estimate(Request $request, $businessSlug)
{
$business = currentBusiness();
$validated = $request->validate([
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
'config' => 'required|array',
'brand_id' => 'nullable|exists:brands,id',
'store_external_id' => 'nullable|string',
]);
$estimate = $this->recommendations->estimateImpact(
$validated,
$business->id,
$validated['store_external_id'] ?? $business->cannaiq_store_id ?? null
);
return response()->json([
'success' => true,
'estimate' => $estimate,
]);
}
/**
* Generate SMS/email copy for promo
*/
public function generateCopy(Request $request, $businessSlug)
{
$business = currentBusiness();
$validated = $request->validate([
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
'config' => 'required|array',
'channel' => 'required|in:sms,email',
'brand_id' => 'nullable|exists:brands,id',
]);
// Get brand name for copy generation
$brandName = null;
if ($validated['brand_id']) {
$brand = Brand::where('business_id', $business->id)
->where('id', $validated['brand_id'])
->first();
$brandName = $brand?->name;
}
$promoConfig = array_merge($validated, ['brand_name' => $brandName ?? 'our products']);
$copy = $validated['channel'] === 'sms'
? $this->recommendations->generateSmsCopy($promoConfig)
: $this->recommendations->generateEmailCopy($promoConfig);
return response()->json([
'success' => true,
'copy' => $copy,
]);
}
/**
* Authorize that the promo belongs to the business
*/
protected function authorizePromo(MarketingPromo $promo, $business): void
{
if ($promo->business_id !== $business->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Jobs\RunMarketingAutomationJob;
use App\Models\Business;
use App\Models\Marketing\MarketingAutomation;
use App\Models\Marketing\MarketingList;
use App\Models\Marketing\MarketingTemplate;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MarketingAutomationController extends Controller
{
public function index(Request $request, Business $business)
{
$this->authorizeForBusiness($business);
$automations = MarketingAutomation::where('business_id', $business->id)
->with('latestRun')
->when($request->status === 'active', fn ($q) => $q->where('is_active', true))
->when($request->status === 'inactive', fn ($q) => $q->where('is_active', false))
->when($request->trigger_type, fn ($q, $type) => $q->where('trigger_type', $type))
->latest()
->paginate(15);
return view('seller.marketing.automations.index', compact('business', 'automations'));
}
public function create(Request $request, Business $business)
{
$this->authorizeForBusiness($business);
$presets = MarketingAutomation::getTypePresets();
$selectedPreset = $request->query('preset');
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->get();
$templates = MarketingTemplate::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.marketing.automations.create', compact(
'business',
'presets',
'selectedPreset',
'lists',
'templates'
));
}
public function store(Request $request, Business $business)
{
$this->authorizeForBusiness($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'scope' => 'required|in:internal,portal',
'trigger_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::TRIGGER_TYPES)),
'trigger_config' => 'required|json',
'condition_config' => 'required|json',
'action_config' => 'required|json',
]);
// Decode JSON configs from the form
$triggerConfig = json_decode($validated['trigger_config'], true) ?? [];
$conditionConfig = json_decode($validated['condition_config'], true) ?? [];
$actionConfig = json_decode($validated['action_config'], true) ?? [];
// Normalize condition config - convert percentage values
if (isset($conditionConfig['min_price_advantage']) && $conditionConfig['min_price_advantage'] > 1) {
$conditionConfig['min_price_advantage'] = $conditionConfig['min_price_advantage'] / 100;
}
// Map velocity_threshold to velocity_30d_threshold for slow mover clearance
if (isset($conditionConfig['velocity_threshold'])) {
$conditionConfig['velocity_30d_threshold'] = $conditionConfig['velocity_threshold'];
unset($conditionConfig['velocity_threshold']);
}
$automation = MarketingAutomation::create([
'business_id' => $business->id,
'name' => $validated['name'],
'description' => $validated['description'],
'is_active' => true,
'scope' => $validated['scope'],
'trigger_type' => $validated['trigger_type'],
'trigger_config' => $triggerConfig,
'condition_config' => $conditionConfig,
'action_config' => $actionConfig,
]);
return redirect()
->route('seller.business.marketing.automations.index', $business)
->with('success', "Automation \"{$automation->name}\" created successfully.");
}
public function edit(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$presets = MarketingAutomation::getTypePresets();
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->get();
$templates = MarketingTemplate::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.marketing.automations.edit', compact(
'business',
'automation',
'presets',
'lists',
'templates'
));
}
public function update(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'scope' => 'required|in:internal,portal',
'trigger_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::TRIGGER_TYPES)),
'trigger_config' => 'required|json',
'condition_config' => 'required|json',
'action_config' => 'required|json',
]);
// Decode JSON configs from the form
$triggerConfig = json_decode($validated['trigger_config'], true) ?? [];
$conditionConfig = json_decode($validated['condition_config'], true) ?? [];
$actionConfig = json_decode($validated['action_config'], true) ?? [];
// Normalize condition config - convert percentage values
if (isset($conditionConfig['min_price_advantage']) && $conditionConfig['min_price_advantage'] > 1) {
$conditionConfig['min_price_advantage'] = $conditionConfig['min_price_advantage'] / 100;
}
// Map velocity_threshold to velocity_30d_threshold for slow mover clearance
if (isset($conditionConfig['velocity_threshold'])) {
$conditionConfig['velocity_30d_threshold'] = $conditionConfig['velocity_threshold'];
unset($conditionConfig['velocity_threshold']);
}
$automation->update([
'name' => $validated['name'],
'description' => $validated['description'],
'scope' => $validated['scope'],
'trigger_type' => $validated['trigger_type'],
'trigger_config' => $triggerConfig,
'condition_config' => $conditionConfig,
'action_config' => $actionConfig,
]);
return redirect()
->route('seller.business.marketing.automations.index', $business)
->with('success', "Automation \"{$automation->name}\" updated successfully.");
}
public function toggle(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$automation->update([
'is_active' => ! $automation->is_active,
]);
$status = $automation->is_active ? 'enabled' : 'disabled';
return redirect()
->back()
->with('success', "Automation \"{$automation->name}\" has been {$status}.");
}
public function runNow(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
if (! $automation->is_active) {
return redirect()
->back()
->with('error', 'Cannot run an inactive automation. Enable it first.');
}
// Dispatch the job
RunMarketingAutomationJob::dispatch($automation->id);
return redirect()
->route('seller.business.marketing.automations.runs.index', [$business, $automation])
->with('success', "Automation \"{$automation->name}\" has been queued to run.");
}
public function destroy(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$name = $automation->name;
$automation->delete();
return redirect()
->route('seller.business.marketing.automations.index', $business)
->with('success', "Automation \"{$name}\" has been deleted.");
}
protected function authorizeForBusiness(Business $business): void
{
$user = Auth::user();
// Check user has access to this business
if (! $user->businesses->contains($business->id) && ! $user->hasRole('Super Admin')) {
abort(403, 'Unauthorized access to this business.');
}
}
protected function ensureAutomationBelongsToBusiness(MarketingAutomation $automation, Business $business): void
{
if ($automation->business_id !== $business->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Marketing\MarketingAutomation;
use App\Models\Marketing\MarketingAutomationRun;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MarketingAutomationRunController extends Controller
{
public function index(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$runs = MarketingAutomationRun::where('marketing_automation_id', $automation->id)
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->orderBy('started_at', 'desc')
->paginate(25);
return view('seller.marketing.automations.runs.index', compact(
'business',
'automation',
'runs'
));
}
public function show(Request $request, Business $business, MarketingAutomation $automation, MarketingAutomationRun $run)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$this->ensureRunBelongsToAutomation($run, $automation);
return view('seller.marketing.automations.runs.show', compact(
'business',
'automation',
'run'
));
}
protected function authorizeForBusiness(Business $business): void
{
$user = Auth::user();
if (! $user->businesses->contains($business->id) && ! $user->hasRole('Super Admin')) {
abort(403, 'Unauthorized access to this business.');
}
}
protected function ensureAutomationBelongsToBusiness(MarketingAutomation $automation, Business $business): void
{
if ($automation->business_id !== $business->id) {
abort(404);
}
}
protected function ensureRunBelongsToAutomation(MarketingAutomationRun $run, MarketingAutomation $automation): void
{
if ($run->marketing_automation_id !== $automation->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Seller\Processing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Processing\ProcBiomassLot;
use App\Models\Processing\ProcVendor;
use App\Models\Product;
use Illuminate\Http\Request;
class BiomassController extends Controller
{
public function index(Request $request, Business $business)
{
$query = ProcBiomassLot::forBusiness($business->id)
->with('product')
->orderByDesc('created_at');
if ($request->filled('status')) {
$query->where('status', $request->status);
}
if ($request->filled('search')) {
$query->where('lot_number', 'ilike', '%'.$request->search.'%');
}
$biomassLots = $query->paginate(25);
return view('seller.processing.biomass.index', compact('business', 'biomassLots'));
}
public function create(Request $request, Business $business)
{
$vendors = ProcVendor::forBusiness($business->id)->active()->get();
$products = Product::where('business_id', $business->id)->get(); // Biomass product types
return view('seller.processing.biomass.create', compact('business', 'vendors', 'products'));
}
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'lot_number' => 'required|string|max:100',
'product_id' => 'nullable|exists:products,id',
'source_type' => 'required|in:internal,external_vendor,internal_business',
'source_id' => 'nullable|integer',
'wet_weight' => 'required|numeric|min:0',
'dry_weight' => 'nullable|numeric|min:0',
'moisture_percent' => 'nullable|numeric|min:0|max:100',
'thc_percent' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string',
]);
$validated['business_id'] = $business->id;
$validated['status'] = 'available';
ProcBiomassLot::create($validated);
return redirect()
->route('seller.processing.biomass.index', $business)
->with('success', 'Biomass lot created successfully.');
}
public function show(Request $request, Business $business, ProcBiomassLot $biomass)
{
$this->authorizeForBusiness($biomass, $business);
$biomass->load(['product', 'extractionRunInputs.extractionRun']);
return view('seller.processing.biomass.show', compact('business', 'biomass'));
}
public function edit(Request $request, Business $business, ProcBiomassLot $biomass)
{
$this->authorizeForBusiness($biomass, $business);
$vendors = ProcVendor::forBusiness($business->id)->active()->get();
$products = Product::where('business_id', $business->id)->get();
return view('seller.processing.biomass.edit', compact('business', 'biomass', 'vendors', 'products'));
}
public function update(Request $request, Business $business, ProcBiomassLot $biomass)
{
$this->authorizeForBusiness($biomass, $business);
$validated = $request->validate([
'lot_number' => 'required|string|max:100',
'product_id' => 'nullable|exists:products,id',
'source_type' => 'required|in:internal,external_vendor,internal_business',
'source_id' => 'nullable|integer',
'wet_weight' => 'required|numeric|min:0',
'dry_weight' => 'nullable|numeric|min:0',
'moisture_percent' => 'nullable|numeric|min:0|max:100',
'thc_percent' => 'nullable|numeric|min:0|max:100',
'status' => 'required|in:available,allocated,depleted,quarantined',
'notes' => 'nullable|string',
]);
$biomass->update($validated);
return redirect()
->route('seller.processing.biomass.show', [$business, $biomass])
->with('success', 'Biomass lot updated successfully.');
}
public function destroy(Request $request, Business $business, ProcBiomassLot $biomass)
{
$this->authorizeForBusiness($biomass, $business);
$biomass->delete();
return redirect()
->route('seller.processing.biomass.index', $business)
->with('success', 'Biomass lot deleted successfully.');
}
protected function authorizeForBusiness(ProcBiomassLot $biomass, Business $business): void
{
if ($biomass->business_id !== $business->id) {
abort(403, 'Unauthorized access to this biomass lot.');
}
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace App\Http\Controllers\Seller\Processing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Processing\ProcComplianceRecord;
use Illuminate\Http\Request;
class ComplianceController extends Controller
{
public function index(Request $request, Business $business)
{
$query = ProcComplianceRecord::forBusiness($business->id)
->orderByDesc('record_date');
if ($request->filled('record_type')) {
$query->where('record_type', $request->record_type);
}
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$records = $query->paginate(25);
return view('seller.processing.compliance.index', compact('business', 'records'));
}
public function create(Request $request, Business $business)
{
$recordTypes = [
'license_renewal' => 'License Renewal',
'inspection' => 'Inspection Report',
'audit' => 'Compliance Audit',
'incident' => 'Incident Report',
'training' => 'Training Record',
'sop_update' => 'SOP Update',
'equipment_cert' => 'Equipment Certification',
'waste_disposal' => 'Waste Disposal Log',
'visitor_log' => 'Visitor Log',
'other' => 'Other',
];
return view('seller.processing.compliance.create', compact('business', 'recordTypes'));
}
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'record_type' => 'required|string|max:50',
'title' => 'required|string|max:200',
'description' => 'nullable|string',
'record_date' => 'required|date',
'expiration_date' => 'nullable|date',
'reference_number' => 'nullable|string|max:100',
'inspector_name' => 'nullable|string|max:100',
'findings' => 'nullable|string',
'corrective_actions' => 'nullable|string',
'notes' => 'nullable|string',
]);
$validated['business_id'] = $business->id;
$validated['status'] = 'active';
ProcComplianceRecord::create($validated);
return redirect()
->route('seller.processing.compliance.index', $business)
->with('success', 'Compliance record created successfully.');
}
public function show(Request $request, Business $business, ProcComplianceRecord $compliance)
{
$this->authorizeForBusiness($compliance, $business);
return view('seller.processing.compliance.show', compact('business', 'compliance'));
}
public function edit(Request $request, Business $business, ProcComplianceRecord $compliance)
{
$this->authorizeForBusiness($compliance, $business);
$recordTypes = [
'license_renewal' => 'License Renewal',
'inspection' => 'Inspection Report',
'audit' => 'Compliance Audit',
'incident' => 'Incident Report',
'training' => 'Training Record',
'sop_update' => 'SOP Update',
'equipment_cert' => 'Equipment Certification',
'waste_disposal' => 'Waste Disposal Log',
'visitor_log' => 'Visitor Log',
'other' => 'Other',
];
return view('seller.processing.compliance.edit', compact('business', 'compliance', 'recordTypes'));
}
public function update(Request $request, Business $business, ProcComplianceRecord $compliance)
{
$this->authorizeForBusiness($compliance, $business);
$validated = $request->validate([
'record_type' => 'required|string|max:50',
'title' => 'required|string|max:200',
'description' => 'nullable|string',
'record_date' => 'required|date',
'expiration_date' => 'nullable|date',
'reference_number' => 'nullable|string|max:100',
'inspector_name' => 'nullable|string|max:100',
'findings' => 'nullable|string',
'corrective_actions' => 'nullable|string',
'status' => 'required|in:active,resolved,expired,archived',
'notes' => 'nullable|string',
]);
$compliance->update($validated);
return redirect()
->route('seller.processing.compliance.show', [$business, $compliance])
->with('success', 'Compliance record updated successfully.');
}
public function destroy(Request $request, Business $business, ProcComplianceRecord $compliance)
{
$this->authorizeForBusiness($compliance, $business);
$compliance->delete();
return redirect()
->route('seller.processing.compliance.index', $business)
->with('success', 'Compliance record deleted successfully.');
}
/**
* Show upcoming expirations and due items.
*/
public function dashboard(Request $request, Business $business)
{
$upcomingExpirations = ProcComplianceRecord::forBusiness($business->id)
->whereNotNull('expiration_date')
->where('expiration_date', '>=', now())
->where('expiration_date', '<=', now()->addDays(90))
->where('status', 'active')
->orderBy('expiration_date')
->get();
$expiredItems = ProcComplianceRecord::forBusiness($business->id)
->whereNotNull('expiration_date')
->where('expiration_date', '<', now())
->where('status', 'active')
->orderByDesc('expiration_date')
->get();
$recentRecords = ProcComplianceRecord::forBusiness($business->id)
->orderByDesc('record_date')
->limit(10)
->get();
return view('seller.processing.compliance.dashboard', compact(
'business',
'upcomingExpirations',
'expiredItems',
'recentRecords'
));
}
protected function authorizeForBusiness(ProcComplianceRecord $record, Business $business): void
{
if ($record->business_id !== $business->id) {
abort(403, 'Unauthorized access to this compliance record.');
}
}
}

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