Compare commits

...

260 Commits

Author SHA1 Message Date
kelly
09ff7b27a5 feat: add New Product button with brand selector to Products page 2025-12-18 09:27:47 -07:00
kelly
fe994205f2 fix: clean up duplicate categories, assign White Label Canna to Bulk
Some checks are pending
ci/woodpecker/pr/ci Pipeline is running
ci/woodpecker/push/ci Pipeline is running
- Merge duplicate parent categories (Accessories, Concentrates, etc.)
- Deactivate duplicate category records
- Move products/children to the kept category
- Assign White Label Canna products to Bulk category (147)
- Mark White Label Canna products as is_raw_material=true
2025-12-18 08:54:22 -07:00
kelly
cf80a8b681 style: use DaisyUI join component for qty stepper
Replaces awkward inline qty controls with proper DaisyUI join
component for a cleaner, more polished button group appearance
2025-12-18 08:20:39 -07:00
kelly
5bdd6a2225 feat: enhance product cards with backorder support and editable qty
- Add Backorder button with qty selector for out-of-stock items
- Replace static qty display with editable number input
- Add backorder event handler to marketplace page
- Update both grid and list variants with consistent behavior
- Qty can now be typed directly, not just incremented/decremented
2025-12-18 08:17:54 -07:00
kelly
93678e59bc feat: implement buyer Deals page with active promotions
- Add deals() method to MarketplaceController
- Wire up existing deals.blade.php view with proper data
- Add promotions() relationship to Brand model
- Update buyer sidebar: rename 'Promotion' to 'Deals', link to route

The deals page shows:
- Stats: total deals, percentage off, BOGO, bundles
- Featured deal products grid
- Grouped sections by promo type (%, BOGO, bundle, price override)
- Brands with active deals for quick navigation
2025-12-18 08:15:34 -07:00
kelly
14c30dad03 feat: enhance product cards with visual improvements
- Add THC/CBD/Terpene visual progress bars with gradients
- Color-coded strain type badges (purple=indica, orange=sativa, green=hybrid)
- Show case pricing and units per case
- Unified "Add" button with embedded qty selector (+/- inside button)
- Enhanced hover overlay with qty stepper on image
- Fix quick-view modal to use hashid instead of numeric ID
- Support quantity parameter in add-to-cart events
2025-12-18 08:12:56 -07:00
kelly
08d49b9b67 perf: optimize buyer marketplace pages
MarketplaceController optimizations:
- Use selective column eager loading (brand:id,name,slug...)
- Cache brands/categories for 5 minutes (rarely change)
- Cache trending products for 10 minutes
- Only load homepage sections when not filtering
- Use whereExists instead of whereHas for better SQL performance
- Reuse cached brands for topBrands instead of separate query

Product::scopeInStock optimization:
- Include inventory_mode=unlimited products (always in stock)
- Use whereExists instead of whereHas (faster subquery)

These changes reduce query count and execution time significantly.
2025-12-18 08:03:56 -07:00
kelly
036ae5c6f6 fix: constrain brand logo fallback to 50% of card size via CSS
Use max-w-[50%] and max-h-[50%] to visually limit the brand logo
when displayed as a product image fallback. This ensures logos
appear smaller and centered rather than filling the entire card.
2025-12-18 07:58:33 -07:00
kelly
4c45805390 fix: show brand logo fallback at 50% size in product cards
When products don't have their own image, the brand logo fallback
is now requested at half the normal size to keep it visually smaller
and more balanced within the product card.
2025-12-18 07:56:21 -07:00
kelly
fc943afb36 fix: contain brand logo fallback in product cards
When a product has no image_path, it falls back to showing the brand logo.
Previously, the logo was displayed with object-cover which caused oversized
logos (like 'White Label Canna') to completely fill the card.

Now:
- Products with their own image: object-cover (fill the card)
- Products using brand logo fallback: object-contain with padding

This keeps the logo properly sized and centered within the card.
2025-12-18 07:54:53 -07:00
kelly
25ae50dcd6 perf: add browser caching headers to image responses
- Add 1-year cache headers (Cache-Control, Expires) to all image responses
- Add ETag header based on file path + model updated_at timestamp
- Use 'immutable' cache directive since image URLs include hashids

This fixes slow image loading on /shop by letting browsers cache images
instead of re-requesting them on every page load.
2025-12-18 07:51:18 -07:00
kelly
d1422efe87 fix: use correct buyer layout name (buyer-app-with-sidebar) 2025-12-18 01:08:10 -07:00
kelly
45da5d075d fix: add business parameter to buyer CRM layout route calls
Use request()->route('business') to get the business from the current
route when $business isn't passed directly from child views.
2025-12-18 01:07:03 -07:00
kelly
bdc54da4ad feat: add Buy It Again page for buyers
Add a dedicated "Buy It Again" feature for buyers to quickly reorder
from their favorite brands and purchase history:

- New BuyAgainController with two tabs:
  - Store favorites: Products from followed brands
  - Purchase history: All previously ordered products
- Products grouped by brand with collapsible sections
- Search filtering across products and brands
- Quantity selector with +/- buttons and bulk "Add all to cart"
- Last ordered date display (Month Year format)
- Optional CannaIQ integration for inventory metrics:
  - In Stock count
  - Days until out (with color-coded badges)
- Empty states with CTAs to browse brands/shop

Route: /b/{business}/buy-again
2025-12-18 01:05:38 -07:00
kelly
cb6dc5e433 fix: pass business parameter to inbox view and route calls
The InboxController now passes $business to the view, and all
route() calls in the inbox index view include the business parameter.
2025-12-18 01:05:09 -07:00
kelly
3add610e85 feat: add buyer-side inbox scopes and tracking to CrmThread
- Add forBuyerBusiness scope for filtering threads by buyer
- Add hasUnreadForBuyer, starredByBuyer, archivedByBuyer scopes
- Add notArchivedByBuyer scope for default inbox view
- Add markAsReadForBuyer, toggleStarForBuyer, archiveForBuyer methods
- Add latestMessage and quote relationships
- Add migration for buyer tracking columns:
  - is_read_by_buyer, read_at_by_buyer
  - buyer_starred_by, buyer_archived_by (JSON arrays)
  - quote_id foreign key
2025-12-18 00:57:05 -07:00
kelly
183a22c475 fix: use route model binding for Business in buyer InboxController
Auth::user()->business doesn't exist - users have a businesses()
relationship (many-to-many via pivot). Updated all controller methods
to accept Business $business from route model binding instead.
2025-12-18 00:51:30 -07:00
kelly
f2297d62f2 feat: align buyer topbar with seller layout
- Add buyer-topbar-account component for user dropdown in topbar
- Add chat/messages icon with unread badge to buyer topbar
- Move user account from sidebar to topbar (like seller)
- Reorder topbar items: search, chat, cart, notifications, theme, user
- Use buyer CRM routes for profile, orders, favorites, settings
2025-12-18 00:41:38 -07:00
kelly
6b994147c3 fix: Placeholder is in Forms\Components, not Schemas\Components 2025-12-18 00:10:50 -07:00
kelly
a6d9e203c2 fix: use correct Filament v4 schema component imports
Section and Placeholder are in Filament\Schemas\Components namespace.
2025-12-18 00:10:00 -07:00
kelly
f652c19b24 fix: hide BannerAdResource when table doesn't exist
Add canAccess() check to prevent resource from loading when
banner_ads migrations haven't been run yet.
2025-12-18 00:08:16 -07:00
kelly
76ce86fb41 fix: use correct Filament v4 bulk action imports
All bulk actions (BulkActionGroup, DeleteBulkAction, ForceDeleteBulkAction,
RestoreBulkAction) are in Filament\Actions namespace in v4.
2025-12-17 23:10:35 -07:00
kelly
5a22f7dbb6 fix: use correct Filament v4 action imports for BannerAdResource
Filament v4 moved ViewAction and EditAction from Tables\Actions to
the main Filament\Actions namespace.
2025-12-17 23:01:32 -07:00
kelly
5b8809b962 fix: handle missing banner_ads table in Filament navigation badge
Check Schema::hasTable before querying in getNavigationBadge() to
prevent errors when banner ad migrations haven't been run yet.
2025-12-17 22:57:18 -07:00
kelly
cdf982ed39 fix: brand page only shows in-stock or unlimited products
- Filter out out-of-stock products from brand storefront
- Products with unlimited inventory always shown
- Featured products section also filters to in-stock only
2025-12-17 22:56:43 -07:00
kelly
aac83a084c fix: support hashid lookup in product detail page
The route uses hashid (e.g., 17re1) but the controller was only
checking slug and numeric ID. Added hashid as the first check.
2025-12-17 22:54:47 -07:00
kelly
5f9613290d feat: marketplace enhancements - search, compare, quick view, recently viewed
Search Autocomplete:
- SearchController with autocomplete and suggestions endpoints
- Marketplace search component with dropdown results
- Shows matching products, brands, and search suggestions
- Added to buyer layout topbar with mobile modal

Recently Viewed Products:
- RecentlyViewedService for session-based tracking (max 20 items)
- Display sections on marketplace homepage and product pages
- Tracks view when visiting product detail page

Quick View Modal:
- ProductController with quickView endpoint returning JSON
- Quick view modal component with product details
- Add to cart functionality from modal
- Button on product cards and hover overlay

Product Comparison:
- ProductComparisonService for session-based comparison (max 4 items)
- CompareController with toggle, remove, clear endpoints
- Comparison page with side-by-side product table
- Floating compare bar showing selected products
- Toggle button on product cards

Brand Directory Redesign:
- Hero section with gradient and search bar
- Featured brands section (top 4 by product count)
- Alphabet quick navigation
- Grid/list view toggle with localStorage persistence
- Search and sort functionality

BannerAd Fix:
- Handle missing banner_ads table gracefully
- Check Schema::hasTable before querying
- Wrap in try-catch to prevent page errors
2025-12-17 22:53:47 -07:00
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
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
0f5901e55f Merge pull request 'fix: convert literal escape sequences in product descriptions' (#210) from fix/product-description-literals into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/210
2025-12-12 02:32:49 +00:00
kelly
8fcc3629bd Merge pull request 'fix: add missing quote_date field to quote creation' (#209) from fix/crystal-quote-date into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/209
2025-12-12 02:27:52 +00:00
kelly
0b54c251bc fix: convert literal escape sequences in product descriptions
- Replace literal '\r\n' strings (4 chars) with actual newlines
- Remove '??' corrupted emoji placeholders
- Clean up excessive newlines

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

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

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Buyer\BuyerBrandFollow;
use App\Models\OrderItem;
use App\Services\Cannaiq\MarketingIntelligenceService;
use Illuminate\Http\Request;
class BuyAgainController extends Controller
{
public function index(Request $request, Business $business)
{
$tab = $request->get('tab', 'favorites'); // 'favorites' or 'history'
if ($tab === 'favorites') {
$brands = $this->getFavoriteBrands($business);
} else {
$brands = $this->getPurchaseHistory($business);
}
// Optional: Enrich with CannaIQ inventory data if business has it
$storeMetrics = null;
if ($business->cannaiq_store_id) {
$storeMetrics = $this->getStoreInventory($business, $brands);
}
return view('buyer.buy-again.index', compact('business', 'brands', 'tab', 'storeMetrics'));
}
private function getFavoriteBrands(Business $business)
{
// Get brands the buyer follows
$followedBrandIds = BuyerBrandFollow::where('business_id', $business->id)
->pluck('brand_id');
if ($followedBrandIds->isEmpty()) {
return collect();
}
// Get products from those brands that user has ordered
return Brand::whereIn('id', $followedBrandIds)
->with(['products' => function ($query) use ($business) {
$query->whereHas('orderItems.order', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->with(['orderItems' => function ($q) use ($business) {
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
->latest()
->limit(1);
}])
->where('is_active', true);
}])
->get()
->filter(fn ($brand) => $brand->products->isNotEmpty());
}
private function getPurchaseHistory(Business $business)
{
// Get all products ever ordered, grouped by brand
$orderedProductIds = OrderItem::whereHas('order', function ($q) use ($business) {
$q->where('business_id', $business->id);
})->distinct()->pluck('product_id');
if ($orderedProductIds->isEmpty()) {
return collect();
}
return Brand::whereHas('products', fn ($q) => $q->whereIn('id', $orderedProductIds))
->with(['products' => function ($query) use ($orderedProductIds, $business) {
$query->whereIn('id', $orderedProductIds)
->with(['orderItems' => function ($q) use ($business) {
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
->latest()
->limit(1);
}]);
}])
->get();
}
private function getStoreInventory(Business $business, $brands)
{
if ($brands->isEmpty()) {
return null;
}
$productIds = $brands->flatMap(fn ($b) => $b->products->pluck('id'));
try {
$cannaiq = app(MarketingIntelligenceService::class);
return $cannaiq->getStoreMetrics($business->cannaiq_store_id, $productIds->toArray());
} catch (\Exception $e) {
// Silently fail if CannaIQ unavailable
return null;
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\ProductComparisonService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CompareController extends Controller
{
public function __construct(
protected ProductComparisonService $comparison
) {}
/**
* Show the comparison page.
*/
public function index(): View
{
$products = $this->comparison->getProducts();
$business = auth()->user()->businesses->first();
return view('buyer.compare.index', compact('products', 'business'));
}
/**
* Get current comparison state (AJAX).
*/
public function state(): JsonResponse
{
return response()->json([
'ids' => $this->comparison->getProductIds(),
'count' => $this->comparison->count(),
'is_full' => $this->comparison->isFull(),
'max' => $this->comparison->maxItems(),
]);
}
/**
* Toggle a product in the comparison list (AJAX).
*/
public function toggle(Product $product): JsonResponse
{
if (! $product->is_active) {
return response()->json(['error' => 'Product not found'], 404);
}
$result = $this->comparison->toggle($product->id);
return response()->json([
'added' => $result['added'],
'count' => $result['count'],
'is_full' => $this->comparison->isFull(),
'message' => $result['added']
? 'Added to comparison'
: 'Removed from comparison',
]);
}
/**
* Remove a product from comparison list (AJAX).
*/
public function remove(Product $product): JsonResponse
{
$this->comparison->remove($product->id);
return response()->json([
'count' => $this->comparison->count(),
'is_full' => $this->comparison->isFull(),
]);
}
/**
* Clear the comparison list.
*/
public function clear(): JsonResponse
{
$this->comparison->clear();
return response()->json([
'count' => 0,
'is_full' => false,
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Buyer\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Buyer\BuyerMessageSettings;
use App\Models\Crm\CrmThread;
use Illuminate\Http\Request;
@@ -10,9 +11,8 @@ use Illuminate\Support\Facades\Auth;
class InboxController extends Controller
{
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = Auth::user()->business;
$user = Auth::user();
$filter = $request->get('filter', 'all');
@@ -20,7 +20,7 @@ class InboxController extends Controller
$query = CrmThread::forBuyerBusiness($business->id)
->with(['brand', 'latestMessage', 'messages' => fn ($q) => $q->latest()->limit(1)])
->withCount(['messages', 'unreadMessages as unread_count' => fn ($q) => $q->unreadForBuyer()]);
->withCount('messages');
// Apply filters
$query = match ($filter) {
@@ -54,6 +54,7 @@ class InboxController extends Controller
];
return view('buyer.crm.inbox.index', compact(
'business',
'threads',
'filter',
'search',
@@ -62,9 +63,8 @@ class InboxController extends Controller
));
}
public function show(CrmThread $thread)
public function show(Business $business, CrmThread $thread)
{
$business = Auth::user()->business;
// Verify thread belongs to this buyer
if ($thread->buyer_business_id !== $business->id) {
@@ -84,9 +84,8 @@ class InboxController extends Controller
return view('buyer.crm.inbox.show', compact('thread'));
}
public function compose(Request $request)
public function compose(Request $request, Business $business)
{
$business = Auth::user()->business;
// Get brands the buyer has ordered from or can message
$brands = \App\Models\Brand::whereHas('products.orderItems.order', function ($q) use ($business) {
@@ -107,7 +106,7 @@ class InboxController extends Controller
));
}
public function store(Request $request)
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'brand_id' => 'required|exists:brands,id',
@@ -117,7 +116,6 @@ class InboxController extends Controller
'quote_id' => 'nullable|exists:crm_quotes,id',
]);
$business = Auth::user()->business;
$user = Auth::user();
// Create thread
@@ -143,9 +141,8 @@ class InboxController extends Controller
->with('success', 'Message sent successfully.');
}
public function star(CrmThread $thread)
public function star(Business $business, CrmThread $thread)
{
$business = Auth::user()->business;
$user = Auth::user();
if ($thread->buyer_business_id !== $business->id) {
@@ -157,9 +154,8 @@ class InboxController extends Controller
return back()->with('success', $thread->isStarredByBuyer($user->id) ? 'Conversation starred.' : 'Star removed.');
}
public function archive(CrmThread $thread)
public function archive(Business $business, CrmThread $thread)
{
$business = Auth::user()->business;
$user = Auth::user();
if ($thread->buyer_business_id !== $business->id) {
@@ -172,9 +168,8 @@ class InboxController extends Controller
->with('success', 'Conversation archived.');
}
public function unarchive(CrmThread $thread)
public function unarchive(Business $business, CrmThread $thread)
{
$business = Auth::user()->business;
$user = Auth::user();
if ($thread->buyer_business_id !== $business->id) {
@@ -186,9 +181,8 @@ class InboxController extends Controller
return back()->with('success', 'Conversation restored.');
}
public function markAllRead()
public function markAllRead(Business $business)
{
$business = Auth::user()->business;
CrmThread::forBuyerBusiness($business->id)
->hasUnreadForBuyer()

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
class ProductController extends Controller
{
/**
* Get quick view data for a product (AJAX endpoint).
*/
public function quickView(Product $product): JsonResponse
{
// Only return active products
if (! $product->is_active) {
return response()->json(['error' => 'Product not found'], 404);
}
// Get the product's brand
$product->load('brand:id,name,slug');
return response()->json([
'id' => $product->id,
'hashid' => $product->hashid,
'name' => $product->name,
'sku' => $product->sku,
'description' => $product->short_description ?? $product->description,
'price' => $product->wholesale_price ?? 0,
'price_unit' => $product->price_unit,
'thc_percentage' => $product->thc_percentage,
'cbd_percentage' => $product->cbd_percentage,
'in_stock' => $product->isInStock(),
'available_quantity' => $product->quantity_on_hand,
'image_url' => $product->getImageUrl('medium'),
'brand_name' => $product->brand?->name,
'brand_slug' => $product->brand?->slug,
'brand_url' => $product->brand ? route('buyer.brands.show', $product->brand->slug) : null,
'url' => $product->brand ? route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) : null,
]);
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Buyer Search Controller
*
* Provides search autocomplete endpoints for the marketplace header.
*/
class SearchController extends Controller
{
/**
* Search autocomplete for products and brands.
*
* GET /b/search/autocomplete?q=...
*
* Returns products and brands matching the query for dropdown suggestions.
*/
public function autocomplete(Request $request): JsonResponse
{
$query = trim($request->input('q', ''));
if (strlen($query) < 2) {
return response()->json(['products' => [], 'brands' => []]);
}
// Search products (limit 8)
$products = Product::query()
->where('is_active', true)
->whereHas('brand', fn ($q) => $q->where('is_active', true))
->with('brand:id,name,slug')
->where(function ($q) use ($query) {
$q->where('name', 'ILIKE', "%{$query}%")
->orWhere('sku', 'ILIKE', "%{$query}%")
->orWhereHas('brand', fn ($b) => $b->where('name', 'ILIKE', "%{$query}%"));
})
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
->orderBy('name')
->limit(8)
->get(['id', 'brand_id', 'name', 'sku', 'wholesale_price', 'image_path']);
// Search brands (limit 4)
$brands = Brand::query()
->where('is_active', true)
->where(function ($q) use ($query) {
$q->where('name', 'ILIKE', "%{$query}%")
->orWhere('description', 'ILIKE', "%{$query}%");
})
->withCount('products')
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
->orderBy('name')
->limit(4)
->get(['id', 'name', 'slug', 'logo_path']);
return response()->json([
'products' => $products->map(fn ($p) => [
'id' => $p->id,
'hashid' => $p->hashid,
'name' => $p->name,
'sku' => $p->sku,
'price' => $p->wholesale_price ?? 0,
'image_url' => $p->getImageUrl('thumb'),
'brand_name' => $p->brand?->name,
'brand_slug' => $p->brand?->slug,
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
]),
'brands' => $brands->map(fn ($b) => [
'id' => $b->id,
'name' => $b->name,
'slug' => $b->slug,
'logo_url' => $b->getLogoUrl('thumb'),
'products_count' => $b->products_count,
'url' => route('buyer.brands.show', $b->slug),
]),
]);
}
/**
* Search suggestions based on popular searches.
*
* GET /b/search/suggestions
*
* Returns popular search terms and trending products.
*/
public function suggestions(): JsonResponse
{
// Popular search terms (could be tracked and stored, for now use static list)
$popularTerms = [
'gummies',
'vape',
'flower',
'indica',
'sativa',
'edibles',
'pre-roll',
'concentrate',
];
// Trending products (recently added or best sellers)
$trending = Product::query()
->where('is_active', true)
->whereHas('brand', fn ($q) => $q->where('is_active', true))
->with('brand:id,name,slug')
->orderBy('created_at', 'desc')
->limit(4)
->get(['id', 'brand_id', 'name', 'image_path']);
return response()->json([
'terms' => $popularTerms,
'trending' => $trending->map(fn ($p) => [
'id' => $p->id,
'hashid' => $p->hashid,
'name' => $p->name,
'image_url' => $p->getImageUrl('thumb'),
'brand_name' => $p->brand?->name,
'brand_slug' => $p->brand?->slug,
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
]),
]);
}
}

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

@@ -53,6 +53,39 @@ use Intervention\Image\ImageManager;
*/
class ImageController extends Controller
{
/**
* Cache duration for images (1 year in seconds)
*/
private const CACHE_TTL = 31536000;
/**
* Return a cached response for an image
*/
private function cachedResponse(string $contents, string $mimeType, ?string $etag = null): \Illuminate\Http\Response
{
$response = response($contents)
->header('Content-Type', $mimeType)
->header('Cache-Control', 'public, max-age='.self::CACHE_TTL.', immutable')
->header('Expires', gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT');
if ($etag) {
$response->header('ETag', '"'.$etag.'"');
}
return $response;
}
/**
* Return a cached file response
*/
private function cachedFileResponse(string $path): \Symfony\Component\HttpFoundation\BinaryFileResponse
{
return response()->file($path, [
'Cache-Control' => 'public, max-age='.self::CACHE_TTL.', immutable',
'Expires' => gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT',
]);
}
/**
* Serve a brand logo at a specific size
* URL: /images/brand-logo/{brand}/{width?}
@@ -67,8 +100,9 @@ class ImageController extends Controller
if (! $width) {
$contents = Storage::get($brand->logo_path);
$mimeType = Storage::mimeType($brand->logo_path);
$etag = md5($brand->logo_path.$brand->updated_at);
return response($contents)->header('Content-Type', $mimeType);
return $this->cachedResponse($contents, $mimeType, $etag);
}
// Map common widths to pre-generated sizes (retina-optimized)
@@ -104,7 +138,7 @@ class ImageController extends Controller
$path = storage_path('app/private/'.$thumbnailPath);
return response()->file($path);
return $this->cachedFileResponse($path);
}
/**
@@ -121,8 +155,9 @@ class ImageController extends Controller
if (! $width) {
$contents = Storage::get($brand->banner_path);
$mimeType = Storage::mimeType($brand->banner_path);
$etag = md5($brand->banner_path.$brand->updated_at);
return response($contents)->header('Content-Type', $mimeType);
return $this->cachedResponse($contents, $mimeType, $etag);
}
// Map common widths to pre-generated sizes (retina-optimized)
@@ -155,7 +190,7 @@ class ImageController extends Controller
$path = storage_path('app/private/'.$thumbnailPath);
return response()->file($path);
return $this->cachedFileResponse($path);
}
/**
@@ -172,8 +207,9 @@ class ImageController extends Controller
if (! $width) {
$contents = Storage::get($product->image_path);
$mimeType = Storage::mimeType($product->image_path);
$etag = md5($product->image_path.$product->updated_at);
return response($contents)->header('Content-Type', $mimeType);
return $this->cachedResponse($contents, $mimeType, $etag);
}
// Check if cached dynamic thumbnail exists in local storage
@@ -202,6 +238,6 @@ class ImageController extends Controller
$path = storage_path('app/private/'.$thumbnailPath);
return response()->file($path);
return $this->cachedFileResponse($path);
}
}

View File

@@ -4,39 +4,58 @@ namespace App\Http\Controllers;
use App\Models\Brand;
use App\Models\Product;
use App\Models\ProductCategory;
use App\Models\Strain;
use App\Services\RecentlyViewedService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MarketplaceController extends Controller
{
public function __construct(
protected RecentlyViewedService $recentlyViewed
) {}
/**
* Display marketplace browse page
* Display marketplace browse page (Amazon/Shopify style)
*/
public function index(Request $request)
{
$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:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type', 'category:id,name,slug'])
->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);
}
// Strain type filter
// Category filter (uses category_id foreign key)
if ($categoryId = $request->input('category_id')) {
$query->where('category_id', $categoryId);
}
// Strain type filter - use join instead of whereHas for performance
if ($strainType = $request->input('strain_type')) {
$query->whereHas('strain', function ($q) use ($strainType) {
$q->where('type', $strainType);
$query->whereExists(function ($q) use ($strainType) {
$q->select(DB::raw(1))
->from('strains')
->whereColumn('strains.id', 'products.strain_id')
->where('strains.type', $strainType);
});
}
@@ -64,23 +83,121 @@ class MarketplaceController extends Controller
default => $query->latest(),
};
// 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();
// Cache brands and categories for 5 minutes (used frequently, rarely change)
$brands = cache()->remember('marketplace:brands', 300, function () {
return Brand::query()
->active()
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('products')
->whereColumn('products.brand_id', 'brands.id')
->where('products.is_active', true);
})
->withCount(['products' => fn ($q) => $q->active()])
->orderBy('name')
->get();
});
// Get featured products for carousel (exclude from main results if in first page)
// Cache categories for 5 minutes
$categories = cache()->remember('marketplace:categories', 300, function () {
return ProductCategory::query()
->whereNull('parent_id')
->where('is_active', true)
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('products')
->whereColumn('products.category_id', 'product_categories.id')
->where('products.is_active', true);
})
->withCount(['products' => fn ($q) => $q->active()])
->orderByDesc('products_count')
->get();
});
// Only load extra sections if not filtering (homepage view)
$featuredProducts = collect();
$topBrands = collect();
$newArrivals = collect();
$trending = collect();
$recentlyViewed = collect();
if (! $hasFilters) {
// Featured products for hero carousel
$featuredProducts = Product::query()
->with(['brand', 'strain'])
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
->featured()
->inStock()
->limit(3)
->limit(5)
->get();
$business = auth()->user()->businesses->first();
// Top brands - reuse cached brands
$topBrands = $brands->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:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
->active()
->inStock()
->where('created_at', '>=', now()->subDays(14))
->orderByDesc('created_at')
->limit(8)
->get();
// Trending products - cache for 10 minutes
$trending = cache()->remember('marketplace:trending', 600, function () {
$trendingIds = DB::table('order_items')
->select('product_id', DB::raw('SUM(quantity) as total_sold'))
->where('created_at', '>=', now()->subDays(30))
->groupBy('product_id')
->orderByDesc('total_sold')
->limit(8)
->pluck('product_id');
if ($trendingIds->isEmpty()) {
return collect();
}
return Product::with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
->whereIn('id', $trendingIds)
->active()
->get()
->sortBy(fn ($p) => array_search($p->id, $trendingIds->toArray()));
});
// Recently viewed products
$recentlyViewed = $this->recentlyViewed->getProducts(6);
}
// Active filters for pills display
$activeFilters = collect([
'search' => $request->input('search'),
'brand_id' => $request->input('brand_id'),
'category_id' => $request->input('category_id'),
'strain_type' => $request->input('strain_type'),
'in_stock' => $request->input('in_stock'),
])->filter();
return view('buyer.marketplace.index', compact(
'products',
'brands',
'categories',
'featuredProducts',
'topBrands',
'newArrivals',
'trending',
'recentlyViewed',
'business',
'viewMode',
'activeFilters',
'hasFilters'
));
}
/**
@@ -94,19 +211,64 @@ class MarketplaceController extends Controller
/**
* Display all brands directory
*/
public function brands()
public function brands(Request $request)
{
$brands = Brand::query()
->active()
->withCount(['products' => function ($query) {
$query->active();
}])
->orderBy('name')
->get();
$search = $request->input('search');
$sort = $request->input('sort', 'name');
// Only cache if no search (search results shouldn't be cached)
$cacheKey = $search ? null : "marketplace:brands_directory:{$sort}";
$brands = $cacheKey
? cache()->remember($cacheKey, 300, fn () => $this->getBrandsQuery($search, $sort))
: $this->getBrandsQuery($search, $sort);
// Group brands alphabetically for index navigation
$alphabetGroups = $brands->groupBy(fn ($b) => strtoupper(substr($b->name, 0, 1)));
// Featured brands (first 4 with most products)
$featuredBrands = $brands->sortByDesc('products_count')->take(4);
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.brands', compact('brands', 'business'));
return view('buyer.marketplace.brands', compact('brands', 'alphabetGroups', 'featuredBrands', 'business'));
}
/**
* Helper to build brands query for directory
*/
private function getBrandsQuery(?string $search, string $sort)
{
$query = Brand::query()
->select(['id', 'name', 'slug', 'hashid', 'tagline', 'logo_path', 'updated_at'])
->active()
// Filter to only brands with active products using EXISTS (faster than having())
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('products')
->whereColumn('products.brand_id', 'brands.id')
->where('products.is_active', true);
})
->withCount(['products' => fn ($q) => $q->active()]);
// Search filter
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'ILIKE', "%{$search}%")
->orWhere('tagline', 'ILIKE', "%{$search}%")
->orWhere('description', 'ILIKE', "%{$search}%");
});
}
// Sorting
match ($sort) {
'name_desc' => $query->orderByDesc('name'),
'products' => $query->orderByDesc('products_count'),
'newest' => $query->orderByDesc('created_at'),
default => $query->orderBy('name'),
};
return $query->get();
}
/**
@@ -124,27 +286,30 @@ class MarketplaceController extends Controller
*/
public function showProduct($brandSlug, $productSlug)
{
// Find brand by slug
// Find brand by slug - minimal columns
$brand = Brand::query()
->select(['id', 'name', 'slug', 'hashid', 'logo_path', 'banner_path', 'tagline', 'description', 'updated_at'])
->where('slug', $brandSlug)
->active()
->firstOrFail();
// Find product by slug within this brand
// Find product by hashid, slug, or numeric ID within this brand
$product = Product::query()
->with([
'brand',
'strain',
'brand:id,name,slug,hashid,logo_path,updated_at',
'strain:id,name,type',
// Only load batches if needed - limit to recent ones
'availableBatches' => function ($query) {
$query->with(['coaFiles'])
->orderBy('production_date', 'desc')
->orderBy('created_at', 'desc');
$query->select(['id', 'product_id', 'batch_number', 'production_date', 'quantity_available'])
->with(['coaFiles:id,batch_id,file_path,file_name'])
->orderByDesc('production_date')
->limit(5);
},
])
->where('brand_id', $brand->id)
->where(function ($query) use ($productSlug) {
$query->where('slug', $productSlug);
// Only try ID lookup if the value is numeric
$query->where('hashid', $productSlug)
->orWhere('slug', $productSlug);
if (is_numeric($productSlug)) {
$query->orWhere('id', $productSlug);
}
@@ -152,9 +317,12 @@ class MarketplaceController extends Controller
->active()
->firstOrFail();
// Get related products from same brand
// Record this view for recently viewed products (async-friendly)
$this->recentlyViewed->recordView($product->id);
// Get related products from same brand - minimal eager loading
$relatedProducts = Product::query()
->with(['brand', 'strain'])
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
->where('brand_id', $product->brand_id)
->where('id', '!=', $product->id)
->active()
@@ -162,9 +330,69 @@ class MarketplaceController extends Controller
->limit(4)
->get();
// Get recently viewed products (excluding current product)
$recentlyViewed = $this->recentlyViewed->getProducts(6, $product->id);
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'brand', 'business'));
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'recentlyViewed', 'brand', 'business'));
}
/**
* Display deals/promotions page for buyers
*/
public function deals()
{
// Get all active promotions with their brands and products
$activePromos = \App\Models\Promotion::query()
->with([
'brand:id,name,slug,hashid,logo_path,updated_at',
'products' => fn ($q) => $q->with(['brand:id,name,slug,hashid,logo_path,updated_at'])->active()->inStock(),
])
->active()
->orderByDesc('discount_value')
->get();
// Group by type for display sections
$percentageDeals = $activePromos->where('type', 'percentage');
$bogoDeals = $activePromos->where('type', 'bogo');
$fixedDeals = $activePromos->where('type', 'bundle');
$priceOverrides = $activePromos->where('type', 'price_override');
// Get all products that are on any active promotion
$dealProducts = Product::query()
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
->whereHas('promotions', fn ($q) => $q->active())
->active()
->inStock()
->limit(16)
->get();
// Get brands with active deals
$brandsWithDeals = Brand::query()
->select(['id', 'name', 'slug', 'hashid', 'logo_path', 'updated_at'])
->whereHas('promotions', fn ($q) => $q->active())
->orderBy('name')
->get();
// Stats for the header
$stats = [
'total_deals' => $activePromos->count(),
'percentage_deals' => $percentageDeals->count(),
'bogo_deals' => $bogoDeals->count(),
'bundle_deals' => $fixedDeals->count() + $priceOverrides->count(),
];
return view('buyer.marketplace.deals', compact(
'activePromos',
'dealProducts',
'percentageDeals',
'bogoDeals',
'fixedDeals',
'priceOverrides',
'brandsWithDeals',
'stats'
));
}
/**
@@ -172,27 +400,30 @@ class MarketplaceController extends Controller
*/
public function showBrand($brandSlug)
{
// Find brand by slug
// Find brand by slug with minimal columns
$brand = Brand::query()
->where('slug', $brandSlug)
->active()
->firstOrFail();
// Get featured products from this brand
// Optimized: Use simple inStock scope instead of expensive whereHas on batches
// The inStock scope should check inventory_mode or quantity_on_hand
$featuredProducts = Product::query()
->with(['strain'])
->with(['strain:id,name,type', 'brand:id,name,slug,hashid,logo_path,updated_at'])
->where('brand_id', $brand->id)
->active()
->featured()
->inStock()
->limit(3)
->get();
// Get all products from this brand
// Get products - use simpler inStock check
$products = Product::query()
->with(['strain'])
->with(['strain:id,name,type', 'brand:id,name,slug,hashid,logo_path,updated_at'])
->where('brand_id', $brand->id)
->active()
->orderBy('is_featured', 'desc')
->inStock()
->orderByDesc('is_featured')
->orderBy('name')
->paginate(20);

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 {
// 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',
@@ -217,7 +215,6 @@ class QuoteController extends Controller
'tax_rate' => $validated['tax_rate'] ?? 0,
'terms' => $validated['terms'] ?? $business->crm_default_terms,
'notes' => $validated['notes'],
'signature_requested' => $validated['signature_requested'] ?? false,
'currency' => 'USD',
]);
@@ -249,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'));
}
@@ -267,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'));
}
/**
@@ -591,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

@@ -29,10 +29,10 @@ class ProductController extends Controller
// Get brand IDs to filter by (respects brand context switcher)
$brandIds = BrandSwitcherController::getFilteredBrandIds();
// Get all brands for the business for the filter dropdown
// Get all brands for the business for the filter dropdown and new product button
$brands = \App\Models\Brand::where('business_id', $business->id)
->orderBy('name')
->get(['id', 'name']);
->get(['id', 'name', 'hashid', 'logo_path', 'slug', 'updated_at']);
// Calculate missing BOM count for health alert
$missingBomCount = Product::whereIn('brand_id', $brandIds)
@@ -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.'%');
}
/**

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