Compare commits

...

235 Commits

Author SHA1 Message Date
kelly
c92cd230d5 fix: correct BannerAdResource navigationGroup type hint
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 22:33:07 -07:00
kelly
bcbfdd3c91 feat: add banner ad system with zones, scheduling, and analytics
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Banner Ad System:
- BannerAd model with scheduling (starts_at, ends_at)
- BannerAdEvent model for impression/click tracking
- BannerAdDailyStat model for analytics rollups
- BannerAdZone enum (6 zones: hero, leaderboard, sidebar, inline, brand, deals)
- BannerAdStatus enum (draft, active, scheduled, paused, expired)

Service & Controller:
- BannerAdService with weighted random rotation, caching
- BannerAdController for click tracking and image serving
- Routes for /ads/click, /ads/impression, /images/banner-ad

Filament Admin:
- Full CRUD resource at /admin/banner-ads
- Image upload to MinIO
- Status/zone filters
- Analytics display (impressions, clicks, CTR)

Display Components:
- <x-banner-ad zone="..." /> Blade component
- Automatic impression tracking
- Click tracking via redirect
- Sponsored badge overlay

View Placements:
- Marketplace homepage: leaderboard + sidebar
- Brand page: banner below breadcrumbs
- Deals page: hero banner

Background Jobs:
- UpdateBannerAdStatuses: activate scheduled, expire ended (every minute)
- RollupBannerAdStats: daily aggregation + event cleanup
2025-12-17 22:18:27 -07:00
kelly
3c21093e66 fix: eager load brand relationship for product cards on brand page
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 21:52:57 -07:00
kelly
e4e0a19873 feat: redesign brand page with Amazon/Shopify style UI
- Hero banner section with gradient overlay and logo
- Brand details section below hero (stats, social links, description)
- Featured products horizontal scroll section
- Menu/category tabs for product filtering
- Grid/list view toggle with localStorage persistence
- Reusable product-card component for all product displays
- Breadcrumb navigation
2025-12-17 21:52:31 -07:00
kelly
5b0503abf5 fix: resolve marketplace query issues
- Fix PostgreSQL having clause error by using collection filtering instead
- Use category_id with ProductCategory model instead of deprecated category string
- Eager load category relationship to prevent lazy loading violations
- Update view to use category_id parameter and ProductCategory names
2025-12-17 21:48:18 -07:00
kelly
cc8aab7ee1 fix: add PWA partial to auth-layout component
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
PWA was not working on login/register pages because the auth-layout
component was missing the @include('partials.pwa') directive.
2025-12-17 21:25:04 -07:00
kelly
2c1f7d093f feat: enterprise accounting UI harmonization for orders/invoices
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add 3-column header layout (Buyer | Seller | Document Info) to:
  - Order create page (new)
  - Invoice create page (updated)
  - Order show page (updated with units/cases, item comments)
  - Invoice show page (updated with seller info, units/cases)
  - Quote show page (updated with seller info, units/cases)

- Add seller-initiated order creation:
  - New /orders/create route and view
  - Orders track created_by (buyer/seller)
  - New Order button on orders index

- Add ping pong order flow feature:
  - ping_pong_enabled on businesses table
  - is_ping_pong toggle per order
  - Admin toggle in Business > Suite Settings

- Add item comments per line item:
  - item_comment field on order_items, crm_invoice_items, crm_quote_items
  - Inline edit UI on order show page

- UI improvements:
  - Units/cases display (X UNITS / Y CASES)
  - Live totals in document headers
  - Consistent styling across all document types
2025-12-17 19:01:13 -07:00
kelly
11a07692ad feat: add CannaiQ product mapping
- Add product_cannaiq_mappings pivot table (many-to-many)
- Add ProductCannaiqMapping model
- Add cannaiqMappings relationship to Product model
- Add mapping page at /s/{business}/brands/{brand}/cannaiq
- Add map/unmap API endpoints
- Update brand settings CannaiQ section with searchable brand dropdown
- Search CannaiQ products and map to Hub products
2025-12-17 18:58:08 -07:00
kelly
05754c9d5b feat: add CannaiQ brand integration
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add cannaiq_brand_key column to brands table for API integration
- Add Brand model methods: isCannaiqConnected(), connectToCannaiq(), disconnectFromCannaiq()
- Add CannaiQ badge to brand tiles on index page
- Add CannaiQ Integration settings section in brand dashboard
- Add connect/disconnect routes and controller methods
2025-12-17 18:25:37 -07:00
kelly
e26da88f22 fix: correct location_id validation in InvoiceController
Change validation from nullable|integer to exists:locations,id
to properly validate against the locations table.
2025-12-17 16:49:13 -07:00
kelly
b703b27676 feat: PWA install prompt, update notifications, and CRM fixes
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
PWA:
- Add install prompt toast (appears after 30 seconds)
- Remembers dismissal for 7 days
- Fix: check for waiting SW on page load for update banner

CRM fixes:
- QuoteController: eager load items.product.brand to prevent N+1
- InvoiceController: change location_id validation to nullable integer
2025-12-17 16:43:36 -07:00
kelly
5dffd96187 feat: add select all checkbox for permission groups in user edit
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 16:22:49 -07:00
kelly
9ff1f2b37b fix: service worker self-destructs on localhost
Some checks failed
ci/woodpecker/push/ci Pipeline failed
SW now checks hostname at startup and unregisters itself on localhost/127.0.0.1.
Prevents dev environment issues with Vite HMR and double-click navigation.
2025-12-17 16:21:34 -07:00
kelly
23ad9f2824 fix: resolve Crystal's issues #14, #11, #8
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Issue #14 - Calendar Edit modal disappears:
- Store event reference before closing detail modal
- closeDetail() was setting selectedEvent to null before openEditModal used it

Issue #11 - Quote edit LazyLoadingViolation:
- CrmQuoteItem booted hooks now check relationLoaded() before accessing quote
- Prevents lazy loading when quote relation not eager-loaded

Issue #8 - Invoice Cannot Submit (location_id column missing):
- Add migration to add location_id column to crm_invoices table
- Make crm_invoice_items.name nullable (controller doesn't provide it)
- CrmInvoiceItem has same lazy loading fix
2025-12-17 16:09:09 -07:00
kelly
b90cb829c9 Merge branch 'fix/product-description-emoji-import' into develop
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 15:20:52 -07:00
kelly
69d9174314 Merge branch 'fix/crystal-issues' into develop
# Conflicts:
#	app/Http/Controllers/Seller/Crm/ThreadController.php
2025-12-17 15:14:42 -07:00
kelly
c350ecbb3c Merge branch 'feat/dashboard-pwa-enhancements' into develop
# Conflicts:
#	.woodpecker/.ci.yml
2025-12-17 15:13:43 -07:00
kelly
06098c7013 fix: prevent lazy loading violations in CRM item models and calendar edit 2025-12-17 15:13:11 -07:00
kelly
709321383c fix(ci): clear bootstrap cache and verify test command
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Clear bootstrap/cache/*.php to force package discovery
- Add verification step to confirm artisan test is available
2025-12-17 13:55:48 -07:00
kelly
7506466c38 fix(ci): rm -rf vendor before composer install
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Ensures dev dependencies like parallel-lint are installed fresh
instead of using stale cached vendor from Docker image.
2025-12-17 13:52:42 -07:00
kelly
c84455a11b fix: force unregister service workers on localhost
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Actively unregister existing service workers on localhost instead of just
skipping registration. Fixes double-click navigation issue caused by
stale SW intercepting requests during local development.
2025-12-17 13:12:34 -07:00
kelly
2e7fff135c fix(ci): use development env and PostgreSQL for unit tests
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Change APP_ENV from production to development (installs dev dependencies)
- Change unit tests from SQLite to PostgreSQL (matches feature tests)
- Fixes parallel-lint not found error
2025-12-17 13:12:12 -07:00
kelly
a1a8e3ee9c fix(ci): point feature tests to primary DB (10.100.7.50)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
10.100.6.50 is a read-only replica, tests need write access.
2025-12-17 13:10:14 -07:00
kelly
72ab5d8baa fix(tests): move service tests to Feature suite
Some checks failed
ci/woodpecker/push/ci Pipeline failed
These tests use database factories which require the DB schema.
Unit tests run with SQLite in-memory (no migrations), so tests
that need real DB records must be in Feature suite (PostgreSQL).
2025-12-17 13:08:58 -07:00
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
ffc9405c34 fix(ci): clean vendor dir before composer install
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Ensures dev dependencies like parallel-lint are installed
fresh instead of potentially using cached vendor directory
that was built without dev deps.
2025-12-17 09:59:12 -07:00
kelly
732c46cabb feat: dashboard UX improvements and mobile PWA enhancements
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Dashboard fixes:
- Fix KPI card label truncation (remove truncate class)
- Fix pipeline snapshot bug (quotes → deals reference)
- Add actionable empty states with CTAs
- Add inventory alerts widget to sidebar
- Add pipeline velocity metrics widget
- Add real-time updates via Reverb (new orders, deal changes)
- Add pull-to-refresh for mobile

Mobile PWA:
- Add mobile bottom navigation (Home, Inbox, Orders, Pipeline)
- Add PWA install prompt with dismissible banner
- Add pull-to-refresh gesture support

Deals kanban:
- Add days-in-stage indicator to deal cards
- Improve mobile scrolling with snap-to-column
- Add pull-to-refresh support

New files:
- app/Events/NewOrderReceived.php
- app/Events/DealStageChanged.php
- resources/views/components/mobile-bottom-nav.blade.php
- resources/views/components/pwa-install-prompt.blade.php
- resources/views/components/pull-to-refresh.blade.php
- resources/views/components/dashboard/inventory-alerts.blade.php
- resources/views/components/dashboard/pipeline-velocity.blade.php
2025-12-17 09:42:27 -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
829d4c6b6c fix: correct users table column names in unified inbox query
The users table has first_name/last_name columns, not a name column.
The User model has a name accessor that concatenates these, but SQL
queries must reference the actual column names.
2025-12-16 17:26:27 -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
db2386b8a9 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:14:52 -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
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
360 changed files with 42207 additions and 6599 deletions

View File

@@ -8,8 +8,8 @@ node_modules
npm-debug.log
yarn-error.log
# Composer
/vendor
# Composer (NOT excluded - Dockerfile.fast needs pre-built vendor)
# /vendor
# Environment
.env
@@ -58,7 +58,7 @@ docker-compose.*.yml
# Build artifacts
/public/hot
/public/storage
/public/build
# /public/build - NOT excluded, Dockerfile.fast needs pre-built assets
# Misc
.env.backup

View File

@@ -24,12 +24,13 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# PostgreSQL: 10.100.6.50:5432
DB_CONNECTION=pgsql
DB_HOST=pgsql
DB_HOST=10.100.6.50
DB_PORT=5432
DB_DATABASE=cannabrands_app
DB_USERNAME=sail
DB_PASSWORD=password
DB_DATABASE=cannabrands_dev
DB_USERNAME=cannabrands
DB_PASSWORD=SpDyCannaBrands2024
SESSION_DRIVER=redis
SESSION_LIFETIME=120
@@ -66,9 +67,10 @@ CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
# Redis: 10.100.9.50:6379
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_HOST=10.100.9.50
REDIS_PASSWORD=SpDyR3d1s2024!
REDIS_PORT=6379
MAIL_MAILER=smtp
@@ -88,43 +90,18 @@ MAIL_FROM_NAME="${APP_NAME}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ┌─────────────────────────────────────────────────────────────────────┐
# │ LOCAL DEVELOPMENT (Docker MinIO)
# │ MinIO (S3-Compatible Storage)
# └─────────────────────────────────────────────────────────────────────┘
# Use local MinIO container for development (versioning enabled)
# Access MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
# Server: 10.100.9.80:9000 | Console: 10.100.9.80:9001
FILESYSTEM_DISK=minio
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
AWS_ACCESS_KEY_ID=cannabrands-app
AWS_SECRET_ACCESS_KEY=cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=media
AWS_ENDPOINT=http://minio:9000
AWS_URL=http://localhost:9000/media
AWS_BUCKET=cannabrands
AWS_ENDPOINT=http://10.100.9.80:9000
AWS_URL=http://10.100.9.80:9000/cannabrands
AWS_USE_PATH_STYLE_ENDPOINT=true
# ┌─────────────────────────────────────────────────────────────────────┐
# │ STAGING/DEVELOP (media-dev bucket) │
# └─────────────────────────────────────────────────────────────────────┘
# FILESYSTEM_DISK=minio
# AWS_ACCESS_KEY_ID=<staging-access-key>
# AWS_SECRET_ACCESS_KEY=<staging-secret-key>
# AWS_DEFAULT_REGION=us-east-1
# AWS_BUCKET=media-dev
# AWS_ENDPOINT=https://cdn.cannabrands.app
# AWS_URL=https://cdn.cannabrands.app/media-dev
# AWS_USE_PATH_STYLE_ENDPOINT=true
# ┌─────────────────────────────────────────────────────────────────────┐
# │ PRODUCTION (media bucket) │
# └─────────────────────────────────────────────────────────────────────┘
# FILESYSTEM_DISK=minio
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
# AWS_DEFAULT_REGION=us-east-1
# AWS_BUCKET=media
# AWS_ENDPOINT=https://cdn.cannabrands.app
# AWS_URL=https://cdn.cannabrands.app/media
# AWS_USE_PATH_STYLE_ENDPOINT=true
VITE_APP_NAME="${APP_NAME}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

View File

@@ -1,29 +1,24 @@
# Woodpecker CI/CD Pipeline for Cannabrands Hub
# Documentation: https://woodpecker-ci.org/docs/intro
# Optimized for fast deploys (~8-10 min)
#
# 2-Environment Workflow (Optimized for small team):
# - develop branch → dev.cannabrands.app (integration/testing)
# - master branch → cannabrands.app (production)
# - tags (2025.X) → cannabrands.app (versioned production releases)
# Optimizations:
# - Parallel composer + frontend builds
# - Split tests (unit + feature run in parallel)
# - Dependency caching (npm + composer)
# - Single-stage Dockerfile.fast
# - Kaniko layer caching
#
# Pipeline Strategy:
# - PRs: Run tests (lint, style, phpunit) IN PARALLEL
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
# - Tags: Build versioned release
#
# Optimization Notes:
# - php-lint, code-style, and tests run in parallel after composer install
# - Uses parallel-lint for faster PHP syntax checking
# - PostgreSQL tuned for CI (fsync disabled)
# - Cache rebuild only on merge builds
# External Services:
# - PostgreSQL: 10.100.6.50:5432 (cannabrands_dev)
# - Redis: 10.100.9.50:6379
# - MinIO: 10.100.9.80:9000
# - Docker Registry: git.spdy.io (for k8s pulls)
when:
- branch: [develop, master]
event: push
- event: [pull_request, tag]
# Use explicit git clone plugin to fix auth issues
# The default clone was failing with "could not read Username"
clone:
git:
image: woodpeckerci/plugin-git
@@ -34,422 +29,273 @@ clone:
steps:
# ============================================
# DEPENDENCY INSTALLATION (Sequential)
# PARALLEL: Composer + Frontend (with caching)
# ============================================
# Restore Composer cache
restore-composer-cache:
image: meltwater/drone-cache:dev
settings:
backend: "filesystem"
restore: true
cache_key: "composer-{{ checksum \"composer.lock\" }}"
archive_format: "gzip"
mount:
- "vendor"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# Install dependencies (uses pre-built Laravel image with all extensions)
composer-install:
image: kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- restore-composer-cache
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
commands:
- echo "Creating minimal .env for package discovery..."
- |
cat > .env << 'EOF'
APP_NAME="Cannabrands Hub"
APP_ENV=testing
APP_ENV=development
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
APP_DEBUG=true
CACHE_STORE=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=testing
DB_USERNAME=testing
DB_PASSWORD=testing
EOF
- echo "Checking for cached dependencies..."
- |
if [ -d "vendor" ] && [ -f "vendor/autoload.php" ]; then
echo "✅ Restored vendor from cache"
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
else
echo "📦 Installing fresh dependencies (cache miss)"
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
fi
- echo "✅ Composer dependencies ready!"
# Restore composer cache if available
- mkdir -p /root/.composer/cache
- if [ -d .composer-cache ]; then cp -r .composer-cache/* /root/.composer/cache/ 2>/dev/null || true; fi
# Clean vendor and bootstrap cache to force fresh install
- rm -rf vendor bootstrap/cache/*.php
- composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
# Verify test command is available
- php artisan list test | head -5
# Save cache for next build
- mkdir -p .composer-cache && cp -r /root/.composer/cache/* .composer-cache/ 2>/dev/null || true
- echo "✅ Composer done"
# Rebuild Composer cache (only on merge builds, not PRs)
rebuild-composer-cache:
image: meltwater/drone-cache:dev
depends_on:
- composer-install
settings:
backend: "filesystem"
rebuild: true
cache_key: "composer-{{ checksum \"composer.lock\" }}"
archive_format: "gzip"
mount:
- "vendor"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
when:
branch: [develop, master]
event: push
build-frontend:
image: 10.100.9.70:5000/library/node:22-alpine
environment:
VITE_REVERB_APP_KEY: 6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU=
VITE_REVERB_HOST: dev.cannabrands.app
VITE_REVERB_PORT: "443"
VITE_REVERB_SCHEME: https
npm_config_cache: .npm-cache
commands:
# Use cached node_modules if available
- npm ci --prefer-offline
- npm run build
- echo "✅ Frontend built"
# ============================================
# PR CHECKS (Run in Parallel for Speed)
# PR CHECKS (Parallel: lint, style, tests)
# ============================================
# PHP Syntax Check - Uses parallel-lint for 5-10x speed improvement
php-lint:
image: kirschbaumdevelopment/laravel-test-runner:8.3
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
commands:
- echo "Checking PHP syntax (parallel)..."
- ./vendor/bin/parallel-lint app routes database config --colors --blame
- echo "✅ PHP syntax check complete!"
when:
event: pull_request
# Run Laravel Pint (code style)
code-style:
image: kirschbaumdevelopment/laravel-test-runner:8.3
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
commands:
- echo "Checking code style with Laravel Pint..."
- ./vendor/bin/pint --test
- echo "✅ Code style check complete!"
when:
event: pull_request
# Run PHPUnit Tests
# Note: Uses array cache/session for speed and isolation (Laravel convention)
# Redis + Reverb services used for real-time broadcasting tests
tests:
image: kirschbaumdevelopment/laravel-test-runner:8.3
# Split tests: Unit tests (with DB - some unit tests use factories)
tests-unit:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
when:
event: pull_request
environment:
APP_ENV: testing
BROADCAST_CONNECTION: reverb
CACHE_STORE: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_HOST: 10.100.6.50
DB_PORT: 5432
DB_DATABASE: testing
DB_USERNAME: testing
DB_PASSWORD: testing
REDIS_HOST: redis
REVERB_APP_ID: test-app-id
REVERB_APP_KEY: test-key
REVERB_APP_SECRET: test-secret
REVERB_HOST: localhost
REVERB_PORT: 8080
REVERB_SCHEME: http
DB_DATABASE: cannabrands_test
DB_USERNAME: cannabrands
DB_PASSWORD: SpDyCannaBrands2024
commands:
- echo "Setting up Laravel environment..."
- cp .env.example .env
- php artisan key:generate
- echo "Waiting for PostgreSQL to be ready..."
- |
for i in 1 2 3 4 5 6 7 8 9 10; do
if pg_isready -h postgres -p 5432 -U testing 2>/dev/null; then
echo "✅ PostgreSQL is ready!"
break
fi
echo "Waiting for postgres... attempt $i/10"
sleep 3
done
- echo "Starting Reverb server in background..."
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
- sleep 2
- echo "Running tests in parallel..."
- php artisan test --parallel
- echo "✅ Tests complete!"
- php artisan test --testsuite=Unit
- echo "✅ Unit tests passed"
# ============================================
# MERGE BUILD STEPS (Sequential, after PR passes)
# ============================================
# Validate migrations before deployment
# Only runs pending migrations - never fresh or seed
validate-migrations:
image: kirschbaumdevelopment/laravel-test-runner:8.3
# Split tests: Feature tests (with DB)
tests-feature:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
when:
event: pull_request
environment:
APP_ENV: production
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: testing
DB_USERNAME: testing
DB_PASSWORD: testing
APP_ENV: testing
CACHE_STORE: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: 10.100.7.50
DB_PORT: 5432
DB_DATABASE: cannabrands_test
DB_USERNAME: cannabrands
DB_PASSWORD: SpDyCannaBrands2024
REDIS_HOST: 10.100.9.50
REDIS_PORT: 6379
REDIS_PASSWORD: SpDyR3d1s2024!
commands:
- echo "Validating migrations..."
- cp .env.example .env
- php artisan key:generate
- echo "Running pending migrations only..."
- php artisan migrate --force
- echo "✅ Migration validation complete!"
- php artisan test --testsuite=Feature
- echo "✅ Feature tests passed"
# ============================================
# BUILD & DEPLOY
# ============================================
# Create Docker config for registry auth (runs before Kaniko)
setup-registry-auth:
image: alpine
depends_on:
- composer-install
- build-frontend
environment:
REGISTRY_USER:
from_secret: registry_user
REGISTRY_PASSWORD:
from_secret: registry_password
commands:
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
- |
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
{"auths":{"registry.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
EOF
- echo "Auth config created"
when:
branch: [develop, master]
event: push
# Build and push Docker image for DEV environment (develop branch)
build-image-dev:
image: woodpeckerci/plugin-docker-buildx
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- validate-migrations
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- dev # Latest dev build → dev.cannabrands.app
- dev-${CI_COMMIT_SHA:0:7} # Unique dev tag with SHA
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
- ${CI_COMMIT_BRANCH} # Branch name (develop)
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "dev"
VITE_REVERB_APP_KEY: "6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU="
VITE_REVERB_HOST: "dev.cannabrands.app"
VITE_REVERB_PORT: "443"
VITE_REVERB_SCHEME: "https"
cache_from:
- code.cannabrands.app/cannabrands/hub:buildcache-dev
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-dev
platforms: linux/amd64
# Disable provenance attestations - can cause Gitea registry 500 errors
provenance: false
- setup-registry-auth
commands:
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
- |
/kaniko/executor \
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
--destination=registry.spdy.io/cannabrands/hub:dev \
--destination=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
--build-arg=APP_VERSION=dev \
--registry-mirror=10.100.9.70:5000 \
--insecure-registry=10.100.9.70:5000 \
--cache=true \
--cache-ttl=168h \
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache
when:
branch: develop
event: push
# Auto-deploy to dev.cannabrands.app (develop branch only)
deploy-dev:
image: bitnami/kubectl:latest
image: 10.100.9.70:5000/bitnami/kubectl:latest
depends_on:
- build-image-dev
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_dev
commands:
- echo "🚀 Auto-deploying to dev.cannabrands.app..."
- echo "Commit SHA${CI_COMMIT_SHA:0:7}"
- echo ""
# Setup kubeconfig
- mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
# Update deployment to use new SHA-tagged image (both app and init containers)
- |
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
migrate=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
app=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
migrate=registry.spdy.io/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
-n cannabrands-dev
# Wait for rollout to complete (timeout 5 minutes)
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
# Verify deployment health
- |
echo ""
echo "✅ Deployment successful!"
echo "Pod status:"
kubectl get pods -n cannabrands-dev -l app=cannabrands-hub
echo ""
echo "Image deployed:"
kubectl get deployment cannabrands-hub -n cannabrands-dev -o jsonpath='{.spec.template.spec.containers[0].image}'
echo ""
- echo "✅ Deployed to dev.cannabrands.app"
when:
branch: develop
event: push
# Build and push Docker image for PRODUCTION (master branch)
build-image-production:
image: woodpeckerci/plugin-docker-buildx
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- validate-migrations
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- latest # Latest production build
- prod-${CI_COMMIT_SHA:0:7} # Unique prod tag with SHA
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
- ${CI_COMMIT_BRANCH} # Branch name (master)
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "production"
cache_from:
- code.cannabrands.app/cannabrands/hub:buildcache-prod
cache_to: code.cannabrands.app/cannabrands/hub:buildcache-prod
platforms: linux/amd64
# Disable provenance attestations - can cause Gitea registry 500 errors
provenance: false
- setup-registry-auth
commands:
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
- |
/kaniko/executor \
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
--destination=git.spdy.io/cannabrands/hub:latest \
--destination=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
--build-arg=APP_VERSION=production \
--cache=true \
--cache-ttl=168h \
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
--insecure \
--insecure-pull \
--skip-tls-verify
when:
branch: master
event: push
# Deploy to production (master branch)
deploy-production:
image: bitnami/kubectl:latest
image: 10.100.9.70:5000/bitnami/kubectl:latest
depends_on:
- build-image-production
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_prod
commands:
- echo "🚀 Deploying to PRODUCTION (cannabrands.app)..."
- echo "Commit SHA ${CI_COMMIT_SHA:0:7}"
- mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
- |
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
migrate=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
app=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
migrate=git.spdy.io/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
-n cannabrands-prod
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
- |
echo ""
echo "✅ PRODUCTION deployment successful!"
echo "Pod status:"
kubectl get pods -n cannabrands-prod -l app=cannabrands-hub
- echo "✅ Deployed to cannabrands.app"
when:
branch: master
event: push
# Build and push Docker image for tagged releases (optional versioned releases)
build-image-release:
image: woodpeckerci/plugin-docker-buildx
# For tags, setup auth first
setup-registry-auth-release:
image: alpine
depends_on:
- composer-install
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- ${CI_COMMIT_TAG} # CalVer tag (e.g., 2025.10.1)
- latest # Latest stable release
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "${CI_COMMIT_TAG}"
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-prod
platforms: linux/amd64
# Disable provenance attestations - can cause Gitea registry 500 errors
provenance: false
- build-frontend
environment:
REGISTRY_USER:
from_secret: registry_user
REGISTRY_PASSWORD:
from_secret: registry_password
commands:
- mkdir -p /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker
- |
cat > /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker/config.json << EOF
{"auths":{"git.spdy.io":{"username":"$REGISTRY_USER","password":"$REGISTRY_PASSWORD"}}}
EOF
when:
event: tag
# Success notification
success:
image: alpine:latest
when:
- evaluate: 'CI_PIPELINE_STATUS == "success"'
build-image-release:
image: 10.100.9.70:5000/kaniko-project/executor:debug
depends_on:
- setup-registry-auth-release
commands:
- echo "✅ Pipeline completed successfully!"
- echo "All checks passed for commit ${CI_COMMIT_SHA:0:7}"
- cp -r /woodpecker/src/git.spdy.io/Cannabrands/hub/.docker /kaniko/.docker
- |
if [ "${CI_PIPELINE_EVENT}" = "tag" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🎉 PRODUCTION RELEASE BUILD COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Version: ${CI_COMMIT_TAG}"
echo "Registry: code.cannabrands.app/cannabrands/hub"
echo ""
echo "Available as:"
echo " - code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
echo " - code.cannabrands.app/cannabrands/hub:latest"
echo ""
echo "🚀 Deploy to PRODUCTION (cannabrands.app):"
echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
echo " docker-compose -f docker-compose.production.yml up -d"
echo ""
echo "⚠️ This is a CUSTOMER-FACING release!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "master" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 PRODUCTION DEPLOYED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Site: https://cannabrands.app"
echo "Image: prod-${CI_COMMIT_SHA:0:7}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "develop" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 DEV BUILD + AUTO-DEPLOY COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Branch: develop"
echo "Commit: ${CI_COMMIT_SHA:0:7}"
echo ""
echo "✅ Built & Tagged:"
echo " - code.cannabrands.app/cannabrands/hub:dev"
echo " - code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7}"
echo " - code.cannabrands.app/cannabrands/hub:sha-${CI_COMMIT_SHA:0:7}"
echo ""
echo "✅ Auto-Deployed to Kubernetes:"
echo " - Environment: dev.cannabrands.app"
echo " - Namespace: cannabrands-dev"
echo " - Image: dev-${CI_COMMIT_SHA:0:7}"
echo ""
echo "🧪 Test your changes:"
echo " - Visit: https://dev.cannabrands.app"
echo " - Login: admin@example.com / password"
echo " - Check: https://dev.cannabrands.app/telescope"
echo ""
echo "Ready for production? Open PR: develop → master"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "pull_request" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ PR CHECKS PASSED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Ready to merge to master for production deployment."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
# Services for tests (optimized for CI speed)
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: testing
POSTGRES_PASSWORD: testing
POSTGRES_DB: testing
# CI-optimized settings via environment (faster writes, safe for ephemeral test DB)
POSTGRES_INITDB_ARGS: "--data-checksums"
POSTGRES_HOST_AUTH_METHOD: trust
redis:
image: redis:7-alpine
commands:
- redis-server --bind 0.0.0.0
/kaniko/executor \
--context=/woodpecker/src/git.spdy.io/Cannabrands/hub \
--dockerfile=/woodpecker/src/git.spdy.io/Cannabrands/hub/Dockerfile.fast \
--destination=git.spdy.io/cannabrands/hub:${CI_COMMIT_TAG} \
--destination=git.spdy.io/cannabrands/hub:latest \
--build-arg=GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7} \
--build-arg=APP_VERSION=${CI_COMMIT_TAG} \
--cache=true \
--cache-ttl=168h \
--cache-repo=10.100.9.70:5000/cannabrands/hub-cache \
--insecure \
--insecure-pull \
--skip-tls-verify
when:
event: tag

View File

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

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).

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

@@ -131,6 +131,20 @@ class Kernel extends ConsoleKernel
->withoutOverlapping()
->runInBackground();
// ─────────────────────────────────────────────────────────────────────
// BANNER ADS
// ─────────────────────────────────────────────────────────────────────
// Update banner ad statuses (activate scheduled, expire ended) - every minute
$schedule->job(new \App\Jobs\UpdateBannerAdStatuses)
->everyMinute()
->withoutOverlapping();
// Rollup daily banner ad stats - daily at 2 AM
$schedule->job(new \App\Jobs\RollupBannerAdStats)
->dailyAt('02:00')
->withoutOverlapping();
// ─────────────────────────────────────────────────────────────────────
// HOUSEKEEPING & MAINTENANCE
// ─────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Enums;
enum BannerAdStatus: string
{
case DRAFT = 'draft';
case ACTIVE = 'active';
case SCHEDULED = 'scheduled';
case PAUSED = 'paused';
case EXPIRED = 'expired';
public function label(): string
{
return match ($this) {
self::DRAFT => 'Draft',
self::ACTIVE => 'Active',
self::SCHEDULED => 'Scheduled',
self::PAUSED => 'Paused',
self::EXPIRED => 'Expired',
};
}
public function color(): string
{
return match ($this) {
self::DRAFT => 'gray',
self::ACTIVE => 'success',
self::SCHEDULED => 'info',
self::PAUSED => 'warning',
self::EXPIRED => 'danger',
};
}
public static function options(): array
{
return collect(self::cases())->mapWithKeys(fn (self $status) => [
$status->value => $status->label(),
])->toArray();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Enums;
enum BannerAdZone: string
{
case MARKETPLACE_HERO = 'marketplace_hero';
case MARKETPLACE_LEADERBOARD = 'marketplace_leaderboard';
case MARKETPLACE_SIDEBAR = 'marketplace_sidebar';
case MARKETPLACE_INLINE = 'marketplace_inline';
case BRAND_PAGE_BANNER = 'brand_page_banner';
case DEALS_PAGE_HERO = 'deals_page_hero';
public function label(): string
{
return match ($this) {
self::MARKETPLACE_HERO => 'Marketplace Hero (Full Width)',
self::MARKETPLACE_LEADERBOARD => 'Marketplace Leaderboard (728x90)',
self::MARKETPLACE_SIDEBAR => 'Marketplace Sidebar (300x250)',
self::MARKETPLACE_INLINE => 'Marketplace Inline (Between Products)',
self::BRAND_PAGE_BANNER => 'Brand Page Banner',
self::DEALS_PAGE_HERO => 'Deals Page Hero',
};
}
public function dimensions(): array
{
return match ($this) {
self::MARKETPLACE_HERO => ['width' => 1920, 'height' => 400, 'display' => '1920x400'],
self::MARKETPLACE_LEADERBOARD => ['width' => 728, 'height' => 90, 'display' => '728x90'],
self::MARKETPLACE_SIDEBAR => ['width' => 300, 'height' => 250, 'display' => '300x250'],
self::MARKETPLACE_INLINE => ['width' => 970, 'height' => 250, 'display' => '970x250'],
self::BRAND_PAGE_BANNER => ['width' => 1344, 'height' => 280, 'display' => '1344x280'],
self::DEALS_PAGE_HERO => ['width' => 1920, 'height' => 350, 'display' => '1920x350'],
};
}
public static function options(): array
{
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
$zone->value => $zone->label().' - '.$zone->dimensions()['display'],
])->toArray();
}
public static function optionsSimple(): array
{
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
$zone->value => $zone->label(),
])->toArray();
}
}

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

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

@@ -0,0 +1,292 @@
<?php
namespace App\Filament\Resources;
use App\Enums\BannerAdStatus;
use App\Enums\BannerAdZone;
use App\Filament\Resources\BannerAdResource\Pages;
use App\Models\BannerAd;
use BackedEnum;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use UnitEnum;
class BannerAdResource extends Resource
{
protected static ?string $model = BannerAd::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPhoto;
protected static UnitEnum|string|null $navigationGroup = 'Marketing';
protected static ?int $navigationSort = 10;
protected static ?string $navigationLabel = 'Banner Ads';
public static function getNavigationBadge(): ?string
{
return cache()->remember('banner_ad_active_count', 60, function () {
$count = static::getModel()::where('status', BannerAdStatus::ACTIVE)->count();
return $count ?: null;
});
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Basic Information')
->columns(2)
->schema([
TextInput::make('name')
->label('Internal Name')
->required()
->maxLength(255)
->helperText('Internal reference name (not shown to users)'),
Select::make('zone')
->label('Ad Zone')
->options(BannerAdZone::options())
->required()
->live()
->afterStateUpdated(fn ($state, $set) => $state
? $set('zone_info', BannerAdZone::from($state)->dimensions()['display'])
: $set('zone_info', null)),
Placeholder::make('zone_info')
->label('Recommended Dimensions')
->content(fn ($get) => $get('zone')
? BannerAdZone::from($get('zone'))->dimensions()['display']
: 'Select a zone'),
Select::make('status')
->options(BannerAdStatus::options())
->default('draft')
->required(),
Select::make('brand_id')
->label('Brand (Optional)')
->relationship('brand', 'name')
->searchable()
->preload()
->helperText('Leave empty for platform-wide ads'),
]),
Section::make('Creative Content')
->columns(2)
->schema([
FileUpload::make('image_path')
->label('Banner Image')
->image()
->required()
->disk('minio')
->directory('banner-ads')
->visibility('public')
->maxSize(5120)
->helperText('Upload banner image at recommended dimensions')
->columnSpanFull(),
TextInput::make('image_alt')
->label('Alt Text')
->maxLength(255)
->helperText('Accessibility description'),
TextInput::make('headline')
->maxLength(100)
->helperText('Optional overlay headline'),
Textarea::make('description')
->maxLength(200)
->helperText('Optional overlay description'),
TextInput::make('cta_text')
->label('Button Text')
->maxLength(50)
->placeholder('Shop Now')
->helperText('Call-to-action button text'),
TextInput::make('cta_url')
->label('Destination URL')
->required()
->url()
->maxLength(500)
->columnSpanFull(),
]),
Section::make('Scheduling')
->columns(2)
->schema([
DateTimePicker::make('starts_at')
->label('Start Date')
->helperText('Leave empty to start immediately'),
DateTimePicker::make('ends_at')
->label('End Date')
->helperText('Leave empty to run indefinitely'),
]),
Section::make('Targeting & Priority')
->columns(2)
->schema([
Toggle::make('is_platform_wide')
->label('Platform Wide')
->default(true)
->helperText('Show to all users'),
Select::make('target_business_types')
->label('Target Business Types')
->multiple()
->options([
'buyer' => 'Buyers (Dispensaries)',
'seller' => 'Sellers (Brands)',
])
->helperText('Leave empty for all types'),
TextInput::make('priority')
->numeric()
->default(0)
->helperText('Higher = shown first (0-100)'),
TextInput::make('weight')
->numeric()
->default(100)
->minValue(1)
->maxValue(1000)
->helperText('Weight for random rotation (1-1000)'),
]),
Section::make('Analytics')
->columns(3)
->schema([
Placeholder::make('impressions_display')
->label('Impressions')
->content(fn (?BannerAd $record) => number_format($record?->impressions ?? 0)),
Placeholder::make('clicks_display')
->label('Clicks')
->content(fn (?BannerAd $record) => number_format($record?->clicks ?? 0)),
Placeholder::make('ctr_display')
->label('CTR')
->content(fn (?BannerAd $record) => ($record?->click_through_rate ?? 0).'%'),
])
->hiddenOn('create'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ImageColumn::make('image_path')
->label('Preview')
->disk('minio')
->width(120)
->height(60),
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('zone')
->badge()
->formatStateUsing(fn ($state) => $state instanceof BannerAdZone
? $state->label()
: BannerAdZone::tryFrom($state)?->label() ?? $state),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn ($state) => $state instanceof BannerAdStatus
? $state->color()
: BannerAdStatus::tryFrom($state)?->color() ?? 'gray'),
Tables\Columns\TextColumn::make('impressions')
->numeric()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('clicks')
->numeric()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('click_through_rate')
->label('CTR')
->suffix('%')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('starts_at')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('ends_at')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
Tables\Filters\SelectFilter::make('status')
->options(BannerAdStatus::options()),
Tables\Filters\SelectFilter::make('zone')
->options(BannerAdZone::optionsSimple()),
Tables\Filters\TrashedFilter::make(),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
Tables\Actions\ForceDeleteBulkAction::make(),
Tables\Actions\RestoreBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListBannerAds::route('/'),
'create' => Pages\CreateBannerAd::route('/create'),
'view' => Pages\ViewBannerAd::route('/{record}'),
'edit' => Pages\EditBannerAd::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Filament\Resources\BannerAdResource\Pages;
use App\Filament\Resources\BannerAdResource;
use Filament\Resources\Pages\CreateRecord;
class CreateBannerAd extends CreateRecord
{
protected static string $resource = BannerAdResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by_user_id'] = auth()->id();
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Filament\Resources\BannerAdResource\Pages;
use App\Filament\Resources\BannerAdResource;
use App\Services\BannerAdService;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditBannerAd extends EditRecord
{
protected static string $resource = BannerAdResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
Actions\ForceDeleteAction::make(),
Actions\RestoreAction::make(),
];
}
protected function afterSave(): void
{
// Clear caches when banner ad is updated
app(BannerAdService::class)->clearAllCaches();
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BannerAdResource\Pages;
use App\Filament\Resources\BannerAdResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListBannerAds extends ListRecords
{
protected static string $resource = BannerAdResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BannerAdResource\Pages;
use App\Filament\Resources\BannerAdResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewBannerAd extends ViewRecord
{
protected static string $resource = BannerAdResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@@ -699,6 +699,11 @@ class BusinessResource extends Resource
'</div>'
);
}),
Toggle::make('ping_pong_enabled')
->label('Ping Pong Order Flow')
->helperText('When enabled, buyers and sellers can send order details back and forth during the order process. Shows order progress stages and enables collaborative order editing.')
->default(false),
]),
// ===== SUITE ASSIGNMENT SECTION =====
@@ -1789,8 +1794,8 @@ class BusinessResource extends Resource
})
->description(fn ($record) => $record->parent ? 'Managed by '.$record->parent->name : null)
->searchable(query: function ($query, $search) {
return $query->where('name', 'like', "%{$search}%")
->orWhere('dba_name', 'like', "%{$search}%");
return $query->where('name', 'ilike', "%{$search}%")
->orWhere('dba_name', 'ilike', "%{$search}%");
})
->sortable(query: fn ($query, $direction) => $query->orderBy('parent_id')->orderBy('name', $direction)),
TextColumn::make('types.label')
@@ -1910,9 +1915,9 @@ class BusinessResource extends Resource
return $query->whereHas('users', function ($q) use ($search) {
$q->wherePivot('is_primary', true)
->where(function ($q2) use ($search) {
$q2->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$q2->where('first_name', 'ilike', "%{$search}%")
->orWhere('last_name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
});
})
@@ -1943,9 +1948,9 @@ class BusinessResource extends Resource
})
->searchable(query: function ($query, $search) {
return $query->whereHas('users', function ($q) use ($search) {
$q->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$q->where('first_name', 'ilike', "%{$search}%")
->orWhere('last_name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
}),
TextColumn::make('users_count')
@@ -2082,6 +2087,7 @@ class BusinessResource extends Resource
public static function getRelations(): array
{
return [
BusinessResource\RelationManagers\DbasRelationManager::class,
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
];
}

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

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

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers;
use App\Models\BannerAd;
use App\Services\BannerAdService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class BannerAdController extends Controller
{
public function __construct(
protected BannerAdService $bannerAdService
) {}
/**
* Handle click tracking and redirect
* URL: /ads/click/{bannerAd}
*/
public function click(Request $request, BannerAd $bannerAd)
{
$this->bannerAdService->recordClick($bannerAd, [
'business_id' => auth()->user()?->businesses->first()?->id,
'user_id' => auth()->id(),
'session_id' => session()->getId(),
'page_url' => $request->header('referer'),
]);
return redirect()->away($bannerAd->cta_url);
}
/**
* Track impression via AJAX (for lazy-loaded ads)
* URL: POST /ads/impression/{bannerAd}
*/
public function impression(Request $request, BannerAd $bannerAd)
{
$this->bannerAdService->recordImpression($bannerAd, [
'business_id' => auth()->user()?->businesses->first()?->id,
'user_id' => auth()->id(),
'session_id' => session()->getId(),
]);
return response()->json(['success' => true]);
}
/**
* Serve banner ad image at specific width
* URL: /images/banner-ad/{bannerAd}/{width?}
*/
public function image(BannerAd $bannerAd, ?int $width = null)
{
if (! $bannerAd->image_path || ! Storage::exists($bannerAd->image_path)) {
abort(404);
}
// Return original if no width specified
if (! $width) {
$contents = Storage::get($bannerAd->image_path);
$mimeType = Storage::mimeType($bannerAd->image_path);
return response($contents)
->header('Content-Type', $mimeType)
->header('Cache-Control', 'public, max-age=86400');
}
// Generate and cache resized version
$ext = pathinfo($bannerAd->image_path, PATHINFO_EXTENSION);
$thumbnailName = "banner-ad-{$bannerAd->id}-{$width}w.{$ext}";
$thumbnailPath = "banner-ads/cache/{$thumbnailName}";
if (! Storage::disk('local')->exists($thumbnailPath)) {
$originalContents = Storage::get($bannerAd->image_path);
$manager = new ImageManager(new Driver);
$image = $manager->read($originalContents);
$image->scale(width: $width);
if (! Storage::disk('local')->exists('banner-ads/cache')) {
Storage::disk('local')->makeDirectory('banner-ads/cache');
}
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
Storage::disk('local')->put($thumbnailPath, $encoded);
}
$mimeType = $ext === 'png' ? 'image/png' : 'image/jpeg';
return response()->file(
storage_path("app/private/{$thumbnailPath}"),
['Content-Type' => $mimeType, 'Cache-Control' => 'public, max-age=86400']
);
}
}

View File

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

View File

@@ -4,33 +4,44 @@ namespace App\Http\Controllers;
use App\Models\Brand;
use App\Models\Product;
use App\Models\ProductCategory;
use App\Models\Strain;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MarketplaceController extends Controller
{
/**
* Display marketplace browse page
* Display marketplace browse page (Amazon/Shopify style)
*/
public function index(Request $request)
{
$business = auth()->user()->businesses->first();
$hasFilters = $request->hasAny(['search', 'brand_id', 'strain_type', 'price_min', 'price_max', 'in_stock', 'category_id']);
// Start with active products only
$query = Product::query()
->with(['brand', 'strain'])
->with(['brand', 'strain', 'category'])
->active();
// Search filter (name, SKU, description)
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('sku', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('sku', 'ilike', "%{$search}%")
->orWhere('description', 'ilike', "%{$search}%");
});
}
// Brand filter
if ($brandId = $request->input('brand_id')) {
$query->where('brand_id', $brandId);
// Brand filter (supports multiple)
if ($brandIds = $request->input('brand_id')) {
$brandIds = is_array($brandIds) ? $brandIds : [$brandIds];
$query->whereIn('brand_id', $brandIds);
}
// Category filter (uses category_id foreign key)
if ($categoryId = $request->input('category_id')) {
$query->where('category_id', $categoryId);
}
// Strain type filter
@@ -64,23 +75,96 @@ class MarketplaceController extends Controller
default => $query->latest(),
};
// View mode (grid/list)
$viewMode = $request->input('view', 'grid');
// Paginate results
$products = $query->paginate(12)->withQueryString();
$perPage = $viewMode === 'list' ? 10 : 12;
$products = $query->paginate($perPage)->withQueryString();
// Get all active brands for filters
$brands = Brand::active()->orderBy('name')->get();
// Get brands with product counts for faceted filter
$brands = Brand::query()
->active()
->withCount(['products' => fn ($q) => $q->active()])
->orderBy('name')
->get()
->filter(fn ($b) => $b->products_count > 0);
// Get featured products for carousel (exclude from main results if in first page)
// Get categories with product counts
$categories = ProductCategory::query()
->whereNull('parent_id') // Only top-level categories
->where('is_active', true)
->withCount(['products' => fn ($q) => $q->active()])
->get()
->filter(fn ($c) => $c->products_count > 0)
->sortByDesc('products_count');
// Featured products for hero carousel
$featuredProducts = Product::query()
->with(['brand', 'strain'])
->featured()
->inStock()
->limit(3)
->limit(5)
->get();
$business = auth()->user()->businesses->first();
// Top brands (by product count) for homepage section
$topBrands = Brand::query()
->active()
->withCount(['products' => fn ($q) => $q->active()])
->get()
->filter(fn ($b) => $b->products_count > 0)
->sortByDesc('products_count')
->take(6);
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts', 'business'));
// New arrivals (products created in last 14 days)
$newArrivals = Product::query()
->with(['brand', 'strain', 'category'])
->active()
->inStock()
->where('created_at', '>=', now()->subDays(14))
->orderByDesc('created_at')
->limit(8)
->get();
// Trending products (most ordered in last 30 days - simplified query)
$trendingIds = DB::table('order_items')
->select('product_id', DB::raw('SUM(quantity) as total_sold'))
->where('created_at', '>=', now()->subDays(30))
->groupBy('product_id')
->orderByDesc('total_sold')
->limit(8)
->pluck('product_id');
$trending = $trendingIds->isNotEmpty()
? Product::with(['brand', 'strain', 'category'])
->whereIn('id', $trendingIds)
->active()
->get()
->sortBy(fn ($p) => array_search($p->id, $trendingIds->toArray()))
: collect();
// Active filters for pills display
$activeFilters = collect([
'search' => $request->input('search'),
'brand_id' => $request->input('brand_id'),
'category_id' => $request->input('category_id'),
'strain_type' => $request->input('strain_type'),
'in_stock' => $request->input('in_stock'),
])->filter();
return view('buyer.marketplace.index', compact(
'products',
'brands',
'categories',
'featuredProducts',
'topBrands',
'newArrivals',
'trending',
'business',
'viewMode',
'activeFilters',
'hasFilters'
));
}
/**
@@ -180,7 +264,7 @@ class MarketplaceController extends Controller
// Get featured products from this brand
$featuredProducts = Product::query()
->with(['strain'])
->with(['strain', 'brand'])
->where('brand_id', $brand->id)
->featured()
->inStock()
@@ -189,7 +273,7 @@ class MarketplaceController extends Controller
// Get all products from this brand
$products = Product::query()
->with(['strain'])
->with(['strain', 'brand'])
->where('brand_id', $brand->id)
->active()
->orderBy('is_featured', 'desc')

View File

@@ -97,6 +97,135 @@ class OrderController extends Controller
return view('seller.orders.index', compact('orders', 'business'));
}
/**
* Show the form for creating a new order (seller-initiated).
*/
public function create(\App\Models\Business $business): View
{
// Get all buyer businesses for the customer dropdown
$buyers = \App\Models\Business::where('is_active', true)
->whereIn('business_type', ['buyer', 'both'])
->with(['locations' => function ($query) {
$query->where('is_active', true)->orderBy('is_primary', 'desc')->orderBy('name');
}])
->orderBy('name')
->get();
// Get recently ordered products (last 30 days, top 10 most common)
$recentProducts = \App\Models\Product::forBusiness($business)
->whereHas('orderItems', function ($query) {
$query->where('created_at', '>=', now()->subDays(30));
})
->with(['brand', 'images'])
->withCount(['orderItems' => function ($query) {
$query->where('created_at', '>=', now()->subDays(30));
}])
->orderByDesc('order_items_count')
->take(10)
->get()
->map(function ($product) use ($business) {
// Calculate inventory from InventoryItem model
$totalOnHand = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_on_hand');
$totalAllocated = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_allocated');
return [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
'brand_name' => $product->brand?->name,
'wholesale_price' => $product->wholesale_price,
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
'type' => $product->type,
];
});
return view('seller.orders.create', compact('business', 'buyers', 'recentProducts'));
}
/**
* Store a newly created order (seller-initiated).
*/
public function store(\App\Models\Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'buyer_business_id' => 'required|exists:businesses,id',
'location_id' => 'nullable|exists:locations,id',
'contact_id' => 'nullable|exists:contacts,id',
'payment_terms' => 'required|in:cod,net_15,net_30,net_60,net_90',
'notes' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.discount_amount' => 'nullable|numeric|min:0',
'items.*.discount_type' => 'nullable|in:fixed,percent',
'items.*.notes' => 'nullable|string|max:500',
'items.*.batch_id' => 'nullable|exists:batches,id',
]);
try {
// Create the order
$order = Order::create([
'business_id' => $validated['buyer_business_id'],
'location_id' => $validated['location_id'] ?? null,
'contact_id' => $validated['contact_id'] ?? null,
'user_id' => auth()->id(),
'status' => 'new',
'created_by' => 'seller',
'payment_terms' => $validated['payment_terms'],
'notes' => $validated['notes'] ?? null,
]);
// Add line items
$subtotal = 0;
foreach ($validated['items'] as $item) {
$product = \App\Models\Product::findOrFail($item['product_id']);
$lineSubtotal = $item['quantity'] * $item['unit_price'];
$discountAmount = 0;
if (! empty($item['discount_amount']) && $item['discount_amount'] > 0) {
if (($item['discount_type'] ?? 'percent') === 'percent') {
$discountAmount = $lineSubtotal * ($item['discount_amount'] / 100);
} else {
$discountAmount = $item['discount_amount'];
}
}
$lineTotal = $lineSubtotal - $discountAmount;
$subtotal += $lineTotal;
$order->items()->create([
'product_id' => $item['product_id'],
'batch_id' => $item['batch_id'] ?? null,
'quantity' => $item['quantity'],
'price' => $item['unit_price'],
'discount_amount' => $discountAmount,
'total' => $lineTotal,
'notes' => $item['notes'] ?? null,
]);
}
// Update order totals
$order->update([
'subtotal' => $subtotal,
'total' => $subtotal, // Tax can be added later
]);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Order created successfully!');
} catch (\Exception $e) {
return back()
->withInput()
->with('error', 'Failed to create order: '.$e->getMessage());
}
}
/**
* Display order detail with workorder/picking ticket functionality.
*/
@@ -213,6 +342,41 @@ class OrderController extends Controller
return back()->with('success', "Order {$order->order_number} has been cancelled.");
}
/**
* Update item comment for an order line item.
*/
public function updateItemComment(\App\Models\Business $business, Order $order, \App\Models\OrderItem $orderItem, Request $request): RedirectResponse
{
// Verify the item belongs to this order
if ($orderItem->order_id !== $order->id) {
abort(404);
}
$validated = $request->validate([
'item_comment' => 'nullable|string|max:2000',
]);
$orderItem->update([
'item_comment' => $validated['item_comment'],
]);
return back()->with('success', 'Item comment updated.');
}
/**
* Toggle ping pong mode for an order.
*/
public function togglePingPong(\App\Models\Business $business, Order $order): RedirectResponse
{
$order->update([
'is_ping_pong' => ! $order->is_ping_pong,
]);
$status = $order->is_ping_pong ? 'enabled' : 'disabled';
return back()->with('success', "Ping Pong flow {$status} for this order.");
}
/**
* Approve order for delivery (after buyer selects delivery method).
*/

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

@@ -55,13 +55,17 @@ class BrandController extends Controller
'is_active' => $brand->is_active,
'is_public' => $brand->is_public,
'is_featured' => $brand->is_featured,
'is_cannaiq_connected' => $brand->isCannaiqConnected(),
'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();
@@ -767,6 +771,11 @@ class BrandController extends Controller
// ═══════════════════════════════════════════════════════════════
$salesStats = $this->calculateBrandStats($brand, $ninetyDaysAgo, now());
// ═══════════════════════════════════════════════════════════════
// STORE INTELLIGENCE (90 days)
// ═══════════════════════════════════════════════════════════════
$storeStats = $this->calculateStoreStats($brand, 90);
// ═══════════════════════════════════════════════════════════════
// PRODUCT VELOCITY DATA
// ═══════════════════════════════════════════════════════════════
@@ -880,6 +889,7 @@ class BrandController extends Controller
'isBrandManager' => $isBrandManager,
// Core stats
'salesStats' => $salesStats,
'storeStats' => $storeStats,
'productCategories' => $productCategories,
'productVelocity' => $productVelocity,
// Product states
@@ -2089,4 +2099,185 @@ class BrandController extends Controller
'store_id' => $storeId,
]);
}
/**
* Connect brand to CannaiQ API.
*
* Normalizes the brand name and stores as cannaiq_brand_key.
*/
public function cannaiqConnect(Request $request, Business $business, Brand $brand)
{
$this->authorize('update', [$brand, $business]);
$validated = $request->validate([
'brand_name' => 'required|string|max:255',
]);
$brand->connectToCannaiq($validated['brand_name']);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'Brand connected to CannaiQ',
'cannaiq_brand_key' => $brand->cannaiq_brand_key,
]);
}
return redirect()
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
->with('success', 'Brand connected to CannaiQ successfully!');
}
/**
* Disconnect brand from CannaiQ API.
*/
public function cannaiqDisconnect(Request $request, Business $business, Brand $brand)
{
$this->authorize('update', [$brand, $business]);
$brand->disconnectFromCannaiq();
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'Brand disconnected from CannaiQ',
]);
}
return redirect()
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
->with('success', 'Brand disconnected from CannaiQ.');
}
/**
* CannaiQ product mapping page.
*
* Shows Hub products for this brand and allows mapping to CannaiQ products.
*/
public function cannaiqMapping(Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
if (! $brand->isCannaiqConnected()) {
return redirect()
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
->with('error', 'Please connect this brand to CannaiQ first.');
}
$products = $brand->products()
->with('cannaiqMappings')
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.brands.cannaiq-mapping', [
'business' => $business,
'brand' => $brand,
'products' => $products,
]);
}
/**
* Map a Hub product to a CannaiQ product.
*/
public function cannaiqMapProduct(Request $request, Business $business, Brand $brand)
{
$this->authorize('update', [$brand, $business]);
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'cannaiq_product_id' => 'required|integer',
'cannaiq_product_name' => 'required|string|max:255',
'cannaiq_store_id' => 'nullable|string|max:255',
'cannaiq_store_name' => 'nullable|string|max:255',
]);
// Verify product belongs to this brand
$product = $brand->products()->findOrFail($validated['product_id']);
// Create mapping (ignore if already exists)
$mapping = $product->cannaiqMappings()->firstOrCreate(
['cannaiq_product_id' => $validated['cannaiq_product_id']],
[
'cannaiq_product_name' => $validated['cannaiq_product_name'],
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
]
);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'mapping' => $mapping,
]);
}
return redirect()
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
->with('success', 'Product mapped successfully.');
}
/**
* Remove a product mapping.
*/
public function cannaiqUnmapProduct(Request $request, Business $business, Brand $brand, \App\Models\ProductCannaiqMapping $mapping)
{
$this->authorize('update', [$brand, $business]);
// Verify mapping belongs to a product of this brand
if ($mapping->product->brand_id !== $brand->id) {
abort(403);
}
$mapping->delete();
if ($request->wantsJson()) {
return response()->json(['success' => true]);
}
return redirect()
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
->with('success', 'Mapping removed.');
}
/**
* Calculate store/distribution metrics for the brand.
*
* Returns metrics about store penetration, SKU stock rate, and average SKUs per store.
*/
private function calculateStoreStats(Brand $brand, int $days = 90): array
{
// Count unique buyer businesses (stores) that ordered this brand in current period
$currentStores = \App\Models\Order::whereHas('items.product', fn ($q) => $q->where('brand_id', $brand->id))
->where('created_at', '>=', now()->subDays($days))
->distinct('business_id')
->count('business_id');
// Previous period for comparison
$previousStores = \App\Models\Order::whereHas('items.product', fn ($q) => $q->where('brand_id', $brand->id))
->whereBetween('created_at', [now()->subDays($days * 2), now()->subDays($days)])
->distinct('business_id')
->count('business_id');
// SKU stock rate: % of brand's active SKUs that have been ordered
$activeSkus = $brand->products()->where('is_active', true)->count();
$orderedSkus = \App\Models\OrderItem::whereHas('product', fn ($q) => $q->where('brand_id', $brand->id))
->whereHas('order', fn ($q) => $q->where('created_at', '>=', now()->subDays($days)))
->distinct('product_id')
->count('product_id');
$stockRate = $activeSkus > 0 ? round(($orderedSkus / $activeSkus) * 100, 1) : 0;
// Avg SKUs per store
$avgSkusPerStore = $currentStores > 0 ? round($orderedSkus / $currentStores, 1) : 0;
return [
'currentStores' => $currentStores,
'storeChange' => $currentStores - $previousStores,
'stockRate' => $stockRate,
'avgSkusPerStore' => $avgSkusPerStore,
'orderedSkus' => $orderedSkus,
'activeSkus' => $activeSkus,
];
}
}

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

@@ -0,0 +1,415 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmActiveView;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmInternalNote;
use App\Models\Crm\CrmThread;
use App\Models\User;
use App\Services\Crm\CrmAiService;
use App\Services\Crm\CrmChannelService;
use App\Services\Crm\CrmSlaService;
use Illuminate\Http\Request;
class ChatController extends Controller
{
public function __construct(
protected CrmChannelService $channelService,
protected CrmSlaService $slaService,
protected CrmAiService $aiService
) {}
/**
* Unified chat inbox view (Chatwoot-style)
*/
public function index(Request $request, Business $business)
{
$query = CrmThread::forBusiness($business->id)
->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)])
->withCount('messages');
// Filters
if ($request->filled('status')) {
$query->where('status', $request->status);
}
if ($request->filled('assigned_to')) {
if ($request->assigned_to === 'unassigned') {
$query->unassigned();
} else {
$query->assignedTo($request->assigned_to);
}
}
if ($request->filled('department')) {
$query->forDepartment($request->department);
}
if ($request->filled('brand_id')) {
$query->forBrand($request->brand_id);
}
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('subject', 'ilike', "%{$request->search}%")
->orWhere('last_message_preview', 'ilike', "%{$request->search}%")
->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%"));
});
}
$threads = $query->orderByDesc('last_message_at')->paginate(50);
// Get team members for assignment dropdown
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
// Get available channels
$channels = $this->channelService->getAvailableChannels($business->id);
// Get brands for filter dropdown
$brands = \App\Models\Brand::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
// Get departments for filter dropdown
$departments = CrmChannel::DEPARTMENTS;
// Get contacts for new conversation modal
// Include: 1) Customer contacts (from businesses that ordered), 2) Own business contacts (coworkers)
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
// Add the seller's own business ID to include coworkers
$allBusinessIds = $customerBusinessIds->push($business->id)->unique();
$contacts = \App\Models\Contact::whereIn('business_id', $allBusinessIds)
->with('business:id,name')
->orderBy('first_name')
->limit(200)
->get();
return view('seller.chat.index', compact(
'business',
'threads',
'teamMembers',
'channels',
'brands',
'departments',
'contacts'
));
}
/**
* API: Get thread data for inline loading
*/
public function getThread(Request $request, Business $business, CrmThread $thread)
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Not found'], 404);
}
$thread->load([
'contact',
'account',
'assignee',
'brand',
'channel',
'messages.attachments',
'messages.user',
'deals',
'internalNotes.user',
'tags.tag',
]);
// Mark as read
$thread->markAsRead($request->user());
// Start viewing (collision detection)
CrmActiveView::startViewing($thread, $request->user());
// Get other viewers
$otherViewers = CrmActiveView::getActiveViewers($thread, $request->user()->id);
// Get SLA status
$slaStatus = $this->slaService->getThreadSlaStatus($thread);
// Get AI suggestions
$suggestions = $thread->aiSuggestions()->pending()->notExpired()->get();
// Get available channels for reply
$channels = $this->channelService->getAvailableChannels($business->id);
// Get team members for assignment dropdown
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
return response()->json([
'thread' => $thread,
'otherViewers' => $otherViewers->map(fn ($v) => [
'id' => $v->user->id,
'name' => $v->user->name,
'type' => $v->view_type,
]),
'slaStatus' => $slaStatus,
'suggestions' => $suggestions,
'channels' => $channels,
'teamMembers' => $teamMembers,
]);
}
/**
* API: Send reply in thread
*/
public function reply(Request $request, Business $business, CrmThread $thread)
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Not found'], 404);
}
$validated = $request->validate([
'body' => 'required|string|max:10000',
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
]);
$contact = $thread->contact;
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
? $contact->email
: $contact->phone;
if (! $to) {
return response()->json(['error' => 'Contact does not have required contact info for this channel.'], 422);
}
$success = $this->channelService->sendMessage(
businessId: $business->id,
channelType: $validated['channel_type'],
to: $to,
body: $validated['body'],
subject: null,
threadId: $thread->id,
contactId: $contact->id,
userId: $request->user()->id,
attachments: []
);
if (! $success) {
return response()->json(['error' => 'Failed to send message.'], 500);
}
// Auto-assign thread to sender if unassigned
if ($thread->assigned_to === null) {
$thread->assigned_to = $request->user()->id;
$thread->save();
}
// Handle SLA
$this->slaService->handleOutboundMessage($thread);
// Reload messages
$thread->load(['messages.attachments', 'messages.user']);
return response()->json([
'success' => true,
'messages' => $thread->messages,
]);
}
/**
* API: Create new thread
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'contact_id' => 'required|exists:contacts,id',
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
'body' => 'required|string|max:10000',
]);
// Get allowed business IDs (customers + own business for coworkers)
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
$allBusinessIds = $customerBusinessIds->push($business->id)->unique();
// SECURITY: Verify contact belongs to a customer business or own business (coworker)
$contact = \App\Models\Contact::whereIn('business_id', $allBusinessIds)
->findOrFail($validated['contact_id']);
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
? $contact->email
: $contact->phone;
if (! $to) {
return response()->json(['error' => 'Contact does not have the required contact info for this channel.'], 422);
}
// Create thread
$thread = CrmThread::create([
'business_id' => $business->id,
'contact_id' => $contact->id,
'account_id' => $contact->account_id,
'status' => 'open',
'priority' => 'normal',
'last_channel_type' => $validated['channel_type'],
'assigned_to' => $request->user()->id,
]);
// Send the message
$success = $this->channelService->sendMessage(
businessId: $business->id,
channelType: $validated['channel_type'],
to: $to,
body: $validated['body'],
subject: null,
threadId: $thread->id,
contactId: $contact->id,
userId: $request->user()->id,
attachments: []
);
if (! $success) {
$thread->delete();
return response()->json(['error' => 'Failed to send message.'], 500);
}
$thread->load(['contact', 'messages']);
return response()->json([
'success' => true,
'thread' => $thread,
]);
}
/**
* API: Assign thread
*/
public function assign(Request $request, Business $business, CrmThread $thread)
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Not found'], 404);
}
$validated = $request->validate([
'assigned_to' => 'nullable|exists:users,id',
]);
if ($validated['assigned_to']) {
$assignee = User::where('id', $validated['assigned_to'])
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->first();
if (! $assignee) {
return response()->json(['error' => 'Invalid user.'], 422);
}
$thread->assignTo($assignee, $request->user());
} else {
$thread->assigned_to = null;
$thread->save();
}
return response()->json(['success' => true]);
}
/**
* API: Close thread
*/
public function close(Request $request, Business $business, CrmThread $thread)
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Not found'], 404);
}
$thread->close($request->user());
return response()->json(['success' => true, 'status' => 'closed']);
}
/**
* API: Reopen thread
*/
public function reopen(Request $request, Business $business, CrmThread $thread)
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Not found'], 404);
}
$thread->reopen($request->user());
$this->slaService->resumeTimers($thread);
return response()->json(['success' => true, 'status' => 'open']);
}
/**
* API: Add internal note
*/
public function addNote(Request $request, Business $business, CrmThread $thread)
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Not found'], 404);
}
$validated = $request->validate([
'content' => 'required|string|max:5000',
]);
$note = CrmInternalNote::create([
'business_id' => $business->id,
'user_id' => $request->user()->id,
'notable_type' => CrmThread::class,
'notable_id' => $thread->id,
'content' => $validated['content'],
]);
$note->load('user');
return response()->json(['success' => true, 'note' => $note]);
}
/**
* API: Generate AI reply
*/
public function generateAiReply(Request $request, Business $business, CrmThread $thread)
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Not found'], 404);
}
$suggestion = $this->aiService->generateReplyDraft($thread, $request->input('tone', 'professional'));
if (! $suggestion) {
return response()->json(['error' => 'Failed to generate reply.'], 500);
}
return response()->json([
'content' => $suggestion->content,
'suggestion_id' => $suggestion->id,
]);
}
/**
* API: Heartbeat for active viewing
*/
public function heartbeat(Request $request, Business $business, CrmThread $thread)
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
CrmActiveView::startViewing($thread, $request->user(), $request->input('view_type', 'viewing'));
$otherViewers = CrmActiveView::getActiveViewers($thread, $request->user()->id);
return response()->json([
'other_viewers' => $otherViewers->map(fn ($v) => [
'id' => $v->user->id,
'name' => $v->user->name,
'type' => $v->view_type,
]),
]);
}
}

View File

@@ -25,12 +25,12 @@ class ConversationController extends Controller
if ($search) {
$query->where(function ($q) use ($search) {
$q->whereHas('contact', function ($c) use ($search) {
$c->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
$c->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%")
->orWhere('phone', 'ilike', "%{$search}%");
})
->orWhereHas('messages', function ($m) use ($search) {
$m->where('message_body', 'like', "%{$search}%");
$m->where('message_body', 'ilike', "%{$search}%");
});
});
}

View File

@@ -19,11 +19,14 @@ use Illuminate\Http\Request;
class AccountController extends Controller
{
/**
* Display accounts listing
* Display accounts listing - only buyers who have ordered from this seller
*/
public function index(Request $request, Business $business)
{
$query = Business::where('type', 'buyer')
->whereHas('orders', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})
->with(['contacts']);
// Search filter
@@ -36,11 +39,16 @@ class AccountController extends Controller
});
}
// Status filter - default to approved, but allow viewing all
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
} else {
$query->where('status', 'approved');
// 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);
@@ -322,7 +330,7 @@ class AccountController extends Controller
'past_due_amount' => $financialStats->past_due_amount ?? 0,
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
'oldest_past_due_days' => $financialStats->oldest_past_due_date
? now()->diffInDays($financialStats->oldest_past_due_date)
? (int) ceil(abs(now()->diffInDays($financialStats->oldest_past_due_date)))
: null,
'last_payment_amount' => $lastPayment->amount ?? null,
'last_payment_date' => $lastPayment->payment_date ?? null,

View File

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

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\ChatQuickReply;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmMessageTemplate;
use App\Models\Crm\CrmPipeline;
@@ -649,4 +650,81 @@ class CrmSettingsController extends Controller
return back()->with('success', 'Role deleted.');
}
/**
* Quick replies list
*/
public function quickReplies(Request $request, Business $business)
{
$quickReplies = ChatQuickReply::where('business_id', $business->id)
->orderBy('sort_order')
->orderBy('label')
->get();
$categories = $quickReplies->pluck('category')->filter()->unique()->values();
return view('seller.crm.settings.quick-replies.index', [
'business' => $business,
'quickReplies' => $quickReplies,
'categories' => $categories,
]);
}
/**
* Store new quick reply
*/
public function storeQuickReply(Request $request, Business $business)
{
$validated = $request->validate([
'label' => 'required|string|max:100',
'message' => 'required|string|max:2000',
'category' => 'nullable|string|max:50',
'is_active' => 'boolean',
]);
$validated['business_id'] = $business->id;
$validated['is_active'] = $request->boolean('is_active', true);
$validated['sort_order'] = ChatQuickReply::where('business_id', $business->id)->max('sort_order') + 1;
ChatQuickReply::create($validated);
return back()->with('success', 'Quick reply created.');
}
/**
* Update quick reply
*/
public function updateQuickReply(Request $request, Business $business, ChatQuickReply $quickReply)
{
if ($quickReply->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'label' => 'required|string|max:100',
'message' => 'required|string|max:2000',
'category' => 'nullable|string|max:50',
'is_active' => 'boolean',
]);
$validated['is_active'] = $request->boolean('is_active', true);
$quickReply->update($validated);
return back()->with('success', 'Quick reply updated.');
}
/**
* Delete quick reply
*/
public function destroyQuickReply(Request $request, Business $business, ChatQuickReply $quickReply)
{
if ($quickReply->business_id !== $business->id) {
abort(404);
}
$quickReply->delete();
return back()->with('success', 'Quick reply deleted.');
}
}

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ use App\Models\Crm\CrmQuoteItem;
use App\Models\Location;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Services\Accounting\ArService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
@@ -38,8 +37,8 @@ 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}%");
});
}
@@ -174,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',
@@ -210,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',
]);
@@ -248,7 +246,7 @@ class QuoteController extends Controller
abort(404);
}
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product', 'invoice', 'files']);
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product.brand', 'invoice', 'files']);
return view('seller.crm.quotes.show', compact('quote', 'business'));
}
@@ -266,16 +264,9 @@ class QuoteController extends Controller
return back()->withErrors(['error' => 'This quote cannot be edited.']);
}
$quote->load('items');
$quote->load(['items.product', 'contact', 'account', 'deal']);
$contacts = Contact::where('business_id', $business->id)->get();
$accounts = Business::whereHas('ordersAsCustomer')->get();
$deals = CrmDeal::forBusiness($business->id)->open()->get();
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->where('is_active', true)
->get();
return view('seller.crm.quotes.edit', compact('quote', 'contacts', 'accounts', 'deals', 'products', 'business'));
return view('seller.crm.quotes.edit', compact('quote', 'business'));
}
/**
@@ -590,7 +581,7 @@ class QuoteController extends Controller
'sellerBusiness' => $business,
]);
return $pdf->inline("{$quote->quote_number}.pdf");
return $pdf->stream("{$quote->quote_number}.pdf");
}
/**

View File

@@ -97,7 +97,19 @@ class TaskController extends Controller
*/
public function create(Request $request, Business $business)
{
return view('seller.crm.tasks.create', compact('business'));
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
// Prefill from query params (when creating task from contact/account/etc)
$prefill = [
'title' => $request->get('title'),
'business_id' => $request->get('business_id'),
'contact_id' => $request->get('contact_id'),
'opportunity_id' => $request->get('opportunity_id'),
'conversation_id' => $request->get('conversation_id'),
'order_id' => $request->get('order_id'),
];
return view('seller.crm.tasks.create', compact('business', 'teamMembers', 'prefill'));
}
/**

View File

@@ -2,17 +2,24 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Events\CrmTypingIndicator;
use App\Http\Controllers\Controller;
use App\Models\AgentStatus;
use App\Models\Business;
use App\Models\ChatQuickReply;
use App\Models\Contact;
use App\Models\Crm\CrmActiveView;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmInternalNote;
use App\Models\Crm\CrmThread;
use App\Models\SalesRepAssignment;
use App\Models\User;
use App\Services\Crm\CrmAiService;
use App\Services\Crm\CrmChannelService;
use App\Services\Crm\CrmSlaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ThreadController extends Controller
{
@@ -164,9 +171,9 @@ class ThreadController extends Controller
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('subject', 'like', "%{$request->search}%")
->orWhere('last_message_preview', 'like', "%{$request->search}%")
->orWhereHas('contact', fn ($c) => $c->where('name', 'like', "%{$request->search}%"));
$q->where('subject', 'ilike', "%{$request->search}%")
->orWhere('last_message_preview', 'ilike', "%{$request->search}%")
->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%"));
});
}
@@ -446,4 +453,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -881,9 +881,9 @@ class ProductController extends Controller
'content' => [
'description' => ['nullable', 'string', 'max:255'],
'tagline' => ['nullable', 'string', 'max:100'],
'long_description' => ['nullable', 'string', 'max:500'],
'consumer_long_description' => ['nullable', 'string', 'max:500'],
'buyer_long_description' => ['nullable', 'string', 'max:500'],
'long_description' => ['nullable', 'string'],
'consumer_long_description' => ['nullable', 'string'],
'buyer_long_description' => ['nullable', 'string'],
'product_link' => 'nullable|url|max:255',
'creatives_json' => 'nullable|json',
'seo_title' => ['nullable', 'string', 'max:70'],
@@ -923,10 +923,10 @@ class ProductController extends Controller
// Define checkbox fields per tab
$checkboxesByTab = [
'overview' => ['is_active', 'is_featured', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
'overview' => ['is_active', 'is_featured', 'is_sellable', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
'pricing' => ['is_case', 'is_box'],
'inventory' => ['sync_bamboo', 'low_stock_alert_enabled', 'is_assembly', 'show_inventory_to_buyers', 'has_varieties'],
'advanced' => ['is_sellable', 'is_fpr', 'is_raw_material'],
'advanced' => ['is_fpr', 'is_raw_material'],
];
// Convert checkboxes to boolean - only for fields in current validation scope
@@ -938,7 +938,7 @@ class ProductController extends Controller
if (array_key_exists($checkbox, $rules)) {
// Use boolean() for fields that send actual values (hidden inputs with 0/1)
// Use has() for traditional checkboxes that are absent when unchecked
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'low_stock_alert_enabled', 'has_varieties']);
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'is_sellable', 'low_stock_alert_enabled', 'has_varieties']);
$validated[$checkbox] = $useBoolean
? $request->boolean($checkbox)
: $request->has($checkbox);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,263 @@
<?php
namespace App\Http\Controllers\Seller\Settings;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\BusinessDba;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DbaController extends Controller
{
/**
* Display a listing of all DBAs for the business.
*/
public function index(Business $business): View
{
$dbas = $business->dbas()
->orderByDesc('is_default')
->orderBy('trade_name')
->get();
return view('seller.settings.dbas.index', compact('business', 'dbas'));
}
/**
* Show the form for creating a new DBA.
*/
public function create(Business $business): View
{
return view('seller.settings.dbas.create', compact('business'));
}
/**
* Store a newly created DBA in storage.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
// Identity
'trade_name' => 'required|string|max:255',
// Address
'address' => 'nullable|string|max:255',
'address_line_2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:255',
'state' => 'nullable|string|max:2',
'zip' => 'nullable|string|max:10',
// License
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string|max:255',
'license_expiration' => 'nullable|date',
// Bank Info
'bank_name' => 'nullable|string|max:255',
'bank_account_name' => 'nullable|string|max:255',
'bank_routing_number' => 'nullable|string|max:50',
'bank_account_number' => 'nullable|string|max:50',
'bank_account_type' => 'nullable|string|in:checking,savings',
// Tax
'tax_id' => 'nullable|string|max:50',
'tax_id_type' => 'nullable|string|in:ein,ssn',
// Contacts
'primary_contact_name' => 'nullable|string|max:255',
'primary_contact_email' => 'nullable|email|max:255',
'primary_contact_phone' => 'nullable|string|max:50',
'ap_contact_name' => 'nullable|string|max:255',
'ap_contact_email' => 'nullable|email|max:255',
'ap_contact_phone' => 'nullable|string|max:50',
// Invoice Settings
'payment_terms' => 'nullable|string|max:50',
'payment_instructions' => 'nullable|string|max:2000',
'invoice_footer' => 'nullable|string|max:2000',
'invoice_prefix' => 'nullable|string|max:10',
// Branding
'logo_path' => 'nullable|string|max:255',
'brand_colors' => 'nullable|array',
// Status
'is_default' => 'boolean',
'is_active' => 'boolean',
]);
$validated['business_id'] = $business->id;
$validated['is_default'] = $request->boolean('is_default');
$validated['is_active'] = $request->boolean('is_active', true);
$dba = BusinessDba::create($validated);
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$dba->trade_name}\" created successfully.");
}
/**
* Show the form for editing the specified DBA.
*/
public function edit(Business $business, BusinessDba $dba): View
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
return view('seller.settings.dbas.edit', compact('business', 'dba'));
}
/**
* Update the specified DBA in storage.
*/
public function update(Request $request, Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
$validated = $request->validate([
// Identity
'trade_name' => 'required|string|max:255',
// Address
'address' => 'nullable|string|max:255',
'address_line_2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:255',
'state' => 'nullable|string|max:2',
'zip' => 'nullable|string|max:10',
// License
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string|max:255',
'license_expiration' => 'nullable|date',
// Bank Info
'bank_name' => 'nullable|string|max:255',
'bank_account_name' => 'nullable|string|max:255',
'bank_routing_number' => 'nullable|string|max:50',
'bank_account_number' => 'nullable|string|max:50',
'bank_account_type' => 'nullable|string|in:checking,savings',
// Tax
'tax_id' => 'nullable|string|max:50',
'tax_id_type' => 'nullable|string|in:ein,ssn',
// Contacts
'primary_contact_name' => 'nullable|string|max:255',
'primary_contact_email' => 'nullable|email|max:255',
'primary_contact_phone' => 'nullable|string|max:50',
'ap_contact_name' => 'nullable|string|max:255',
'ap_contact_email' => 'nullable|email|max:255',
'ap_contact_phone' => 'nullable|string|max:50',
// Invoice Settings
'payment_terms' => 'nullable|string|max:50',
'payment_instructions' => 'nullable|string|max:2000',
'invoice_footer' => 'nullable|string|max:2000',
'invoice_prefix' => 'nullable|string|max:10',
// Branding
'logo_path' => 'nullable|string|max:255',
'brand_colors' => 'nullable|array',
// Status
'is_default' => 'boolean',
'is_active' => 'boolean',
]);
$validated['is_default'] = $request->boolean('is_default');
$validated['is_active'] = $request->boolean('is_active', true);
// Don't overwrite encrypted fields if left blank (preserve existing values)
$encryptedFields = ['bank_routing_number', 'bank_account_number', 'tax_id'];
foreach ($encryptedFields as $field) {
if (empty($validated[$field])) {
unset($validated[$field]);
}
}
$dba->update($validated);
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$dba->trade_name}\" updated successfully.");
}
/**
* Remove the specified DBA from storage.
*/
public function destroy(Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
// Check if this is the only active DBA
$activeCount = $business->dbas()->where('is_active', true)->count();
if ($activeCount <= 1 && $dba->is_active) {
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('error', 'You cannot delete the only active DBA. Create another DBA first or deactivate this one.');
}
$tradeName = $dba->trade_name;
$dba->delete();
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$tradeName}\" deleted successfully.");
}
/**
* Set the specified DBA as the default for the business.
*/
public function setDefault(Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
$dba->markAsDefault();
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "\"{$dba->trade_name}\" is now your default DBA for invoices.");
}
/**
* Toggle the active status of a DBA.
*/
public function toggleActive(Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
// Prevent deactivating if it's the only active DBA
if ($dba->is_active) {
$activeCount = $business->dbas()->where('is_active', true)->count();
if ($activeCount <= 1) {
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('error', 'You cannot deactivate the only active DBA.');
}
}
$dba->update(['is_active' => ! $dba->is_active]);
$status = $dba->is_active ? 'activated' : 'deactivated';
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$dba->trade_name}\" has been {$status}.");
}
}

View File

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

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Jobs;
use App\Models\BannerAdDailyStat;
use App\Models\BannerAdEvent;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class RollupBannerAdStats implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
protected ?string $date = null
) {
$this->date = $date ?? now()->subDay()->toDateString();
}
public function handle(): void
{
$stats = BannerAdEvent::query()
->whereDate('created_at', $this->date)
->select([
'banner_ad_id',
DB::raw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) as impressions"),
DB::raw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) as clicks"),
DB::raw("COUNT(DISTINCT CASE WHEN event_type = 'impression' THEN session_id END) as unique_impressions"),
DB::raw("COUNT(DISTINCT CASE WHEN event_type = 'click' THEN session_id END) as unique_clicks"),
])
->groupBy('banner_ad_id')
->get();
$created = 0;
foreach ($stats as $stat) {
BannerAdDailyStat::updateOrCreate(
[
'banner_ad_id' => $stat->banner_ad_id,
'date' => $this->date,
],
[
'impressions' => $stat->impressions,
'clicks' => $stat->clicks,
'unique_impressions' => $stat->unique_impressions,
'unique_clicks' => $stat->unique_clicks,
]
);
$created++;
}
if ($created > 0) {
Log::info("Banner ad daily stats rolled up: {$created} records for {$this->date}");
}
// Optionally clean up old events (older than 30 days)
$deleted = BannerAdEvent::where('created_at', '<', now()->subDays(30))->delete();
if ($deleted > 0) {
Log::info("Cleaned up {$deleted} old banner ad events");
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Jobs;
use App\Services\BannerAdService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class UpdateBannerAdStatuses implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(BannerAdService $service): void
{
$updated = $service->updateScheduledStatuses();
if ($updated > 0) {
Log::info("Banner ad statuses updated: {$updated} ads changed");
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Mail\Concerns;
use App\Models\Business;
use App\Models\User;
use Illuminate\Mail\Mailables\Address;
/**
* Trait for adding Reply-To header using business's primary email identity.
*
* This ensures replies to transactional emails (quotes, invoices, orders)
* are routed back to the CRM inbox.
*
* Supports plus addressing: inbox+user123@domain.com routes replies
* to the specific user who sent the original message.
*/
trait HasBusinessReplyTo
{
/**
* Get the Reply-To addresses for the business.
*
* @param User|int|null $user Optional user for plus addressing
* @return array<Address>
*/
protected function getBusinessReplyTo(Business $business, User|int|null $user = null): array
{
$inboundEmail = $business->primaryEmailIdentity?->email;
if (! $inboundEmail) {
return [];
}
// Add plus addressing for user routing
if ($user) {
$userId = $user instanceof User ? $user->id : $user;
$inboundEmail = $this->addPlusAddress($inboundEmail, "u{$userId}");
}
return [new Address($inboundEmail, $business->name)];
}
/**
* Add plus addressing to an email.
* inbox@domain.com + "u123" => inbox+u123@domain.com
*/
protected function addPlusAddress(string $email, string $tag): string
{
$parts = explode('@', $email);
if (count($parts) !== 2) {
return $email;
}
return $parts[0].'+'.$tag.'@'.$parts[1];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Mail\Invoices;
use App\Mail\Concerns\HasBusinessReplyTo;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Attachment;
@@ -12,7 +13,7 @@ use Illuminate\Queue\SerializesModels;
class InvoiceSentMail extends Mailable
{
use Queueable, SerializesModels;
use HasBusinessReplyTo, Queueable, SerializesModels;
public function __construct(
public Invoice $invoice,
@@ -22,9 +23,19 @@ class InvoiceSentMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: "Invoice {$this->invoice->invoice_number} from ".config('app.name'),
$business = $this->invoice->sellerBusiness;
$envelope = new Envelope(
subject: "Invoice {$this->invoice->invoice_number} from ".($business?->name ?? config('app.name')),
);
if ($business) {
$replyTo = $this->getBusinessReplyTo($business);
if ($replyTo) {
$envelope->replyTo($replyTo);
}
}
return $envelope;
}
public function content(): Content

View File

@@ -2,6 +2,7 @@
namespace App\Mail\Orders;
use App\Mail\Concerns\HasBusinessReplyTo;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
class OrderAcceptedMail extends Mailable
{
use Queueable, SerializesModels;
use HasBusinessReplyTo, Queueable, SerializesModels;
/**
* Create a new message instance.
@@ -25,9 +26,19 @@ class OrderAcceptedMail extends Mailable
*/
public function envelope(): Envelope
{
return new Envelope(
$business = $this->order->sellerBusiness;
$envelope = new Envelope(
subject: "Order {$this->order->order_number} Accepted",
);
if ($business) {
$replyTo = $this->getBusinessReplyTo($business);
if ($replyTo) {
$envelope->replyTo($replyTo);
}
}
return $envelope;
}
/**

View File

@@ -2,6 +2,7 @@
namespace App\Mail\Orders;
use App\Mail\Concerns\HasBusinessReplyTo;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
class OrderDeliveredMail extends Mailable
{
use Queueable, SerializesModels;
use HasBusinessReplyTo, Queueable, SerializesModels;
public function __construct(
public Order $order
@@ -19,9 +20,19 @@ class OrderDeliveredMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
$business = $this->order->sellerBusiness;
$envelope = new Envelope(
subject: "Order {$this->order->order_number} Delivered",
);
if ($business) {
$replyTo = $this->getBusinessReplyTo($business);
if ($replyTo) {
$envelope->replyTo($replyTo);
}
}
return $envelope;
}
public function content(): Content

View File

@@ -2,6 +2,7 @@
namespace App\Mail\Orders;
use App\Mail\Concerns\HasBusinessReplyTo;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
class OrderReadyForDeliveryMail extends Mailable
{
use Queueable, SerializesModels;
use HasBusinessReplyTo, Queueable, SerializesModels;
public function __construct(
public Order $order
@@ -19,9 +20,19 @@ class OrderReadyForDeliveryMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
$business = $this->order->sellerBusiness;
$envelope = new Envelope(
subject: "Order {$this->order->order_number} Ready for Delivery",
);
if ($business) {
$replyTo = $this->getBusinessReplyTo($business);
if ($replyTo) {
$envelope->replyTo($replyTo);
}
}
return $envelope;
}
public function content(): Content

View File

@@ -2,6 +2,7 @@
namespace App\Mail;
use App\Mail\Concerns\HasBusinessReplyTo;
use App\Models\Business;
use App\Models\Crm\CrmQuote;
use Illuminate\Bus\Queueable;
@@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Storage;
class QuoteMail extends Mailable
{
use Queueable, SerializesModels;
use HasBusinessReplyTo, Queueable, SerializesModels;
public function __construct(
public CrmQuote $quote,
@@ -25,9 +26,16 @@ class QuoteMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
$envelope = new Envelope(
subject: "Quote {$this->quote->quote_number} from {$this->business->name}",
);
$replyTo = $this->getBusinessReplyTo($this->business);
if ($replyTo) {
$envelope->replyTo($replyTo);
}
return $envelope;
}
public function content(): Content

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

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

View File

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

View File

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

View File

@@ -57,6 +57,11 @@ class Activity extends Model
'task.reminder_sent' => ['icon' => 'heroicons--bell', 'color' => 'text-warning', 'label' => 'Reminder Sent'],
'event.reminder_sent' => ['icon' => 'heroicons--bell', 'color' => 'text-warning', 'label' => 'Reminder Sent'],
// Email engagement activities
'email.opened' => ['icon' => 'heroicons--envelope-open', 'color' => 'text-success', 'label' => 'Email Opened'],
'email.clicked' => ['icon' => 'heroicons--cursor-arrow-rays', 'color' => 'text-info', 'label' => 'Email Link Clicked'],
'email.bounced' => ['icon' => 'heroicons--exclamation-circle', 'color' => 'text-error', 'label' => 'Email Bounced'],
// Generic
'note.added' => ['icon' => 'heroicons--document-text', 'color' => 'text-base-content', 'label' => 'Note Added'],
];
@@ -158,7 +163,7 @@ class Activity extends Model
*/
public function scopeOfTypeGroup($query, string $prefix)
{
return $query->where('type', 'like', $prefix.'%');
return $query->where('type', 'ilike', $prefix.'%');
}
/**

View File

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

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

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

View File

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

View File

@@ -2,7 +2,9 @@
namespace App\Models\Analytics;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Contact;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -118,6 +120,9 @@ class EmailInteraction extends Model
// Update campaign stats
if ($isFirstOpen && $this->campaign) {
$this->campaign->increment('opened_count');
// Log activity for first open only
$this->logActivity('email.opened', "Opened '{$this->campaign->subject}'");
}
}
@@ -147,6 +152,10 @@ class EmailInteraction extends Model
// Update campaign stats
if ($isFirstClick && $this->campaign) {
$this->campaign->increment('clicked_count');
// Log activity for first click only
$subject = $this->campaign->subject;
$this->logActivity('email.clicked', "Clicked link in '{$subject}'", ['url' => $url]);
}
}
@@ -179,4 +188,32 @@ class EmailInteraction extends Model
$this->update(['engagement_score' => min(100, $score)]);
}
/**
* Log activity for email engagement.
* Finds contact by email and logs activity to their timeline.
*/
protected function logActivity(string $type, string $description, ?array $meta = null): void
{
// Find contact by email in the sender's business
$contact = Contact::where('email', $this->recipient_email)->first();
if (! $contact || ! $this->business_id) {
return;
}
Activity::create([
'seller_business_id' => $this->business_id,
'business_id' => $contact->business_id,
'contact_id' => $contact->id,
'subject_type' => self::class,
'subject_id' => $this->id,
'type' => $type,
'description' => $description,
'meta' => array_merge($meta ?? [], [
'campaign_id' => $this->email_campaign_id,
'interaction_id' => $this->id,
]),
]);
}
}

201
app/Models/BannerAd.php Normal file
View File

@@ -0,0 +1,201 @@
<?php
namespace App\Models;
use App\Enums\BannerAdStatus;
use App\Enums\BannerAdZone;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
class BannerAd extends Model
{
use SoftDeletes;
protected $fillable = [
'brand_id',
'created_by_user_id',
'name',
'headline',
'description',
'cta_text',
'cta_url',
'image_path',
'image_alt',
'zone',
'starts_at',
'ends_at',
'target_business_types',
'is_platform_wide',
'status',
'priority',
'weight',
'impressions',
'clicks',
];
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'target_business_types' => 'array',
'is_platform_wide' => 'boolean',
'status' => BannerAdStatus::class,
'zone' => BannerAdZone::class,
'impressions' => 'integer',
'clicks' => 'integer',
'priority' => 'integer',
'weight' => 'integer',
];
// Relationships
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function events(): HasMany
{
return $this->hasMany(BannerAdEvent::class);
}
public function dailyStats(): HasMany
{
return $this->hasMany(BannerAdDailyStat::class);
}
// Scopes
public function scopeActive($query)
{
return $query->where('status', BannerAdStatus::ACTIVE)
->where(function ($q) {
$q->whereNull('starts_at')->orWhere('starts_at', '<=', now());
})
->where(function ($q) {
$q->whereNull('ends_at')->orWhere('ends_at', '>=', now());
});
}
public function scopeForZone($query, BannerAdZone|string $zone)
{
$zoneValue = $zone instanceof BannerAdZone ? $zone->value : $zone;
return $query->where('zone', $zoneValue);
}
public function scopePlatformWide($query)
{
return $query->where('is_platform_wide', true);
}
public function scopeForBusinessType($query, ?string $businessType)
{
if (! $businessType) {
return $query;
}
return $query->where(function ($q) use ($businessType) {
$q->whereNull('target_business_types')
->orWhereJsonContains('target_business_types', $businessType);
});
}
public function scopeScheduled($query)
{
return $query->where('status', BannerAdStatus::SCHEDULED)
->whereNotNull('starts_at')
->where('starts_at', '>', now());
}
public function scopeExpired($query)
{
return $query->where('status', BannerAdStatus::EXPIRED);
}
// Accessors
public function getImageUrlAttribute(): ?string
{
if (! $this->image_path) {
return null;
}
return Storage::url($this->image_path);
}
public function getClickThroughRateAttribute(): float
{
if ($this->impressions === 0) {
return 0;
}
return round(($this->clicks / $this->impressions) * 100, 2);
}
public function getIsCurrentlyActiveAttribute(): bool
{
if ($this->status !== BannerAdStatus::ACTIVE) {
return false;
}
$now = now();
if ($this->starts_at && $this->starts_at > $now) {
return false;
}
if ($this->ends_at && $this->ends_at < $now) {
return false;
}
return true;
}
public function getDimensionsAttribute(): array
{
return $this->zone?->dimensions() ?? ['width' => 728, 'height' => 90, 'display' => '728x90'];
}
// Methods
public function incrementImpressions(): void
{
$this->increment('impressions');
}
public function incrementClicks(): void
{
$this->increment('clicks');
}
/**
* Get image URL for serving via controller
*/
public function getImageUrl(?int $width = null): ?string
{
if (! $this->image_path) {
return null;
}
return route('image.banner-ad', [
'bannerAd' => $this->id,
'width' => $width,
]);
}
/**
* Get click tracking URL
*/
public function getClickUrl(): string
{
return route('banner-ad.click', $this->id);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BannerAdDailyStat extends Model
{
protected $fillable = [
'banner_ad_id',
'date',
'impressions',
'clicks',
'unique_impressions',
'unique_clicks',
];
protected $casts = [
'date' => 'date',
'impressions' => 'integer',
'clicks' => 'integer',
'unique_impressions' => 'integer',
'unique_clicks' => 'integer',
];
// Relationships
public function bannerAd(): BelongsTo
{
return $this->belongsTo(BannerAd::class);
}
// Accessors
public function getClickThroughRateAttribute(): float
{
if ($this->impressions === 0) {
return 0;
}
return round(($this->clicks / $this->impressions) * 100, 2);
}
public function getUniqueClickThroughRateAttribute(): float
{
if ($this->unique_impressions === 0) {
return 0;
}
return round(($this->unique_clicks / $this->unique_impressions) * 100, 2);
}
// Scopes
public function scopeForDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('date', [$startDate, $endDate]);
}
public function scopeForAd($query, $bannerAdId)
{
return $query->where('banner_ad_id', $bannerAdId);
}
}

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