Compare commits

...

133 Commits

Author SHA1 Message Date
kelly
08f5a3adac feat: add build date/time to sidebar version display
- Add appBuildDate variable from AppServiceProvider
- In local dev: shows commit date (e.g., 'Dec 10, 2:30pm')
- In production: reads BUILD_DATE from version.env
- Updated all sidebars: seller-suites, buyer, seller-legacy, brand-portal
- Updated Filament admin footer
2025-12-10 16:49:56 -07:00
kelly
329c01523a Merge pull request 'fix: resolve Crystal issues #162, #163, #165, #166' (#171) from fix/crystal-issues-162-166 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/171
2025-12-10 22:29:15 +00:00
kelly
5fb26f901d Merge pull request 'fix: resolve Crystal issues #167, #172, #173' (#174) from fix/gitea-critical-issues into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/174
2025-12-10 22:29:02 +00:00
kelly
6baadf5744 fix: resolve Crystal issues #167, #172, #173
Issue #167: Invoice - No products populate for manual invoice
- Added scopeForBusiness() to Product model
- SearchController::invoiceProducts() now works correctly

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Result: Dashboard loads in ~10ms (Redis read) instead of 30+ seconds.
2025-12-07 16:16:41 -07:00
kelly
2370f31a18 Merge pull request 'ci: trigger rebuild after Woodpecker repair' (#145) from ci/trigger-rebuild into develop 2025-12-07 22:15:36 +00:00
kelly
27c8395d5a ci: trigger rebuild after Woodpecker repair 2025-12-07 15:04:11 -07:00
kelly
dbee401f61 Merge pull request 'feature/suite-shares' (#144) from feature/suite-shares into develop 2025-12-07 21:39:13 +00:00
kelly
b17bc590bb Merge pull request 'feat: add visibility guards and Shared badge for child division menu items' (#143) from feature/shared-menu-badges-clean into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/143
2025-12-07 21:35:58 +00:00
kelly
6ce5ca14e2 feat: improve Suite Shares UI and expand Sales Suite menu
Suite Shares improvements:
- Fix sharing direction: share FROM this business TO target
- Add suite selector to choose which suite to share items from
- Dynamically show menu items based on selected suite
- Add shared_suite_key column to track source suite

Sales Suite menu expansion:
- Add Commerce section: Orders, Invoices, Backorders, All Customers
- Add Growth section: Campaigns, Channels, Templates, Automations
- Add Sales CRM section: Overview, Pipeline, Accounts, Tasks, Activity, Calendar
- Add Inbox section: Overview, Contacts, Conversations
- Add Stock to Inventory section
- Remove AR Overview, AR Accounts, AI Copilot, My Expenses (not core Sales)
2025-12-07 14:24:53 -07:00
kelly
454b85ffb1 fix: require explicit Suite Shares for division menu items
Remove automatic division menu items (requisitions, vendors, AR/AP snapshots)
for child businesses. These features must now be explicitly configured via
Suite Shares from the parent company.

This aligns with the core product vision - Sales Suite is a marketplace
platform like LeafLink. Accounting features are opt-in, not automatic.
2025-12-07 13:52:38 -07:00
kelly
e13d7cd7ad feat: add Shared badge to sidebar menu items
Display 'Shared' badge next to menu items that have shared_from_parent flag,
indicating items shared from parent business via Suite Shares.
2025-12-07 13:38:43 -07:00
kelly
f3436d35ec feat: add Suite Shares system and restore shared menu badges
- Remove "Navigation Settings" block (everyone uses suite navigation now)
- Remove "Suite info" placeholder from Suite Assignment section
- Add "Suite Shares" section in admin to share menu items with other businesses
- Create business_suite_shares migration and BusinessSuiteShare model
- Add suiteShares and receivedSuiteShares relationships to Business model
- Update SuiteMenuResolver to include shared menu items with shared_from_parent flag
- Restore "Shared" badge rendering in sidebar for shared menu items

Suite Shares allow a parent business to share specific menu items (like
Requisitions, Vendors, AR/AP Snapshots) with child businesses, and those
items appear in the child's sidebar with a "Shared" badge.
2025-12-07 13:29:19 -07:00
kelly
a46b44055e Merge pull request 'fix: prevent empty doughnut chart from rendering as black circle' (#141) from fix/empty-doughnut-chart into develop 2025-12-07 20:00:22 +00:00
kelly
a3dda1520e fix: prevent empty doughnut chart from rendering as black circle
When all task counts are zero, the Chart.js doughnut renders as a solid
black/dark circle. Added a check to show a gray placeholder segment
with "No tasks yet" label instead.
2025-12-07 12:04:07 -07:00
kelly
4068bfc0b2 feat: add visibility guards and Shared badge for child division menu items
- Add shared_from_parent flag to division alias menu items:
  - requisitions (Purchasing section)
  - division_vendors (Accounting section)
  - division_ar_snapshot (Accounting section)
  - division_ap_snapshot (Accounting section)

- Add permission guard for requisitions menu item via userCanSubmitRequisitions()
  - Checks for can_submit_requisition, can_view_requisitions, can_manage_requisitions
  - Business owners and admins always have access

- Update resolveMenuItems() to pass through shared_from_parent flag

- Update seller-sidebar-suites.blade.php to render "Shared" badge
  - Small uppercase badge appears next to shared menu items
  - Uses DaisyUI-style pill: bg-base-200, text-base-content/60

Guard logic ensures:
- Standalone SaaS businesses (no parent_id) never see division alias items
- Child businesses see aliases only when user has appropriate permissions
- Shared items are visually marked with badge
2025-12-07 12:02:50 -07:00
kelly
497523ee0c Merge pull request 'feat: Management Suite with Finance, Accounting, AP/AR, Budgets, and CFO Dashboard' (#140) from feature/management-suite-accounting into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/140
2025-12-07 18:23:06 +00:00
kelly
94d68f80e4 fix: resolve multiple bugs in products, work orders, AR invoices, and sidebar
- Add null check for brand in products/edit.blade.php when creating new product
- Fix WorkOrder model to use scheduled_date column (matching migration) instead of due_date
- Fix AR invoice query to use total_amount column instead of total
- Rename 'Settings' sidebar section to 'Finances' with wallet icon
2025-12-07 11:08:18 -07:00
kelly
c091c3c168 feat: complete Management Suite implementation
- Add dashboard performance optimizations with caching
- Add InterBusiness settlement controller, service, and models
- Add Bank Accounts, Transfers, and Reconciliation
- Add Chart of Accounts management
- Add Accounting Periods with period lock enforcement
- Add Action Center for approvals
- Add Advanced Analytics dashboards
- Add Finance Roles and Permissions management
- Add Requisitions Approval workflow
- Add Forecasting and Cash Flow projections
- Add Inventory Valuation
- Add Operations dashboard
- Add Usage & Billing analytics
- Fix dashboard routing to use overview() method
- All naming conventions follow inter_business (not intercompany)
2025-12-07 10:56:26 -07:00
kelly
7c54ece253 style: fix pint formatting in QuoteController 2025-12-07 10:51:38 -07:00
kelly
f7294fcf83 perf: increase php-fpm worker pool for cold-start handling 2025-12-07 10:39:36 -07:00
kelly
6d64d9527a feat: implement Management Suite core features
Bank Account Management:
- BankAccountsController with full CRUD operations
- BankAccountService for account management logic
- Bank account views (index, create, edit)
- GL account linking for cash accounts

Bank Transfers:
- BankTransfersController with approval workflow
- BankTransfer model with status management
- Inter-business transfer support
- Transfer views (index, create, show)

Plaid Integration Infrastructure:
- PlaidItem, PlaidAccount, PlaidTransaction models
- PlaidIntegrationService with 10+ methods (stubbed API)
- BankReconciliationController with match/learn flows
- Bank match rules for auto-categorization

Journal Entry Automation:
- JournalEntryService for automatic JE creation
- Bill approval creates expense entries
- Payment completion creates cash entries
- Inter-company Due To/Due From entries

AR Enhancements:
- ArService with getArSummary and getTopArAccounts
- Finance dashboard AR section with drill-down stats
- Credit hold and at-risk tracking
- Top AR accounts table with division column

UI/Navigation:
- Updated SuiteMenuResolver with new menu items
- Removed Usage & Billing from sidebar (moved to Owner)
- Brand Manager Suite menu items added
- Vendors page shows divisions using each vendor

Models and Migrations:
- BankAccount, BankTransfer, BankMatchRule models
- PlaidItem, PlaidAccount, PlaidTransaction models
- Credit hold fields on ArCustomer
- journal_entry_id on ap_bills and ap_payments
2025-12-07 00:34:53 -07:00
kelly
08df003b20 fix: add hasChildBusinesses() method to Business model 2025-12-06 23:38:16 -07:00
kelly
59cd09eb5b refactor: rename inter_company to inter_business for naming convention
Rename table and model from inter_company_transactions to
inter_business_transactions to comply with platform naming
conventions (no 'company' terminology).
2025-12-06 23:27:11 -07:00
kelly
3a6ab1c207 chore: re-trigger CI 2025-12-06 23:27:11 -07:00
kelly
404a731bd9 fix: correct selectedDivision key name in CashFlowForecastController 2025-12-06 23:27:11 -07:00
kelly
2b30deed11 feat: switch to suite-based navigation and add management suite middleware
- Use seller-sidebar-suites component instead of legacy seller-sidebar
- Add suite:management middleware to all Management Suite routes
- Ignore local database backup files
2025-12-06 23:27:11 -07:00
kelly
109d9cd39d feat: add canopy/leopard/curagreen users and block migrate:fresh
- Add canopy@example.com, leopardaz@example.com, curagreen@example.com users
- Each seller business now has its own dedicated owner user
- SafeFreshCommand blocks migrate:fresh except for test databases
- Prevents accidental data loss in local, dev, staging, and production
2025-12-06 23:26:14 -07:00
kelly
aadd7a500a fix: update Business model and seeder for is_approved to status migration
- scopeApproved() now uses status='approved' instead of is_approved=true
- ProductionSyncSeeder uses status column for business approval state
2025-12-06 23:26:14 -07:00
kelly
111ef20684 fix: correct migration ordering and remove missing bank_accounts FK
- Rename fixed assets migrations from 100xxx to 110xxx so they run after
  ap_vendors and gl_accounts tables (which they reference)
- Remove bank_account_id foreign key constraint from ar_payments since
  bank_accounts table doesn't exist (use unconstrained bigint like ap_payments)
2025-12-06 23:26:14 -07:00
kelly
85fdb71f92 fix: rename fixed assets migrations to run after businesses table
The fixed assets migrations were dated 2024-12-06 but the businesses table
is dated 2025-08-08, causing foreign key failures since the referenced
table didn't exist yet. Renamed to 2025-12-06 to ensure proper ordering.
2025-12-06 23:26:14 -07:00
kelly
08e2eb3ac6 style: fix pint formatting in journal entries migration 2025-12-06 23:26:14 -07:00
kelly
87e8384aca fix: make all accounting migrations idempotent
Add Schema::hasTable() guards to all create table migrations and
Schema::hasColumn() guards to alter table migrations to prevent
duplicate table/column errors when migrations are run multiple times.
2025-12-06 23:26:14 -07:00
kelly
e56ad20568 docs: add accounting QA checklist for Management Suite 2025-12-06 23:26:14 -07:00
kelly
fafb05e29b test: add skip guards to accounting tests for schema/route readiness 2025-12-06 23:26:14 -07:00
kelly
a322d7609b test: add accounting feature tests and demo seeder
Adds 13 feature tests covering:
- AP flow (bills, payments, approvals)
- AR flow (invoices, payments, aging)
- Bank accounts and transfers
- Budgets and variance tracking
- Business scoping/isolation
- Expense reimbursement workflow
- Fixed assets and depreciation
- Management mutations
- Migration safety
- Notifications
- Recurring transactions
- Suite menu visibility

Also adds AccountingDemoSeeder for local development.
2025-12-06 23:26:14 -07:00
kelly
2aefba3619 style: fix pint formatting 2025-12-06 23:26:14 -07:00
kelly
b47fc35857 feat: expand Management Suite with AR, budgets, expenses, fixed assets, recurring schedules, and CFO dashboard
Adds comprehensive financial management capabilities:
- Accounts Receivable (AR) with customers, invoices, payments
- Budget management with lines and variance tracking
- Expense tracking with approval workflow
- Fixed asset management with depreciation
- Recurring transaction schedules (AP, AR, journal entries)
- CFO dashboard with financial KPIs
- Cash flow forecasting
- Directory views for customers and vendors
- Division filtering support across all modules
2025-12-06 23:26:14 -07:00
kelly
e5e1dea055 feat: add Management Suite routes for AP, Finance, and Accounting 2025-12-06 23:25:37 -07:00
kelly
e5e485d636 feat: add Management Suite accounting module
- Add GL accounts, AP vendors, bills, payments, and journal entries
- Add financial reporting (P&L, Balance Sheet, Cash Flow)
- Add finance dashboards (AP Aging, Cash Forecast, Division Rollup)
- Fix PostgreSQL compatibility in JournalEntry::generateEntryNumber()
- All migrations are additive (new tables only, no destructive changes)
2025-12-06 23:25:37 -07:00
kelly
3d383e0490 Merge pull request 'fix: disable seeders in dev/staging K8s init container' (#139) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/139
2025-12-07 02:19:33 +00:00
kelly
df188e21ce style: fix pint formatting 2025-12-06 19:12:28 -07:00
kelly
55016f7009 feat: add brands:sync-media-paths command and configure MinIO for dev
- Add artisan command to sync brand logo_path/banner_path from MinIO storage
- Configure dev K8s overlay with MinIO environment variables
- Command supports --dry-run and --business options
2025-12-06 19:02:52 -07:00
kelly
9cf89c7b1a fix: disable seeders in dev/staging K8s init container
DO NOT re-enable. This is staging - we do not reseed ever again.
Migrations only from now on.
2025-12-06 18:10:07 -07:00
kelly
0d810dff27 Merge pull request 'fix: remove post-deploy seeding steps from CI' (#138) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/138
2025-12-07 00:03:25 +00:00
kelly
624a36d2c5 fix: remove post-deploy seeding steps from CI
The db:restore-cannabrands and SuitesSeeder commands were running
on every deploy, which is unnecessary and was causing OOM kills
(exit code 137). Migrations handle schema changes; seeding should
only run manually when needed.
2025-12-06 16:54:15 -07:00
kelly
92e3e171e1 Merge pull request 'fix: ensure brands array serializes correctly when empty' (#137) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/137
2025-12-06 23:41:56 +00:00
kelly
58ca83c8c2 fix: ensure brands array serializes correctly when empty
Added ->values() to prevent empty Collection from serializing as {} instead of []
This fixes Alpine.js error when all brands have null hashids
2025-12-06 16:26:58 -07:00
kelly
7f175709a5 Merge pull request 'fix: consolidated fixes - suites, brand portal, login, hashid backfills' (#136) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/136
2025-12-06 23:06:25 +00:00
kelly
26a903bdd9 Merge branch 'fix/login-bugs' into fix/brand-user-assignments
# Conflicts:
#	resources/views/admin/quick-switch.blade.php
#	routes/seller.php
2025-12-06 16:01:22 -07:00
kelly
e871426817 Merge branch 'fix/hashid-backfills' into fix/brand-user-assignments
# Conflicts:
#	.woodpecker/.ci.yml
2025-12-06 16:00:49 -07:00
kelly
c99511d696 fix: suite sidebar, brand portal access, and seeder corrections
- Fix SuiteMenuResolver to use correct route names for Management Suite
- Add Brand Manager mode support to SuiteMenuResolver
- Update sidebar layout to conditionally use suite-based navigation
- Fix EnsureBrandPortalAccess middleware to allow Brand Manager users
- Refactor BrandPortalController with validateAccessAndGetBrandIds helper
- Fix DepartmentSeeder to create organizational departments (not product categories)
- Fix MinioMediaSeeder to not set department_id on products
- Remove Settings from all suite sidebar menus (accessible via user dropdown only)
2025-12-06 15:38:44 -07:00
kelly
963f00cd39 fix: add confirmation prompt to dev:setup --fresh to prevent accidental data loss
Development data is being preserved for production release. The --fresh
flag now requires explicit confirmation before dropping all tables.
2025-12-06 09:50:46 -07:00
kelly
0db70220c7 fix: add migration to backfill brand hashids after data import
Previous migration ran before data was imported from backup,
so brands created in production were missing hashids. This
migration ensures all brands have hashids for URL routing.
2025-12-06 09:49:01 -07:00
kelly
4bcd0cca8a Merge pull request 'feat: implement brand settings page with full CRUD functionality' (#133) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/133
2025-12-06 03:39:20 +00:00
kelly
6c7d7016c9 feat: implement brand settings page with full CRUD functionality
- Add BrandSettingsController for managing brands in seller settings
- Create brands-edit.blade.php for editing individual brand details
- Update brands.blade.php with full brand list, actions, and drag-and-drop reorder
- Add routes for brand CRUD operations under /s/{business}/settings/brands
- Support brand logo upload/removal, status toggles (active, sales enabled, public, featured)
- Implement team access management for assigning internal users to brands
- Add migration to make products.department_id nullable (was incorrectly required)
- Update department tests to reflect nullable department_id behavior
- Various view fixes for CRM, inventory, and messaging pages
2025-12-05 20:31:53 -07:00
kelly
6d92f37ea7 Merge pull request 'fix: brand user assignments and CRM thread $business variable' (#130) from fix/brand-user-assignments into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/130
2025-12-06 02:11:36 +00:00
kelly
318d6b4fe8 fix: pass $business to CRM thread views 2025-12-05 19:04:17 -07:00
kelly
9ea69447ec fix: assign all Cannabrands brands to crystal user
Update brand_user.sql to assign all 18 Cannabrands brands to crystal@cannabrands.com (user_id=7) so they appear in the brand switcher dropdown.

Previously only 2 brands (Doobz, Thunder Bud) were assigned, limiting the dropdown display for non-owner users.
2025-12-05 19:02:57 -07:00
kelly
a24fbaac9a Merge pull request 'fix: stabilize CI pod selection for post-deploy commands' (#129) from fix/ci-pod-stability into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/129
2025-12-06 01:36:09 +00:00
kelly
412a3beeed Merge pull request 'fix: update CRM views to use business-scoped route patterns' (#128) from fix/crm-route-patterns into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/128
2025-12-06 01:36:00 +00:00
kelly
d0e9369795 fix: stabilize CI pod selection for post-deploy commands
- Add 15 second delay after rollout completes
- Target only Running pods with --field-selector
- Log which pod is being used for exec
2025-12-05 18:24:59 -07:00
kelly
085ca6c415 ci: run SuitesSeeder before DevSuitesSeeder on deploy
SuitesSeeder creates the Suite records in the database.
DevSuitesSeeder assigns suites to businesses.
Must run in this order or suite assignment fails.
2025-12-05 17:50:04 -07:00
kelly
1d363d7157 fix: backfill brand hashids and add defensive filter
- Migration to generate hashids for brands without them
- Skip brands without hashid in brand-switcher component
2025-12-05 17:43:47 -07:00
kelly
4adc611e83 fix: Allow quick-switch access while impersonating
When impersonating a user, the quick-switch controller was checking if
the impersonated user could impersonate, which always failed. Now it
checks if the impersonator (admin) can impersonate.

Also improved the switch() method to handle switching between
impersonated users in the same tab by leaving the current impersonation
before starting a new one.

Fixes 403 error when accessing /admin/quick-switch while impersonating.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:53:30 -07:00
kelly
3c88bbfb4d feat: Open quick-switch users in new tab
- Add target="_blank" to quick-switch links to open in new tab
- Update tips to reflect multi-tab testing capability
- Enables testing multiple impersonated users simultaneously

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:44:37 -07:00
kelly
3496421264 fix: Allow impersonated users to access business routes
When an admin impersonates a user via quick-switch, they need to access
the business routes for that user. The route model binding for 'business'
was blocking access because it checked if auth()->user()->businesses
contains the business, which fails during impersonation.

Now checks if user isImpersonated() and bypasses the business ownership
check, allowing admins to view any business when impersonating.

Fixes 403 error when accessing /s/{business}/dashboard while impersonating.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:41:30 -07:00
kelly
91f1ae217a feat: Use impersonation for quick-switch to enable multi-tab testing
Changed quick-switch to use the lab404/impersonate library instead of
session replacement. This allows admins to:

- Stay logged in as admin while impersonating users
- Impersonate multiple different users in separate browser tabs
- Easily switch between admin view and user views
- Test multi-user scenarios without logging in/out

Benefits:
 Admin session is maintained while impersonating
 Multiple tabs can show different impersonated users simultaneously
 Proper impersonation tracking and security
 Easy to leave impersonation with backToAdmin()

Changes:
- QuickSwitchController::switch() now uses ImpersonateManager::take()
- QuickSwitchController::backToAdmin() now uses ImpersonateManager::leave()
- Removed manual session manipulation (admin_user_id, quick_switch_mode)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:22:44 -07:00
kelly
5b7a2dd7bf fix: Enable admin quick-switch by registering custom auth middleware
Fixed issue where admin users couldn't access quick-switch functionality
after logging in through Filament.

Changes:
- Registered custom Authenticate middleware in bootstrap/app.php so our
  custom authentication logic is used instead of Laravel's default
- Added bidirectional guard auto-login:
  - Admins on web guard auto-login to admin guard (for Filament access)
  - Admins on admin guard auto-login to web guard (for quick-switch)
- Improved redirectTo() to use str_starts_with() for more reliable path matching

This fixes:
 /admin/quick-switch redirects to /admin/login when unauthenticated
 Admins logged into Filament can access quick-switch without re-login
 Cross-guard authentication works seamlessly for admin users

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:00:55 -07:00
kelly
c991d3f141 fix: Redirect /admin to /admin/login instead of /login
When unauthenticated users access /admin, they should be redirected to
the Filament admin login page at /admin/login, not the unified buyer/
seller login at /login.

Changed FilamentAdminAuthenticate middleware to explicitly redirect to
the Filament admin login route instead of throwing AuthenticationException,
which was being caught by Laravel and redirecting to the default /login route.

Also removed unused AuthenticationException import.

Tested:
-  /admin redirects to /admin/login when unauthenticated
-  /login shows unified login for buyers/sellers
-  /admin/login shows Filament admin login

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 14:52:53 -07:00
848 changed files with 108120 additions and 9051 deletions

1
.gitignore vendored
View File

@@ -82,3 +82,4 @@ SESSION_*
# AI workflow personal context files
CLAUDE.local.md
claude.*.md
cannabrands_dev_backup.dump

View File

@@ -7,17 +7,26 @@
# - tags (2025.X) → cannabrands.app (versioned production releases)
#
# Pipeline Strategy:
# - PRs: Run tests (lint, style, phpunit)
# - 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
when:
- branch: [develop, master]
event: push
- event: [pull_request, tag]
# Install dependencies first (needed for php-lint to resolve traits/classes)
steps:
# ============================================
# DEPENDENCY INSTALLATION (Sequential)
# ============================================
# Restore Composer cache
restore-composer-cache:
image: meltwater/drone-cache:dev
@@ -34,6 +43,8 @@ steps:
# Install dependencies (uses pre-built Laravel image with all extensions)
composer-install:
image: kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- restore-composer-cache
commands:
- echo "Creating minimal .env for package discovery..."
- |
@@ -63,9 +74,11 @@ steps:
fi
- echo "✅ Composer dependencies ready!"
# Rebuild Composer cache
# 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
@@ -75,22 +88,31 @@ steps:
- "vendor"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
when:
branch: [develop, master]
event: push
# PHP Syntax Check (PRs only - skipped on merge since tests already passed)
# ============================================
# PR CHECKS (Run in Parallel for Speed)
# ============================================
# PHP Syntax Check - Uses parallel-lint for 5-10x speed improvement
php-lint:
image: kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
commands:
- echo "Checking PHP syntax..."
- find app -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- find routes -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- find database -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- echo "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 (PRs only - skipped on merge since tests already passed)
# Run Laravel Pint (code style)
code-style:
image: kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
commands:
- echo "Checking code style with Laravel Pint..."
- ./vendor/bin/pint --test
@@ -98,11 +120,13 @@ steps:
when:
event: pull_request
# Run PHPUnit Tests (PRs only - skipped on merge since tests already passed)
# 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
depends_on:
- composer-install
when:
event: pull_request
environment:
@@ -128,20 +152,35 @@ steps:
- 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..."
- echo "Running tests in parallel..."
- php artisan test --parallel
- echo "Tests complete!"
- echo "Tests complete!"
# Validate seeders that run in dev/staging environments
# This prevents deployment failures caused by seeder errors (e.g., fake() crashes)
# Uses APP_ENV=development to match K8s init container behavior
validate-seeders:
# ============================================
# 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
depends_on:
- composer-install
environment:
APP_ENV: development
APP_ENV: production
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
@@ -152,20 +191,21 @@ steps:
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
commands:
- echo "Validating seeders (matches K8s init container)..."
- echo "Validating migrations..."
- cp .env.example .env
- php artisan key:generate
- echo "Running migrate:fresh --seed with APP_ENV=development..."
- php artisan migrate:fresh --seed --force
- echo "✅ Seeder validation complete!"
- echo "Running pending migrations only..."
- php artisan migrate --force
- echo "✅ Migration validation complete!"
when:
branch: [develop, master]
event: push
status: success
# Build and push Docker image for DEV environment (develop branch)
build-image-dev:
image: woodpeckerci/plugin-docker-buildx
depends_on:
- validate-migrations
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
@@ -194,11 +234,12 @@ steps:
when:
branch: develop
event: push
status: success
# Auto-deploy to dev.cannabrands.app (develop branch only)
deploy-dev:
image: bitnami/kubectl:latest
depends_on:
- build-image-dev
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_dev
@@ -218,20 +259,6 @@ steps:
-n cannabrands-dev
# Wait for rollout to complete (timeout 5 minutes)
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
# Restore Cannabrands seed data (idempotent - uses ON CONFLICT DO NOTHING)
- |
echo ""
echo "📦 Restoring Cannabrands seed data..."
POD=$(kubectl get pods -n cannabrands-dev -l app=cannabrands-hub -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n cannabrands-dev $POD -- php artisan db:restore-cannabrands
echo "✅ Cannabrands data restore complete!"
# Seed suites for Cannabrands (assigns Sales Suite to Cannabrands business)
- |
echo ""
echo "📦 Seeding suites for Cannabrands..."
POD=$(kubectl get pods -n cannabrands-dev -l app=cannabrands-hub -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n cannabrands-dev $POD -- php artisan db:seed --class=DevSuitesSeeder --force
echo "✅ Suites seeded!"
# Verify deployment health
- |
echo ""
@@ -245,11 +272,12 @@ steps:
when:
branch: develop
event: push
status: success
# Build and push Docker image for PRODUCTION (master branch)
build-image-production:
image: woodpeckerci/plugin-docker-buildx
depends_on:
- validate-migrations
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
@@ -274,11 +302,12 @@ steps:
when:
branch: master
event: push
status: success
# Deploy to production (master branch)
deploy-production:
image: bitnami/kubectl:latest
depends_on:
- build-image-production
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_prod
@@ -302,11 +331,12 @@ steps:
when:
branch: master
event: push
status: success
# Build and push Docker image for tagged releases (optional versioned releases)
build-image-release:
image: woodpeckerci/plugin-docker-buildx
depends_on:
- composer-install
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
@@ -327,7 +357,6 @@ steps:
provenance: false
when:
event: tag
status: success
# Success notification
success:
@@ -398,7 +427,7 @@ steps:
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
# Services for tests
# Services for tests (optimized for CI speed)
services:
postgres:
image: postgres:15
@@ -406,6 +435,9 @@ services:
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

139
CLAUDE.md
View File

@@ -191,6 +191,101 @@ if ($product->image_path) {
**This has caused multiple production outages - review docs before ANY storage changes!**
### 12. Dashboard & Metrics Performance (CRITICAL!)
**Production outages have occurred from violating these rules.**
#### The Golden Rule
**NEVER compute aggregations in HTTP controllers. Dashboard data comes from Redis, period.**
#### What Goes Where
| Location | Allowed | Not Allowed |
|----------|---------|-------------|
| Controller | `Redis::get()`, simple lookups by ID | `->sum()`, `->count()`, `->avg()`, loops with queries |
| Background Job | All aggregations, joins, complex queries | N/A |
#### ❌ BANNED Patterns in Controllers:
```php
// BANNED: Aggregation in controller
$revenue = Order::sum('total');
// BANNED: N+1 in loop
$items->map(fn($i) => Order::where('product_id', $i->id)->sum('qty'));
// BANNED: Query per day/iteration
for ($i = 0; $i < 30; $i++) {
$data[] = Order::whereDate('created_at', $date)->sum('total');
}
// BANNED: Selecting columns that don't exist
->select('id', 'stage_1_metadata') // Column doesn't exist!
```
#### ✅ REQUIRED Pattern:
```php
// Controller: Just read Redis
public function analytics(Business $business)
{
$data = Redis::get("dashboard:{$business->id}:analytics");
if (!$data) {
CalculateDashboardMetrics::dispatch($business->id);
return view('dashboard.analytics', ['data' => $this->emptyState()]);
}
return view('dashboard.analytics', ['data' => json_decode($data, true)]);
}
// Background Job: Do all the heavy lifting
public function handle()
{
// Batch query - ONE query for all products
$salesByProduct = OrderItem::whereIn('product_id', $productIds)
->groupBy('product_id')
->selectRaw('product_id, SUM(quantity) as total')
->pluck('total', 'product_id');
Redis::setex("dashboard:{$businessId}:analytics", 900, json_encode($data));
}
```
#### Before Merging Dashboard PRs:
1. Search for `->sum(`, `->count(`, `->avg(` in the controller
2. Search for `->map(function` with queries inside
3. If found → Move to background job
4. Query count must be < 20 for any dashboard page
#### The Architecture
```
BACKGROUND (every 10 min) HTTP REQUEST
======================== =============
┌─────────────────────┐ ┌─────────────────────┐
│ CalculateMetricsJob │ │ DashboardController │
│ │ │ │
│ - Heavy queries │ │ - Redis::get() only │
│ - Joins │──► Redis ──►│ - No aggregations │
│ - Aggregations │ │ - No loops+queries │
│ - Loops are OK here │ │ │
└─────────────────────┘ └─────────────────────┘
Takes 5-30 sec Takes 10ms
Runs in background User waits for this
```
#### Prevention Checklist for Future Dashboard Work
- [ ] All `->sum()`, `->count()`, `->avg()` are in background jobs, not controllers
- [ ] No `->map(function` with queries inside in controllers
- [ ] Redis keys exist after job runs (`redis-cli KEYS "dashboard:*"`)
- [ ] Job completes without errors (check `storage/logs/worker.log`)
- [ ] Controller only does `Redis::get()` for metrics
- [ ] Column names in `->select()` match actual database schema
---
## Tech Stack by Area
@@ -307,6 +402,48 @@ Product::where('is_active', true)->get(); // No business_id filter!
---
## Performance Requirements
**Database Queries:**
- NEVER write N+1 queries - always use eager loading (`with()`) for relationships
- NEVER run queries inside loops - batch them before the loop
- Avoid multiple queries when one JOIN or subquery works
- Dashboard/index pages should use MAX 5-10 queries total, not 50+
- Use `DB::enableQueryLog()` mentally - if a page would log 20+ queries, refactor
- Cache expensive aggregations (Redis, 5-min TTL) instead of recalculating every request
- Test with `DB::listen()` or Laravel Debugbar before committing controller code
**Before submitting controller code, verify:**
1. No queries inside foreach/map loops
2. All relationships eager loaded
3. Aggregations done in SQL, not PHP collections
4. Would this cause a 503 under load? If unsure, simplify.
**Examples:**
```php
// ❌ N+1 query - DON'T DO THIS
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // Query per iteration!
}
// ✅ Eager loaded - DO THIS
$orders = Order::with('customer')->get();
// ❌ Query in loop - DON'T DO THIS
foreach ($products as $product) {
$stock = Inventory::where('product_id', $product->id)->sum('quantity');
}
// ✅ Batch query - DO THIS
$stocks = Inventory::whereIn('product_id', $products->pluck('id'))
->groupBy('product_id')
->selectRaw('product_id, SUM(quantity) as total')
->pluck('total', 'product_id');
```
---
## What You Often Forget
✅ Scope by business_id BEFORE finding by ID
@@ -315,3 +452,5 @@ Product::where('is_active', true)->get(); // No business_id filter!
✅ DaisyUI for buyer/seller, Filament only for admin
✅ NO inline styles - use Tailwind/DaisyUI classes only
✅ Run tests before committing
✅ Eager load relationships to prevent N+1 queries
✅ No queries inside loops - batch before the loop

View File

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

View File

@@ -7,7 +7,7 @@ use Illuminate\Console\Command;
class DevSetup extends Command
{
protected $signature = 'dev:setup
{--fresh : Drop all tables and re-run migrations}
{--fresh : Drop all tables and re-run migrations (DESTRUCTIVE - requires confirmation)}
{--skip-seed : Skip seeding dev fixtures}';
protected $description = 'Set up local development environment with migrations and dev fixtures';
@@ -25,8 +25,18 @@ class DevSetup extends Command
// Run migrations
if ($this->option('fresh')) {
$this->warn('Dropping all tables and re-running migrations...');
$this->call('migrate:fresh');
$this->newLine();
$this->error('WARNING: --fresh will DELETE ALL DATA in the database!');
$this->warn('This includes development data being preserved for production release.');
$this->newLine();
if (! $this->confirm('Are you SURE you want to drop all tables and lose all data?', false)) {
$this->info('Aborted. Running normal migrations instead...');
$this->call('migrate');
} else {
$this->warn('Dropping all tables and re-running migrations...');
$this->call('migrate:fresh');
}
} else {
$this->info('Running migrations...');
$this->call('migrate');

View File

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

View File

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

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Business;
use App\Services\Accounting\FixedAssetService;
use Carbon\Carbon;
use Illuminate\Console\Command;
/**
* Run monthly depreciation for fixed assets.
*
* This command calculates and posts depreciation entries for all
* eligible fixed assets. Can be run for a specific business or all
* businesses with Management Suite enabled.
*
* Safe to run multiple times in the same month - assets that have
* already been depreciated for the period will be skipped.
*/
class RunFixedAssetDepreciation extends Command
{
protected $signature = 'fixed-assets:run-depreciation
{business_id? : Specific business ID to run for}
{--period= : Period date (Y-m-d format, defaults to end of current month)}
{--dry-run : Show what would be depreciated without making changes}';
protected $description = 'Run monthly depreciation for fixed assets';
public function __construct(
protected FixedAssetService $assetService
) {
parent::__construct();
}
public function handle(): int
{
$businessId = $this->argument('business_id');
$periodOption = $this->option('period');
$dryRun = $this->option('dry-run');
// Parse period date
$periodDate = $periodOption
? Carbon::parse($periodOption)->endOfMonth()
: Carbon::now()->endOfMonth();
$this->info("Running depreciation for period: {$periodDate->format('Y-m')}");
if ($dryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
// Get businesses to process
$businesses = $this->getBusinesses($businessId);
if ($businesses->isEmpty()) {
$this->warn('No businesses found to process.');
return Command::SUCCESS;
}
$totalRuns = 0;
$totalAmount = 0;
foreach ($businesses as $business) {
$this->line('');
$this->info("Processing: {$business->name}");
if ($dryRun) {
$results = $this->previewDepreciation($business, $periodDate);
} else {
$results = $this->assetService->runBatchDepreciation($business, $periodDate);
}
$count = $results->count();
$amount = $results->sum('depreciation_amount');
if ($count > 0) {
$this->line(" - Depreciated {$count} assets");
$this->line(" - Total amount: \${$amount}");
$totalRuns += $count;
$totalAmount += $amount;
} else {
$this->line(' - No assets to depreciate');
}
}
$this->line('');
$this->info('=== Summary ===');
$this->info("Total assets depreciated: {$totalRuns}");
$this->info("Total depreciation amount: \${$totalAmount}");
if ($dryRun) {
$this->warn('This was a dry run. Run without --dry-run to apply changes.');
}
return Command::SUCCESS;
}
/**
* Get businesses to process.
*/
protected function getBusinesses(?string $businessId): \Illuminate\Support\Collection
{
if ($businessId) {
$business = Business::find($businessId);
if (! $business) {
$this->error("Business with ID {$businessId} not found.");
return collect();
}
if (! $business->hasManagementSuite()) {
$this->warn("Business {$business->name} does not have Management Suite enabled.");
}
return collect([$business]);
}
// Get all businesses with Management Suite
return Business::whereHas('suites', function ($query) {
$query->where('key', 'management');
})->get();
}
/**
* Preview depreciation without making changes.
*/
protected function previewDepreciation(Business $business, Carbon $periodDate): \Illuminate\Support\Collection
{
$period = $periodDate->format('Y-m');
$assets = \App\Models\Accounting\FixedAsset::where('business_id', $business->id)
->where('status', \App\Models\Accounting\FixedAsset::STATUS_ACTIVE)
->where('category', '!=', \App\Models\Accounting\FixedAsset::CATEGORY_LAND)
->get();
$results = collect();
foreach ($assets as $asset) {
// Skip if already depreciated for this period
$existing = \App\Models\Accounting\FixedAssetDepreciationRun::where('fixed_asset_id', $asset->id)
->where('period', $period)
->where('is_reversed', false)
->exists();
if ($existing) {
continue;
}
// Skip if fully depreciated
if ($asset->book_value <= $asset->salvage_value) {
continue;
}
$depreciationAmount = $asset->monthly_depreciation;
$maxDepreciation = $asset->book_value - $asset->salvage_value;
$depreciationAmount = min($depreciationAmount, $maxDepreciation);
if ($depreciationAmount > 0) {
$results->push((object) [
'fixed_asset_id' => $asset->id,
'asset_name' => $asset->name,
'depreciation_amount' => $depreciationAmount,
]);
$this->line(" - {$asset->name}: \${$depreciationAmount}");
}
}
return $results;
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Accounting\RecurringSchedulerService;
use Carbon\Carbon;
use Illuminate\Console\Command;
class RunRecurringSchedules extends Command
{
protected $signature = 'recurring:run
{--date= : The date to run schedules for (YYYY-MM-DD, default: today)}
{--business= : Specific business ID to run schedules for}
{--dry-run : Preview what would be generated without actually creating transactions}';
protected $description = 'Run due recurring schedules to generate AR invoices, AP bills, and journal entries';
public function __construct(
protected RecurringSchedulerService $schedulerService
) {
parent::__construct();
}
public function handle(): int
{
$dateString = $this->option('date');
$businessId = $this->option('business') ? (int) $this->option('business') : null;
$dryRun = $this->option('dry-run');
$date = $dateString ? Carbon::parse($dateString) : now();
$this->info("Running recurring schedules for {$date->toDateString()}...");
if ($businessId) {
$this->info("Filtering to business ID: {$businessId}");
}
// Get due schedules
$dueSchedules = $this->schedulerService->getDueSchedules($date, $businessId);
if ($dueSchedules->isEmpty()) {
$this->info('No schedules are due for execution.');
return self::SUCCESS;
}
$this->info("Found {$dueSchedules->count()} schedule(s) due for execution.");
if ($dryRun) {
$this->warn('DRY RUN MODE - No transactions will be created.');
$this->table(
['ID', 'Name', 'Type', 'Business', 'Next Run Date', 'Auto Post'],
$dueSchedules->map(fn ($s) => [
$s->id,
$s->name,
$s->type_label,
$s->business->name ?? 'N/A',
$s->next_run_date->toDateString(),
$s->auto_post ? 'Yes' : 'No',
])
);
return self::SUCCESS;
}
// Run all due schedules
$results = $this->schedulerService->runAllDue($date, $businessId);
// Output results
$this->newLine();
$this->info('Execution Summary:');
$this->line(" Processed: {$results['processed']}");
$this->line(" Successful: {$results['success']}");
$this->line(" Failed: {$results['failed']}");
if (! empty($results['generated'])) {
$this->newLine();
$this->info('Generated Transactions:');
$this->table(
['Schedule', 'Type', 'Result ID'],
collect($results['generated'])->map(fn ($g) => [
$g['schedule_name'],
$g['type'],
$g['result_id'],
])
);
}
if (! empty($results['errors'])) {
$this->newLine();
$this->error('Errors:');
foreach ($results['errors'] as $error) {
$this->line(" [{$error['schedule_id']}] {$error['schedule_name']}: {$error['error']}");
}
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use Illuminate\Database\Console\Migrations\FreshCommand;
/**
* Override migrate:fresh to prevent accidental data loss.
*
* This command blocks migrate:fresh in all environments except when
* explicitly targeting a test database (DB_DATABASE=testing or *_test_*).
*/
class SafeFreshCommand extends FreshCommand
{
public function handle()
{
// Check both config and direct env (env var may not be in config yet)
$database = env('DB_DATABASE', config('database.connections.pgsql.database'));
// Allow migrate:fresh ONLY for test databases
$isTestDatabase = $database === 'testing'
|| str_contains($database, '_test_')
|| str_contains($database, 'testing_');
if (! $isTestDatabase) {
$this->components->error('migrate:fresh is BLOCKED to prevent data loss!');
$this->components->warn("Database: {$database}");
$this->newLine();
$this->components->bulletList([
'This command drops ALL tables and destroys ALL data.',
'It is blocked in local, dev, staging, and production.',
'For testing: DB_DATABASE=testing ./vendor/bin/sail artisan migrate:fresh',
'To seed existing data: php artisan db:seed --class=ProductionSyncSeeder',
]);
return 1;
}
$this->components->info("Running migrate:fresh on TEST database: {$database}");
return parent::handle();
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use App\Models\Brand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class SyncBrandMediaPaths extends Command
{
protected $signature = 'brands:sync-media-paths
{--dry-run : Preview changes without applying}
{--business= : Limit to specific business slug}';
protected $description = 'Sync brand logo_path and banner_path from MinIO storage';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$businessFilter = $this->option('business');
if ($dryRun) {
$this->warn('DRY RUN - No changes will be made');
}
$this->info('Scanning MinIO for brand media...');
$businessDirs = Storage::directories('businesses');
$updated = 0;
$skipped = 0;
foreach ($businessDirs as $businessDir) {
$businessSlug = basename($businessDir);
if ($businessFilter && $businessSlug !== $businessFilter) {
continue;
}
$brandsDir = $businessDir.'/brands';
if (! Storage::exists($brandsDir)) {
continue;
}
$brandDirs = Storage::directories($brandsDir);
foreach ($brandDirs as $brandDir) {
$brandSlug = basename($brandDir);
$brandingDir = $brandDir.'/branding';
if (! Storage::exists($brandingDir)) {
continue;
}
$brand = Brand::where('slug', $brandSlug)->first();
if (! $brand) {
$this->line(" <fg=yellow>?</> {$brandSlug} - not found in database");
$skipped++;
continue;
}
$files = Storage::files($brandingDir);
$logoPath = null;
$bannerPath = null;
foreach ($files as $file) {
$filename = strtolower(basename($file));
if (str_starts_with($filename, 'logo.')) {
$logoPath = $file;
} elseif (str_starts_with($filename, 'banner.')) {
$bannerPath = $file;
}
}
$changes = [];
if ($logoPath && $brand->logo_path !== $logoPath) {
$changes[] = "logo: {$logoPath}";
}
if ($bannerPath && $brand->banner_path !== $bannerPath) {
$changes[] = "banner: {$bannerPath}";
}
if (empty($changes)) {
$this->line(" <fg=green>✓</> {$brandSlug} - already synced");
continue;
}
if (! $dryRun) {
if ($logoPath) {
$brand->logo_path = $logoPath;
}
if ($bannerPath) {
$brand->banner_path = $bannerPath;
}
$brand->save();
}
$this->line(" <fg=blue>↻</> {$brandSlug} - ".implode(', ', $changes));
$updated++;
}
}
$this->newLine();
$this->info("Updated: {$updated} | Skipped: {$skipped}");
if ($dryRun && $updated > 0) {
$this->warn('Run without --dry-run to apply changes');
}
return Command::SUCCESS;
}
}

View File

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

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use App\Models\Accounting\AccountingPeriod;
class PeriodLockedException extends \Exception
{
public function __construct(
string $message,
public readonly ?AccountingPeriod $period = null,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
public function getPeriod(): ?AccountingPeriod
{
return $this->period;
}
}

View File

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

View File

@@ -701,6 +701,17 @@ class BusinessResource extends Resource
}),
]),
// ===== CANNAIQ SECTION =====
// CannaiQ Marketing Intelligence Engine
Section::make('CannaiQ')
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
->schema([
Toggle::make('cannaiq_enabled')
->label('Enable CannaiQ')
->helperText('When enabled, this business gets access to Intelligence and Promos features under the Growth menu.')
->default(false),
]),
// ===== SUITE ASSIGNMENT SECTION =====
// Suites control feature access (menus, screens, capabilities)
Section::make('Suite Assignment')
@@ -722,53 +733,83 @@ class BusinessResource extends Resource
->bulkToggleable()
->helperText('Select the suites this business should have access to. Each suite enables specific features and menu items.'),
Forms\Components\Placeholder::make('suite_info')
->label('')
->content(function () {
// Show available suites (excluding deprecated and internal)
$suites = \App\Models\Suite::available()->orderBy('sort_order')->get();
$html = '<div class="grid grid-cols-2 gap-4 text-sm mt-4">';
foreach ($suites as $suite) {
$colorClass = match ($suite->color) {
'emerald' => 'border-emerald-300 bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-950', // Sales
'pink' => 'border-pink-300 bg-pink-50 dark:border-pink-700 dark:bg-pink-950', // Marketing
'cyan' => 'border-cyan-300 bg-cyan-50 dark:border-cyan-700 dark:bg-cyan-950', // Inventory
'blue' => 'border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-950', // Processing
'orange' => 'border-orange-300 bg-orange-50 dark:border-orange-700 dark:bg-orange-950', // Manufacturing
'indigo' => 'border-indigo-300 bg-indigo-50 dark:border-indigo-700 dark:bg-indigo-950', // Procurement
'violet' => 'border-violet-300 bg-violet-50 dark:border-violet-700 dark:bg-violet-950', // Distribution
'green' => 'border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-950', // Finance
'amber' => 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950', // Compliance
'sky' => 'border-sky-300 bg-sky-50 dark:border-sky-700 dark:bg-sky-950', // Inbox
'slate' => 'border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-950', // Tools
'gray' => 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-950', // Management
'lime' => 'border-lime-300 bg-lime-50 dark:border-lime-700 dark:bg-lime-950', // Dispensary
'gold' => 'border-yellow-300 bg-yellow-50 dark:border-yellow-700 dark:bg-yellow-950', // Enterprise
'teal' => 'border-teal-300 bg-teal-50 dark:border-teal-700 dark:bg-teal-950', // Brand Manager
'red' => 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-950',
'rose' => 'border-rose-300 bg-rose-50 dark:border-rose-700 dark:bg-rose-950',
'fuchsia' => 'border-fuchsia-300 bg-fuchsia-50 dark:border-fuchsia-700 dark:bg-fuchsia-950',
default => 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-950',
};
$features = is_array($suite->included_features) ? implode(', ', $suite->included_features) : '';
$html .= '<div class="border rounded-lg p-3 '.$colorClass.'">';
$html .= '<div class="font-medium">'.e($suite->name).'</div>';
$html .= '<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">'.e($features).'</div>';
$html .= '</div>';
}
$html .= '</div>';
return new \Illuminate\Support\HtmlString($html);
}),
]),
Section::make('Navigation Settings')
->description('Control how this business experiences the seller sidebar navigation.')
// ===== SUITE SHARES SECTION =====
// Allows this business to share parts of their suite TO other businesses
Section::make('Suite Shares')
->description('Share parts of THIS business\'s suite with other businesses. The recipient will see these menu items with a "Shared" badge.')
->collapsed()
->schema([
Toggle::make('use_suite_navigation')
->label('Use Suite Navigation (beta)')
->helperText('When enabled, this business uses the new suite-based sidebar instead of the legacy menu.')
->default(false),
Forms\Components\Repeater::make('suiteShares')
->relationship('suiteShares')
->label('')
->schema([
Select::make('target_business_id')
->label('Share TO Business')
->options(function (callable $get) {
$currentBusinessId = $get('../../id');
return \App\Models\Business::query()
->when($currentBusinessId, fn ($q) => $q->where('id', '!=', $currentBusinessId))
->orderBy('name')
->pluck('name', 'id');
})
->searchable()
->required()
->helperText('Select the business that will RECEIVE these shared menu items'),
Select::make('shared_suite_key')
->label('Suite to Share From')
->options(function ($livewire) {
// Get suites assigned to THIS business (source)
$business = $livewire->record;
if (! $business) {
return [];
}
return $business->suites()
->orderBy('sort_order')
->pluck('name', 'key')
->toArray();
})
->required()
->reactive()
->helperText('Select which of THIS business\'s suites to share items from'),
CheckboxList::make('shared_menu_keys')
->label('Menu Items to Share')
->options(function (callable $get) {
$suiteKey = $get('shared_suite_key');
if (! $suiteKey) {
return [];
}
// Get menu keys for this suite from config
$menuKeys = config("suites.menus.{$suiteKey}", []);
$resolver = app(\App\Services\SuiteMenuResolver::class);
$options = [];
foreach ($menuKeys as $key) {
$def = $resolver->getMenuDefinition($key);
if ($def) {
$options[$key] = $def['label'].' ('.$def['section'].')';
}
}
return $options;
})
->columns(2)
->required()
->visible(fn (callable $get) => ! empty($get('shared_suite_key'))),
])
->columns(1)
->defaultItems(0)
->addActionLabel('Add Suite Share')
->reorderable(false)
->collapsible()
->itemLabel(fn (array $state): ?string => isset($state['target_business_id'])
? 'Share to: '.(\App\Models\Business::find($state['target_business_id'])?->name ?? 'New Share')
: 'New Share'
),
]),
Section::make('Sales Suite Usage Limits')

View File

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

View File

@@ -55,6 +55,22 @@ class OrchestratorOutcomesChart extends ChartWidget
->pending()
->count();
// If all values are zero, show a placeholder to prevent empty doughnut rendering
$total = $completed + $dismissed + $snoozed + $pending;
if ($total === 0) {
return [
'datasets' => [
[
'label' => 'No Data',
'data' => [1],
'backgroundColor' => ['rgba(209, 213, 219, 0.5)'], // gray placeholder
'borderWidth' => 0,
],
],
'labels' => ['No tasks yet'],
];
}
return [
'datasets' => [
[

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Accounting;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
use App\Models\Business;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class ApVendorController extends Controller
{
/**
* List vendors for a business.
*
* GET /api/{business}/ap/vendors
*/
public function index(Request $request, Business $business): JsonResponse
{
$query = ApVendor::where('business_id', $business->id);
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
});
}
// Active filter
if ($request->has('active')) {
$query->where('is_active', $request->boolean('active'));
}
$vendors = $query->orderBy('name')->paginate($request->get('per_page', 50));
return response()->json([
'success' => true,
'data' => $vendors->items(),
'meta' => [
'current_page' => $vendors->currentPage(),
'last_page' => $vendors->lastPage(),
'per_page' => $vendors->perPage(),
'total' => $vendors->total(),
],
]);
}
/**
* Get a single vendor.
*
* GET /api/{business}/ap/vendors/{vendor}
*/
public function show(Business $business, ApVendor $vendor): JsonResponse
{
if ($vendor->business_id !== $business->id) {
return response()->json([
'success' => false,
'message' => 'Vendor does not belong to this business.',
], 403);
}
return response()->json([
'success' => true,
'data' => $vendor,
]);
}
/**
* Create a new vendor.
*
* POST /api/{business}/ap/vendors
*/
public function store(Request $request, Business $business): JsonResponse
{
try {
$validated = $request->validate([
'code' => 'nullable|string|max:50',
'name' => 'required|string|max:255',
'legal_name' => 'nullable|string|max:255',
'tax_id' => 'nullable|string|max:50',
'default_payment_terms' => 'nullable|integer|min:0',
'default_gl_account_id' => 'nullable|integer|exists:gl_accounts,id',
'contact_name' => 'nullable|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'is_1099' => 'boolean',
'notes' => 'nullable|string|max:1000',
]);
// Generate code if not provided
if (empty($validated['code'])) {
$validated['code'] = $this->generateVendorCode($business->id, $validated['name']);
}
$vendor = ApVendor::create([
'business_id' => $business->id,
...$validated,
'is_active' => true,
]);
return response()->json([
'success' => true,
'message' => "Vendor {$vendor->name} created.",
'data' => $vendor,
], 201);
} catch (\Exception $e) {
Log::error('Vendor creation failed', [
'business_id' => $business->id,
'error' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'message' => 'Failed to create vendor: '.$e->getMessage(),
], 500);
}
}
/**
* Update a vendor.
*
* PUT /api/{business}/ap/vendors/{vendor}
*/
public function update(Request $request, Business $business, ApVendor $vendor): JsonResponse
{
if ($vendor->business_id !== $business->id) {
return response()->json([
'success' => false,
'message' => 'Vendor does not belong to this business.',
], 403);
}
try {
$validated = $request->validate([
'code' => 'nullable|string|max:50',
'name' => 'required|string|max:255',
'legal_name' => 'nullable|string|max:255',
'tax_id' => 'nullable|string|max:50',
'default_payment_terms' => 'nullable|integer|min:0',
'default_gl_account_id' => 'nullable|integer|exists:gl_accounts,id',
'contact_name' => 'nullable|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'is_1099' => 'boolean',
'is_active' => 'boolean',
'notes' => 'nullable|string|max:1000',
]);
$vendor->update($validated);
return response()->json([
'success' => true,
'message' => "Vendor {$vendor->name} updated.",
'data' => $vendor->fresh(),
]);
} catch (\Exception $e) {
Log::error('Vendor update failed', [
'vendor_id' => $vendor->id,
'error' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'message' => 'Failed to update vendor: '.$e->getMessage(),
], 500);
}
}
/**
* Generate vendor code from name.
*/
protected function generateVendorCode(int $businessId, string $name): string
{
$words = preg_split('/\s+/', strtoupper($name));
$prefix = '';
foreach ($words as $word) {
$prefix .= substr(preg_replace('/[^A-Z0-9]/', '', $word), 0, 3);
if (strlen($prefix) >= 6) {
break;
}
}
$prefix = substr($prefix, 0, 6);
$count = ApVendor::where('business_id', $businessId)
->where('code', 'like', "{$prefix}%")
->count();
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -73,9 +73,9 @@ class OrderController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('order_number', 'like', "%{$search}%")
$q->where('order_number', 'ILIKE', "%{$search}%")
->orWhereHas('business', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
$q->where('name', 'ILIKE', "%{$search}%");
});
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Accounting;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ApVendor;
use App\Models\Accounting\ArInvoice;
use App\Models\Business;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Read-only accounting alias controllers for child businesses (divisions).
*
* Child businesses can view limited accounting data from their parent company.
* This provides visibility without granting write access to financial systems.
*
* Requirements:
* - Business must have parent_id (be a division)
* - User must have appropriate viewing permissions
*/
class DivisionAccountingController extends Controller
{
/**
* Display vendor list (read-only from parent company).
*
* GET /s/{business}/accounting/vendors
*/
public function vendorsIndex(Request $request, Business $business): View
{
$this->authorizeChildBusiness($business);
// Get parent's vendors
$parentId = $business->parent_id;
$query = ApVendor::where('business_id', $parentId)
->where('is_active', true);
// Search filter
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
$vendors = $query->orderBy('name')->paginate(30)->withQueryString();
return view('seller.accounting.vendors.index', [
'business' => $business,
'vendors' => $vendors,
'filters' => $request->only(['search']),
]);
}
/**
* Display AR snapshot (read-only summary for division).
*
* GET /s/{business}/accounting/ar-snapshot
*/
public function arSnapshot(Request $request, Business $business): View
{
$this->authorizeChildBusiness($business);
$parentId = $business->parent_id;
// Get AR summary stats (scoped to this division's invoices if possible,
// otherwise show high-level parent metrics)
$stats = [
'total_outstanding' => ArInvoice::where('business_id', $business->id)
->where('status', '!=', 'paid')
->sum('balance_due'),
'overdue_count' => ArInvoice::where('business_id', $business->id)
->where('status', '!=', 'paid')
->where('due_date', '<', now())
->count(),
'overdue_amount' => ArInvoice::where('business_id', $business->id)
->where('status', '!=', 'paid')
->where('due_date', '<', now())
->sum('balance_due'),
'current_month_billed' => ArInvoice::where('business_id', $business->id)
->whereMonth('invoice_date', now()->month)
->whereYear('invoice_date', now()->year)
->sum('total_amount'),
];
// Recent invoices for this division
$recentInvoices = ArInvoice::where('business_id', $business->id)
->with('customer')
->orderByDesc('invoice_date')
->limit(10)
->get();
return view('seller.accounting.ar-snapshot', [
'business' => $business,
'stats' => $stats,
'recentInvoices' => $recentInvoices,
]);
}
/**
* Display AP snapshot (read-only summary for division).
*
* GET /s/{business}/accounting/ap-snapshot
*/
public function apSnapshot(Request $request, Business $business): View
{
$this->authorizeChildBusiness($business);
$parentId = $business->parent_id;
// Get AP summary stats scoped to this division's bills
$stats = [
'total_outstanding' => ApBill::where('business_id', $business->id)
->whereIn('status', ['approved', 'partial'])
->sum('balance_due'),
'overdue_count' => ApBill::where('business_id', $business->id)
->whereIn('status', ['approved', 'partial'])
->where('due_date', '<', now())
->count(),
'overdue_amount' => ApBill::where('business_id', $business->id)
->whereIn('status', ['approved', 'partial'])
->where('due_date', '<', now())
->sum('balance_due'),
'pending_approval' => ApBill::where('business_id', $business->id)
->whereIn('status', ['draft', 'pending'])
->count(),
];
// Recent bills for this division
$recentBills = ApBill::where('business_id', $business->id)
->with('vendor')
->orderByDesc('bill_date')
->limit(10)
->get();
return view('seller.accounting.ap-snapshot', [
'business' => $business,
'stats' => $stats,
'recentBills' => $recentBills,
]);
}
/**
* Ensure this is a child business with parent_id.
*/
protected function authorizeChildBusiness(Business $business): void
{
if ($business->parent_id === null) {
abort(404, 'This feature is only available for division businesses.');
}
}
}

View File

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

View File

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

View File

@@ -14,8 +14,12 @@ use Illuminate\Support\Facades\Auth;
/**
* Brand Portal Controller
*
* Handles all Brand Portal functionality for external brand partners.
* Brand Portal users have read-only access to data scoped to their linked brands.
* Handles all Brand Portal functionality for external brand partners and brand managers.
* Both user types have read-only access to data scoped to their linked brands.
*
* Supported access modes:
* - Brand Portal users (in "Brand Partner" department with linked brands)
* - Brand Manager users (contact_type = 'brand_manager' with linked brands)
*
* Key constraints:
* - All data is scoped to the user's linked brands (via brand_user pivot)
@@ -25,6 +29,26 @@ use Illuminate\Support\Facades\Auth;
*/
class BrandPortalController extends Controller
{
/**
* Check if user has brand access (Portal or Manager) and get their brand IDs.
*/
protected function validateAccessAndGetBrandIds(Business $business): array
{
$user = Auth::user();
// Check for Brand Portal access
if ($user->isBrandPortalUser($business)) {
return $user->getBrandIdsForPortal($business);
}
// Check for Brand Manager access
if ($user->isBrandManagerUser($business)) {
return $user->getBrandIdsForManager($business);
}
abort(403, 'Access denied. Brand Portal or Brand Manager access required.');
}
/**
* Brand Portal Dashboard - Brand Overview.
*
@@ -36,14 +60,7 @@ class BrandPortalController extends Controller
*/
public function dashboard(Business $business)
{
$user = Auth::user();
// Ensure user is in Brand Portal mode
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// Summary stats
@@ -90,13 +107,7 @@ class BrandPortalController extends Controller
*/
public function orders(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// Filter by brand if specified
@@ -135,13 +146,7 @@ class BrandPortalController extends Controller
*/
public function accounts(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// Get businesses that have ordered products from linked brands
@@ -177,13 +182,7 @@ class BrandPortalController extends Controller
*/
public function inventory(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// Filter by brand if specified
@@ -233,13 +232,7 @@ class BrandPortalController extends Controller
*/
public function promotions(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// Filter by brand if specified
@@ -280,13 +273,7 @@ class BrandPortalController extends Controller
*/
public function inbox(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// For inbox, we show conversations but in a limited Brand Portal context
@@ -305,13 +292,7 @@ class BrandPortalController extends Controller
*/
public function contacts(Request $request, Business $business)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// For contacts, show in Brand Portal context
@@ -326,13 +307,7 @@ class BrandPortalController extends Controller
*/
public function showOrder(Business $business, Order $order)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
// Verify order contains products from user's linked brands
$hasLinkedBrandProducts = $order->items()
@@ -364,13 +339,7 @@ class BrandPortalController extends Controller
*/
public function showProduct(Business $business, Product $product)
{
$user = Auth::user();
if (! $user->isBrandPortalUser($business)) {
abort(403, 'Access denied. Brand Portal access required.');
}
$brandIds = $user->getBrandIdsForPortal($business);
$brandIds = $this->validateAccessAndGetBrandIds($business);
// Verify product belongs to user's linked brands
if (! in_array($product->brand_id, $brandIds)) {

View File

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

View File

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

View File

@@ -5,8 +5,11 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Crm\CrmEvent;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmTask;
use App\Models\Invoice;
use App\Models\SalesOpportunity;
use App\Models\SendMenuLog;
use Illuminate\Http\Request;
@@ -18,15 +21,143 @@ class AccountController extends Controller
*/
public function index(Request $request, Business $business)
{
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->with(['contacts'])
->orderBy('name')
->paginate(25);
$query = Business::where('type', 'buyer')
->with(['contacts']);
// Search filter
if ($request->filled('q')) {
$search = $request->q;
$query->where(function ($q) use ($search) {
$q->where('name', 'ILIKE', "%{$search}%")
->orWhere('dba_name', 'ILIKE', "%{$search}%")
->orWhere('business_email', 'ILIKE', "%{$search}%");
});
}
// Status filter - default to approved, but allow viewing all
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
} else {
$query->where('status', 'approved');
}
$accounts = $query->orderBy('name')->paginate(25);
return view('seller.crm.accounts.index', compact('business', 'accounts'));
}
/**
* Show create customer form
*/
public function create(Request $request, Business $business)
{
return view('seller.crm.accounts.create', compact('business'));
}
/**
* Store a new customer (buyer business)
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'business_email' => 'nullable|email|max:255',
'business_phone' => 'nullable|string|max:50',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:50',
'physical_zipcode' => 'nullable|string|max:20',
'contact_name' => 'nullable|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'contact_title' => 'nullable|string|max:100',
]);
// Create the buyer business
$account = Business::create([
'name' => $validated['name'],
'dba_name' => $validated['dba_name'] ?? null,
'license_number' => $validated['license_number'] ?? null,
'business_email' => $validated['business_email'] ?? null,
'business_phone' => $validated['business_phone'] ?? null,
'physical_address' => $validated['physical_address'] ?? null,
'physical_city' => $validated['physical_city'] ?? null,
'physical_state' => $validated['physical_state'] ?? null,
'physical_zipcode' => $validated['physical_zipcode'] ?? null,
'type' => 'buyer',
'status' => 'approved', // Auto-approve customers created by sellers
]);
// Create primary contact if provided
if (! empty($validated['contact_name'])) {
$account->contacts()->create([
'first_name' => explode(' ', $validated['contact_name'])[0],
'last_name' => implode(' ', array_slice(explode(' ', $validated['contact_name']), 1)) ?: null,
'email' => $validated['contact_email'] ?? null,
'phone' => $validated['contact_phone'] ?? null,
'title' => $validated['contact_title'] ?? null,
'is_primary' => true,
]);
}
// Log the creation event
CrmEvent::log(
sellerBusinessId: $business->id,
eventType: 'account_created',
summary: "Customer {$account->name} created",
buyerBusinessId: $account->id,
userId: auth()->id(),
channel: 'system'
);
// Return JSON for AJAX requests
if ($request->expectsJson()) {
return response()->json([
'id' => $account->id,
'name' => $account->name,
'slug' => $account->slug,
]);
}
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Customer created successfully.');
}
/**
* Show edit customer form
*/
public function edit(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.edit', compact('business', 'account'));
}
/**
* Update a customer (buyer business)
*/
public function update(Request $request, Business $business, Business $account)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'business_email' => 'nullable|email|max:255',
'business_phone' => 'nullable|string|max:50',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:50',
'physical_zipcode' => 'nullable|string|max:20',
]);
$account->update($validated);
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Customer updated successfully.');
}
/**
* Show account details
*/
@@ -34,17 +165,37 @@ class AccountController extends Controller
{
$account->load(['contacts']);
// Get orders for this account from this seller
// Get orders for this account from this seller (with invoices)
$orders = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->with(['invoice'])
->latest()
->limit(10)
->get();
// Get quotes for this account
$quotes = CrmQuote::where('business_id', $business->id)
->where('account_id', $account->id)
->with(['contact', 'items'])
->latest()
->limit(10)
->get();
// Get invoices for this account (via orders)
$invoices = Invoice::whereHas('order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->with(['order', 'payments'])
->latest()
->limit(10)
->get();
// Get opportunities for this account from this seller
// SalesOpportunity uses business_id for the buyer
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['stage', 'brand'])
@@ -52,7 +203,6 @@ class AccountController extends Controller
->get();
// Get tasks related to this account
// CrmTask uses business_id for the buyer
$tasks = CrmTask::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->whereNull('completed_at')
@@ -84,26 +234,71 @@ class AccountController extends Controller
->limit(20)
->get();
// Compute stats for this account (orders from this seller)
$ordersQuery = $account->orders()
// Compute stats for this account with efficient queries
$orderStats = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
});
})
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
->first();
$pipelineValue = $opportunities->where('status', 'open')->sum('value');
$opportunityStats = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->where('status', 'open')
->selectRaw('COUNT(*) as open_count, COALESCE(SUM(value), 0) as pipeline_value')
->first();
// Financial stats from invoices
$financialStats = Invoice::whereHas('order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->selectRaw('
COALESCE(SUM(amount_due), 0) as outstanding_balance,
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due_amount,
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoice_count,
MIN(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN due_date END) as oldest_past_due_date
')
->first();
// Get last payment info
$lastPayment = \App\Models\InvoicePayment::whereHas('invoice.order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->latest('payment_date')
->first();
$stats = [
'total_orders' => $ordersQuery->count(),
'total_revenue' => $ordersQuery->sum('total') ?? 0,
'open_opportunities' => $opportunities->where('status', 'open')->count(),
'pipeline_value' => $pipelineValue ?? 0,
'total_orders' => $orderStats->total_orders ?? 0,
'total_revenue' => $orderStats->total_revenue ?? 0,
'open_opportunities' => $opportunityStats->open_count ?? 0,
'pipeline_value' => $opportunityStats->pipeline_value ?? 0,
];
$financials = [
'outstanding_balance' => $financialStats->outstanding_balance ?? 0,
'past_due_amount' => $financialStats->past_due_amount ?? 0,
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
'oldest_past_due_days' => $financialStats->oldest_past_due_date
? now()->diffInDays($financialStats->oldest_past_due_date)
: null,
'last_payment_amount' => $lastPayment->amount ?? null,
'last_payment_date' => $lastPayment->payment_date ?? null,
];
return view('seller.crm.accounts.show', compact(
'business',
'account',
'stats',
'financials',
'orders',
'quotes',
'invoices',
'opportunities',
'tasks',
'conversationEvents',
@@ -135,7 +330,15 @@ class AccountController extends Controller
*/
public function orders(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.orders', compact('business', 'account'));
$orders = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->with(['items.product.brand'])
->latest()
->paginate(25);
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders'));
}
/**
@@ -143,7 +346,13 @@ class AccountController extends Controller
*/
public function activity(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.activity', compact('business', 'account'));
$activities = Activity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['causer'])
->latest()
->paginate(50);
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities'));
}
/**
@@ -176,4 +385,104 @@ class AccountController extends Controller
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Note added successfully.');
}
/**
* Store a new contact for an account
*/
public function storeContact(Request $request, Business $business, Business $account)
{
$validated = $request->validate([
'first_name' => 'required|string|max:100',
'last_name' => 'nullable|string|max:100',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'title' => 'nullable|string|max:100',
'is_primary' => 'boolean',
]);
// If setting as primary, unset other primary contacts
if ($validated['is_primary'] ?? false) {
$account->contacts()->update(['is_primary' => false]);
}
$contact = $account->contacts()->create($validated);
// Return JSON for AJAX requests
if ($request->expectsJson()) {
return response()->json([
'id' => $contact->id,
'first_name' => $contact->first_name,
'last_name' => $contact->last_name,
'email' => $contact->email,
'phone' => $contact->phone,
'title' => $contact->title,
]);
}
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact added successfully.');
}
/**
* Show edit contact form
*/
public function editContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
return view('seller.crm.accounts.contacts-edit', compact('business', 'account', 'contact'));
}
/**
* Update a contact
*/
public function updateContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
$validated = $request->validate([
'first_name' => 'required|string|max:100',
'last_name' => 'nullable|string|max:100',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'title' => 'nullable|string|max:100',
'is_primary' => 'boolean',
'is_active' => 'boolean',
]);
// If setting as primary, unset other primary contacts
if ($validated['is_primary'] ?? false) {
$account->contacts()->where('id', '!=', $contact->id)->update(['is_primary' => false]);
}
$contact->update($validated);
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact updated successfully.');
}
/**
* Delete a contact
*/
public function destroyContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
$contact->delete();
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact deleted successfully.');
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ class CrmSettingsController extends Controller
'tags' => CrmTag::where('business_id', $business->id)->count(),
];
return view('seller.crm.settings.index', compact('stats'));
return view('seller.crm.settings.index', compact('stats', 'business'));
}
// ================== CHANNELS ==================
@@ -44,17 +44,17 @@ class CrmSettingsController extends Controller
->get()
->groupBy('type');
return view('seller.crm.settings.channels.index', compact('channels'));
return view('seller.crm.settings.channels.index', compact('channels', 'business'));
}
/**
* Create channel form
*/
public function createChannel(Request $request)
public function createChannel(Request $request, Business $business)
{
$types = CrmChannel::TYPES;
return view('seller.crm.settings.channels.create', compact('types'));
return view('seller.crm.settings.channels.create', compact('types', 'business'));
}
/**
@@ -86,7 +86,7 @@ class CrmSettingsController extends Controller
'is_default' => $validated['is_default'] ?? false,
]);
return redirect()->route('seller.crm.settings.channels')
return redirect()->route('seller.business.crm.settings.channels', $business)
->with('success', 'Channel created successfully.');
}
@@ -102,7 +102,7 @@ class CrmSettingsController extends Controller
$types = CrmChannel::TYPES;
return view('seller.crm.settings.channels.edit', compact('channel', 'types'));
return view('seller.crm.settings.channels.edit', compact('channel', 'types', 'business'));
}
/**
@@ -132,7 +132,7 @@ class CrmSettingsController extends Controller
$channel->update($validated);
return redirect()->route('seller.crm.settings.channels')
return redirect()->route('seller.business.crm.settings.channels', $business)
->with('success', 'Channel updated.');
}
@@ -164,15 +164,15 @@ class CrmSettingsController extends Controller
->orderBy('name')
->get();
return view('seller.crm.settings.pipelines.index', compact('pipelines'));
return view('seller.crm.settings.pipelines.index', compact('pipelines', 'business'));
}
/**
* Create pipeline form
*/
public function createPipeline()
public function createPipeline(Request $request, Business $business)
{
return view('seller.crm.settings.pipelines.create');
return view('seller.crm.settings.pipelines.create', compact('business'));
}
/**
@@ -206,7 +206,7 @@ class CrmSettingsController extends Controller
'is_default' => $validated['is_default'] ?? false,
]);
return redirect()->route('seller.crm.settings.pipelines')
return redirect()->route('seller.business.crm.settings.pipelines', $business)
->with('success', 'Pipeline created.');
}
@@ -220,7 +220,7 @@ class CrmSettingsController extends Controller
abort(404);
}
return view('seller.crm.settings.pipelines.edit', compact('pipeline'));
return view('seller.crm.settings.pipelines.edit', compact('pipeline', 'business'));
}
/**
@@ -253,7 +253,7 @@ class CrmSettingsController extends Controller
$pipeline->update($validated);
return redirect()->route('seller.crm.settings.pipelines')
return redirect()->route('seller.business.crm.settings.pipelines', $business)
->with('success', 'Pipeline updated.');
}
@@ -288,15 +288,15 @@ class CrmSettingsController extends Controller
->orderBy('priority')
->get();
return view('seller.crm.settings.sla.index', compact('policies'));
return view('seller.crm.settings.sla.index', compact('policies', 'business'));
}
/**
* Create SLA policy form
*/
public function createSlaPolicy()
public function createSlaPolicy(Request $request, Business $business)
{
return view('seller.crm.settings.sla.create');
return view('seller.crm.settings.sla.create', compact('business'));
}
/**
@@ -330,7 +330,7 @@ class CrmSettingsController extends Controller
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()->route('seller.crm.settings.sla')
return redirect()->route('seller.business.crm.settings.sla', $business)
->with('success', 'SLA policy created.');
}
@@ -344,7 +344,7 @@ class CrmSettingsController extends Controller
abort(404);
}
return view('seller.crm.settings.sla.edit', compact('policy'));
return view('seller.crm.settings.sla.edit', compact('policy', 'business'));
}
/**
@@ -371,7 +371,7 @@ class CrmSettingsController extends Controller
$policy->update($validated);
return redirect()->route('seller.crm.settings.sla')
return redirect()->route('seller.business.crm.settings.sla', $business)
->with('success', 'SLA policy updated.');
}
@@ -403,7 +403,7 @@ class CrmSettingsController extends Controller
->orderBy('name')
->get();
return view('seller.crm.settings.tags.index', compact('tags'));
return view('seller.crm.settings.tags.index', compact('tags', 'business'));
}
/**
@@ -478,7 +478,7 @@ class CrmSettingsController extends Controller
->get()
->groupBy('category');
return view('seller.crm.settings.templates.index', compact('templates'));
return view('seller.crm.settings.templates.index', compact('templates', 'business'));
}
/**
@@ -489,7 +489,7 @@ class CrmSettingsController extends Controller
$categories = CrmMessageTemplate::CATEGORIES;
$channels = CrmChannel::TYPES;
return view('seller.crm.settings.templates.create', compact('categories', 'channels'));
return view('seller.crm.settings.templates.create', compact('categories', 'channels', 'business'));
}
/**
@@ -519,7 +519,7 @@ class CrmSettingsController extends Controller
'is_active' => true,
]);
return redirect()->route('seller.crm.settings.templates')
return redirect()->route('seller.business.crm.settings.templates', $business)
->with('success', 'Template created.');
}
@@ -536,7 +536,7 @@ class CrmSettingsController extends Controller
$categories = CrmMessageTemplate::CATEGORIES;
$channels = CrmChannel::TYPES;
return view('seller.crm.settings.templates.edit', compact('template', 'categories', 'channels'));
return view('seller.crm.settings.templates.edit', compact('template', 'categories', 'channels', 'business'));
}
/**
@@ -560,7 +560,7 @@ class CrmSettingsController extends Controller
$template->update($validated);
return redirect()->route('seller.crm.settings.templates')
return redirect()->route('seller.business.crm.settings.templates', $business)
->with('success', 'Template updated.');
}
@@ -592,7 +592,7 @@ class CrmSettingsController extends Controller
->orderBy('name')
->get();
return view('seller.crm.settings.roles.index', compact('roles'));
return view('seller.crm.settings.roles.index', compact('roles', 'business'));
}
/**

View File

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

View File

@@ -32,24 +32,33 @@ class InvoiceController extends Controller
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('invoice_number', 'like', "%{$request->search}%")
->orWhere('title', 'like', "%{$request->search}%");
$q->where('invoice_number', 'ILIKE', "%{$request->search}%")
->orWhere('title', 'ILIKE', "%{$request->search}%");
});
}
$invoices = $query->orderByDesc('created_at')->paginate(25);
// Stats
// Stats - single efficient query with conditional aggregation
$invoiceStats = CrmInvoice::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN 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
")
->first();
$paidThisMonth = CrmInvoicePayment::whereHas('invoice', fn ($q) => $q->where('business_id', $business->id))
->whereMonth('payment_date', now()->month)
->whereYear('payment_date', now()->year)
->sum('amount');
$stats = [
'outstanding' => CrmInvoice::forBusiness($business->id)->outstanding()->sum('amount_due'),
'overdue' => CrmInvoice::forBusiness($business->id)->overdue()->sum('amount_due'),
'paid_this_month' => CrmInvoicePayment::whereHas('invoice', fn ($q) => $q->where('business_id', $business->id))
->whereMonth('payment_date', now()->month)
->whereYear('payment_date', now()->year)
->sum('amount'),
'outstanding' => $invoiceStats->outstanding ?? 0,
'overdue' => $invoiceStats->overdue ?? 0,
'paid_this_month' => $paidThisMonth,
];
return view('seller.crm.invoices.index', compact('invoices', 'stats'));
return view('seller.crm.invoices.index', compact('invoices', 'stats', 'business'));
}
/**
@@ -63,7 +72,7 @@ class InvoiceController extends Controller
$invoice->load(['contact', 'account', 'quote', 'creator', 'items.product', 'payments']);
return view('seller.crm.invoices.show', compact('invoice'));
return view('seller.crm.invoices.show', compact('invoice', 'business'));
}
/**
@@ -71,13 +80,23 @@ class InvoiceController extends Controller
*/
public function create(Request $request, Business $business)
{
$contacts = \App\Models\Contact::where('business_id', $business->id)->get();
// 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();
// 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')
->limit(50)
->get();
return view('seller.crm.invoices.create', compact('contacts', 'quotes'));
return view('seller.crm.invoices.create', compact('contacts', 'quotes', 'business'));
}
/**
@@ -144,7 +163,7 @@ class InvoiceController extends Controller
$invoice->calculateTotals();
return redirect()->route('seller.crm.invoices.show', $invoice)
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created successfully.');
}
@@ -259,7 +278,7 @@ class InvoiceController extends Controller
$invoice->delete();
return redirect()->route('seller.crm.invoices.index')
return redirect()->route('seller.business.crm.invoices.index', $business)
->with('success', 'Invoice deleted.');
}
}

View File

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

View File

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

View File

@@ -3,13 +3,21 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Mail\QuoteMail;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmQuoteItem;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Services\Accounting\ArService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
class QuoteController extends Controller
{
@@ -36,7 +44,7 @@ class QuoteController extends Controller
$quotes = $query->orderByDesc('created_at')->paginate(25);
return view('seller.crm.quotes.index', compact('quotes'));
return view('seller.crm.quotes.index', compact('quotes', 'business'));
}
/**
@@ -44,21 +52,24 @@ class QuoteController extends Controller
*/
public function create(Request $request, Business $business)
{
$contacts = Contact::where('business_id', $business->id)->get();
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})->get();
$deals = CrmDeal::forBusiness($business->id)->open()->get();
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->where('is_active', true)
// Get buyer businesses that have contacts (potential and existing customers)
// Contacts are loaded dynamically via /search/contacts?customer_id={account_id}
// Include locations for delivery address selection
$accounts = Business::where('type', 'buyer')
->whereHas('contacts')
->with('locations:id,business_id,name,is_primary')
->orderBy('name')
->select(['id', 'name', 'slug'])
->get();
$deals = CrmDeal::forBusiness($business->id)->open()->get();
// Products are loaded via AJAX search (/search/products) for better performance
// Pre-fill from deal if provided
$deal = $request->filled('deal_id')
? CrmDeal::forBusiness($business->id)->find($request->deal_id)
: null;
return view('seller.crm.quotes.create', compact('contacts', 'accounts', 'deals', 'products', 'deal'));
return view('seller.crm.quotes.create', compact('accounts', 'deals', 'deal', 'business'));
}
/**
@@ -86,10 +97,13 @@ class QuoteController extends Controller
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
]);
// SECURITY: Verify contact belongs to business
Contact::where('id', $validated['contact_id'])
->where('business_id', $business->id)
->firstOrFail();
// SECURITY: Verify contact belongs to the selected account (customer business)
// Contacts are associated with buyer businesses, not the seller
if (! empty($validated['account_id'])) {
Contact::where('id', $validated['contact_id'])
->where('business_id', $validated['account_id'])
->firstOrFail();
}
// SECURITY: Verify deal belongs to business if provided
if (! empty($validated['deal_id'])) {
@@ -135,7 +149,7 @@ class QuoteController extends Controller
$quote->calculateTotals();
return redirect()->route('seller.crm.quotes.show', $quote)
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
->with('success', 'Quote created successfully.');
}
@@ -150,7 +164,7 @@ class QuoteController extends Controller
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product', 'invoice', 'files']);
return view('seller.crm.quotes.show', compact('quote'));
return view('seller.crm.quotes.show', compact('quote', 'business'));
}
/**
@@ -175,7 +189,7 @@ class QuoteController extends Controller
->where('is_active', true)
->get();
return view('seller.crm.quotes.edit', compact('quote', 'contacts', 'accounts', 'deals', 'products'));
return view('seller.crm.quotes.edit', compact('quote', 'contacts', 'accounts', 'deals', 'products', 'business'));
}
/**
@@ -234,12 +248,12 @@ class QuoteController extends Controller
$quote->calculateTotals();
return redirect()->route('seller.crm.quotes.show', $quote)
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
->with('success', 'Quote updated successfully.');
}
/**
* Send quote to contact
* Send quote via email
*/
public function send(Request $request, Business $business, CrmQuote $quote)
{
@@ -247,21 +261,256 @@ class QuoteController extends Controller
abort(404);
}
if (! $quote->canBeSent()) {
return back()->withErrors(['error' => 'This quote cannot be sent.']);
$validated = $request->validate([
'to' => 'required|email',
'cc' => 'nullable|string',
'message' => 'nullable|string|max:2000',
'attach_pdf' => 'boolean',
]);
// Generate PDF if needed
$pdfPath = null;
if ($validated['attach_pdf'] ?? true) {
$pdfPath = $this->generateQuotePdf($quote, $business);
}
$quote->send($request->user());
// Send email
$ccEmails = [];
if (! empty($validated['cc'])) {
$ccEmails = array_map('trim', explode(',', $validated['cc']));
}
// TODO: Send email notification to contact
Mail::to($validated['to'])
->cc($ccEmails)
->send(new QuoteMail($quote, $business, $validated['message'] ?? null, $pdfPath));
// Update quote status if draft
if ($quote->status === CrmQuote::STATUS_DRAFT) {
$quote->send($request->user());
}
// Log activity
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.emailed',
description: "Quote {$quote->quote_number} emailed to {$validated['to']}",
causer: $request->user(),
contactId: $quote->contact_id,
);
return back()->with('success', 'Quote sent successfully.');
}
/**
* Update quote status (accept/decline/expire)
*/
public function updateStatus(Request $request, Business $business, CrmQuote $quote)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'status' => 'required|in:accepted,rejected,expired',
'note' => 'nullable|string|max:1000',
]);
$oldStatus = $quote->status;
if ($validated['status'] === 'accepted') {
$quote->accept();
} elseif ($validated['status'] === 'rejected') {
$quote->reject($validated['note'] ?? 'Declined by seller');
} else {
$quote->update([
'status' => CrmQuote::STATUS_EXPIRED,
]);
}
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.status_changed',
description: "Quote {$quote->quote_number} status changed from {$oldStatus} to {$validated['status']}",
causer: $request->user(),
contactId: $quote->contact_id,
);
return back()->with('success', 'Quote status updated.');
}
/**
* Convert quote to order
*/
public function convertToOrder(Request $request, Business $business, CrmQuote $quote)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
if ($quote->order_id) {
return back()->withErrors(['error' => 'This quote already has an order.']);
}
$validated = $request->validate([
'also_create_invoice' => 'boolean',
]);
// Create order from quote
$orderNumber = 'ORD-'.strtoupper(uniqid());
$order = Order::create([
'order_number' => $orderNumber,
'business_id' => $quote->account_id, // Buyer business
'seller_business_id' => $business->id,
'contact_id' => $quote->contact_id,
'user_id' => $request->user()->id,
'subtotal' => $quote->subtotal,
'surcharge' => 0,
'tax' => $quote->tax_amount,
'total' => $quote->total,
'status' => 'new',
'created_by' => 'seller',
'payment_terms' => 'net_30',
'notes' => $quote->notes,
]);
// Copy line items
foreach ($quote->items as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
'line_total' => $item->line_total,
'product_name' => $item->product?->name ?? $item->description,
'product_sku' => $item->product?->sku ?? '',
'brand_name' => $item->product?->brand?->name ?? '',
]);
}
// Link quote to order and update status
$quote->update([
'order_id' => $order->id,
'status' => CrmQuote::STATUS_ACCEPTED,
'accepted_at' => now(),
]);
// Log activity
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.converted_to_order',
description: "Quote {$quote->quote_number} converted to Order {$orderNumber}",
causer: $request->user(),
contactId: $quote->contact_id,
);
// Optionally create invoice
if ($validated['also_create_invoice'] ?? false) {
$invoice = $quote->convertToInvoice();
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Order and invoice created from quote.');
}
return redirect()->route('seller.business.orders.show', [$business, $order])
->with('success', 'Order created from quote.');
}
/**
* Generate invoice from quote (or its order)
*/
public function generateInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
if ($quote->invoice) {
return back()->withErrors(['error' => 'This quote already has an invoice.']);
}
// Credit check if there's a buyer account
if ($quote->account_id) {
$buyerBusiness = Business::find($quote->account_id);
if ($buyerBusiness) {
$creditCheck = $arService->checkCreditForAccount(
$business,
$buyerBusiness,
(float) $quote->total
);
if (! $creditCheck['can_extend']) {
return back()->withErrors([
'error' => 'Cannot create invoice: '.$creditCheck['reason'],
]);
}
}
}
$invoice = $quote->convertToInvoice();
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.invoice_generated',
description: "Invoice {$invoice->invoice_number} generated from Quote {$quote->quote_number}",
causer: $request->user(),
contactId: $quote->contact_id,
);
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created from quote.');
}
/**
* Generate and store quote PDF
*/
protected function generateQuotePdf(CrmQuote $quote, Business $business): ?string
{
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
$pdf = Pdf::loadView('pdfs.crm-quote', [
'quote' => $quote,
'business' => $business,
'sellerBusiness' => $business,
]);
$filename = "quotes/{$quote->quote_number}.pdf";
Storage::put($filename, $pdf->output());
$quote->update(['pdf_path' => $filename]);
return $filename;
}
/**
* View quote PDF
*/
public function pdf(Request $request, Business $business, CrmQuote $quote)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
$pdf = Pdf::loadView('pdfs.crm-quote', [
'quote' => $quote,
'business' => $business,
'sellerBusiness' => $business,
]);
return $pdf->inline("{$quote->quote_number}.pdf");
}
/**
* Convert quote to invoice
*/
public function convertToInvoice(Request $request, Business $business, CrmQuote $quote)
public function convertToInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
{
if ($quote->business_id !== $business->id) {
abort(404);
@@ -275,9 +524,33 @@ class QuoteController extends Controller
return back()->withErrors(['error' => 'This quote already has an invoice.']);
}
// Credit check enforcement - only if there's an account (buyer business)
if ($quote->account_id) {
$buyerBusiness = Business::find($quote->account_id);
if ($buyerBusiness) {
$creditCheck = $arService->checkCreditForAccount(
$business,
$buyerBusiness,
(float) $quote->total
);
if (! $creditCheck['can_extend']) {
return back()->withErrors([
'error' => 'Cannot create invoice: '.$creditCheck['reason'],
]);
}
// Store warning in session if present
if (! empty($creditCheck['details']['warning'])) {
session()->flash('warning', $creditCheck['details']['warning']);
}
}
}
$invoice = $quote->convertToInvoice();
return redirect()->route('seller.crm.invoices.show', $invoice)
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created from quote.');
}
@@ -305,7 +578,7 @@ class QuoteController extends Controller
$quote->delete();
return redirect()->route('seller.crm.quotes.index')
return redirect()->route('seller.business.crm.quotes.index', $business)
->with('success', 'Quote deleted.');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmTask;
use App\Models\User;
use Illuminate\Http\Request;
class TaskController extends Controller
@@ -41,25 +42,32 @@ class TaskController extends Controller
$tasks = $tasksQuery->paginate(25);
// Get stats
// Get stats with single efficient query
$statsQuery = CrmTask::where('seller_business_id', $business->id)
->selectRaw('
SUM(CASE WHEN assigned_to = ? AND completed_at IS NULL THEN 1 ELSE 0 END) as my_tasks,
SUM(CASE WHEN completed_at IS NULL AND due_at < NOW() THEN 1 ELSE 0 END) as overdue,
SUM(CASE WHEN completed_at IS NULL AND DATE(due_at) = CURRENT_DATE THEN 1 ELSE 0 END) as due_today
', [$user->id])
->first();
$stats = [
'my_tasks' => CrmTask::where('seller_business_id', $business->id)
->where('assigned_to', $user->id)
->whereNull('completed_at')
->count(),
'overdue' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '<', now())
->count(),
'due_today' => CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->whereDate('due_at', today())
->count(),
'my_tasks' => $statsQuery->my_tasks ?? 0,
'overdue' => $statsQuery->overdue ?? 0,
'due_today' => $statsQuery->due_today ?? 0,
];
$counts = $stats; // View expects $counts
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts'));
// Get team members for assignment filter
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
// Get buyer businesses (accounts) for filtering
$buyerBusinesses = Business::where('type', 'buyer')
->orderBy('name')
->get(['id', 'name']);
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers', 'buyerBusinesses'));
}
/**

View File

@@ -22,13 +22,113 @@ class ThreadController extends Controller
protected CrmAiService $aiService
) {}
/**
* Show compose form for new thread
*/
public function create(Request $request, Business $business)
{
// Get customer business IDs (businesses that have ordered from this seller)
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
// Get contacts from customer businesses (accounts)
$contacts = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
->with('business:id,name')
->orderBy('first_name')
->limit(200)
->get();
// Get available channels
$channels = $this->channelService->getAvailableChannels($business->id);
// Pre-select contact if provided
$selectedContact = null;
if ($request->filled('contact_id')) {
$selectedContact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
->find($request->contact_id);
}
return view('seller.crm.threads.create', compact('business', 'contacts', 'channels', 'selectedContact'));
}
/**
* Store a new thread and send initial message
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'contact_id' => 'required|exists:contacts,id',
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
'subject' => 'nullable|string|max:255',
'body' => 'required|string|max:10000',
'attachments.*' => 'nullable|file|max:10240',
]);
// Get customer business IDs (businesses that have ordered from this seller)
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
// SECURITY: Verify contact belongs to a customer business
$contact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
->findOrFail($validated['contact_id']);
// Determine recipient address
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
? $contact->email
: $contact->phone;
if (! $to) {
return back()->withInput()->withErrors([
'channel_type' => 'Contact does not have the required contact info for this channel.',
]);
}
// Create thread first
$thread = CrmThread::create([
'business_id' => $business->id,
'contact_id' => $contact->id,
'account_id' => $contact->account_id,
'subject' => $validated['subject'],
'status' => 'open',
'priority' => 'normal',
'last_channel_type' => $validated['channel_type'],
'assigned_to' => $request->user()->id,
]);
// Send the message
$success = $this->channelService->sendMessage(
businessId: $business->id,
channelType: $validated['channel_type'],
to: $to,
body: $validated['body'],
subject: $validated['subject'] ?? null,
threadId: $thread->id,
contactId: $contact->id,
userId: $request->user()->id,
attachments: $request->file('attachments', [])
);
if (! $success) {
// Delete the thread if message failed
$thread->delete();
return back()->withInput()->withErrors(['body' => 'Failed to send message.']);
}
return redirect()
->route('seller.business.crm.threads.show', [$business, $thread])
->with('success', 'Conversation started successfully.');
}
/**
* Display unified inbox
*/
public function index(Request $request, Business $business)
{
$query = CrmThread::forBusiness($business->id)
->with(['contact', 'assignee', 'messages' => fn ($q) => $q->latest()->limit(1)])
->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)])
->withCount('messages');
// Filters
@@ -52,6 +152,16 @@ class ThreadController extends Controller
$query->withPriority($request->priority);
}
// Department filter
if ($request->filled('department')) {
$query->forDepartment($request->department);
}
// Brand filter
if ($request->filled('brand_id')) {
$query->forBrand($request->brand_id);
}
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('subject', 'like', "%{$request->search}%")
@@ -70,7 +180,16 @@ class ThreadController extends Controller
// Get available channels
$channels = $this->channelService->getAvailableChannels($business->id);
return view('seller.crm.threads.index', compact('threads', 'teamMembers', 'channels'));
// Get brands for filter dropdown
$brands = \App\Models\Brand::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
// Get departments for filter dropdown
$departments = CrmChannel::DEPARTMENTS;
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels', 'brands', 'departments'));
}
/**
@@ -88,6 +207,8 @@ class ThreadController extends Controller
'contact',
'account',
'assignee',
'brand',
'channel',
'messages.attachments',
'messages.user',
'deals',
@@ -117,6 +238,7 @@ class ThreadController extends Controller
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
return view('seller.crm.threads.show', compact(
'business',
'thread',
'otherViewers',
'slaStatus',
@@ -167,6 +289,12 @@ class ThreadController extends Controller
return back()->withErrors(['body' => 'Failed to send message.']);
}
// Auto-assign thread to sender if unassigned
if ($thread->assigned_to === null) {
$thread->assigned_to = $request->user()->id;
$thread->save();
}
// Handle SLA
$this->slaService->handleOutboundMessage($thread);

View File

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

View File

@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Accounting\Expense;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Models\Department;
use App\Services\Accounting\ExpenseService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Staff/Child Business Expense Controller.
*
* Handles expense creation and submission by employees.
* Approval and payment are handled by Management controller.
*/
class ExpensesController extends Controller
{
public function __construct(
protected ExpenseService $expenseService
) {}
/**
* List expenses for the current business.
*
* GET /s/{business}/expenses
*/
public function index(Request $request, Business $business): View
{
$user = auth()->user();
$query = Expense::where('business_id', $business->id)
->with(['department', 'createdBy', 'items']);
// Non-admins only see their own expenses
if (! $this->canViewAllExpenses($user, $business)) {
$query->where('created_by_user_id', $user->id);
}
// Status filter
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Department filter
if ($request->filled('department_id')) {
$query->where('department_id', $request->department_id);
}
$expenses = $query->orderByDesc('expense_date')->paginate(20)->withQueryString();
// Get departments for filter
$departments = Department::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
// Stats for current user
$myStats = [
'draft' => Expense::where('business_id', $business->id)
->where('created_by_user_id', $user->id)
->status(Expense::STATUS_DRAFT)
->count(),
'submitted' => Expense::where('business_id', $business->id)
->where('created_by_user_id', $user->id)
->status(Expense::STATUS_SUBMITTED)
->count(),
'approved' => Expense::where('business_id', $business->id)
->where('created_by_user_id', $user->id)
->status(Expense::STATUS_APPROVED)
->count(),
'total_pending' => Expense::where('business_id', $business->id)
->where('created_by_user_id', $user->id)
->whereIn('status', [Expense::STATUS_SUBMITTED, Expense::STATUS_APPROVED])
->sum('total_amount'),
];
return view('seller.expenses.index', compact(
'business',
'expenses',
'departments',
'myStats'
));
}
/**
* Show create expense form.
*
* GET /s/{business}/expenses/create
*/
public function create(Request $request, Business $business): View
{
$departments = Department::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->where('account_type', 'expense')
->orderBy('account_number')
->get();
$paymentMethods = Expense::getPaymentMethods();
return view('seller.expenses.create', compact(
'business',
'departments',
'glAccounts',
'paymentMethods'
));
}
/**
* Store a new expense.
*
* POST /s/{business}/expenses
*/
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'expense_date' => 'required|date',
'department_id' => 'nullable|integer|exists:departments,id',
'payment_method' => 'required|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
'reference' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:255',
'items.*.amount' => 'required|numeric|min:0.01',
'items.*.gl_expense_account_id' => 'required|integer|exists:gl_accounts,id',
'items.*.department_id' => 'nullable|integer|exists:departments,id',
'items.*.tax_amount' => 'nullable|numeric|min:0',
'submit' => 'nullable|boolean',
]);
$user = auth()->user();
$items = $validated['items'];
unset($validated['items'], $validated['submit']);
// Set default status
$validated['status'] = $request->boolean('submit')
? Expense::STATUS_SUBMITTED
: Expense::STATUS_DRAFT;
$expense = $this->expenseService->createExpense($business, $user, $validated, $items);
$message = $expense->isSubmitted()
? "Expense {$expense->expense_number} submitted for approval."
: "Expense {$expense->expense_number} saved as draft.";
return redirect()
->route('seller.business.expenses.show', [$business, $expense])
->with('success', $message);
}
/**
* Show expense details.
*
* GET /s/{business}/expenses/{expense}
*/
public function show(Request $request, Business $business, Expense $expense): View
{
$this->authorizeExpenseAccess($expense, $business);
$expense->load(['items.glAccount', 'items.department', 'department', 'createdBy', 'approvedBy', 'paidBy']);
return view('seller.expenses.show', compact('business', 'expense'));
}
/**
* Show edit expense form (draft only).
*
* GET /s/{business}/expenses/{expense}/edit
*/
public function edit(Request $request, Business $business, Expense $expense): View
{
$this->authorizeExpenseAccess($expense, $business);
if (! $expense->canEdit()) {
abort(403, 'Only draft expenses can be edited.');
}
$expense->load(['items']);
$departments = Department::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->where('account_type', 'expense')
->orderBy('account_number')
->get();
$paymentMethods = Expense::getPaymentMethods();
return view('seller.expenses.edit', compact(
'business',
'expense',
'departments',
'glAccounts',
'paymentMethods'
));
}
/**
* Update an expense (draft only).
*
* PUT /s/{business}/expenses/{expense}
*/
public function update(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->authorizeExpenseAccess($expense, $business);
if (! $expense->canEdit()) {
return back()->with('error', 'Only draft expenses can be edited.');
}
$validated = $request->validate([
'expense_date' => 'required|date',
'department_id' => 'nullable|integer|exists:departments,id',
'payment_method' => 'required|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
'reference' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:255',
'items.*.amount' => 'required|numeric|min:0.01',
'items.*.gl_expense_account_id' => 'required|integer|exists:gl_accounts,id',
'items.*.department_id' => 'nullable|integer|exists:departments,id',
'items.*.tax_amount' => 'nullable|numeric|min:0',
'submit' => 'nullable|boolean',
]);
$items = $validated['items'];
unset($validated['items'], $validated['submit']);
// Update status if submitting
if ($request->boolean('submit')) {
$validated['status'] = Expense::STATUS_SUBMITTED;
}
$expense = $this->expenseService->updateExpense($expense, $validated, $items);
$message = $expense->isSubmitted()
? "Expense {$expense->expense_number} submitted for approval."
: "Expense {$expense->expense_number} updated.";
return redirect()
->route('seller.business.expenses.show', [$business, $expense])
->with('success', $message);
}
/**
* Submit an expense for approval.
*
* POST /s/{business}/expenses/{expense}/submit
*/
public function submit(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->authorizeExpenseAccess($expense, $business);
try {
$this->expenseService->submitExpense($expense, auth()->user());
return back()->with('success', "Expense {$expense->expense_number} submitted for approval.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Delete a draft expense.
*
* DELETE /s/{business}/expenses/{expense}
*/
public function destroy(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->authorizeExpenseAccess($expense, $business);
try {
$this->expenseService->deleteExpense($expense);
return redirect()
->route('seller.business.expenses.index', $business)
->with('success', 'Expense deleted.');
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Check if user can view all expenses (not just their own).
*/
protected function canViewAllExpenses($user, Business $business): bool
{
// Business owners and admins can view all
$pivot = $user->businesses()
->where('businesses.id', $business->id)
->first()
?->pivot;
if ($pivot && in_array($pivot->role ?? $pivot->contact_type ?? '', ['owner', 'primary', 'admin'])) {
return true;
}
return $user->user_type === 'admin';
}
/**
* Authorize access to a specific expense.
*/
protected function authorizeExpenseAccess(Expense $expense, Business $business): void
{
// Must belong to this business
if ($expense->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$user = auth()->user();
// Must be creator or have view-all permission
if ($expense->created_by_user_id !== $user->id && ! $this->canViewAllExpenses($user, $business)) {
abort(403, 'Access denied.');
}
}
}

View File

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

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Services\Accounting\AccountingReportingService;
use App\Services\Accounting\ReportExportService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AccountingController extends Controller
{
public function __construct(
protected AccountingReportingService $reportingService,
protected ReportExportService $exportService
) {}
/**
* General Ledger Account Detail.
*
* GET /s/{business}/management/accounting/gl
*/
public function gl(Request $request, Business $business)
{
$fromDate = $request->get('from_date', now()->startOfMonth()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$accountId = $request->get('account_id');
$accounts = $this->reportingService->getAccountsForSelect($business);
$isParent = $this->reportingService->isParentCompany($business);
$ledgerData = null;
if ($accountId) {
$ledgerData = $this->reportingService->getGeneralLedger(
$business,
(int) $accountId,
$fromDate,
$toDate
);
}
return view('seller.management.accounting.gl', compact(
'business',
'accounts',
'ledgerData',
'fromDate',
'toDate',
'accountId',
'isParent'
));
}
/**
* Journal Entry Browser.
*
* GET /s/{business}/management/accounting/journals
*/
public function journals(Request $request, Business $business)
{
$filters = [
'from_date' => $request->get('from_date', now()->startOfMonth()->format('Y-m-d')),
'to_date' => $request->get('to_date', now()->format('Y-m-d')),
'source_type' => $request->get('source_type'),
'status' => $request->get('status'),
'division_id' => $request->get('division_id'),
'include_children' => true,
];
$entries = $this->reportingService->getJournalEntries($business, $filters);
$isParent = $this->reportingService->isParentCompany($business);
$divisions = $isParent ? $this->reportingService->getDivisions($business) : collect();
$sourceTypes = [
'manual' => 'Manual Entry',
'ap_bill' => 'AP Bill',
'ap_payment' => 'AP Payment',
'inter_company' => 'Inter-Company',
];
$statuses = [
'draft' => 'Draft',
'posted' => 'Posted',
'reversed' => 'Reversed',
];
return view('seller.management.accounting.journals', compact(
'business',
'entries',
'filters',
'isParent',
'divisions',
'sourceTypes',
'statuses'
));
}
/**
* Trial Balance Report.
*
* GET /s/{business}/management/accounting/trial-balance
*/
public function trialBalance(Request $request, Business $business)
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$filters = [
'include_children' => $isParent && $includeChildren,
];
$trialBalance = $this->reportingService->getTrialBalance(
$business,
$fromDate,
$toDate,
$filters
);
// Calculate totals
$totals = [
'debits' => $trialBalance->sum('debits'),
'credits' => $trialBalance->sum('credits'),
'net_debit' => $trialBalance->where('closing_balance', '>', 0)->sum('closing_balance'),
'net_credit' => abs($trialBalance->where('closing_balance', '<', 0)->sum('closing_balance')),
];
return view('seller.management.accounting.trial-balance', compact(
'business',
'trialBalance',
'totals',
'fromDate',
'toDate',
'includeChildren',
'isParent'
));
}
/**
* Export Trial Balance as CSV.
*
* GET /s/{business}/management/accounting/trial-balance/export
*/
public function exportTrialBalance(Request $request, Business $business): StreamedResponse
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$filters = [
'include_children' => $isParent && $includeChildren,
];
$trialBalance = $this->reportingService->getTrialBalance(
$business,
$fromDate,
$toDate,
$filters
);
$filename = 'trial_balance_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
return $this->exportService->exportTrialBalance($trialBalance, $filename);
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\AccountingPeriod;
use App\Models\Business;
use App\Services\Accounting\PeriodLockService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AccountingPeriodsController extends Controller
{
public function __construct(
protected PeriodLockService $periodLockService
) {}
/**
* Display accounting periods.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$year = $request->input('year', now()->year);
$periods = $this->periodLockService->getPeriodsForBusiness($business, (int) $year);
// Get available years
$yearsWithPeriods = AccountingPeriod::forBusiness($business->id)
->selectRaw('EXTRACT(YEAR FROM period_start) as year')
->distinct()
->pluck('year')
->map(fn ($y) => (int) $y)
->sort()
->values();
// Always include current and next year
$availableYears = $yearsWithPeriods
->push(now()->year)
->push(now()->year + 1)
->unique()
->sort()
->values();
$canClosePeriods = $this->periodLockService->userHasPermission($business, $request->user(), 'can_close_periods');
$canReopenPeriods = $this->periodLockService->userHasPermission($business, $request->user(), 'can_reopen_periods');
return view('seller.management.accounting.periods.index', [
'business' => $business,
'periods' => $periods,
'year' => (int) $year,
'availableYears' => $availableYears,
'canClosePeriods' => $canClosePeriods,
'canReopenPeriods' => $canReopenPeriods,
]);
}
/**
* Generate periods for a year.
*/
public function generate(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_close_periods');
$validated = $request->validate([
'year' => 'required|integer|min:2000|max:2100',
]);
$periods = $this->periodLockService->ensurePeriodsExist($business, (int) $validated['year']);
return back()->with('success', 'Generated '.count($periods).' periods for '.$validated['year'].'.');
}
/**
* Close a period.
*/
public function close(Request $request, Business $business, AccountingPeriod $period): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_close_periods');
// Ensure period belongs to this business
if ($period->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'status' => 'required|in:soft_closed,hard_closed',
'notes' => 'nullable|string|max:1000',
]);
$this->periodLockService->closePeriod(
$period,
$validated['status'],
$request->user(),
$validated['notes'] ?? null
);
$statusLabel = $validated['status'] === 'soft_closed' ? 'soft closed' : 'hard closed';
return back()->with('success', "Period {$period->period_label} has been {$statusLabel}.");
}
/**
* Reopen a period.
*/
public function reopen(Request $request, Business $business, AccountingPeriod $period): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_reopen_periods');
// Ensure period belongs to this business
if ($period->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'notes' => 'nullable|string|max:1000',
]);
$this->periodLockService->reopenPeriod(
$period,
$request->user(),
$validated['notes'] ?? null
);
return back()->with('success', "Period {$period->period_label} has been reopened.");
}
/**
* Require Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Require a specific finance permission.
*/
private function requirePermission(Business $business, $user, string $permission): void
{
// Business owners always have access
if ($business->owner_user_id === $user->id) {
return;
}
// Check bypass mode
if (config('finance_roles.bypass_permissions', false)) {
return;
}
if (! $this->periodLockService->userHasPermission($business, $user, $permission)) {
abort(403, 'You do not have permission for this action.');
}
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ArInvoice;
use App\Models\Accounting\Expense;
use App\Models\Accounting\RecurringTransaction;
use App\Models\Business;
use App\Services\Accounting\BillService;
use App\Services\Accounting\ExpenseService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Action Center - Centralized hub for pending approvals and exceptions.
*
* Management Suite only - provides quick access to items needing attention:
* - Bills pending approval
* - Expenses pending approval
* - Recurring drafts needing review
* - AR exceptions (credit limits, holds, past due)
* - Budget exceptions
*/
class ActionCenterController extends Controller
{
public function __construct(
protected BillService $billService,
protected ExpenseService $expenseService
) {}
/**
* Display the Action Center dashboard.
*/
public function index(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
// 1. Bills Pending Approval
$pendingBills = ApBill::whereIn('business_id', $allBusinessIds)
->where('status', ApBill::STATUS_PENDING)
->with(['vendor', 'business'])
->orderBy('due_date')
->limit(20)
->get();
$pendingBillsCount = ApBill::whereIn('business_id', $allBusinessIds)
->where('status', ApBill::STATUS_PENDING)
->count();
// 2. Expenses Pending Approval
$pendingExpenses = Expense::whereIn('business_id', $allBusinessIds)
->where('status', Expense::STATUS_SUBMITTED)
->with(['user', 'business', 'glAccount'])
->orderBy('expense_date')
->limit(20)
->get();
$pendingExpensesCount = Expense::whereIn('business_id', $allBusinessIds)
->where('status', Expense::STATUS_SUBMITTED)
->count();
// 3. Recurring Drafts Needing Review
$recurringDrafts = collect();
$recurringDraftsCount = 0;
if (class_exists(RecurringTransaction::class)) {
$recurringDrafts = RecurringTransaction::whereIn('business_id', $allBusinessIds)
->where('status', 'draft')
->with('business')
->limit(20)
->get();
$recurringDraftsCount = RecurringTransaction::whereIn('business_id', $allBusinessIds)
->where('status', 'draft')
->count();
}
// 4. AR Exceptions
$arExceptions = $this->getArExceptions($allBusinessIds);
// 5. Budget Exceptions (placeholder - will expand when budget variance tracking exists)
$budgetExceptions = $this->getBudgetExceptions($parentBusiness);
// Summary counts
$totalActionItems = $pendingBillsCount + $pendingExpensesCount + $recurringDraftsCount
+ $arExceptions['count'] + $budgetExceptions['count'];
return view('seller.management.action-center.index', compact(
'business',
'parentBusiness',
'pendingBills',
'pendingBillsCount',
'pendingExpenses',
'pendingExpensesCount',
'recurringDrafts',
'recurringDraftsCount',
'arExceptions',
'budgetExceptions',
'totalActionItems'
));
}
/**
* Bulk approve pending bills.
*/
public function bulkApproveBills(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'bill_ids' => 'required|array',
'bill_ids.*' => 'exists:ap_bills,id',
]);
$approved = 0;
$errors = [];
foreach ($validated['bill_ids'] as $billId) {
try {
$bill = ApBill::findOrFail($billId);
$this->billService->approveBill($bill, auth()->id());
$approved++;
} catch (\Exception $e) {
$errors[] = "Bill #{$billId}: {$e->getMessage()}";
}
}
$message = "{$approved} bill(s) approved successfully.";
if (! empty($errors)) {
$message .= ' Errors: '.implode(', ', $errors);
}
return back()->with($errors ? 'warning' : 'success', $message);
}
/**
* Bulk approve pending expenses.
*/
public function bulkApproveExpenses(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'expense_ids' => 'required|array',
'expense_ids.*' => 'exists:expenses,id',
]);
$approved = 0;
$errors = [];
foreach ($validated['expense_ids'] as $expenseId) {
try {
$expense = Expense::findOrFail($expenseId);
$this->expenseService->approve($expense, auth()->id());
$approved++;
} catch (\Exception $e) {
$errors[] = "Expense #{$expenseId}: {$e->getMessage()}";
}
}
$message = "{$approved} expense(s) approved successfully.";
if (! empty($errors)) {
$message .= ' Errors: '.implode(', ', $errors);
}
return back()->with($errors ? 'warning' : 'success', $message);
}
/**
* Bulk reject pending expenses.
*/
public function bulkRejectExpenses(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'expense_ids' => 'required|array',
'expense_ids.*' => 'exists:expenses,id',
'rejection_reason' => 'required|string|max:500',
]);
$rejected = 0;
foreach ($validated['expense_ids'] as $expenseId) {
$expense = Expense::findOrFail($expenseId);
$this->expenseService->reject($expense, auth()->id(), $validated['rejection_reason']);
$rejected++;
}
return back()->with('success', "{$rejected} expense(s) rejected.");
}
/**
* Get AR exceptions (credit limits, holds, past due).
*/
protected function getArExceptions(array $businessIds): array
{
$exceptions = [
'over_credit_limit' => collect(),
'credit_hold' => collect(),
'past_due_60' => collect(),
'past_due_90' => collect(),
'count' => 0,
];
// Past due > 60 days
$pastDue60 = ArInvoice::whereIn('business_id', $businessIds)
->where('status', ArInvoice::STATUS_OVERDUE)
->where('due_date', '<', now()->subDays(60))
->where('balance_due', '>', 0)
->with(['customer', 'business'])
->get();
$exceptions['past_due_60'] = $pastDue60->filter(fn ($inv) => $inv->due_date >= now()->subDays(90));
// Past due > 90 days
$exceptions['past_due_90'] = ArInvoice::whereIn('business_id', $businessIds)
->where('status', ArInvoice::STATUS_OVERDUE)
->where('due_date', '<', now()->subDays(90))
->where('balance_due', '>', 0)
->with(['customer', 'business'])
->get();
$exceptions['count'] = $exceptions['past_due_60']->count() + $exceptions['past_due_90']->count();
return $exceptions;
}
/**
* Get budget exceptions (over budget items).
*/
protected function getBudgetExceptions(Business $business): array
{
// Placeholder - will expand when budget variance tracking is implemented
return [
'items' => collect(),
'count' => 0,
];
}
}

View File

@@ -0,0 +1,508 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ApPayment;
use App\Models\Accounting\ArInvoice;
use App\Models\Accounting\Expense;
use App\Models\Accounting\JournalEntry;
use App\Models\Accounting\JournalEntryLine;
use App\Models\Business;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* Advanced Analytics - Deep dive dashboards for financial analysis.
*
* Provides:
* - AR Analytics (aging, DSO, collection rate)
* - AP Analytics (payment timing, vendor analysis)
* - Cash Analytics (position, forecast, runway)
* - Expense Analytics (category breakdown, trends)
*/
class AdvancedAnalyticsController extends Controller
{
/**
* AR Analytics Dashboard.
*/
public function arAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
$endDate = Carbon::parse($request->get('end_date', now()));
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
// Aging buckets
$aging = $this->calculateArAging($allBusinessIds);
// DSO (Days Sales Outstanding)
$dso = $this->calculateDSO($allBusinessIds, $startDate, $endDate);
// Collection rate (last 12 months)
$collectionRate = $this->calculateCollectionRate($allBusinessIds, $startDate, $endDate);
// Monthly AR trend
$monthlyTrend = $this->getArMonthlyTrend($allBusinessIds, 12);
// Top customers by AR balance
$topCustomers = ArInvoice::whereIn('business_id', $allBusinessIds)
->where('balance_due', '>', 0)
->selectRaw('customer_id, SUM(balance_due) as total_balance')
->groupBy('customer_id')
->with('customer')
->orderByDesc('total_balance')
->limit(10)
->get();
return view('seller.management.analytics.ar', compact(
'business',
'parentBusiness',
'aging',
'dso',
'collectionRate',
'monthlyTrend',
'topCustomers',
'startDate',
'endDate'
));
}
/**
* AP Analytics Dashboard.
*/
public function apAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
$endDate = Carbon::parse($request->get('end_date', now()));
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
// Aging buckets
$aging = $this->calculateApAging($allBusinessIds);
// DPO (Days Payable Outstanding)
$dpo = $this->calculateDPO($allBusinessIds, $startDate, $endDate);
// Payment timing analysis
$paymentTiming = $this->analyzePaymentTiming($allBusinessIds, $startDate, $endDate);
// Top vendors by AP balance
$topVendors = ApBill::whereIn('business_id', $allBusinessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->selectRaw('vendor_id, SUM(total - paid_amount) as total_balance')
->groupBy('vendor_id')
->with('vendor')
->orderByDesc('total_balance')
->limit(10)
->get();
// Monthly AP trend
$monthlyTrend = $this->getApMonthlyTrend($allBusinessIds, 12);
return view('seller.management.analytics.ap', compact(
'business',
'parentBusiness',
'aging',
'dpo',
'paymentTiming',
'topVendors',
'monthlyTrend',
'startDate',
'endDate'
));
}
/**
* Cash Analytics Dashboard.
*/
public function cashAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
// Current cash position from GL
$cashPosition = $this->calculateCashPosition($parentBusiness);
// Cash flow by month (last 12 months)
$monthlyCashFlow = $this->getMonthlyCashFlow($parentBusiness, 12);
// Expected collections (upcoming AR)
$expectedCollections = ArInvoice::whereIn('business_id', $allBusinessIds)
->where('balance_due', '>', 0)
->where('due_date', '>=', now())
->where('due_date', '<=', now()->addDays(90))
->selectRaw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END as period,
SUM(balance_due) as total
")
->groupBy(DB::raw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END
"))
->get()
->pluck('total', 'period');
// Expected payments (upcoming AP)
$expectedPayments = ApBill::whereIn('business_id', $allBusinessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->where('due_date', '>=', now())
->where('due_date', '<=', now()->addDays(90))
->selectRaw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END as period,
SUM(total - paid_amount) as total
")
->groupBy(DB::raw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END
"))
->get()
->pluck('total', 'period');
// Cash runway (months of runway based on avg monthly expenses)
$avgMonthlyExpenses = $this->getAverageMonthlyExpenses($allBusinessIds, 6);
$cashRunway = $avgMonthlyExpenses > 0 ? round($cashPosition / $avgMonthlyExpenses, 1) : null;
return view('seller.management.analytics.cash', compact(
'business',
'parentBusiness',
'cashPosition',
'monthlyCashFlow',
'expectedCollections',
'expectedPayments',
'avgMonthlyExpenses',
'cashRunway'
));
}
/**
* Expense Analytics Dashboard.
*/
public function expenseAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
$endDate = Carbon::parse($request->get('end_date', now()));
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
// Expenses by category
$byCategory = Expense::whereIn('business_id', $allBusinessIds)
->whereBetween('expense_date', [$startDate, $endDate])
->where('status', Expense::STATUS_APPROVED)
->selectRaw('gl_account_id, SUM(amount) as total')
->groupBy('gl_account_id')
->with('glAccount')
->orderByDesc('total')
->get();
// Expenses by division
$byDivision = Expense::whereIn('business_id', $allBusinessIds)
->whereBetween('expense_date', [$startDate, $endDate])
->where('status', Expense::STATUS_APPROVED)
->selectRaw('business_id, SUM(amount) as total')
->groupBy('business_id')
->with('business')
->orderByDesc('total')
->get();
// Monthly expense trend
$monthlyTrend = Expense::whereIn('business_id', $allBusinessIds)
->where('status', Expense::STATUS_APPROVED)
->where('expense_date', '>=', now()->subMonths(12))
->selectRaw("DATE_TRUNC('month', expense_date) as month, SUM(amount) as total")
->groupBy(DB::raw("DATE_TRUNC('month', expense_date)"))
->orderBy('month')
->get();
// Top expense categories (from GL)
$topCategories = JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness, $startDate, $endDate) {
$q->where('business_id', $parentBusiness->id)
->where('status', JournalEntry::STATUS_POSTED)
->whereBetween('entry_date', [$startDate, $endDate]);
})
->whereHas('glAccount', function ($q) {
$q->where('account_type', 'expense');
})
->selectRaw('gl_account_id, SUM(debit_amount) as total_debit')
->groupBy('gl_account_id')
->with('glAccount')
->orderByDesc('total_debit')
->limit(15)
->get();
return view('seller.management.analytics.expense', compact(
'business',
'parentBusiness',
'byCategory',
'byDivision',
'monthlyTrend',
'topCategories',
'startDate',
'endDate'
));
}
// =========================================================================
// HELPER METHODS
// =========================================================================
protected function calculateArAging(array $businessIds): array
{
$buckets = [
'current' => 0,
'1_30' => 0,
'31_60' => 0,
'61_90' => 0,
'over_90' => 0,
];
$invoices = ArInvoice::whereIn('business_id', $businessIds)
->where('balance_due', '>', 0)
->get();
foreach ($invoices as $invoice) {
$daysOverdue = $invoice->due_date ? now()->diffInDays($invoice->due_date, false) : 0;
if ($daysOverdue <= 0) {
$buckets['current'] += $invoice->balance_due;
} elseif ($daysOverdue <= 30) {
$buckets['1_30'] += $invoice->balance_due;
} elseif ($daysOverdue <= 60) {
$buckets['31_60'] += $invoice->balance_due;
} elseif ($daysOverdue <= 90) {
$buckets['61_90'] += $invoice->balance_due;
} else {
$buckets['over_90'] += $invoice->balance_due;
}
}
$buckets['total'] = array_sum($buckets);
return $buckets;
}
protected function calculateApAging(array $businessIds): array
{
$buckets = [
'current' => 0,
'1_30' => 0,
'31_60' => 0,
'61_90' => 0,
'over_90' => 0,
];
$bills = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->get();
foreach ($bills as $bill) {
$balance = $bill->total - $bill->paid_amount;
$daysOverdue = $bill->due_date ? now()->diffInDays($bill->due_date, false) : 0;
if ($daysOverdue <= 0) {
$buckets['current'] += $balance;
} elseif ($daysOverdue <= 30) {
$buckets['1_30'] += $balance;
} elseif ($daysOverdue <= 60) {
$buckets['31_60'] += $balance;
} elseif ($daysOverdue <= 90) {
$buckets['61_90'] += $balance;
} else {
$buckets['over_90'] += $balance;
}
}
$buckets['total'] = array_sum($buckets);
return $buckets;
}
protected function calculateDSO(array $businessIds, Carbon $startDate, Carbon $endDate): float
{
$totalAR = ArInvoice::whereIn('business_id', $businessIds)
->where('balance_due', '>', 0)
->sum('balance_due');
$totalRevenue = ArInvoice::whereIn('business_id', $businessIds)
->whereBetween('invoice_date', [$startDate, $endDate])
->sum('total');
$days = $startDate->diffInDays($endDate);
if ($totalRevenue > 0 && $days > 0) {
$avgDailyRevenue = $totalRevenue / $days;
return round($totalAR / $avgDailyRevenue, 1);
}
return 0;
}
protected function calculateDPO(array $businessIds, Carbon $startDate, Carbon $endDate): float
{
$totalAP = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->selectRaw('SUM(total - paid_amount) as balance')
->value('balance') ?? 0;
$totalPurchases = ApBill::whereIn('business_id', $businessIds)
->whereBetween('bill_date', [$startDate, $endDate])
->sum('total');
$days = $startDate->diffInDays($endDate);
if ($totalPurchases > 0 && $days > 0) {
$avgDailyPurchases = $totalPurchases / $days;
return round($totalAP / $avgDailyPurchases, 1);
}
return 0;
}
protected function calculateCollectionRate(array $businessIds, Carbon $startDate, Carbon $endDate): float
{
$totalInvoiced = ArInvoice::whereIn('business_id', $businessIds)
->whereBetween('invoice_date', [$startDate, $endDate])
->sum('total');
$totalCollected = ArInvoice::whereIn('business_id', $businessIds)
->whereBetween('invoice_date', [$startDate, $endDate])
->selectRaw('SUM(total - balance_due) as collected')
->value('collected') ?? 0;
if ($totalInvoiced > 0) {
return round(($totalCollected / $totalInvoiced) * 100, 1);
}
return 0;
}
protected function analyzePaymentTiming(array $businessIds, Carbon $startDate, Carbon $endDate): array
{
$payments = ApPayment::whereIn('business_id', $businessIds)
->whereBetween('payment_date', [$startDate, $endDate])
->with('bill')
->get();
$early = 0;
$onTime = 0;
$late = 0;
foreach ($payments as $payment) {
if (! $payment->bill?->due_date) {
continue;
}
$daysDiff = $payment->payment_date->diffInDays($payment->bill->due_date, false);
if ($daysDiff > 5) {
$early++;
} elseif ($daysDiff >= -5) {
$onTime++;
} else {
$late++;
}
}
$total = $early + $onTime + $late;
return [
'early' => $early,
'on_time' => $onTime,
'late' => $late,
'early_pct' => $total > 0 ? round(($early / $total) * 100, 1) : 0,
'on_time_pct' => $total > 0 ? round(($onTime / $total) * 100, 1) : 0,
'late_pct' => $total > 0 ? round(($late / $total) * 100, 1) : 0,
];
}
protected function getArMonthlyTrend(array $businessIds, int $months): \Illuminate\Support\Collection
{
return ArInvoice::whereIn('business_id', $businessIds)
->where('invoice_date', '>=', now()->subMonths($months))
->selectRaw("DATE_TRUNC('month', invoice_date) as month, SUM(total) as invoiced, SUM(total - balance_due) as collected")
->groupBy(DB::raw("DATE_TRUNC('month', invoice_date)"))
->orderBy('month')
->get();
}
protected function getApMonthlyTrend(array $businessIds, int $months): \Illuminate\Support\Collection
{
return ApBill::whereIn('business_id', $businessIds)
->where('bill_date', '>=', now()->subMonths($months))
->selectRaw("DATE_TRUNC('month', bill_date) as month, SUM(total) as billed, SUM(paid_amount) as paid")
->groupBy(DB::raw("DATE_TRUNC('month', bill_date)"))
->orderBy('month')
->get();
}
protected function calculateCashPosition(Business $parentBusiness): float
{
// Sum of all cash accounts (1000-1099 range)
return JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness) {
$q->where('business_id', $parentBusiness->id)
->where('status', JournalEntry::STATUS_POSTED);
})
->whereHas('glAccount', function ($q) {
$q->where('account_number', '>=', '1000')
->where('account_number', '<', '1100');
})
->selectRaw('SUM(debit_amount - credit_amount) as balance')
->value('balance') ?? 0;
}
protected function getMonthlyCashFlow(Business $parentBusiness, int $months): \Illuminate\Support\Collection
{
return JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness) {
$q->where('business_id', $parentBusiness->id)
->where('status', JournalEntry::STATUS_POSTED)
->where('entry_date', '>=', now()->subMonths($months));
})
->whereHas('glAccount', function ($q) {
$q->where('account_number', '>=', '1000')
->where('account_number', '<', '1100');
})
->join('journal_entries', 'journal_entry_lines.journal_entry_id', '=', 'journal_entries.id')
->selectRaw("DATE_TRUNC('month', journal_entries.entry_date) as month, SUM(debit_amount) as inflows, SUM(credit_amount) as outflows")
->groupBy(DB::raw("DATE_TRUNC('month', journal_entries.entry_date)"))
->orderBy('month')
->get();
}
protected function getAverageMonthlyExpenses(array $businessIds, int $months): float
{
$total = Expense::whereIn('business_id', $businessIds)
->where('status', Expense::STATUS_APPROVED)
->where('expense_date', '>=', now()->subMonths($months))
->sum('amount');
return $total / max($months, 1);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AnalyticsController extends Controller
{
use ManagementDivisionFilter;
public function index(Request $request, Business $business)
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$businessIds = $filterData['business_ids'];
// Collect analytics data across all businesses
$analytics = $this->collectAnalytics($businessIds);
return view('seller.management.analytics.index', $this->withDivisionFilter([
'business' => $business,
'analytics' => $analytics,
], $filterData));
}
/**
* Require Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
protected function collectAnalytics(array $businessIds): array
{
// Revenue by division
$revenueByDivision = DB::table('orders')
->join('businesses', 'orders.business_id', '=', 'businesses.id')
->whereIn('orders.business_id', $businessIds)
->where('orders.status', 'completed')
->select(
'businesses.name as division_name',
DB::raw('SUM(orders.total) as total_revenue'),
DB::raw('COUNT(orders.id) as order_count')
)
->groupBy('businesses.id', 'businesses.name')
->orderByDesc('total_revenue')
->get();
// Expenses by division
$expensesByDivision = DB::table('ap_bills')
->join('businesses', 'ap_bills.business_id', '=', 'businesses.id')
->whereIn('ap_bills.business_id', $businessIds)
->whereIn('ap_bills.status', ['approved', 'paid'])
->select(
'businesses.name as division_name',
DB::raw('SUM(ap_bills.total) as total_expenses'),
DB::raw('COUNT(ap_bills.id) as bill_count')
)
->groupBy('businesses.id', 'businesses.name')
->orderByDesc('total_expenses')
->get();
// AR totals by division
$arByDivision = DB::table('invoices')
->join('businesses', 'invoices.business_id', '=', 'businesses.id')
->whereIn('invoices.business_id', $businessIds)
->whereIn('invoices.payment_status', ['sent', 'partial', 'overdue'])
->select(
'businesses.name as division_name',
DB::raw('SUM(invoices.total) as total_ar'),
DB::raw('SUM(invoices.amount_due) as outstanding_ar')
)
->groupBy('businesses.id', 'businesses.name')
->orderByDesc('outstanding_ar')
->get();
// Calculate totals
$totalRevenue = $revenueByDivision->sum('total_revenue');
$totalExpenses = $expensesByDivision->sum('total_expenses');
$totalAr = $arByDivision->sum('outstanding_ar');
return [
'revenue_by_division' => $revenueByDivision,
'expenses_by_division' => $expensesByDivision,
'ar_by_division' => $arByDivision,
'totals' => [
'revenue' => $totalRevenue,
'expenses' => $totalExpenses,
'net_income' => $totalRevenue - $totalExpenses,
'outstanding_ar' => $totalAr,
],
];
}
}

View File

@@ -0,0 +1,340 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ApVendor;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Models\Department;
use App\Models\PurchaseOrder;
use App\Services\Accounting\BillService;
use App\Services\Accounting\PaymentService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ApBillsController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BillService $billService,
protected PaymentService $paymentService
) {}
/**
* Bills list page.
*
* GET /s/{business}/management/ap/bills
*/
public function index(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
// Get bills with filters - use division filter
$query = ApBill::forBusinesses($filterData['business_ids'])
->with(['vendor', 'purchaseOrder', 'business']);
// Status filter
if ($request->filled('status')) {
$query->status($request->status);
}
// Vendor filter
if ($request->filled('vendor_id')) {
$query->where('vendor_id', $request->vendor_id);
}
// Date range filter
if ($request->filled('from_date')) {
$query->whereDate('bill_date', '>=', $request->from_date);
}
if ($request->filled('to_date')) {
$query->whereDate('bill_date', '<=', $request->to_date);
}
// Unpaid filter
if ($request->boolean('unpaid')) {
$query->unpaid();
}
// Overdue filter
if ($request->boolean('overdue')) {
$query->overdue();
}
// Sort
$sortField = $request->get('sort', 'due_date');
$sortDir = $request->get('dir', 'asc');
$query->orderBy($sortField, $sortDir);
$bills = $query->paginate(20)->withQueryString();
// Get vendors for filter dropdown (from all filtered businesses)
$vendors = ApVendor::whereIn('business_id', $filterData['business_ids'])
->where('is_active', true)
->orderBy('name')
->get();
// Stats (scoped to filtered businesses)
$stats = [
'total_outstanding' => ApBill::forBusinesses($filterData['business_ids'])->unpaid()->sum('balance_due'),
'overdue_count' => ApBill::forBusinesses($filterData['business_ids'])->overdue()->count(),
'overdue_amount' => ApBill::forBusinesses($filterData['business_ids'])->overdue()->sum('balance_due'),
'pending_approval' => ApBill::forBusinesses($filterData['business_ids'])->whereIn('status', ['draft', 'pending'])->count(),
];
// Check if user can pay (parent company only)
$canPay = $business->parent_id === null;
return view('seller.management.ap.bills.index', $this->withDivisionFilter([
'business' => $business,
'bills' => $bills,
'vendors' => $vendors,
'stats' => $stats,
'canPay' => $canPay,
], $filterData));
}
/**
* Bill detail page.
*
* GET /s/{business}/management/ap/bills/{bill}
*/
public function show(Request $request, Business $business, ApBill $bill)
{
// Verify bill belongs to this business or its divisions
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($bill->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
$bill->load([
'vendor',
'items.glAccount',
'items.department',
'purchaseOrder.items',
'paymentApplications.payment',
'approvedBy',
'createdBy',
]);
// Check if user can pay (parent company only)
$canPay = $business->parent_id === null;
// Check if user can approve
$canApprove = in_array($bill->status, [ApBill::STATUS_DRAFT, ApBill::STATUS_PENDING]);
return view('seller.management.ap.bills.show', compact(
'business',
'bill',
'canPay',
'canApprove'
));
}
/**
* Create bill page (manual entry).
*
* GET /s/{business}/management/ap/bills/create
*/
public function create(Request $request, Business $business)
{
// Get vendors
$vendors = ApVendor::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
// Get GL accounts for line items
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->orderBy('account_number')
->get();
// Get departments
$departments = Department::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
// If creating from PO, get the PO
$purchaseOrder = null;
if ($request->filled('po_id')) {
$purchaseOrder = PurchaseOrder::where('business_id', $business->id)
->with('items')
->findOrFail($request->po_id);
}
return view('seller.management.ap.bills.create', compact(
'business',
'vendors',
'glAccounts',
'departments',
'purchaseOrder'
));
}
/**
* Store a new bill (web form submission).
*
* POST /s/{business}/management/ap/bills
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'vendor_id' => ['required', 'integer', Rule::exists('ap_vendors', 'id')->where('business_id', $business->id)],
'vendor_invoice_number' => 'required|string|max:100',
'bill_date' => 'required|date',
'due_date' => 'required|date|after_or_equal:bill_date',
'payment_terms' => 'nullable|integer|min:0',
'department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')->where('business_id', $business->id)],
'tax_amount' => 'nullable|numeric|min:0',
'notes' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:255',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.gl_account_id' => ['required', 'integer', Rule::exists('gl_accounts', 'id')->where('business_id', $business->id)],
'items.*.department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')->where('business_id', $business->id)],
'purchase_order_id' => ['nullable', 'integer', Rule::exists('purchase_orders', 'id')->where('business_id', $business->id)],
]);
try {
// Check if creating from PO
if (! empty($validated['purchase_order_id'])) {
$po = PurchaseOrder::where('business_id', $business->id)
->findOrFail($validated['purchase_order_id']);
$bill = $this->billService->createFromPurchaseOrder(
$po,
$validated['vendor_invoice_number'],
$validated
);
} else {
$bill = $this->billService->createManualBill(
$business->id,
$validated['vendor_id'],
$validated['vendor_invoice_number'],
$validated['items'],
$validated
);
}
return redirect()
->route('seller.business.management.ap.bills.show', [$business, $bill])
->with('success', "Bill {$bill->bill_number} created successfully.");
} catch (\InvalidArgumentException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
}
/**
* Approve a bill.
*
* POST /s/{business}/management/ap/bills/{bill}/approve
*/
public function approve(Business $business, ApBill $bill)
{
if ($bill->business_id !== $business->id) {
abort(403);
}
try {
$this->billService->approveBill($bill, auth()->id());
return back()->with('success', "Bill {$bill->bill_number} approved.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Pay a bill (parent company only).
*
* POST /s/{business}/management/ap/bills/{bill}/pay
*/
public function pay(Request $request, Business $business, ApBill $bill)
{
// Only parent company can pay
if ($business->parent_id !== null) {
abort(403, 'Only parent company can make payments.');
}
// Bill must be from this business or a child
$canPay = $bill->business_id === $business->id
|| $bill->business->parent_id === $business->id;
if (! $canPay) {
abort(403, 'Cannot pay this bill.');
}
$validated = $request->validate([
'payment_method' => 'required|in:check,ach,wire,card,cash',
'amount' => 'nullable|numeric|min:0.01',
'discount' => 'nullable|numeric|min:0',
'reference_number' => 'nullable|string|max:100',
'memo' => 'nullable|string|max:500',
]);
try {
$discount = $validated['discount'] ?? 0;
$amount = $validated['amount'] ?? bcsub((string) $bill->balance_due, (string) $discount, 2);
$payment = $this->paymentService->createPayment(
$business,
$bill->vendor_id,
(float) $amount,
$validated['payment_method'],
[
[
'bill_id' => $bill->id,
'amount' => $amount,
'discount' => $discount,
],
],
[
'reference_number' => $validated['reference_number'] ?? null,
'memo' => $validated['memo'] ?? null,
]
);
$this->paymentService->completePayment($payment);
return back()->with('success', "Payment {$payment->payment_number} applied to bill {$bill->bill_number}.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Void a bill.
*
* POST /s/{business}/management/ap/bills/{bill}/void
*/
public function void(Request $request, Business $business, ApBill $bill)
{
if ($bill->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'reason' => 'nullable|string|max:500',
]);
try {
$this->billService->voidBill($bill, $validated['reason'] ?? null);
return redirect()
->route('seller.business.management.ap.bills.index', $business)
->with('success', "Bill {$bill->bill_number} voided.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
}

View File

@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ApVendorsController extends Controller
{
use ManagementDivisionFilter;
/**
* Vendors list page.
*
* GET /s/{business}/management/ap/vendors
*/
public function index(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
$isParent = $business->parent_id === null && Business::where('parent_id', $business->id)->exists();
$query = ApVendor::whereIn('business_id', $filterData['business_ids'])
->with('business')
->withCount('bills');
// Search filter
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}%");
});
}
// Active filter
if ($request->has('active')) {
$query->where('is_active', $request->boolean('active'));
}
$vendors = $query->orderBy('name')->paginate(20)->withQueryString();
// For parent business, compute which child divisions use each vendor
if ($isParent) {
$childBusinessIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
$childBusinesses = Business::whereIn('id', $childBusinessIds)->get()->keyBy('id');
$vendors->getCollection()->transform(function ($vendor) use ($childBusinessIds, $childBusinesses) {
// Get divisions that have bills or POs with this vendor
$divisionsUsingVendor = collect();
// Check if vendor belongs to a child directly
if (in_array($vendor->business_id, $childBusinessIds)) {
$divisionsUsingVendor->push($childBusinesses[$vendor->business_id] ?? null);
}
// Check for bills from other children using this vendor
$billBusinessIds = $vendor->bills()
->whereIn('business_id', $childBusinessIds)
->distinct()
->pluck('business_id')
->toArray();
foreach ($billBusinessIds as $bizId) {
if (! $divisionsUsingVendor->contains('id', $bizId) && isset($childBusinesses[$bizId])) {
$divisionsUsingVendor->push($childBusinesses[$bizId]);
}
}
$vendor->divisions_using = $divisionsUsingVendor->filter()->unique('id')->values();
return $vendor;
});
}
// Get GL accounts for default expense account dropdown
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->whereIn('account_type', ['expense', 'asset'])
->orderBy('account_number')
->get();
return view('seller.management.ap.vendors.index', $this->withDivisionFilter([
'business' => $business,
'vendors' => $vendors,
'glAccounts' => $glAccounts,
'isParent' => $isParent,
], $filterData));
}
/**
* Store a new vendor.
*
* POST /s/{business}/management/ap/vendors
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'code' => 'nullable|string|max:50',
'name' => 'required|string|max:255',
'legal_name' => 'nullable|string|max:255',
'tax_id' => 'nullable|string|max:50',
'default_payment_terms' => 'nullable|integer|min:0',
'default_gl_account_id' => ['nullable', 'integer', Rule::exists('gl_accounts', 'id')->where('business_id', $business->id)],
'contact_name' => 'nullable|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'is_1099' => 'boolean',
'notes' => 'nullable|string|max:1000',
]);
// Generate code if not provided
if (empty($validated['code'])) {
$validated['code'] = $this->generateVendorCode($business->id, $validated['name']);
}
$vendor = ApVendor::create([
'business_id' => $business->id,
...$validated,
'is_active' => true,
]);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'vendor' => $vendor,
]);
}
return back()->with('success', "Vendor {$vendor->name} created successfully.");
}
/**
* Show create vendor form.
*
* GET /s/{business}/management/ap/vendors/create
*/
public function create(Request $request, Business $business)
{
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->whereIn('account_type', ['expense', 'asset'])
->orderBy('account_number')
->get();
return view('seller.management.ap.vendors.create', compact(
'business',
'glAccounts'
));
}
/**
* Show vendor details.
*
* GET /s/{business}/management/ap/vendors/{vendor}
*/
public function show(Request $request, Business $business, ApVendor $vendor)
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
$vendor->load(['defaultGlAccount']);
// Get recent bills
$recentBills = $vendor->bills()
->with(['glAccount'])
->orderByDesc('bill_date')
->limit(10)
->get();
// Get recent payments
$recentPayments = $vendor->payments()
->with(['bills'])
->orderByDesc('payment_date')
->limit(10)
->get();
// Calculate metrics
$metrics = [
'total_bills' => $vendor->bills()->count(),
'unpaid_balance' => $vendor->bills()->unpaid()->sum('balance_due'),
'overdue_balance' => $vendor->bills()->overdue()->sum('balance_due'),
'ytd_payments' => $vendor->payments()
->whereYear('payment_date', now()->year)
->completed()
->sum('amount'),
];
return view('seller.management.ap.vendors.show', compact(
'business',
'vendor',
'recentBills',
'recentPayments',
'metrics'
));
}
/**
* Show edit vendor form.
*
* GET /s/{business}/management/ap/vendors/{vendor}/edit
*/
public function edit(Request $request, Business $business, ApVendor $vendor)
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
$glAccounts = GlAccount::where('business_id', $business->id)
->where('is_active', true)
->where('is_header', false)
->whereIn('account_type', ['expense', 'asset'])
->orderBy('account_number')
->get();
return view('seller.management.ap.vendors.edit', compact(
'business',
'vendor',
'glAccounts'
));
}
/**
* Update a vendor.
*
* PUT /s/{business}/management/ap/vendors/{vendor}
*/
public function update(Request $request, Business $business, ApVendor $vendor)
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
$validated = $request->validate([
'code' => 'nullable|string|max:50',
'name' => 'required|string|max:255',
'legal_name' => 'nullable|string|max:255',
'tax_id' => 'nullable|string|max:50',
'default_payment_terms' => 'nullable|integer|min:0',
'default_gl_account_id' => ['nullable', 'integer', Rule::exists('gl_accounts', 'id')->where('business_id', $business->id)],
'contact_name' => 'nullable|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'is_1099' => 'boolean',
'is_active' => 'boolean',
'notes' => 'nullable|string|max:1000',
]);
$vendor->update($validated);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'vendor' => $vendor->fresh(),
]);
}
return back()->with('success', "Vendor {$vendor->name} updated successfully.");
}
/**
* Toggle vendor active status.
*
* POST /s/{business}/management/ap/vendors/{vendor}/toggle-active
*/
public function toggleActive(Business $business, ApVendor $vendor)
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
$vendor->update(['is_active' => ! $vendor->is_active]);
$status = $vendor->is_active ? 'activated' : 'deactivated';
return back()->with('success', "Vendor {$vendor->name} {$status}.");
}
/**
* Generate vendor code from name.
*/
protected function generateVendorCode(int $businessId, string $name): string
{
// Take first 3 chars of each word, uppercase
$words = preg_split('/\s+/', strtoupper($name));
$prefix = '';
foreach ($words as $word) {
$prefix .= substr(preg_replace('/[^A-Z0-9]/', '', $word), 0, 3);
if (strlen($prefix) >= 6) {
break;
}
}
$prefix = substr($prefix, 0, 6);
// Check for uniqueness
$count = ApVendor::where('business_id', $businessId)
->where('code', 'like', "{$prefix}%")
->count();
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ArCustomer;
use App\Models\Business;
use App\Services\Accounting\ArAnalyticsService;
use App\Services\Accounting\ArService;
use App\Services\Accounting\CustomerFinancialService;
use App\Services\Accounting\ReportExportService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ArController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected ArAnalyticsService $analyticsService,
protected ArService $arService,
protected CustomerFinancialService $customerService,
protected ReportExportService $exportService
) {}
/**
* AR Overview dashboard.
*/
public function index(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
$metrics = $this->analyticsService->getARMetrics($business, $filterData['business_ids']);
$aging = $this->analyticsService->getARAging($business, $filterData['business_ids']);
$topCustomers = $this->analyticsService->getARBreakdownByCustomer($business, $filterData['business_ids'], 5);
return view('seller.management.ar.index', $this->withDivisionFilter([
'business' => $business,
'metrics' => $metrics,
'aging' => $aging,
'topCustomers' => $topCustomers,
], $filterData));
}
/**
* AR Aging detail page.
*/
public function aging(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
$aging = $this->analyticsService->getARAging($business, $filterData['business_ids']);
$byDivision = $this->analyticsService->getARBreakdownByDivision($business, $filterData['business_ids']);
$byCustomer = $this->analyticsService->getARBreakdownByCustomer($business, $filterData['business_ids'], 10);
// Check for bucket filter from drill-down
$bucket = $request->get('bucket');
return view('seller.management.ar.aging', $this->withDivisionFilter([
'business' => $business,
'aging' => $aging,
'byDivision' => $byDivision,
'byCustomer' => $byCustomer,
'activeBucket' => $bucket,
], $filterData));
}
/**
* AR Accounts list page.
*/
public function accounts(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
$filters = [
'on_hold' => $request->boolean('on_hold'),
'at_risk' => $request->boolean('at_risk'),
'search' => $request->get('search'),
];
$accounts = $this->arService->getAccountsWithBalances(
$business,
$filterData['business_ids'],
$filters
);
$metrics = $this->analyticsService->getARMetrics($business, $filterData['business_ids']);
return view('seller.management.ar.accounts', $this->withDivisionFilter([
'business' => $business,
'accounts' => $accounts,
'metrics' => $metrics,
'filters' => $filters,
], $filterData));
}
/**
* Single account detail page.
*/
public function showAccount(Request $request, Business $business, ArCustomer $customer)
{
// Verify customer belongs to this business or a child
$isParent = $this->arService->isParentCompany($business);
$allowedBusinessIds = $isParent
? $this->arService->getBusinessIdsWithChildren($business)
: [$business->id];
if (! in_array($customer->business_id, $allowedBusinessIds)) {
abort(404);
}
$summary = $this->customerService->getFinancialSummary($customer, $business, $isParent);
$invoices = $this->customerService->getInvoices($customer, $business, $isParent);
$payments = $this->customerService->getPayments($customer, $business, $isParent);
$activities = $this->customerService->getRecentActivity($customer, $business);
return view('seller.management.ar.account-detail', [
'business' => $business,
'customer' => $customer,
'summary' => $summary,
'invoices' => $invoices,
'payments' => $payments,
'activities' => $activities,
'isParent' => $isParent,
]);
}
/**
* Update credit limit (Management only).
*/
public function updateCreditLimit(Request $request, Business $business, ArCustomer $customer)
{
$request->validate([
'credit_limit' => 'required|numeric|min:0',
]);
$this->arService->updateCreditLimit(
$customer,
(float) $request->input('credit_limit'),
auth()->id()
);
return back()->with('success', 'Credit limit updated successfully.');
}
/**
* Update payment terms (Management only).
*/
public function updateTerms(Request $request, Business $business, ArCustomer $customer)
{
$request->validate([
'payment_terms' => 'required|string',
]);
$this->arService->updatePaymentTerms(
$customer,
$request->input('payment_terms'),
auth()->id()
);
return back()->with('success', 'Payment terms updated successfully.');
}
/**
* Place credit hold (Management only).
*/
public function placeHold(Request $request, Business $business, ArCustomer $customer)
{
$request->validate([
'reason' => 'required|string|max:500',
]);
$this->arService->placeCreditHold(
$customer,
$request->input('reason'),
auth()->id()
);
return back()->with('success', 'Credit hold placed successfully.');
}
/**
* Remove credit hold (Management only).
*/
public function removeHold(Request $request, Business $business, ArCustomer $customer)
{
$this->arService->removeCreditHold($customer, auth()->id());
return back()->with('success', 'Credit hold removed successfully.');
}
/**
* Export AR Aging report as CSV.
*/
public function exportAging(Request $request, Business $business): StreamedResponse
{
$filterData = $this->getDivisionFilterData($business, $request);
$byCustomer = $this->analyticsService->getARBreakdownByCustomer($business, $filterData['business_ids'], 1000);
$filename = 'ar_aging_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
return $this->exportService->exportArAging($byCustomer, $filename);
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Services\Accounting\BankAccountService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BankAccountsController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BankAccountService $bankAccountService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the list of bank accounts.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
// Determine which business to show accounts for
$targetBusiness = $filterData['selected_division'] ?? $business;
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
$accounts = $this->bankAccountService->getAccountsForBusiness($targetBusiness, $includeChildren);
$totalBalance = $this->bankAccountService->getTotalCashBalance($targetBusiness, $includeChildren);
return view('seller.management.bank-accounts.index', $this->withDivisionFilter([
'business' => $business,
'accounts' => $accounts,
'totalBalance' => $totalBalance,
], $filterData));
}
/**
* Show the form for creating a new bank account.
*/
public function create(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$glAccounts = GlAccount::where('business_id', $business->id)
->where('account_type', 'asset')
->orderBy('account_number')
->get();
return view('seller.management.bank-accounts.create', [
'business' => $business,
'glAccounts' => $glAccounts,
'accountTypes' => [
BankAccount::TYPE_CHECKING => 'Checking',
BankAccount::TYPE_SAVINGS => 'Savings',
BankAccount::TYPE_MONEY_MARKET => 'Money Market',
],
]);
}
/**
* Store a newly created bank account.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'account_type' => 'required|string|in:checking,savings,money_market',
'bank_name' => 'nullable|string|max:255',
'account_number_last4' => 'nullable|string|max:4',
'routing_number' => 'nullable|string|max:9',
'current_balance' => 'nullable|numeric|min:0',
'gl_account_id' => 'nullable|exists:gl_accounts,id',
'is_primary' => 'boolean',
'is_active' => 'boolean',
'notes' => 'nullable|string',
]);
$this->bankAccountService->createAccount($business, $validated, auth()->user());
return redirect()
->route('seller.business.management.bank-accounts.index', $business)
->with('success', 'Bank account created successfully.');
}
/**
* Display the specified bank account.
*/
public function show(Request $request, Business $business, BankAccount $bankAccount): View
{
$this->requireManagementSuite($business);
if ($bankAccount->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$recentTransfers = $bankAccount->outgoingTransfers()
->orWhere('to_bank_account_id', $bankAccount->id)
->with(['fromAccount', 'toAccount'])
->orderBy('transfer_date', 'desc')
->limit(10)
->get();
return view('seller.management.bank-accounts.show', [
'business' => $business,
'account' => $bankAccount,
'recentTransfers' => $recentTransfers,
]);
}
/**
* Show the form for editing the bank account.
*/
public function edit(Request $request, Business $business, BankAccount $bankAccount): View
{
$this->requireManagementSuite($business);
if ($bankAccount->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$glAccounts = GlAccount::where('business_id', $business->id)
->where('account_type', 'asset')
->orderBy('account_number')
->get();
return view('seller.management.bank-accounts.edit', [
'business' => $business,
'account' => $bankAccount,
'glAccounts' => $glAccounts,
'accountTypes' => [
BankAccount::TYPE_CHECKING => 'Checking',
BankAccount::TYPE_SAVINGS => 'Savings',
BankAccount::TYPE_MONEY_MARKET => 'Money Market',
],
]);
}
/**
* Update the specified bank account.
*/
public function update(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
if ($bankAccount->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'account_type' => 'required|string|in:checking,savings,money_market',
'bank_name' => 'nullable|string|max:255',
'account_number_last4' => 'nullable|string|max:4',
'routing_number' => 'nullable|string|max:9',
'gl_account_id' => 'nullable|exists:gl_accounts,id',
'is_primary' => 'boolean',
'is_active' => 'boolean',
'notes' => 'nullable|string',
]);
$this->bankAccountService->updateAccount($bankAccount, $validated);
return redirect()
->route('seller.business.management.bank-accounts.index', $business)
->with('success', 'Bank account updated successfully.');
}
/**
* Toggle the active status of a bank account.
*/
public function toggleActive(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
if ($bankAccount->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$bankAccount->update(['is_active' => ! $bankAccount->is_active]);
$status = $bankAccount->is_active ? 'activated' : 'deactivated';
return redirect()
->back()
->with('success', "Bank account {$status} successfully.");
}
}

View File

@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApPayment;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\BankMatchRule;
use App\Models\Accounting\JournalEntry;
use App\Models\Accounting\PlaidAccount;
use App\Models\Accounting\PlaidTransaction;
use App\Models\Business;
use App\Services\Accounting\BankReconciliationService;
use App\Services\Accounting\PlaidIntegrationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BankReconciliationController extends Controller
{
public function __construct(
protected BankReconciliationService $reconciliationService,
protected PlaidIntegrationService $plaidService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the reconciliation dashboard for a bank account.
*/
public function show(Request $request, Business $business, BankAccount $bankAccount): View
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$summary = $this->reconciliationService->getReconciliationSummary($bankAccount);
$unmatchedTransactions = $this->reconciliationService->getUnmatchedTransactions($bankAccount);
$proposedMatches = $this->reconciliationService->getProposedAutoMatches($bankAccount);
return view('seller.management.bank-accounts.reconciliation', [
'business' => $business,
'account' => $bankAccount,
'summary' => $summary,
'unmatchedTransactions' => $unmatchedTransactions,
'proposedMatches' => $proposedMatches,
]);
}
/**
* Sync transactions from Plaid.
*/
public function syncTransactions(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$sinceDate = $request->input('since_date')
? new \DateTime($request->input('since_date'))
: now()->subDays(30);
$synced = $this->plaidService->syncTransactions($business, $sinceDate);
// Run auto-matching
$matched = $this->reconciliationService->runAutoMatching($bankAccount);
return redirect()
->back()
->with('success', "Synced {$synced} transactions. {$matched} proposed auto-matches found.");
}
/**
* Find potential matches for a transaction (AJAX).
*/
public function findMatches(Request $request, Business $business, BankAccount $bankAccount, PlaidTransaction $transaction): JsonResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$matches = $this->reconciliationService->findPotentialMatches($transaction, $business);
return response()->json([
'ap_payments' => $matches['ap_payments']->map(fn ($p) => [
'id' => $p->id,
'type' => 'ap_payment',
'label' => "AP Payment #{$p->id} - ".($p->bill?->vendor?->name ?? 'Unknown'),
'amount' => $p->amount,
'date' => $p->payment_date->format('Y-m-d'),
]),
'journal_entries' => $matches['journal_entries']->map(fn ($je) => [
'id' => $je->id,
'type' => 'journal_entry',
'label' => "JE #{$je->entry_number} - {$je->memo}",
'date' => $je->entry_date->format('Y-m-d'),
]),
]);
}
/**
* Match a transaction to an AP payment.
*/
public function matchToApPayment(
Request $request,
Business $business,
BankAccount $bankAccount,
PlaidTransaction $transaction
): RedirectResponse {
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'ap_payment_id' => 'required|exists:ap_payments,id',
]);
$payment = ApPayment::findOrFail($validated['ap_payment_id']);
$this->reconciliationService->matchToApPayment($transaction, $payment, auth()->user());
return redirect()
->back()
->with('success', 'Transaction matched to AP payment successfully.');
}
/**
* Match a transaction to a journal entry.
*/
public function matchToJournalEntry(
Request $request,
Business $business,
BankAccount $bankAccount,
PlaidTransaction $transaction
): RedirectResponse {
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'journal_entry_id' => 'required|exists:journal_entries,id',
]);
$entry = JournalEntry::findOrFail($validated['journal_entry_id']);
$this->reconciliationService->matchToJournalEntry($transaction, $entry, auth()->user());
return redirect()
->back()
->with('success', 'Transaction matched to journal entry successfully.');
}
/**
* Confirm selected auto-matches.
*/
public function confirmAutoMatches(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'transaction_ids' => 'required|array',
'transaction_ids.*' => 'exists:plaid_transactions,id',
]);
$confirmed = $this->reconciliationService->confirmAutoMatches(
$validated['transaction_ids'],
auth()->user()
);
return redirect()
->back()
->with('success', "Confirmed {$confirmed} auto-matched transactions.");
}
/**
* Reject selected auto-matches.
*/
public function rejectAutoMatches(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'transaction_ids' => 'required|array',
'transaction_ids.*' => 'exists:plaid_transactions,id',
]);
$rejected = $this->reconciliationService->rejectAutoMatches(
$validated['transaction_ids'],
auth()->user()
);
return redirect()
->back()
->with('success', "Rejected {$rejected} auto-matched transactions.");
}
/**
* Ignore selected transactions.
*/
public function ignoreTransactions(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'transaction_ids' => 'required|array',
'transaction_ids.*' => 'exists:plaid_transactions,id',
]);
$ignored = $this->reconciliationService->ignoreTransactions($validated['transaction_ids']);
return redirect()
->back()
->with('success', "Ignored {$ignored} transactions.");
}
/**
* Display match rules for a bank account.
*/
public function matchRules(Request $request, Business $business, BankAccount $bankAccount): View
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$rules = $this->reconciliationService->getMatchRules($bankAccount);
$eligibleRules = $this->reconciliationService->getEligibleRules($bankAccount);
return view('seller.management.bank-accounts.match-rules', [
'business' => $business,
'account' => $bankAccount,
'rules' => $rules,
'eligibleRules' => $eligibleRules,
]);
}
/**
* Toggle auto-enable for a match rule.
*/
public function toggleRuleAutoEnable(
Request $request,
Business $business,
BankAccount $bankAccount,
BankMatchRule $rule
): RedirectResponse {
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
if ($rule->bank_account_id !== $bankAccount->id) {
abort(403, 'Access denied.');
}
$enabled = $request->boolean('enabled');
try {
$this->reconciliationService->toggleRuleAutoEnable($rule, $enabled);
$status = $enabled ? 'enabled' : 'disabled';
return redirect()
->back()
->with('success', "Auto-matching {$status} for rule: {$rule->pattern_name}");
} catch (\Exception $e) {
return redirect()
->back()
->with('error', $e->getMessage());
}
}
/**
* Link a Plaid account to a bank account.
*/
public function linkPlaidAccount(Request $request, Business $business, BankAccount $bankAccount): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeAccountAccess($business, $bankAccount);
$validated = $request->validate([
'plaid_account_id' => 'required|exists:plaid_accounts,id',
]);
$plaidAccount = PlaidAccount::findOrFail($validated['plaid_account_id']);
$this->plaidService->linkPlaidAccountToBankAccount($plaidAccount, $bankAccount);
return redirect()
->back()
->with('success', 'Plaid account linked successfully.');
}
/**
* Authorize access to a bank account.
*/
private function authorizeAccountAccess(Business $business, BankAccount $bankAccount): void
{
// Allow access if account belongs to this business or a child business
if ($bankAccount->business_id === $business->id) {
return;
}
if ($business->isParentCompany()) {
$childIds = $business->divisions()->pluck('id')->toArray();
if (in_array($bankAccount->business_id, $childIds)) {
return;
}
}
abort(403, 'Access denied.');
}
}

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\BankAccount;
use App\Models\Accounting\BankTransfer;
use App\Models\Business;
use App\Services\Accounting\BankAccountService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BankTransfersController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BankAccountService $bankAccountService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the list of bank transfers.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$filters = [
'status' => $request->get('status'),
'from_date' => $request->get('from_date'),
'to_date' => $request->get('to_date'),
];
$targetBusiness = $filterData['selected_division'] ?? $business;
$transfers = $this->bankAccountService->getTransfersForBusiness($targetBusiness, $filters);
return view('seller.management.bank-transfers.index', $this->withDivisionFilter([
'business' => $business,
'transfers' => $transfers,
'filters' => $filters,
], $filterData));
}
/**
* Show the form for creating a new bank transfer.
*/
public function create(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$accounts = BankAccount::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.management.bank-transfers.create', [
'business' => $business,
'accounts' => $accounts,
]);
}
/**
* Store a newly created bank transfer.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$validated = $request->validate([
'from_bank_account_id' => 'required|exists:bank_accounts,id',
'to_bank_account_id' => 'required|exists:bank_accounts,id|different:from_bank_account_id',
'amount' => 'required|numeric|min:0.01',
'transfer_date' => 'required|date',
'reference' => 'nullable|string|max:255',
'memo' => 'nullable|string',
]);
// Verify accounts belong to this business
$fromAccount = BankAccount::where('id', $validated['from_bank_account_id'])
->where('business_id', $business->id)
->firstOrFail();
$toAccount = BankAccount::where('id', $validated['to_bank_account_id'])
->where('business_id', $business->id)
->firstOrFail();
$transfer = $this->bankAccountService->createTransfer($business, $validated, auth()->user());
return redirect()
->route('seller.business.management.bank-transfers.show', [$business, $transfer])
->with('success', 'Bank transfer created successfully.');
}
/**
* Display the specified bank transfer.
*/
public function show(Request $request, Business $business, BankTransfer $bankTransfer): View
{
$this->requireManagementSuite($business);
if ($bankTransfer->business_id !== $business->id) {
abort(403, 'Access denied.');
}
$bankTransfer->load(['fromAccount', 'toAccount', 'createdBy', 'approvedBy', 'journalEntry']);
return view('seller.management.bank-transfers.show', [
'business' => $business,
'transfer' => $bankTransfer,
]);
}
/**
* Complete/approve a pending bank transfer.
*/
public function complete(Request $request, Business $business, BankTransfer $bankTransfer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($bankTransfer->business_id !== $business->id) {
abort(403, 'Access denied.');
}
if (! $bankTransfer->isPending()) {
return redirect()
->back()
->with('error', 'Only pending transfers can be completed.');
}
try {
$this->bankAccountService->completeTransfer($bankTransfer, auth()->user());
return redirect()
->route('seller.business.management.bank-transfers.show', [$business, $bankTransfer])
->with('success', 'Bank transfer completed successfully.');
} catch (\Exception $e) {
return redirect()
->back()
->with('error', 'Failed to complete transfer: '.$e->getMessage());
}
}
/**
* Cancel a pending bank transfer.
*/
public function cancel(Request $request, Business $business, BankTransfer $bankTransfer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($bankTransfer->business_id !== $business->id) {
abort(403, 'Access denied.');
}
if (! $bankTransfer->isPending()) {
return redirect()
->back()
->with('error', 'Only pending transfers can be cancelled.');
}
try {
$this->bankAccountService->cancelTransfer($bankTransfer);
return redirect()
->route('seller.business.management.bank-transfers.index', $business)
->with('success', 'Bank transfer cancelled.');
} catch (\Exception $e) {
return redirect()
->back()
->with('error', 'Failed to cancel transfer: '.$e->getMessage());
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\Budget;
use App\Models\Business;
use App\Services\Accounting\BudgetService;
use App\Services\Accounting\ReportExportService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BudgetReportingController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BudgetService $budgetService,
protected ReportExportService $exportService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List budgets with variance summary for reporting.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
// Get all budgets with quick variance summary
$budgets = Budget::whereIn('business_id', $filterData['business_ids'])
->active()
->with(['business'])
->withCount('lines')
->orderByDesc('fiscal_year')
->orderByDesc('created_at')
->get()
->map(function ($budget) {
$summary = $this->budgetService->getBudgetSummary($budget);
return [
'budget' => $budget,
'total_budget' => $summary['total_budget'],
'total_actual' => $summary['total_actual'],
'variance_amount' => $summary['variance_amount'],
'variance_percent' => $summary['variance_percent'],
];
});
return view('seller.management.financials.budget-vs-actual.index', $this->withDivisionFilter([
'business' => $business,
'budgets' => $budgets,
], $filterData));
}
/**
* Show detailed Budget vs Actual report for a specific budget.
*/
public function show(Request $request, Business $business, Budget $budget): View
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$filterData = $this->getDivisionFilterData($business, $request);
// Get grouping preference
$groupBy = $request->get('group_by', 'department');
// Build filters for the report - use snake_case keys from getDivisionFilterData()
$filters = [
'include_children' => ($filterData['selected_division'] === null && $business->hasChildBusinesses()),
];
if ($filterData['selected_division']) {
$filters['division_id'] = $filterData['selected_division']->id;
}
$report = $this->budgetService->getBudgetVsActual($budget, $groupBy, $filters);
// Get all budgets for the selector
$allBudgets = Budget::whereIn('business_id', $filterData['business_ids'])
->active()
->orderByDesc('fiscal_year')
->get();
return view('seller.management.financials.budget-vs-actual.show', $this->withDivisionFilter([
'business' => $business,
'budget' => $budget,
'report' => $report,
'groupBy' => $groupBy,
'allBudgets' => $allBudgets,
], $filterData));
}
/**
* Validate budget belongs to allowed business.
*/
private function authorizeForBusiness(Business $business, Budget $budget): void
{
$allowedIds = $this->getAllowedBusinessIds($business);
if (! in_array($budget->business_id, $allowedIds)) {
abort(404);
}
}
/**
* Export Budget vs Actual report as CSV.
*/
public function export(Request $request, Business $business, Budget $budget): StreamedResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$filterData = $this->getDivisionFilterData($business, $request);
$groupBy = $request->get('group_by', 'department');
$filters = [
'include_children' => ($filterData['selected_division'] === null && $business->hasChildBusinesses()),
];
if ($filterData['selected_division']) {
$filters['division_id'] = $filterData['selected_division']->id;
}
$report = $this->budgetService->getBudgetVsActual($budget, $groupBy, $filters);
$filename = 'budget_vs_actual_'.$budget->name.'_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
$filename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $filename);
return $this->exportService->exportBudgetVsActual($report, $filename);
}
}

View File

@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\Budget;
use App\Models\Accounting\BudgetLine;
use App\Models\Business;
use App\Services\Accounting\BudgetService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BudgetsController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected BudgetService $budgetService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List budgets for the business.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$budgets = Budget::whereIn('business_id', $filterData['business_ids'])
->with(['business', 'createdBy', 'approvedBy'])
->withCount('lines')
->orderByDesc('fiscal_year')
->orderByDesc('created_at')
->paginate(20);
return view('seller.management.budgets.index', $this->withDivisionFilter([
'business' => $business,
'budgets' => $budgets,
], $filterData));
}
/**
* Show budget creation form.
*/
public function create(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$currentYear = now()->year;
$years = range($currentYear - 1, $currentYear + 2);
return view('seller.management.budgets.create', [
'business' => $business,
'years' => $years,
'periodTypes' => Budget::getPeriodTypes(),
]);
}
/**
* Store a new budget.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'fiscal_year' => 'nullable|integer|min:2020|max:2100',
'currency' => 'nullable|string|size:3',
'notes' => 'nullable|string|max:1000',
]);
$budget = Budget::create([
'business_id' => $business->id,
'name' => $validated['name'],
'fiscal_year' => $validated['fiscal_year'] ?? now()->year,
'currency' => $validated['currency'] ?? 'USD',
'is_active' => true,
'created_by_user_id' => auth()->id(),
'notes' => $validated['notes'],
]);
return redirect()
->route('seller.business.management.budgets.edit', [$business, $budget])
->with('success', 'Budget created. Now add budget lines.');
}
/**
* Show budget details.
*/
public function show(Request $request, Business $business, Budget $budget): View
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$summary = $this->budgetService->getBudgetSummary($budget);
return view('seller.management.budgets.show', [
'business' => $business,
'budget' => $budget->load(['createdBy', 'approvedBy', 'lines.department', 'lines.glAccount']),
'summary' => $summary,
]);
}
/**
* Edit budget (metadata and lines).
*/
public function edit(Request $request, Business $business, Budget $budget): View
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$expenseAccounts = $this->budgetService->getExpenseAccounts($business);
$departments = $this->budgetService->getDepartments($business);
// Group lines by department for the grid view
$lines = $budget->lines()
->with(['department', 'glAccount'])
->orderBy('department_id')
->orderBy('gl_account_id')
->orderBy('period_start')
->get();
return view('seller.management.budgets.edit', [
'business' => $business,
'budget' => $budget,
'lines' => $lines,
'expenseAccounts' => $expenseAccounts,
'departments' => $departments,
'periodTypes' => Budget::getPeriodTypes(),
]);
}
/**
* Update budget metadata.
*/
public function update(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$validated = $request->validate([
'name' => 'required|string|max:255',
'fiscal_year' => 'nullable|integer|min:2020|max:2100',
'notes' => 'nullable|string|max:1000',
'is_active' => 'boolean',
]);
$budget->update([
'name' => $validated['name'],
'fiscal_year' => $validated['fiscal_year'],
'notes' => $validated['notes'],
'is_active' => $validated['is_active'] ?? $budget->is_active,
]);
return back()->with('success', 'Budget updated.');
}
/**
* Add a budget line.
*/
public function addLine(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$validated = $request->validate([
'gl_account_id' => 'required|exists:gl_accounts,id',
'department_id' => 'nullable|exists:departments,id',
'period_type' => 'required|in:monthly,quarterly,yearly',
'amount' => 'required|numeric|min:0',
'year' => 'required|integer|min:2020|max:2100',
]);
$year = (int) $validated['year'];
$amount = (float) $validated['amount'];
// Generate lines based on period type
match ($validated['period_type']) {
'monthly' => $this->budgetService->generateMonthlyLines(
$budget,
(int) $validated['gl_account_id'],
$validated['department_id'] ? (int) $validated['department_id'] : null,
$amount,
$year
),
'quarterly' => $this->budgetService->generateQuarterlyLines(
$budget,
(int) $validated['gl_account_id'],
$validated['department_id'] ? (int) $validated['department_id'] : null,
$amount,
$year
),
'yearly' => BudgetLine::create([
'budget_id' => $budget->id,
'gl_account_id' => (int) $validated['gl_account_id'],
'department_id' => $validated['department_id'] ? (int) $validated['department_id'] : null,
'period_type' => Budget::PERIOD_YEARLY,
'period_start' => "{$year}-01-01",
'period_end' => "{$year}-12-31",
'amount' => $amount,
]),
};
return back()->with('success', 'Budget line(s) added.');
}
/**
* Update budget line amounts.
*/
public function updateLines(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$validated = $request->validate([
'lines' => 'required|array',
'lines.*' => 'required|numeric|min:0',
]);
$this->budgetService->updateBudgetLines($budget, $validated['lines']);
return back()->with('success', 'Budget amounts updated.');
}
/**
* Delete a budget line.
*/
public function deleteLine(Request $request, Business $business, Budget $budget, BudgetLine $line): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
if ($line->budget_id !== $budget->id) {
abort(404);
}
$line->delete();
return back()->with('success', 'Budget line deleted.');
}
/**
* Approve a budget.
*/
public function approve(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$budget->approve(auth()->id());
return back()->with('success', 'Budget approved.');
}
/**
* Unapprove a budget.
*/
public function unapprove(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$budget->unapprove();
return back()->with('success', 'Budget approval removed.');
}
/**
* Copy budget to new fiscal year.
*/
public function copy(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$validated = $request->validate([
'target_year' => 'required|integer|min:2020|max:2100',
]);
$newBudget = $this->budgetService->copyBudget(
$budget,
(int) $validated['target_year'],
auth()->id()
);
return redirect()
->route('seller.business.management.budgets.edit', [$business, $newBudget])
->with('success', 'Budget copied to '.$validated['target_year'].'.');
}
/**
* Delete a budget.
*/
public function destroy(Request $request, Business $business, Budget $budget): RedirectResponse
{
$this->requireManagementSuite($business);
$this->authorizeForBusiness($business, $budget);
$budget->delete();
return redirect()
->route('seller.business.management.budgets.index', $business)
->with('success', 'Budget deleted.');
}
/**
* Validate budget belongs to allowed business.
*/
private function authorizeForBusiness(Business $business, Budget $budget): void
{
$allowedIds = $this->getAllowedBusinessIds($business);
if (! in_array($budget->business_id, $allowedIds)) {
abort(404);
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Services\Accounting\CashFlowForecastService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CashFlowForecastController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected CashFlowForecastService $forecastService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the cash flow forecast.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
// Get forecast options from request
$horizonDays = (int) $request->get('horizon', 60);
$horizonDays = in_array($horizonDays, [30, 60, 90]) ? $horizonDays : 60;
$granularity = $request->get('granularity', 'weekly');
$granularity = in_array($granularity, ['daily', 'weekly']) ? $granularity : 'weekly';
$includeBudgets = $request->boolean('include_budgets', true);
$includeRecurring = $request->boolean('include_recurring', true);
// Determine which business to forecast
$forecastBusiness = $filterData['selected_division'] ?? $business;
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
// Generate forecast
$forecast = $this->forecastService->getForecastTimeline($forecastBusiness, [
'horizon_days' => $horizonDays,
'granularity' => $granularity,
'include_children' => $includeChildren,
'include_budgets' => $includeBudgets,
'include_recurring' => $includeRecurring,
]);
return view('seller.management.financials.cash-flow-forecast', $this->withDivisionFilter([
'business' => $business,
'forecast' => $forecast,
'horizonDays' => $horizonDays,
'granularity' => $granularity,
'includeBudgets' => $includeBudgets,
'includeRecurring' => $includeRecurring,
], $filterData));
}
}

View File

@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ArCustomer;
use App\Models\Accounting\ArInvoice;
use App\Models\Accounting\Budget;
use App\Models\Business;
use App\Services\Accounting\ArAnalyticsService;
use App\Services\Accounting\BudgetService;
use App\Services\Accounting\CashFlowForecastService;
use App\Services\Accounting\FinanceAnalyticsService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CfoDashboardController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected CashFlowForecastService $cashFlowService,
protected ArAnalyticsService $arService,
protected FinanceAnalyticsService $financeService,
protected BudgetService $budgetService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the CFO Dashboard.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
// Determine scope - use snake_case keys from getDivisionFilterData()
$targetBusiness = $filterData['selected_division'] ?? $business;
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
$businessIds = $filterData['business_ids'];
// ═══════════════════════════════════════════════════════════════
// CASH POSITION & FORECAST
// ═══════════════════════════════════════════════════════════════
$cashData = $this->getCashData($targetBusiness, $includeChildren);
// ═══════════════════════════════════════════════════════════════
// AR (RECEIVABLES) DATA
// ═══════════════════════════════════════════════════════════════
$arData = $this->getArData($business, $businessIds);
// ═══════════════════════════════════════════════════════════════
// AP (PAYABLES) DATA
// ═══════════════════════════════════════════════════════════════
$apData = $this->getApData($business, $businessIds);
// ═══════════════════════════════════════════════════════════════
// BUDGET VS ACTUAL
// ═══════════════════════════════════════════════════════════════
$budgetData = $this->getBudgetData($business, $businessIds);
// ═══════════════════════════════════════════════════════════════
// TOP CUSTOMERS & VENDORS
// ═══════════════════════════════════════════════════════════════
$topCustomers = $this->getTopCustomers($businessIds);
$topVendors = $this->getTopVendors($businessIds);
// ═══════════════════════════════════════════════════════════════
// RISK INDICATORS
// ═══════════════════════════════════════════════════════════════
$riskData = $this->getRiskIndicators($businessIds);
return view('seller.management.cfo.dashboard', $this->withDivisionFilter([
'business' => $business,
'cashData' => $cashData,
'arData' => $arData,
'apData' => $apData,
'budgetData' => $budgetData,
'topCustomers' => $topCustomers,
'topVendors' => $topVendors,
'riskData' => $riskData,
'isParent' => $business->hasChildBusinesses(),
], $filterData));
}
/**
* Get cash position and forecast data.
*/
protected function getCashData(Business $business, bool $includeChildren): array
{
$startingCash = $this->cashFlowService->getStartingCashBalance($business, now(), $includeChildren);
$forecast = $this->cashFlowService->getForecastTimeline($business, [
'horizon_days' => 30,
'granularity' => 'daily',
'include_children' => $includeChildren,
'include_budgets' => true,
'include_recurring' => true,
]);
return [
'current_cash' => $startingCash['gl_balance'],
'plaid_balance' => $startingCash['plaid_balance'],
'plaid_difference' => $startingCash['difference'],
'projected_30d_ending' => $forecast['summary']['ending_cash'],
'projected_30d_min' => $forecast['summary']['min_cash'],
'projected_30d_max' => $forecast['summary']['max_cash'],
'min_cash_date' => $forecast['summary']['min_cash_date'],
'total_inflows' => $forecast['summary']['total_inflows'],
'total_outflows' => $forecast['summary']['total_outflows'],
'timeline' => $forecast['timeline'],
];
}
/**
* Get AR metrics and aging.
*/
protected function getArData(Business $business, array $businessIds): array
{
$metrics = $this->arService->getARMetrics($business, $businessIds);
$aging = $this->arService->getARAging($business, $businessIds);
// Count at-risk and on-hold customers
$atRiskCount = ArCustomer::whereIn('business_id', $businessIds)
->where(function ($q) {
$q->where('on_credit_hold', true)
->orWhereHas('invoices', function ($q2) {
$q2->where('status', ArInvoice::STATUS_OVERDUE)
->where('balance_due', '>', 0);
});
})
->count();
$onHoldCount = ArCustomer::whereIn('business_id', $businessIds)
->where('on_credit_hold', true)
->count();
return [
'total_outstanding' => $metrics['total_outstanding'],
'overdue_amount' => $metrics['overdue_amount'],
'overdue_count' => $metrics['overdue_count'],
'invoice_count' => $metrics['total_invoice_count'],
'ytd_collections' => $metrics['ytd_collections'],
'avg_days_to_pay' => $metrics['avg_days_to_pay'],
'aging' => $aging,
'at_risk_count' => $atRiskCount,
'on_hold_count' => $onHoldCount,
'over_90_amount' => $aging['over_90'] ?? 0,
];
}
/**
* Get AP metrics and aging.
*/
protected function getApData(Business $business, array $businessIds): array
{
$aging = $this->financeService->getAPAging($business);
// Get 30-day AP due
$next30DaysAp = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->where('balance_due', '>', 0)
->whereBetween('due_date', [now(), now()->addDays(30)])
->sum('balance_due');
$pastDueAmount = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->where('balance_due', '>', 0)
->where('due_date', '<', now())
->sum('balance_due');
return [
'total_outstanding' => $aging['total'],
'past_due_amount' => (float) $pastDueAmount,
'next_30d_due' => (float) $next30DaysAp,
'aging_buckets' => $aging['buckets'],
'overdue_bill_count' => $aging['overdue_bills']->count(),
];
}
/**
* Get budget vs actual summary.
*/
protected function getBudgetData(Business $business, array $businessIds): array
{
$currentYear = now()->year;
// Get the first active budget for current year
$budget = Budget::whereIn('business_id', $businessIds)
->where('fiscal_year', $currentYear)
->active()
->first();
if (! $budget) {
return [
'has_budget' => false,
'total_budget' => 0,
'total_actual' => 0,
'variance_amount' => 0,
'variance_percent' => 0,
'top_overbudget' => [],
];
}
$summary = $this->budgetService->getBudgetSummary($budget);
// Get top 3 departments over budget
$topOverbudget = collect($summary['by_department'])
->filter(fn ($dept) => $dept['actual'] > $dept['budget'])
->sortByDesc(fn ($dept) => $dept['actual'] - $dept['budget'])
->take(3)
->values();
return [
'has_budget' => true,
'budget_name' => $budget->name,
'total_budget' => $summary['total_budget'],
'total_actual' => $summary['total_actual'],
'variance_amount' => $summary['variance_amount'],
'variance_percent' => $summary['variance_percent'],
'top_overbudget' => $topOverbudget,
];
}
/**
* Get top AR customers by outstanding balance.
*/
protected function getTopCustomers(array $businessIds): \Illuminate\Support\Collection
{
return ArInvoice::whereIn('ar_invoices.business_id', $businessIds)
->whereNotIn('ar_invoices.status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('ar_invoices.balance_due', '>', 0)
->with(['customer', 'business'])
->get()
->groupBy('customer_id')
->map(function ($invoices) {
$customer = $invoices->first()->customer;
$oldestInvoice = $invoices->sortBy('due_date')->first();
$daysOverdue = $oldestInvoice->due_date && $oldestInvoice->due_date->isPast()
? $oldestInvoice->due_date->diffInDays(now())
: 0;
return [
'customer' => $customer,
'division' => $invoices->first()->business,
'balance' => (float) $invoices->sum('balance_due'),
'invoice_count' => $invoices->count(),
'days_overdue' => $daysOverdue,
'on_hold' => $customer?->on_credit_hold ?? false,
];
})
->sortByDesc('balance')
->take(5)
->values();
}
/**
* Get top AP vendors by outstanding balance.
*/
protected function getTopVendors(array $businessIds): \Illuminate\Support\Collection
{
return ApBill::whereIn('ap_bills.business_id', $businessIds)
->whereIn('ap_bills.status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->where('ap_bills.balance_due', '>', 0)
->with(['vendor', 'business'])
->get()
->groupBy('vendor_id')
->map(function ($bills) {
$vendor = $bills->first()->vendor;
$oldestBill = $bills->sortBy('due_date')->first();
$daysOverdue = $oldestBill->due_date && $oldestBill->due_date->isPast()
? $oldestBill->due_date->diffInDays(now())
: 0;
// Get divisions
$divisions = $bills->pluck('business')->unique('id');
return [
'vendor' => $vendor,
'divisions' => $divisions,
'balance' => (float) $bills->sum('balance_due'),
'bill_count' => $bills->count(),
'days_overdue' => $daysOverdue,
];
})
->sortByDesc('balance')
->take(5)
->values();
}
/**
* Get risk indicators summary.
*/
protected function getRiskIndicators(array $businessIds): array
{
// Credit holds
$creditHoldsCount = ArCustomer::whereIn('business_id', $businessIds)
->where('on_credit_hold', true)
->count();
// Severely overdue AR (90+ days)
$severeArCount = ArInvoice::whereIn('business_id', $businessIds)
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->where('due_date', '<', now()->subDays(90))
->count();
// Past due AP
$pastDueApCount = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->where('balance_due', '>', 0)
->where('due_date', '<', now())
->count();
return [
'credit_holds' => $creditHoldsCount,
'severe_ar_count' => $severeArCount,
'past_due_ap_count' => $pastDueApCount,
'total_risks' => $creditHoldsCount + $severeArCount + $pastDueApCount,
];
}
}

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ChartOfAccountsController extends Controller
{
/**
* Display the Chart of Accounts.
*/
public function index(Request $request, Business $business): View
{
$parentBusiness = $this->getParentBusiness($business);
$query = GlAccount::where('business_id', $parentBusiness->id)
->orderBy('account_number');
// Filter by account type
if ($request->filled('type')) {
$query->where('account_type', $request->type);
}
// Filter by account class
if ($request->filled('class')) {
$query->where('account_class', $request->class);
}
// Filter by active status
if ($request->filled('active')) {
$query->where('is_active', $request->active === 'true');
}
// Search by number or name
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('account_number', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
}
$accounts = $query->get();
// Group accounts by type for tree view
$accountsByType = $accounts->groupBy('account_type');
// Summary stats
$stats = [
'total' => $accounts->count(),
'active' => $accounts->where('is_active', true)->count(),
'inactive' => $accounts->where('is_active', false)->count(),
'reconciliation' => $accounts->where('is_reconciliation', true)->count(),
'system' => $accounts->where('is_system', true)->count(),
];
return view('seller.management.chart-of-accounts.index', compact(
'business',
'parentBusiness',
'accounts',
'accountsByType',
'stats'
));
}
/**
* Show form to create a new GL account.
*/
public function create(Request $request, Business $business): View
{
$parentBusiness = $this->getParentBusiness($business);
// Get existing accounts for parent selection
$parentAccounts = GlAccount::where('business_id', $parentBusiness->id)
->where('is_header', true)
->orderBy('account_number')
->get();
return view('seller.management.chart-of-accounts.create', compact(
'business',
'parentBusiness',
'parentAccounts'
));
}
/**
* Store a new GL account.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$parentBusiness = $this->getParentBusiness($business);
$validated = $request->validate([
'account_number' => [
'required',
'string',
'max:20',
"unique:gl_accounts,account_number,NULL,id,business_id,{$parentBusiness->id}",
],
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'account_type' => 'required|in:asset,liability,equity,revenue,expense',
'account_subtype' => 'nullable|string|max:50',
'parent_account_id' => 'nullable|exists:gl_accounts,id',
'is_header' => 'boolean',
'is_reconciliation' => 'boolean',
'reconciliation_type' => 'nullable|in:ar,ap,fixed_asset,inventory,bank',
'cash_flow_category' => 'nullable|in:operating,investing,financing',
]);
// Set defaults based on account type
$validated['business_id'] = $parentBusiness->id;
$validated['normal_balance'] = GlAccount::getDefaultNormalBalance($validated['account_type']);
$validated['account_class'] = GlAccount::getDefaultAccountClass($validated['account_type']);
$validated['is_operating'] = ! in_array($validated['account_subtype'] ?? '', ['interest', 'other_income', 'other_expense']);
$validated['is_active'] = true;
$validated['is_system'] = false;
GlAccount::create($validated);
return redirect()
->route('seller.business.management.chart-of-accounts.index', $business)
->with('success', 'GL Account created successfully.');
}
/**
* Show form to edit a GL account.
*/
public function edit(Request $request, Business $business, GlAccount $account): View
{
$parentBusiness = $this->getParentBusiness($business);
// Verify account belongs to parent business
if ($account->business_id !== $parentBusiness->id) {
abort(403, 'Account does not belong to this organization.');
}
// Get existing accounts for parent selection
$parentAccounts = GlAccount::where('business_id', $parentBusiness->id)
->where('is_header', true)
->where('id', '!=', $account->id)
->orderBy('account_number')
->get();
return view('seller.management.chart-of-accounts.edit', compact(
'business',
'parentBusiness',
'account',
'parentAccounts'
));
}
/**
* Update a GL account.
*/
public function update(Request $request, Business $business, GlAccount $account): RedirectResponse
{
$parentBusiness = $this->getParentBusiness($business);
// Verify account belongs to parent business
if ($account->business_id !== $parentBusiness->id) {
abort(403, 'Account does not belong to this organization.');
}
// System accounts have limited editability
$rules = [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'cash_flow_category' => 'nullable|in:operating,investing,financing',
];
if (! $account->is_system) {
$rules['account_number'] = [
'required',
'string',
'max:20',
"unique:gl_accounts,account_number,{$account->id},id,business_id,{$parentBusiness->id}",
];
$rules['account_type'] = 'required|in:asset,liability,equity,revenue,expense';
$rules['account_subtype'] = 'nullable|string|max:50';
$rules['parent_account_id'] = 'nullable|exists:gl_accounts,id';
$rules['is_header'] = 'boolean';
$rules['is_reconciliation'] = 'boolean';
$rules['reconciliation_type'] = 'nullable|in:ar,ap,fixed_asset,inventory,bank';
}
$validated = $request->validate($rules);
// Update type-dependent fields if type changed
if (! $account->is_system && isset($validated['account_type'])) {
$validated['normal_balance'] = GlAccount::getDefaultNormalBalance($validated['account_type']);
$validated['account_class'] = GlAccount::getDefaultAccountClass($validated['account_type']);
}
$account->update($validated);
return redirect()
->route('seller.business.management.chart-of-accounts.index', $business)
->with('success', 'GL Account updated successfully.');
}
/**
* Toggle active status of a GL account.
*/
public function toggleActive(Request $request, Business $business, GlAccount $account): RedirectResponse
{
$parentBusiness = $this->getParentBusiness($business);
if ($account->business_id !== $parentBusiness->id) {
abort(403, 'Account does not belong to this organization.');
}
if ($account->is_system) {
return back()->with('error', 'System accounts cannot be deactivated.');
}
if ($account->is_active && ! $account->canBeDeactivated()) {
return back()->with('error', 'Account has open balance and cannot be deactivated.');
}
$account->update(['is_active' => ! $account->is_active]);
$status = $account->is_active ? 'activated' : 'deactivated';
return back()->with('success', "Account {$status} successfully.");
}
/**
* Delete a GL account.
*/
public function destroy(Request $request, Business $business, GlAccount $account): RedirectResponse
{
$parentBusiness = $this->getParentBusiness($business);
if ($account->business_id !== $parentBusiness->id) {
abort(403, 'Account does not belong to this organization.');
}
if (! $account->canBeDeleted()) {
return back()->with('error', 'This account cannot be deleted. It is either a system account or has transactions.');
}
$account->delete();
return redirect()
->route('seller.business.management.chart-of-accounts.index', $business)
->with('success', 'GL Account deleted successfully.');
}
/**
* Get the parent business for GL account management.
* GL accounts belong to the parent company, not divisions.
*/
private function getParentBusiness(Business $business): Business
{
return $business->parent ?? $business;
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ArCustomer;
use App\Models\Business;
use App\Services\Accounting\CustomerFinancialService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DirectoryCustomersController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected CustomerFinancialService $customerService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List AR customers.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$customers = ArCustomer::whereIn('business_id', $filterData['business_ids'])
->with(['business'])
->withCount('invoices')
->orderBy('name')
->paginate(20);
return view('seller.management.directory.customers.index', $this->withDivisionFilter([
'business' => $business,
'customers' => $customers,
], $filterData));
}
/**
* Show customer financial summary.
*/
public function showFinancials(Request $request, Business $business, ArCustomer $customer): View
{
$this->requireManagementSuite($business);
// Validate ownership
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($customer->business_id, $allowedBusinessIds)) {
abort(404);
}
$isParent = $business->hasChildBusinesses();
$includeChildren = $isParent;
$summary = $this->customerService->getFinancialSummary($customer, $business, $includeChildren);
$invoices = $this->customerService->getInvoices($customer, $business, $includeChildren);
$payments = $this->customerService->getPayments($customer, $business, $includeChildren);
$activities = $this->customerService->getRecentActivity($customer, $business);
return view('seller.management.directory.customers.financials', [
'business' => $business,
'customer' => $customer,
'summary' => $summary,
'invoices' => $invoices,
'payments' => $payments,
'activities' => $activities,
'isParent' => $isParent,
]);
}
/**
* Update customer credit limit.
*/
public function updateCreditLimit(Request $request, Business $business, ArCustomer $customer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($customer->business_id !== $business->id) {
abort(403, 'Can only modify customers in your own business.');
}
$validated = $request->validate([
'credit_limit' => 'required|numeric|min:0',
'reason' => 'nullable|string|max:255',
]);
$this->customerService->updateCreditLimit(
$customer,
(float) $validated['credit_limit'],
auth()->id(),
$validated['reason'] ?? null
);
return back()->with('success', 'Credit limit updated.');
}
/**
* Update customer terms.
*/
public function updateTerms(Request $request, Business $business, ArCustomer $customer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($customer->business_id !== $business->id) {
abort(403, 'Can only modify customers in your own business.');
}
$validated = $request->validate([
'payment_terms' => 'required|string|max:50',
]);
$this->customerService->updateTerms(
$customer,
$validated['payment_terms'],
auth()->id()
);
return back()->with('success', 'Payment terms updated.');
}
/**
* Place credit hold.
*/
public function placeCreditHold(Request $request, Business $business, ArCustomer $customer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($customer->business_id !== $business->id) {
abort(403, 'Can only modify customers in your own business.');
}
$validated = $request->validate([
'reason' => 'required|string|max:255',
]);
$this->customerService->placeCreditHold(
$customer,
auth()->id(),
$validated['reason']
);
return back()->with('success', 'Credit hold placed.');
}
/**
* Remove credit hold.
*/
public function removeCreditHold(Request $request, Business $business, ArCustomer $customer): RedirectResponse
{
$this->requireManagementSuite($business);
if ($customer->business_id !== $business->id) {
abort(403, 'Can only modify customers in your own business.');
}
$this->customerService->removeCreditHold(
$customer,
auth()->id()
);
return back()->with('success', 'Credit hold removed.');
}
/**
* Add a note.
*/
public function addNote(Request $request, Business $business, ArCustomer $customer): RedirectResponse
{
$this->requireManagementSuite($business);
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($customer->business_id, $allowedBusinessIds)) {
abort(404);
}
$validated = $request->validate([
'note' => 'required|string|max:1000',
]);
$this->customerService->addNote(
$customer,
auth()->id(),
$validated['note']
);
return back()->with('success', 'Note added.');
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
use App\Models\Business;
use App\Services\Accounting\VendorFinancialService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DirectoryVendorsController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected VendorFinancialService $vendorService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List AP vendors.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$isParent = $business->hasChildBusinesses();
$vendors = ApVendor::whereIn('business_id', $filterData['business_ids'])
->with(['business'])
->withCount('bills')
->orderBy('name')
->paginate(20);
// For parent companies, load division usage info for vendors
$vendorDivisionUsage = [];
if ($isParent && $vendors->isNotEmpty()) {
// Get all divisions that have bills with each vendor
$vendorIds = $vendors->pluck('id');
$billsByVendor = \App\Models\Accounting\ApBill::whereIn('vendor_id', $vendorIds)
->selectRaw('vendor_id, business_id, COUNT(*) as bill_count')
->groupBy('vendor_id', 'business_id')
->with('business:id,name,division_name')
->get();
foreach ($billsByVendor as $bill) {
if (! isset($vendorDivisionUsage[$bill->vendor_id])) {
$vendorDivisionUsage[$bill->vendor_id] = [];
}
$vendorDivisionUsage[$bill->vendor_id][] = [
'business' => $bill->business,
'bill_count' => $bill->bill_count,
];
}
}
return view('seller.management.directory.vendors.index', $this->withDivisionFilter([
'business' => $business,
'vendors' => $vendors,
'vendorDivisionUsage' => $vendorDivisionUsage,
], $filterData));
}
/**
* Show vendor financial summary.
*/
public function showFinancials(Request $request, Business $business, ApVendor $vendor): View
{
$this->requireManagementSuite($business);
// Validate ownership
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(404);
}
$isParent = $business->hasChildBusinesses();
$includeChildren = $isParent;
$summary = $this->vendorService->getFinancialSummary($vendor, $business, $includeChildren);
$bills = $this->vendorService->getBills($vendor, $business, $includeChildren);
$payments = $this->vendorService->getPayments($vendor, $business, $includeChildren);
$activities = $this->vendorService->getRecentActivity($vendor, $business);
return view('seller.management.directory.vendors.financials', [
'business' => $business,
'vendor' => $vendor,
'summary' => $summary,
'bills' => $bills,
'payments' => $payments,
'activities' => $activities,
'isParent' => $isParent,
]);
}
/**
* Update vendor terms.
*/
public function updateTerms(Request $request, Business $business, ApVendor $vendor): RedirectResponse
{
$this->requireManagementSuite($business);
if ($vendor->business_id !== $business->id) {
abort(403, 'Can only modify vendors in your own business.');
}
$validated = $request->validate([
'payment_terms' => 'required|string|max:50',
]);
$this->vendorService->updateTerms(
$vendor,
$validated['payment_terms'],
auth()->id()
);
return back()->with('success', 'Payment terms updated.');
}
/**
* Add a note.
*/
public function addNote(Request $request, Business $business, ApVendor $vendor): RedirectResponse
{
$this->requireManagementSuite($business);
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($vendor->business_id, $allowedBusinessIds)) {
abort(404);
}
$validated = $request->validate([
'note' => 'required|string|max:1000',
]);
$this->vendorService->addNote(
$vendor,
auth()->id(),
$validated['note']
);
return back()->with('success', 'Note added.');
}
}

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\Expense;
use App\Models\Business;
use App\Models\Department;
use App\Services\Accounting\ExpenseService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Management Suite Expenses Controller.
*
* Handles expense approval, rejection, and payment by finance team.
* Parent companies can see and manage expenses from all child businesses.
*/
class ExpensesController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected ExpenseService $expenseService
) {}
/**
* Validate that the expense belongs to the current business or its divisions.
*/
private function validateExpenseOwnership(Business $business, Expense $expense): void
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($expense->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
}
/**
* Ensure the business has Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List expenses for management review.
*
* GET /s/{business}/management/expenses
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$query = Expense::whereIn('business_id', $filterData['business_ids'])
->with(['department', 'createdBy', 'approvedBy', 'business', 'items']);
// Status filter
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Department filter
if ($request->filled('department_id')) {
$query->where('department_id', $request->department_id);
}
// Payment method filter
if ($request->filled('payment_method')) {
$query->where('payment_method', $request->payment_method);
}
// Date range
if ($request->filled('from_date')) {
$query->whereDate('expense_date', '>=', $request->from_date);
}
if ($request->filled('to_date')) {
$query->whereDate('expense_date', '<=', $request->to_date);
}
$expenses = $query->orderByDesc('expense_date')->paginate(20)->withQueryString();
// Get departments for filter (from all filtered businesses)
$departments = Department::whereIn('business_id', $filterData['business_ids'])
->where('is_active', true)
->orderBy('name')
->get();
// Metrics
$metrics = $this->expenseService->getExpenseMetrics($business, $filterData['business_ids']);
return view('seller.management.expenses.index', $this->withDivisionFilter([
'business' => $business,
'expenses' => $expenses,
'departments' => $departments,
'metrics' => $metrics,
'statuses' => Expense::getStatuses(),
'paymentMethods' => Expense::getPaymentMethods(),
], $filterData));
}
/**
* Show expense details for management review.
*
* GET /s/{business}/management/expenses/{expense}
*/
public function show(Request $request, Business $business, Expense $expense): View
{
$this->requireManagementSuite($business);
$this->validateExpenseOwnership($business, $expense);
$expense->load([
'items.glAccount',
'items.department',
'department',
'createdBy',
'approvedBy',
'paidBy',
'business',
'journalEntry',
'apBill',
]);
$isParent = $business->hasChildBusinesses();
return view('seller.management.expenses.show', compact('business', 'expense', 'isParent'));
}
/**
* Approve an expense.
*
* POST /s/{business}/management/expenses/{expense}/approve
*/
public function approve(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->requireManagementSuite($business);
$this->validateExpenseOwnership($business, $expense);
$validated = $request->validate([
'payment_method' => 'nullable|string|in:'.implode(',', array_keys(Expense::getPaymentMethods())),
]);
try {
$this->expenseService->approveExpense($expense, auth()->user(), $validated);
return back()->with('success', "Expense {$expense->expense_number} approved.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Reject an expense.
*
* POST /s/{business}/management/expenses/{expense}/reject
*/
public function reject(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->requireManagementSuite($business);
$this->validateExpenseOwnership($business, $expense);
$validated = $request->validate([
'rejection_reason' => 'nullable|string|max:500',
]);
try {
$this->expenseService->rejectExpense($expense, auth()->user(), $validated['rejection_reason'] ?? null);
return back()->with('success', "Expense {$expense->expense_number} rejected.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Mark an expense as paid.
*
* POST /s/{business}/management/expenses/{expense}/mark-paid
*/
public function markPaid(Request $request, Business $business, Expense $expense): RedirectResponse
{
$this->requireManagementSuite($business);
$this->validateExpenseOwnership($business, $expense);
$validated = $request->validate([
'paid_date' => 'nullable|date',
'reference' => 'nullable|string|max:255',
]);
try {
$this->expenseService->markExpensePaid($expense, auth()->user(), $validated);
return back()->with('success', "Expense {$expense->expense_number} marked as paid.");
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Bulk approve expenses.
*
* POST /s/{business}/management/expenses/bulk-approve
*/
public function bulkApprove(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$validated = $request->validate([
'expense_ids' => 'required|array|min:1',
'expense_ids.*' => 'integer|exists:expenses,id',
]);
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
$approver = auth()->user();
$approved = 0;
$errors = [];
foreach ($validated['expense_ids'] as $expenseId) {
$expense = Expense::find($expenseId);
if (! $expense || ! in_array($expense->business_id, $allowedBusinessIds)) {
continue;
}
try {
$this->expenseService->approveExpense($expense, $approver);
$approved++;
} catch (\InvalidArgumentException $e) {
$errors[] = "{$expense->expense_number}: {$e->getMessage()}";
}
}
$message = "{$approved} expense(s) approved.";
if (count($errors) > 0) {
$message .= ' Errors: '.implode('; ', $errors);
}
return back()->with($errors ? 'warning' : 'success', $message);
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Services\Accounting\ArService;
use App\Services\Accounting\FinanceAnalyticsService;
use App\Services\Accounting\ReportExportService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FinanceController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected FinanceAnalyticsService $analyticsService,
protected ArService $arService,
protected ReportExportService $exportService
) {}
public function apAging(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
$aging = $this->analyticsService->getAPAging($business, $filterData['business_ids']);
$byDivision = $this->analyticsService->getAPBreakdownByDivision($business, $filterData['business_ids']);
$byVendor = $this->analyticsService->getAPBreakdownByVendor($business, $filterData['business_ids']);
return view('seller.management.finance.ap-aging', $this->withDivisionFilter([
'business' => $business,
'aging' => $aging,
'byDivision' => $byDivision,
'byVendor' => $byVendor,
], $filterData));
}
public function cashForecast(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
$days = $request->integer('days', 30);
$days = in_array($days, [7, 14, 30]) ? $days : 30;
$forecast = $this->analyticsService->getCashForecast($business, $days, $filterData['business_ids']);
return view('seller.management.finance.cash-forecast', $this->withDivisionFilter([
'business' => $business,
'forecast' => $forecast,
'days' => $days,
], $filterData));
}
public function divisionRollup(Request $request, Business $business)
{
if (! $this->analyticsService->isParentCompany($business)) {
abort(403, 'Only parent companies can view divisional rollups.');
}
$divisions = $this->analyticsService->getDivisionRollup($business);
$totals = [
// AP Totals
'ap_outstanding' => $divisions->sum('ap_outstanding'),
'ap_overdue' => $divisions->sum('ap_overdue'),
'ytd_payments' => $divisions->sum('ytd_payments'),
'pending_approval' => $divisions->sum('pending_approval'),
// AR Totals
'ar_total' => $divisions->sum('ar_total'),
'ar_overdue' => $divisions->sum('ar_overdue'),
'ar_at_risk' => $divisions->sum('ar_at_risk'),
'ar_on_hold' => $divisions->sum('ar_on_hold'),
];
return view('seller.management.finance.divisions', compact('business', 'divisions', 'totals'));
}
public function vendorSpend(Request $request, Business $business)
{
$isParent = $this->analyticsService->isParentCompany($business);
$divisions = collect();
$selectedDivisionId = null;
$selectedDivision = null;
if ($isParent) {
$divisions = $this->analyticsService->getChildBusinesses($business);
$divisionIdParam = $request->get('division_id');
if ($divisionIdParam && $divisionIdParam !== 'all') {
$selectedDivisionId = (int) $divisionIdParam;
$selectedDivision = $divisions->firstWhere('id', $selectedDivisionId);
if (! $selectedDivision) {
$selectedDivisionId = null;
}
}
}
$spend = $this->analyticsService->getVendorSpend($business, $selectedDivisionId);
return view('seller.management.finance.vendor-spend', compact(
'business', 'spend', 'isParent', 'divisions', 'selectedDivisionId', 'selectedDivision'
));
}
public function index(Request $request, Business $business)
{
$filterData = $this->getDivisionFilterData($business, $request);
// AP Data
$aging = $this->analyticsService->getAPAging($business, $filterData['business_ids']);
$forecast = $this->analyticsService->getCashForecast($business, 7, $filterData['business_ids']);
// AR Data
$arSummary = $this->arService->getArSummary($business, $filterData['business_ids']);
$topArAccounts = $this->arService->getTopArAccounts($business, 5, $filterData['business_ids']);
return view('seller.management.finance.index', $this->withDivisionFilter([
'business' => $business,
'aging' => $aging,
'forecast' => $forecast,
'arSummary' => $arSummary,
'topArAccounts' => $topArAccounts,
], $filterData));
}
/**
* Export AP Aging report as CSV.
*/
public function exportApAging(Request $request, Business $business): StreamedResponse
{
$filterData = $this->getDivisionFilterData($business, $request);
$byVendor = $this->analyticsService->getAPBreakdownByVendor($business, $filterData['business_ids']);
$filename = 'ap_aging_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
return $this->exportService->exportApAging($byVendor, $filename);
}
/**
* Export AR Aging report as CSV.
*/
public function exportArAging(Request $request, Business $business): StreamedResponse
{
$filterData = $this->getDivisionFilterData($business, $request);
$arAccounts = $this->arService->getArAgingReport($business, $filterData['business_ids']);
$filename = 'ar_aging_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
return $this->exportService->exportArAging($arAccounts, $filename);
}
/**
* Export Cash Flow Forecast as CSV.
*/
public function exportCashForecast(Request $request, Business $business): StreamedResponse
{
$filterData = $this->getDivisionFilterData($business, $request);
$days = $request->integer('days', 30);
$forecast = $this->analyticsService->getCashForecast($business, $days, $filterData['business_ids']);
$filename = 'cash_forecast_'.$business->slug.'_'.now()->format('Y-m-d').'.csv';
return $this->exportService->exportCashFlowForecast($forecast, $filename);
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\User;
use App\Services\Accounting\PeriodLockService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FinanceRolesController extends Controller
{
public function __construct(
protected PeriodLockService $periodLockService
) {}
/**
* Display finance role settings.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_manage_finance_roles');
// Get all users in this business
$users = User::whereHas('businesses', function ($query) use ($business) {
$query->where('businesses.id', $business->id);
})->with(['businesses' => function ($query) use ($business) {
$query->where('businesses.id', $business->id);
}])->orderBy('name')->get();
// Add finance roles to each user
$users->each(function ($user) use ($business) {
$user->finance_roles = $this->periodLockService->getUserFinanceRoles($business, $user);
$user->finance_permissions = $this->periodLockService->getUserPermissions($business, $user);
});
$availableRoles = config('finance_roles.roles', []);
$allPermissions = config('finance_roles.permissions', []);
return view('seller.management.settings.finance-roles', [
'business' => $business,
'users' => $users,
'availableRoles' => $availableRoles,
'allPermissions' => $allPermissions,
]);
}
/**
* Update finance roles for a user.
*/
public function update(Request $request, Business $business, User $user): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_manage_finance_roles');
$validated = $request->validate([
'finance_roles' => 'nullable|array',
'finance_roles.*' => 'string|in:'.implode(',', array_keys(config('finance_roles.roles', []))),
]);
// Ensure user belongs to this business
$pivot = $user->businesses()->where('businesses.id', $business->id)->first()?->pivot;
if (! $pivot) {
return back()->with('error', 'User does not belong to this business.');
}
// Update the pivot record
$user->businesses()->updateExistingPivot($business->id, [
'finance_roles' => json_encode($validated['finance_roles'] ?? []),
]);
return back()->with('success', "Finance roles updated for {$user->name}.");
}
/**
* Bulk update finance roles for multiple users.
*/
public function bulkUpdate(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requirePermission($business, $request->user(), 'can_manage_finance_roles');
$validated = $request->validate([
'users' => 'required|array',
'users.*.id' => 'required|exists:users,id',
'users.*.finance_roles' => 'nullable|array',
'users.*.finance_roles.*' => 'string|in:'.implode(',', array_keys(config('finance_roles.roles', []))),
]);
$updated = 0;
foreach ($validated['users'] as $userData) {
$user = User::find($userData['id']);
// Ensure user belongs to this business
$pivot = $user->businesses()->where('businesses.id', $business->id)->first()?->pivot;
if ($pivot) {
$user->businesses()->updateExistingPivot($business->id, [
'finance_roles' => json_encode($userData['finance_roles'] ?? []),
]);
$updated++;
}
}
return back()->with('success', "Finance roles updated for {$updated} users.");
}
/**
* Require Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Require a specific finance permission.
*/
private function requirePermission(Business $business, User $user, string $permission): void
{
// Business owners always have access
if ($business->owner_user_id === $user->id) {
return;
}
// Check bypass mode
if (config('finance_roles.bypass_permissions', false)) {
return;
}
if (! $this->periodLockService->userHasPermission($business, $user, $permission)) {
abort(403, 'You do not have permission to manage finance roles.');
}
}
}

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Services\Accounting\AccountingReportingService;
use App\Services\Accounting\ReportExportService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FinancialsController extends Controller
{
public function __construct(
protected AccountingReportingService $reportingService,
protected ReportExportService $exportService
) {}
/**
* Profit & Loss Statement.
*
* GET /s/{business}/management/financials/profit-and-loss
*/
public function profitAndLoss(Request $request, Business $business)
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$pnl = $this->reportingService->getProfitAndLoss(
$business,
$fromDate,
$toDate,
$isParent && $includeChildren
);
// Get prior period for comparison (same duration, previous period)
$periodDays = now()->parse($fromDate)->diffInDays(now()->parse($toDate));
$priorFromDate = now()->parse($fromDate)->subDays($periodDays + 1)->format('Y-m-d');
$priorToDate = now()->parse($fromDate)->subDay()->format('Y-m-d');
$priorPnl = $this->reportingService->getProfitAndLoss(
$business,
$priorFromDate,
$priorToDate,
$isParent && $includeChildren
);
return view('seller.management.financials.profit-and-loss', compact(
'business',
'pnl',
'priorPnl',
'fromDate',
'toDate',
'includeChildren',
'isParent'
));
}
/**
* Balance Sheet.
*
* GET /s/{business}/management/financials/balance-sheet
*/
public function balanceSheet(Request $request, Business $business)
{
$asOfDate = $request->get('as_of_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$balanceSheet = $this->reportingService->getBalanceSheet(
$business,
$asOfDate,
$isParent && $includeChildren
);
return view('seller.management.financials.balance-sheet', compact(
'business',
'balanceSheet',
'asOfDate',
'includeChildren',
'isParent'
));
}
/**
* Cash Flow Statement (Indirect Method).
*
* GET /s/{business}/management/financials/cash-flow
*/
public function cashFlow(Request $request, Business $business)
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$cashFlow = $this->reportingService->getCashFlowIndirect(
$business,
$fromDate,
$toDate,
$isParent && $includeChildren
);
return view('seller.management.financials.cash-flow', compact(
'business',
'cashFlow',
'fromDate',
'toDate',
'includeChildren',
'isParent'
));
}
/**
* Consolidated P&L - Side-by-side comparison of all divisions.
*
* GET /s/{business}/management/financials/consolidated-pnl
*/
public function consolidatedPnl(Request $request, Business $business)
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$parentBusiness = $business->parent ?? $business;
$divisions = Business::where('parent_id', $parentBusiness->id)
->orderBy('name')
->get();
// Get P&L for each division
$divisionPnls = [];
foreach ($divisions as $division) {
$divisionPnls[$division->id] = [
'division' => $division,
'pnl' => $this->reportingService->getProfitAndLoss($division, $fromDate, $toDate, false),
];
}
// Get consolidated (total) P&L
$consolidatedPnl = $this->reportingService->getProfitAndLoss(
$parentBusiness,
$fromDate,
$toDate,
true
);
return view('seller.management.financials.consolidated-pnl', compact(
'business',
'parentBusiness',
'divisions',
'divisionPnls',
'consolidatedPnl',
'fromDate',
'toDate'
));
}
/**
* Consolidated Balance Sheet - Side-by-side comparison of all divisions.
*
* GET /s/{business}/management/financials/consolidated-balance-sheet
*/
public function consolidatedBalanceSheet(Request $request, Business $business)
{
$asOfDate = $request->get('as_of_date', now()->format('Y-m-d'));
$parentBusiness = $business->parent ?? $business;
$divisions = Business::where('parent_id', $parentBusiness->id)
->orderBy('name')
->get();
// Get Balance Sheet for each division
$divisionBalanceSheets = [];
foreach ($divisions as $division) {
$divisionBalanceSheets[$division->id] = [
'division' => $division,
'balanceSheet' => $this->reportingService->getBalanceSheet($division, $asOfDate, false),
];
}
// Get consolidated Balance Sheet
$consolidatedBalanceSheet = $this->reportingService->getBalanceSheet(
$parentBusiness,
$asOfDate,
true
);
return view('seller.management.financials.consolidated-balance-sheet', compact(
'business',
'parentBusiness',
'divisions',
'divisionBalanceSheets',
'consolidatedBalanceSheet',
'asOfDate'
));
}
/**
* Export Profit & Loss as CSV.
*
* GET /s/{business}/management/financials/profit-and-loss/export
*/
public function exportProfitAndLoss(Request $request, Business $business): StreamedResponse
{
$fromDate = $request->get('from_date', now()->startOfYear()->format('Y-m-d'));
$toDate = $request->get('to_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$pnl = $this->reportingService->getProfitAndLoss(
$business,
$fromDate,
$toDate,
$isParent && $includeChildren
);
$filename = "profit_loss_{$business->slug}_{$fromDate}_to_{$toDate}.csv";
return $this->exportService->exportProfitLoss($pnl, $filename);
}
/**
* Export Balance Sheet as CSV.
*
* GET /s/{business}/management/financials/balance-sheet/export
*/
public function exportBalanceSheet(Request $request, Business $business): StreamedResponse
{
$asOfDate = $request->get('as_of_date', now()->format('Y-m-d'));
$includeChildren = $request->boolean('include_children', true);
$isParent = $this->reportingService->isParentCompany($business);
$balanceSheet = $this->reportingService->getBalanceSheet(
$business,
$asOfDate,
$isParent && $includeChildren
);
$filename = "balance_sheet_{$business->slug}_{$asOfDate}.csv";
return $this->exportService->exportBalanceSheet($balanceSheet, $filename);
}
}

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\FixedAsset;
use App\Models\Accounting\GlAccount;
use App\Models\Business;
use App\Services\Accounting\FixedAssetService;
use App\Support\ManagementDivisionFilter;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FixedAssetsController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected FixedAssetService $assetService
) {}
/**
* Validate that the asset belongs to the current business or its divisions.
* Prevents cross-tenant access via route model binding.
*/
private function validateAssetOwnership(Business $business, FixedAsset $asset): void
{
$allowedBusinessIds = $this->getAllowedBusinessIds($business);
if (! in_array($asset->business_id, $allowedBusinessIds)) {
abort(403, 'Access denied.');
}
}
/**
* Ensure the business has Management Suite access for mutating actions.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Ensure the business is not a child division (read-only access only).
* Only parent companies can create/update/delete assets.
*/
private function requireParentCompany(Business $business): void
{
if ($business->isDivision()) {
abort(403, 'Divisions have read-only access to fixed assets.');
}
}
/**
* Display fixed assets listing.
*/
public function index(Request $request, Business $business): View
{
$filterData = $this->getDivisionFilterData($business, $request);
$query = FixedAsset::whereIn('business_id', $filterData['business_ids'])
->with(['vendor', 'business']);
// Filter by status
if ($status = $request->get('status')) {
$query->where('status', $status);
}
// Filter by category
if ($category = $request->get('category')) {
$query->where('category', $category);
}
$assets = $query->orderBy('name')->paginate(25)->withQueryString();
$metrics = $this->assetService->getAssetMetrics($business, $filterData['business_ids']);
return view('seller.management.fixed-assets.index', $this->withDivisionFilter([
'business' => $business,
'assets' => $assets,
'metrics' => $metrics,
'categories' => FixedAsset::getCategories(),
'statuses' => FixedAsset::getStatuses(),
'currentStatus' => $request->get('status'),
'currentCategory' => $request->get('category'),
], $filterData));
}
/**
* Show create asset form.
* Requires Management Suite and parent company access.
*/
public function create(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$glAccounts = GlAccount::where('business_id', $business->id)
->orderBy('account_number')
->get();
return view('seller.management.fixed-assets.create', [
'business' => $business,
'categories' => FixedAsset::getCategories(),
'glAccounts' => $glAccounts,
]);
}
/**
* Store new asset.
* Requires Management Suite and parent company access.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category' => 'required|string|in:'.implode(',', array_keys(FixedAsset::getCategories())),
'location' => 'nullable|string|max:255',
'serial_number' => 'nullable|string|max:255',
'acquisition_date' => 'required|date',
'acquisition_cost' => 'required|numeric|min:0',
'acquisition_method' => 'required|string|in:purchase,lease,donation',
'useful_life_months' => 'required|integer|min:1',
'salvage_value' => 'nullable|numeric|min:0',
'depreciation_account_id' => 'nullable|exists:gl_accounts,id',
'accumulated_depreciation_account_id' => 'nullable|exists:gl_accounts,id',
'expense_account_id' => 'nullable|exists:gl_accounts,id',
'notes' => 'nullable|string',
]);
$validated['depreciation_method'] = FixedAsset::METHOD_STRAIGHT_LINE;
$validated['salvage_value'] = $validated['salvage_value'] ?? 0;
$asset = $this->assetService->createAsset($business, $validated);
return redirect()
->route('seller.business.management.fixed-assets.show', [$business, $asset])
->with('success', 'Fixed asset created successfully.');
}
/**
* Show asset details.
* Parent companies can view all divisions' assets.
* Divisions can only view their own assets.
*/
public function show(Request $request, Business $business, FixedAsset $fixedAsset): View
{
$this->validateAssetOwnership($business, $fixedAsset);
$fixedAsset->load(['vendor', 'improvements', 'depreciationRuns', 'disposal']);
return view('seller.management.fixed-assets.show', [
'business' => $business,
'asset' => $fixedAsset,
]);
}
/**
* Show edit form.
* Requires Management Suite, parent company access, and asset ownership.
*/
public function edit(Request $request, Business $business, FixedAsset $fixedAsset): View
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$this->validateAssetOwnership($business, $fixedAsset);
$glAccounts = GlAccount::where('business_id', $business->id)
->orderBy('account_number')
->get();
return view('seller.management.fixed-assets.edit', [
'business' => $business,
'asset' => $fixedAsset,
'categories' => FixedAsset::getCategories(),
'glAccounts' => $glAccounts,
]);
}
/**
* Update asset.
* Requires Management Suite, parent company access, and asset ownership.
*/
public function update(Request $request, Business $business, FixedAsset $fixedAsset): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$this->validateAssetOwnership($business, $fixedAsset);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category' => 'required|string|in:'.implode(',', array_keys(FixedAsset::getCategories())),
'location' => 'nullable|string|max:255',
'serial_number' => 'nullable|string|max:255',
'useful_life_months' => 'required|integer|min:1',
'salvage_value' => 'nullable|numeric|min:0',
'depreciation_account_id' => 'nullable|exists:gl_accounts,id',
'accumulated_depreciation_account_id' => 'nullable|exists:gl_accounts,id',
'expense_account_id' => 'nullable|exists:gl_accounts,id',
'notes' => 'nullable|string',
]);
$this->assetService->updateAsset($fixedAsset, $validated);
return redirect()
->route('seller.business.management.fixed-assets.show', [$business, $fixedAsset])
->with('success', 'Fixed asset updated successfully.');
}
/**
* Record an improvement.
* Requires Management Suite, parent company access, and asset ownership.
*/
public function storeImprovement(Request $request, Business $business, FixedAsset $fixedAsset): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$this->validateAssetOwnership($business, $fixedAsset);
$validated = $request->validate([
'description' => 'required|string|max:255',
'improvement_date' => 'required|date',
'cost' => 'required|numeric|min:0',
'extends_life' => 'boolean',
'additional_life_months' => 'nullable|integer|min:1',
'notes' => 'nullable|string',
]);
$this->assetService->recordImprovement($fixedAsset, $validated);
return redirect()
->route('seller.business.management.fixed-assets.show', [$business, $fixedAsset])
->with('success', 'Improvement recorded successfully.');
}
/**
* Run depreciation for a period.
* Requires Management Suite and parent company access.
*/
public function runDepreciation(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$validated = $request->validate([
'period_date' => 'required|date',
]);
$periodDate = Carbon::parse($validated['period_date']);
$filterData = $this->getDivisionFilterData($business, $request);
$runs = $this->assetService->runBatchDepreciation($business, $periodDate, $filterData['business_ids']);
return redirect()
->route('seller.business.management.fixed-assets.index', $business)
->with('success', "Depreciation run complete. {$runs->count()} assets depreciated.");
}
/**
* Show disposal form.
* Requires Management Suite, parent company access, and asset ownership.
*/
public function showDisposeForm(Request $request, Business $business, FixedAsset $fixedAsset): View
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$this->validateAssetOwnership($business, $fixedAsset);
return view('seller.management.fixed-assets.dispose', [
'business' => $business,
'asset' => $fixedAsset,
'methods' => \App\Models\Accounting\FixedAssetDisposal::getMethods(),
]);
}
/**
* Dispose of an asset.
* Requires Management Suite, parent company access, and asset ownership.
*/
public function dispose(Request $request, Business $business, FixedAsset $fixedAsset): RedirectResponse
{
$this->requireManagementSuite($business);
$this->requireParentCompany($business);
$this->validateAssetOwnership($business, $fixedAsset);
$validated = $request->validate([
'disposal_date' => 'required|date',
'disposal_method' => 'required|string',
'proceeds' => 'nullable|numeric|min:0',
'buyer_name' => 'nullable|string|max:255',
'buyer_contact' => 'nullable|string|max:255',
'reason' => 'nullable|string',
'notes' => 'nullable|string',
]);
$validated['proceeds'] = $validated['proceeds'] ?? 0;
$this->assetService->disposeAsset($fixedAsset, $validated);
return redirect()
->route('seller.business.management.fixed-assets.index', $business)
->with('success', 'Asset disposed successfully.');
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ForecastingController extends Controller
{
use ManagementDivisionFilter;
public function index(Request $request, Business $business)
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$businessIds = $filterData['business_ids'];
// Generate 12-month forecast
$forecast = $this->generateForecast($businessIds);
return view('seller.management.forecasting.index', $this->withDivisionFilter([
'business' => $business,
'forecast' => $forecast,
], $filterData));
}
/**
* Require Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
protected function generateForecast(array $businessIds): array
{
// Get historical data for the past 12 months
$historicalData = $this->getHistoricalData($businessIds);
// Calculate trends
$revenueTrend = $this->calculateTrend($historicalData['revenue']);
$expenseTrend = $this->calculateTrend($historicalData['expenses']);
// Generate forecast for next 12 months
$forecastMonths = [];
$lastRevenue = end($historicalData['revenue'])['amount'] ?? 0;
$lastExpenses = end($historicalData['expenses'])['amount'] ?? 0;
for ($i = 1; $i <= 12; $i++) {
$month = Carbon::now()->addMonths($i);
$projectedRevenue = max(0, $lastRevenue * (1 + ($revenueTrend / 100)));
$projectedExpenses = max(0, $lastExpenses * (1 + ($expenseTrend / 100)));
$forecastMonths[] = [
'month' => $month->format('M Y'),
'month_key' => $month->format('Y-m'),
'projected_revenue' => $projectedRevenue,
'projected_expenses' => $projectedExpenses,
'projected_net' => $projectedRevenue - $projectedExpenses,
];
$lastRevenue = $projectedRevenue;
$lastExpenses = $projectedExpenses;
}
return [
'historical' => $historicalData,
'forecast' => $forecastMonths,
'trends' => [
'revenue' => $revenueTrend,
'expenses' => $expenseTrend,
],
'summary' => [
'total_projected_revenue' => collect($forecastMonths)->sum('projected_revenue'),
'total_projected_expenses' => collect($forecastMonths)->sum('projected_expenses'),
'total_projected_net' => collect($forecastMonths)->sum('projected_net'),
],
];
}
protected function getHistoricalData(array $businessIds): array
{
$startDate = Carbon::now()->subMonths(12)->startOfMonth();
$endDate = Carbon::now()->endOfMonth();
// Revenue (from orders)
$revenueByMonth = DB::table('orders')
->whereIn('business_id', $businessIds)
->where('status', 'completed')
->whereBetween('created_at', [$startDate, $endDate])
->select(
DB::raw("TO_CHAR(created_at, 'YYYY-MM') as month_key"),
DB::raw('SUM(total) as amount')
)
->groupBy('month_key')
->orderBy('month_key')
->get()
->keyBy('month_key');
// Expenses (from AP bills)
$expensesByMonth = DB::table('ap_bills')
->whereIn('business_id', $businessIds)
->whereIn('status', ['approved', 'paid'])
->whereBetween('bill_date', [$startDate, $endDate])
->select(
DB::raw("TO_CHAR(bill_date, 'YYYY-MM') as month_key"),
DB::raw('SUM(total) as amount')
)
->groupBy('month_key')
->orderBy('month_key')
->get()
->keyBy('month_key');
// Fill in missing months with zeros
$revenue = [];
$expenses = [];
$current = $startDate->copy();
while ($current <= $endDate) {
$key = $current->format('Y-m');
$revenue[] = [
'month' => $current->format('M Y'),
'month_key' => $key,
'amount' => $revenueByMonth[$key]->amount ?? 0,
];
$expenses[] = [
'month' => $current->format('M Y'),
'month_key' => $key,
'amount' => $expensesByMonth[$key]->amount ?? 0,
];
$current->addMonth();
}
return [
'revenue' => $revenue,
'expenses' => $expenses,
];
}
protected function calculateTrend(array $data): float
{
if (count($data) < 2) {
return 0;
}
$amounts = array_column($data, 'amount');
$n = count($amounts);
// Simple linear regression
$sumX = 0;
$sumY = 0;
$sumXY = 0;
$sumXX = 0;
for ($i = 0; $i < $n; $i++) {
$sumX += $i;
$sumY += $amounts[$i];
$sumXY += $i * $amounts[$i];
$sumXX += $i * $i;
}
$denominator = ($n * $sumXX - $sumX * $sumX);
if ($denominator == 0) {
return 0;
}
$slope = ($n * $sumXY - $sumX * $sumY) / $denominator;
$avgY = $sumY / $n;
if ($avgY == 0) {
return 0;
}
// Convert slope to percentage trend
return ($slope / $avgY) * 100;
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\InterBusinessSettlement;
use App\Models\Business;
use App\Services\Accounting\InterBusinessSettlementService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Inter-Business Settlement - Manage balances and settlements between divisions.
*
* Allows CFOs to:
* - View inter-business balance matrix
* - Create settlements to zero out balances
* - Post settlements (creates journal entries)
*/
class InterBusinessSettlementController extends Controller
{
public function __construct(
protected InterBusinessSettlementService $settlementService
) {}
/**
* Display inter-business balances and settlement history.
*/
public function index(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
// Get balance matrix
$matrixData = $this->settlementService->getBalanceMatrix($parentBusiness);
// Get outstanding balances for quick view
$outstandingBalances = $this->settlementService->getOutstandingBalances($parentBusiness);
// Get recent settlements
$settlements = InterBusinessSettlement::where('parent_business_id', $parentBusiness->id)
->with(['lines.fromBusiness', 'lines.toBusiness', 'createdByUser', 'postedByUser'])
->orderByDesc('created_at')
->limit(20)
->get();
// Calculate totals
$totalOutstanding = $outstandingBalances->sum('balance');
return view('seller.management.inter-business.index', compact(
'business',
'parentBusiness',
'matrixData',
'outstandingBalances',
'settlements',
'totalOutstanding'
));
}
/**
* Show form to create a new settlement.
*/
public function create(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
// Get divisions
$divisions = Business::where('parent_id', $parentBusiness->id)
->orderBy('name')
->get();
// Suggest settlements based on outstanding balances
$suggestedLines = $this->settlementService->suggestSettlements($parentBusiness);
return view('seller.management.inter-business.create', compact(
'business',
'parentBusiness',
'divisions',
'suggestedLines'
));
}
/**
* Store a new settlement (as draft).
*/
public function store(Request $request, Business $business): RedirectResponse
{
$parentBusiness = $business->parent ?? $business;
$validated = $request->validate([
'description' => 'nullable|string|max:500',
'lines' => 'required|array|min:1',
'lines.*.from_business_id' => 'required|exists:businesses,id',
'lines.*.to_business_id' => 'required|exists:businesses,id|different:lines.*.from_business_id',
'lines.*.amount' => 'required|numeric|min:0.01',
'lines.*.description' => 'nullable|string|max:255',
]);
$settlement = $this->settlementService->createSettlement(
$parentBusiness,
$validated['lines'],
$validated['description'],
auth()->id()
);
return redirect()
->route('seller.business.management.inter-business.show', [$business, $settlement])
->with('success', "Settlement {$settlement->settlement_number} created as draft.");
}
/**
* Show settlement details.
*/
public function show(Request $request, Business $business, InterBusinessSettlement $settlement): View
{
$settlement->load(['lines.fromBusiness', 'lines.toBusiness', 'createdByUser', 'postedByUser']);
return view('seller.management.inter-business.show', compact(
'business',
'settlement'
));
}
/**
* Post a settlement (create journal entries).
*/
public function post(Request $request, Business $business, InterBusinessSettlement $settlement): RedirectResponse
{
if (! $settlement->isDraft()) {
return back()->with('error', 'Settlement has already been posted.');
}
try {
$this->settlementService->postSettlement($settlement, auth()->id());
return redirect()
->route('seller.business.management.inter-business.show', [$business, $settlement])
->with('success', "Settlement {$settlement->settlement_number} posted successfully.");
} catch (\RuntimeException $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Quick settle all outstanding balances.
*/
public function settleAll(Request $request, Business $business): RedirectResponse
{
$parentBusiness = $business->parent ?? $business;
$suggestedLines = $this->settlementService->suggestSettlements($parentBusiness);
if (empty($suggestedLines)) {
return back()->with('warning', 'No outstanding inter-business balances to settle.');
}
$settlement = $this->settlementService->createSettlement(
$parentBusiness,
$suggestedLines,
'Complete inter-business settlement',
auth()->id()
);
return redirect()
->route('seller.business.management.inter-business.show', [$business, $settlement])
->with('success', "Settlement {$settlement->settlement_number} created for all outstanding balances. Review and post when ready.");
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Services\Accounting\InventoryValuationService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\Request;
use Illuminate\View\View;
class InventoryValuationController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected InventoryValuationService $valuationService
) {}
/**
* Ensure the business has Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* Display the inventory valuation dashboard.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
// Determine scope - use snake_case keys from getDivisionFilterData()
$targetBusiness = $filterData['selected_division'] ?? $business;
$includeChildren = $filterData['selected_division'] === null && $business->hasChildBusinesses();
$businessIds = $filterData['business_ids'];
// Get valuation data
$summary = $this->valuationService->getValuationSummary($businessIds);
$byType = $this->valuationService->getValuationByType($businessIds);
$byDivision = $includeChildren ? $this->valuationService->getValuationByDivision($businessIds) : collect();
$byCategory = $this->valuationService->getValuationByCategory($businessIds);
$byLocation = $this->valuationService->getValuationByLocation($businessIds);
$topItems = $this->valuationService->getTopItemsByValue($businessIds, 10);
$aging = $this->valuationService->getInventoryAging($businessIds);
$atRisk = $this->valuationService->getInventoryAtRisk($businessIds);
return view('seller.management.inventory-valuation.index', $this->withDivisionFilter([
'business' => $business,
'summary' => $summary,
'byType' => $byType,
'byDivision' => $byDivision,
'byCategory' => $byCategory,
'byLocation' => $byLocation,
'topItems' => $topItems,
'aging' => $aging,
'atRisk' => $atRisk,
'isParent' => $business->hasChildBusinesses(),
], $filterData));
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class OperationsController extends Controller
{
use ManagementDivisionFilter;
public function index(Request $request, Business $business)
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$businessIds = $filterData['business_ids'];
// Collect operations data
$operations = $this->collectOperationsData($businessIds);
return view('seller.management.operations.index', $this->withDivisionFilter([
'business' => $business,
'operations' => $operations,
], $filterData));
}
/**
* Require Management Suite access.
*/
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
protected function collectOperationsData(array $businessIds): array
{
$today = Carbon::today();
$startOfMonth = Carbon::now()->startOfMonth();
$startOfWeek = Carbon::now()->startOfWeek();
// Order stats
$orderStats = DB::table('orders')
->whereIn('business_id', $businessIds)
->select([
DB::raw('COUNT(CASE WHEN status = \'pending\' THEN 1 END) as pending_orders'),
DB::raw('COUNT(CASE WHEN status = \'processing\' THEN 1 END) as processing_orders'),
DB::raw('COUNT(CASE WHEN status = \'completed\' AND created_at >= \''.$startOfMonth->toDateString().'\' THEN 1 END) as completed_this_month'),
DB::raw('COUNT(CASE WHEN created_at >= \''.$startOfWeek->toDateString().'\' THEN 1 END) as orders_this_week'),
])
->first();
// Product stats
$productStats = DB::table('products')
->join('brands', 'products.brand_id', '=', 'brands.id')
->whereIn('brands.business_id', $businessIds)
->select([
DB::raw('COUNT(*) as total_products'),
DB::raw('COUNT(CASE WHEN products.is_active = true THEN 1 END) as active_products'),
DB::raw('COUNT(CASE WHEN products.quantity_on_hand <= products.low_stock_threshold AND products.quantity_on_hand > 0 THEN 1 END) as low_stock_products'),
DB::raw('COUNT(CASE WHEN products.quantity_on_hand = 0 THEN 1 END) as out_of_stock_products'),
])
->first();
// Customer stats (AR customers)
$customerStats = DB::table('ar_customers')
->whereIn('business_id', $businessIds)
->where('is_active', true)
->select([
DB::raw('COUNT(*) as total_customers'),
DB::raw('COUNT(CASE WHEN created_at >= \''.$startOfMonth->toDateString().'\' THEN 1 END) as new_this_month'),
])
->first();
// Bill stats
$billStats = DB::table('ap_bills')
->whereIn('business_id', $businessIds)
->select([
DB::raw('COUNT(CASE WHEN status = \'pending\' THEN 1 END) as pending_bills'),
DB::raw('COUNT(CASE WHEN status = \'approved\' THEN 1 END) as approved_bills'),
DB::raw('COUNT(CASE WHEN status = \'overdue\' THEN 1 END) as overdue_bills'),
DB::raw('COALESCE(SUM(CASE WHEN status IN (\'pending\', \'approved\') THEN total ELSE 0 END), 0) as pending_amount'),
])
->first();
// Expense stats
$expenseStats = DB::table('expenses')
->whereIn('business_id', $businessIds)
->select([
DB::raw('COUNT(CASE WHEN status = \'pending\' THEN 1 END) as pending_expenses'),
DB::raw('COALESCE(SUM(CASE WHEN status = \'pending\' THEN total_amount ELSE 0 END), 0) as pending_amount'),
])
->first();
// Recent activity
$recentOrders = DB::table('orders')
->join('businesses', 'orders.business_id', '=', 'businesses.id')
->whereIn('orders.business_id', $businessIds)
->orderByDesc('orders.created_at')
->limit(5)
->select(['orders.*', 'businesses.name as business_name'])
->get();
return [
'orders' => $orderStats,
'products' => $productStats,
'customers' => $customerStats,
'bills' => $billStats,
'expenses' => $expenseStats,
'recent_orders' => $recentOrders,
];
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\User;
use App\Services\Management\ManagementPermissionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Management Suite Permissions Controller.
*
* Allows CFOs/Admins to manage fine-grained permissions for users
* within the Management Suite.
*/
class PermissionsController extends Controller
{
public function __construct(
protected ManagementPermissionService $permissionService
) {}
/**
* Display list of users and their permission levels.
*/
public function index(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$users = $this->permissionService->getUsersWithAccess($parentBusiness);
// Get permission summary for each user
$userSummaries = [];
foreach ($users as $user) {
$userSummaries[$user->id] = [
'user' => $user,
'role' => $user->businesses->first()?->pivot?->role ?? 'member',
'summary' => $this->permissionService->getPermissionSummary($user, $parentBusiness),
'permissions' => $this->permissionService->getUserPermissions($user, $parentBusiness),
];
}
$roleTemplates = $this->permissionService->getRoleTemplates();
return view('seller.management.permissions.index', compact(
'business',
'parentBusiness',
'users',
'userSummaries',
'roleTemplates'
));
}
/**
* Show edit form for a user's permissions.
*/
public function edit(Request $request, Business $business, User $user): View
{
$parentBusiness = $business->parent ?? $business;
// Verify user belongs to this business
$pivot = $user->businesses()
->where('businesses.id', $parentBusiness->id)
->first()?->pivot;
if (! $pivot) {
abort(404, 'User not found in this business.');
}
$permissionCategories = $this->permissionService->getPermissionDefinitions();
$currentPermissions = $this->permissionService->getUserPermissions($user, $parentBusiness);
$roleTemplates = $this->permissionService->getRoleTemplates();
return view('seller.management.permissions.edit', compact(
'business',
'parentBusiness',
'user',
'pivot',
'permissionCategories',
'currentPermissions',
'roleTemplates'
));
}
/**
* Update a user's permissions.
*/
public function update(Request $request, Business $business, User $user): RedirectResponse
{
$parentBusiness = $business->parent ?? $business;
// Verify user belongs to this business
$pivot = $user->businesses()
->where('businesses.id', $parentBusiness->id)
->first()?->pivot;
if (! $pivot) {
abort(404, 'User not found in this business.');
}
$validated = $request->validate([
'permissions' => 'nullable|array',
'permissions.*' => 'string',
]);
$permissions = $validated['permissions'] ?? [];
$this->permissionService->setUserPermissions($user, $parentBusiness, $permissions);
return redirect()
->route('seller.business.management.permissions.index', $business)
->with('success', "Permissions updated for {$user->name}.");
}
/**
* Apply a role template to a user.
*/
public function applyTemplate(Request $request, Business $business, User $user): RedirectResponse
{
$parentBusiness = $business->parent ?? $business;
// Verify user belongs to this business
$pivot = $user->businesses()
->where('businesses.id', $parentBusiness->id)
->first()?->pivot;
if (! $pivot) {
abort(404, 'User not found in this business.');
}
$validated = $request->validate([
'template' => 'required|string',
]);
$templates = $this->permissionService->getRoleTemplates();
if (! isset($templates[$validated['template']])) {
return back()->with('error', 'Invalid role template.');
}
$this->permissionService->applyRoleTemplate($user, $parentBusiness, $validated['template']);
$templateLabel = $templates[$validated['template']]['label'];
return redirect()
->route('seller.business.management.permissions.edit', [$business, $user])
->with('success', "{$templateLabel} template applied to {$user->name}.");
}
}

View File

@@ -0,0 +1,419 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
use App\Models\Accounting\ArCustomer;
use App\Models\Accounting\GlAccount;
use App\Models\Accounting\RecurringApTemplate;
use App\Models\Accounting\RecurringArTemplate;
use App\Models\Accounting\RecurringJournalEntryTemplate;
use App\Models\Accounting\RecurringSchedule;
use App\Models\Business;
use App\Models\Department;
use App\Services\Accounting\RecurringSchedulerService;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RecurringController extends Controller
{
use ManagementDivisionFilter;
public function __construct(
protected RecurringSchedulerService $schedulerService
) {}
private function requireManagementSuite(Business $business): void
{
if (! $business->hasManagementSuite()) {
abort(403, 'Management Suite access required.');
}
}
/**
* List recurring schedules.
*/
public function index(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filters = [
'type' => $request->type,
'is_active' => $request->has('is_active') ? (bool) $request->is_active : null,
];
$schedules = $this->schedulerService->getSchedulesForBusiness($business, $filters);
return view('seller.management.recurring.index', [
'business' => $business,
'schedules' => $schedules,
'types' => RecurringSchedule::getTypes(),
]);
}
/**
* Show create form.
*/
public function create(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$type = $request->type ?? RecurringSchedule::TYPE_AR_INVOICE;
return view('seller.management.recurring.create', [
'business' => $business,
'type' => $type,
'types' => RecurringSchedule::getTypes(),
'frequencies' => RecurringSchedule::getFrequencies(),
'weekdays' => RecurringSchedule::getWeekdays(),
'customers' => ArCustomer::where('business_id', $business->id)->orderBy('name')->get(),
'vendors' => ApVendor::where('business_id', $business->id)->orderBy('name')->get(),
'glAccounts' => GlAccount::where('business_id', $business->id)->orderBy('account_number')->get(),
'departments' => Department::where('business_id', $business->id)->where('is_active', true)->orderBy('name')->get(),
]);
}
/**
* Store new recurring schedule.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$this->requireManagementSuite($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'type' => 'required|in:ar_invoice,ap_bill,journal_entry',
'frequency' => 'required|in:weekly,monthly,quarterly,yearly,custom',
'interval' => 'required|integer|min:1|max:365',
'day_of_month' => 'nullable|integer|min:1|max:31',
'weekday' => 'nullable|string',
'next_run_date' => 'required|date|after_or_equal:today',
'end_date' => 'nullable|date|after:next_run_date',
'auto_post' => 'boolean',
'create_as_draft' => 'boolean',
// AR template fields
'ar_customer_id' => 'required_if:type,ar_invoice|nullable|exists:ar_customers,id',
'ar_terms' => 'nullable|string|max:50',
'ar_memo' => 'nullable|string|max:255',
'ar_items' => 'required_if:type,ar_invoice|nullable|array|min:1',
'ar_items.*.description' => 'required_with:ar_items|string',
'ar_items.*.quantity' => 'required_with:ar_items|numeric|min:0.0001',
'ar_items.*.unit_price' => 'required_with:ar_items|numeric|min:0',
'ar_items.*.gl_revenue_account_id' => 'required_with:ar_items|exists:gl_accounts,id',
// AP template fields
'vendor_id' => 'required_if:type,ap_bill|nullable|exists:ap_vendors,id',
'ap_terms' => 'nullable|string|max:50',
'ap_memo' => 'nullable|string|max:255',
'ap_items' => 'required_if:type,ap_bill|nullable|array|min:1',
'ap_items.*.description' => 'required_with:ap_items|string',
'ap_items.*.amount' => 'required_with:ap_items|numeric|min:0',
'ap_items.*.gl_expense_account_id' => 'required_with:ap_items|exists:gl_accounts,id',
'ap_items.*.department_id' => 'nullable|exists:departments,id',
// JE template fields
'je_memo' => 'nullable|string|max:255',
'je_lines' => 'required_if:type,journal_entry|nullable|array|min:2',
'je_lines.*.gl_account_id' => 'required_with:je_lines|exists:gl_accounts,id',
'je_lines.*.department_id' => 'nullable|exists:departments,id',
'je_lines.*.debit' => 'nullable|numeric|min:0',
'je_lines.*.credit' => 'nullable|numeric|min:0',
'je_lines.*.description' => 'nullable|string',
]);
// Validate JE balance
if ($validated['type'] === 'journal_entry' && ! empty($validated['je_lines'])) {
$totalDebit = collect($validated['je_lines'])->sum(fn ($l) => (float) ($l['debit'] ?? 0));
$totalCredit = collect($validated['je_lines'])->sum(fn ($l) => (float) ($l['credit'] ?? 0));
if (abs($totalDebit - $totalCredit) > 0.01) {
return back()->withInput()->withErrors(['je_lines' => 'Journal entry must be balanced (debits = credits).']);
}
}
// Create schedule
$schedule = RecurringSchedule::create([
'business_id' => $business->id,
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'type' => $validated['type'],
'frequency' => $validated['frequency'],
'interval' => $validated['interval'],
'day_of_month' => $validated['day_of_month'] ?? null,
'weekday' => $validated['weekday'] ?? null,
'next_run_date' => $validated['next_run_date'],
'end_date' => $validated['end_date'] ?? null,
'auto_post' => $validated['auto_post'] ?? false,
'create_as_draft' => $validated['create_as_draft'] ?? true,
'is_active' => true,
'created_by_user_id' => auth()->id(),
]);
// Create template based on type
match ($validated['type']) {
'ar_invoice' => $this->createArTemplate($schedule, $validated),
'ap_bill' => $this->createApTemplate($schedule, $validated),
'journal_entry' => $this->createJeTemplate($schedule, $validated),
};
return redirect()
->route('seller.business.management.recurring.show', [$business, $schedule])
->with('success', 'Recurring schedule created successfully.');
}
/**
* Show schedule details.
*/
public function show(Request $request, Business $business, RecurringSchedule $recurring): View
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
$recurring->load([
'arTemplate.items.glAccount',
'arTemplate.customer',
'apTemplate.items.glAccount',
'apTemplate.items.department',
'apTemplate.vendor',
'journalEntryTemplate.lines.glAccount',
'journalEntryTemplate.lines.department',
'createdBy',
]);
$generatedTransactions = $this->schedulerService->getGeneratedTransactions($recurring);
return view('seller.management.recurring.show', [
'business' => $business,
'schedule' => $recurring,
'generatedTransactions' => $generatedTransactions,
]);
}
/**
* Show edit form.
*/
public function edit(Request $request, Business $business, RecurringSchedule $recurring): View
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
$recurring->load([
'arTemplate.items',
'apTemplate.items',
'journalEntryTemplate.lines',
]);
return view('seller.management.recurring.edit', [
'business' => $business,
'schedule' => $recurring,
'types' => RecurringSchedule::getTypes(),
'frequencies' => RecurringSchedule::getFrequencies(),
'weekdays' => RecurringSchedule::getWeekdays(),
'customers' => ArCustomer::where('business_id', $business->id)->orderBy('name')->get(),
'vendors' => ApVendor::where('business_id', $business->id)->orderBy('name')->get(),
'glAccounts' => GlAccount::where('business_id', $business->id)->orderBy('account_number')->get(),
'departments' => Department::where('business_id', $business->id)->where('is_active', true)->orderBy('name')->get(),
]);
}
/**
* Update schedule.
*/
public function update(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'frequency' => 'required|in:weekly,monthly,quarterly,yearly,custom',
'interval' => 'required|integer|min:1|max:365',
'day_of_month' => 'nullable|integer|min:1|max:31',
'weekday' => 'nullable|string',
'next_run_date' => 'required|date',
'end_date' => 'nullable|date|after:next_run_date',
'auto_post' => 'boolean',
'create_as_draft' => 'boolean',
'is_active' => 'boolean',
]);
$recurring->update([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'frequency' => $validated['frequency'],
'interval' => $validated['interval'],
'day_of_month' => $validated['day_of_month'] ?? null,
'weekday' => $validated['weekday'] ?? null,
'next_run_date' => $validated['next_run_date'],
'end_date' => $validated['end_date'] ?? null,
'auto_post' => $validated['auto_post'] ?? false,
'create_as_draft' => $validated['create_as_draft'] ?? true,
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()
->route('seller.business.management.recurring.show', [$business, $recurring])
->with('success', 'Recurring schedule updated.');
}
/**
* Toggle active status.
*/
public function toggle(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
$recurring->update(['is_active' => ! $recurring->is_active]);
$status = $recurring->is_active ? 'activated' : 'deactivated';
return back()->with('success', "Schedule {$status}.");
}
/**
* Delete schedule.
*/
public function destroy(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
$recurring->delete();
return redirect()
->route('seller.business.management.recurring.index', $business)
->with('success', 'Recurring schedule deleted.');
}
/**
* Show review queue for draft recurring transactions.
*/
public function review(Request $request, Business $business): View
{
$this->requireManagementSuite($business);
$filterData = $this->getDivisionFilterData($business, $request);
$drafts = $this->schedulerService->getDraftTransactionsForReview($business, $filterData['business_ids']);
return view('seller.management.recurring.review', $this->withDivisionFilter([
'business' => $business,
'arInvoices' => $drafts['ar_invoices'],
'apBills' => $drafts['ap_bills'],
'journalEntries' => $drafts['journal_entries'],
], $filterData));
}
/**
* Run schedule manually.
*/
public function runNow(Request $request, Business $business, RecurringSchedule $recurring): RedirectResponse
{
$this->requireManagementSuite($business);
if ($recurring->business_id !== $business->id) {
abort(404);
}
try {
$result = $this->schedulerService->runSchedule($recurring, now());
if ($result) {
$type = class_basename($result);
return back()->with('success', "{$type} generated successfully.");
}
return back()->with('error', 'Schedule is not due for execution.');
} catch (\Exception $e) {
return back()->with('error', 'Failed to run schedule: '.$e->getMessage());
}
}
/**
* Create AR template with items.
*/
private function createArTemplate(RecurringSchedule $schedule, array $data): void
{
$template = RecurringArTemplate::create([
'recurring_schedule_id' => $schedule->id,
'ar_customer_id' => $data['ar_customer_id'],
'terms' => $data['ar_terms'] ?? null,
'default_memo' => $data['ar_memo'] ?? null,
'currency' => 'USD',
]);
foreach ($data['ar_items'] as $item) {
$template->items()->create([
'description' => $item['description'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'gl_revenue_account_id' => $item['gl_revenue_account_id'],
]);
}
}
/**
* Create AP template with items.
*/
private function createApTemplate(RecurringSchedule $schedule, array $data): void
{
$template = RecurringApTemplate::create([
'recurring_schedule_id' => $schedule->id,
'vendor_id' => $data['vendor_id'],
'terms' => $data['ap_terms'] ?? null,
'default_memo' => $data['ap_memo'] ?? null,
'currency' => 'USD',
]);
foreach ($data['ap_items'] as $item) {
$template->items()->create([
'description' => $item['description'],
'amount' => $item['amount'],
'gl_expense_account_id' => $item['gl_expense_account_id'],
'department_id' => $item['department_id'] ?? null,
]);
}
}
/**
* Create JE template with lines.
*/
private function createJeTemplate(RecurringSchedule $schedule, array $data): void
{
$template = RecurringJournalEntryTemplate::create([
'recurring_schedule_id' => $schedule->id,
'memo' => $data['je_memo'] ?? $schedule->name,
]);
foreach ($data['je_lines'] as $line) {
$template->lines()->create([
'gl_account_id' => $line['gl_account_id'],
'department_id' => $line['department_id'] ?? null,
'debit' => $line['debit'] ?? 0,
'credit' => $line['credit'] ?? 0,
'description' => $line['description'] ?? null,
]);
}
}
}

View File

@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApVendor;
use App\Models\Business;
use App\Models\PurchaseOrder;
use App\Models\Purchasing\PurchaseRequisition;
use App\Support\ManagementDivisionFilter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Controller for Management Suite requisition approval workflow.
*
* Parent companies (Canopy) use this to:
* - View all requisitions from child businesses
* - Approve or reject requisitions
* - Convert approved requisitions to Purchase Orders
*/
class RequisitionsApprovalController extends Controller
{
use ManagementDivisionFilter;
/**
* Display list of requisitions from all divisions.
*
* GET /s/{business}/management/requisitions
*/
public function index(Request $request, Business $business): View
{
// Only parent companies can access this
if (! $business->isParentCompany()) {
abort(403, 'Only parent companies can manage requisition approvals.');
}
$filterData = $this->getDivisionFilterData($business, $request);
// Get requisitions from child businesses (not from parent itself)
$childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
$queryBusinessIds = $filterData['selected_division_id']
? [$filterData['selected_division_id']]
: $childIds;
$query = PurchaseRequisition::whereIn('business_id', $queryBusinessIds)
->with(['requestedBy', 'vendor', 'department', 'approvedBy', 'business'])
->withCount('items');
// Status filter
if ($status = $request->get('status')) {
$query->where('status', $status);
}
// Priority filter
if ($priority = $request->get('priority')) {
$query->where('priority', $priority);
}
// Search
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('requisition_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
});
}
$requisitions = $query->orderByDesc('created_at')->paginate(20)->withQueryString();
// Status counts for all child businesses
$statusCounts = PurchaseRequisition::whereIn('business_id', $childIds)
->selectRaw('status, COUNT(*) as count')
->groupBy('status')
->pluck('count', 'status');
// Summary stats
$stats = [
'awaiting_approval' => PurchaseRequisition::whereIn('business_id', $childIds)
->whereIn('status', [PurchaseRequisition::STATUS_SUBMITTED, PurchaseRequisition::STATUS_UNDER_REVIEW])
->count(),
'approved_pending_po' => PurchaseRequisition::whereIn('business_id', $childIds)
->where('status', PurchaseRequisition::STATUS_APPROVED)
->whereNull('linked_po_id')
->count(),
'urgent_count' => PurchaseRequisition::whereIn('business_id', $childIds)
->whereIn('status', [PurchaseRequisition::STATUS_SUBMITTED, PurchaseRequisition::STATUS_UNDER_REVIEW])
->where('priority', PurchaseRequisition::PRIORITY_URGENT)
->count(),
];
return view('seller.management.requisitions.index', $this->withDivisionFilter([
'business' => $business,
'requisitions' => $requisitions,
'statusCounts' => $statusCounts,
'stats' => $stats,
'filters' => $request->only(['status', 'priority', 'search']),
], $filterData));
}
/**
* Show a single requisition for review/approval.
*
* GET /s/{business}/management/requisitions/{requisition}
*/
public function show(Request $request, Business $business, PurchaseRequisition $requisition): View
{
if (! $business->isParentCompany()) {
abort(403, 'Only parent companies can manage requisition approvals.');
}
// Verify the requisition is from a child business
$childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
if (! in_array($requisition->business_id, $childIds)) {
abort(403, 'This requisition does not belong to your divisions.');
}
$requisition->load([
'items.suggestedVendor',
'items.glAccount',
'requestedBy',
'approvedBy',
'vendor',
'department',
'purchaseOrder',
'business',
]);
// Get available vendors for PO creation (from parent business)
$vendors = ApVendor::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.management.requisitions.show', [
'business' => $business,
'requisition' => $requisition,
'vendors' => $vendors,
]);
}
/**
* Mark a requisition as under review.
*
* POST /s/{business}/management/requisitions/{requisition}/review
*/
public function markUnderReview(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeAction($business, $requisition);
if (! $requisition->isSubmitted()) {
return back()->with('error', 'Only submitted requisitions can be marked under review.');
}
$requisition->markUnderReview();
return back()->with('success', 'Requisition marked as under review.');
}
/**
* Approve a requisition.
*
* POST /s/{business}/management/requisitions/{requisition}/approve
*/
public function approve(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeAction($business, $requisition);
if (! $requisition->canBeApproved()) {
return back()->with('error', 'This requisition cannot be approved.');
}
$requisition->approve(auth()->user());
return back()->with('success', 'Requisition approved.');
}
/**
* Reject a requisition.
*
* POST /s/{business}/management/requisitions/{requisition}/reject
*/
public function reject(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeAction($business, $requisition);
if (! $requisition->canBeApproved()) {
return back()->with('error', 'This requisition cannot be rejected.');
}
$validated = $request->validate([
'rejection_reason' => 'required|string|max:1000',
]);
$requisition->reject(auth()->user(), $validated['rejection_reason']);
return back()->with('success', 'Requisition rejected.');
}
/**
* Convert an approved requisition to a Purchase Order.
*
* POST /s/{business}/management/requisitions/{requisition}/convert-to-po
*/
public function convertToPo(Request $request, Business $business, PurchaseRequisition $requisition): RedirectResponse
{
$this->authorizeAction($business, $requisition);
if (! $requisition->canBeConvertedToPo()) {
return back()->with('error', 'This requisition cannot be converted to a PO. It must be approved and not already linked to a PO.');
}
$validated = $request->validate([
'vendor_id' => 'nullable|exists:ap_vendors,id',
'supplier_name' => 'required_without:vendor_id|nullable|string|max:255',
'expected_delivery_date' => 'nullable|date|after:today',
'notes' => 'nullable|string|max:2000',
]);
// Use vendor from requisition or from form
$vendor = null;
$supplierName = $validated['supplier_name'] ?? null;
if (! empty($validated['vendor_id'])) {
$vendor = ApVendor::find($validated['vendor_id']);
$supplierName = $vendor?->name;
} elseif ($requisition->vendor_id) {
$vendor = $requisition->vendor;
$supplierName = $vendor?->name;
}
// Create the PO on the PARENT business (Canopy)
$po = PurchaseOrder::create([
'business_id' => $business->id, // Parent creates the PO
'po_number' => $this->generatePoNumber($business),
'supplier_name' => $supplierName ?? 'Unknown Supplier',
'supplier_contact' => $vendor?->contact_name,
'supplier_phone' => $vendor?->phone,
'supplier_email' => $vendor?->email,
'product_type' => 'materials',
'quantity' => $requisition->items->sum('quantity'),
'unit' => 'ea',
'price_per_unit' => $requisition->estimated_total / max(1, $requisition->items->sum('quantity')),
'price_unit' => 'ea',
'status' => 'pending',
'order_date' => now(),
'expected_delivery_date' => $validated['expected_delivery_date'] ?? $requisition->needed_by_date,
'notes' => "Created from requisition {$requisition->requisition_number} (Division: {$requisition->business->name})\n\n".($validated['notes'] ?? ''),
'created_by_user_id' => auth()->id(),
'metadata' => [
'source_requisition_id' => $requisition->id,
'source_requisition_number' => $requisition->requisition_number,
'source_business_id' => $requisition->business_id,
'source_business_name' => $requisition->business->name,
'items' => $requisition->items->map(fn ($item) => [
'description' => $item->description,
'quantity' => $item->quantity,
'unit' => $item->unit,
'est_unit_cost' => $item->est_unit_cost,
])->toArray(),
],
]);
// Link the requisition to the PO
$requisition->markConvertedToPo($po);
return redirect()
->route('seller.business.management.requisitions.show', [$business, $requisition])
->with('success', "Purchase Order #{$po->po_number} created successfully.");
}
/**
* Authorize that the current user can perform actions on this requisition.
*/
protected function authorizeAction(Business $business, PurchaseRequisition $requisition): void
{
if (! $business->isParentCompany()) {
abort(403, 'Only parent companies can manage requisition approvals.');
}
$childIds = Business::where('parent_id', $business->id)->pluck('id')->toArray();
if (! in_array($requisition->business_id, $childIds)) {
abort(403, 'This requisition does not belong to your divisions.');
}
}
/**
* Generate a PO number for the business.
*/
protected function generatePoNumber(Business $business): string
{
$prefix = 'PO';
$year = now()->format('y');
$lastPo = PurchaseOrder::where('business_id', $business->id)
->whereYear('created_at', now()->year)
->orderByDesc('id')
->first();
if ($lastPo && preg_match('/PO-\d{2}-(\d+)/', $lastPo->po_number ?? '', $matches)) {
$nextNum = (int) $matches[1] + 1;
} else {
$nextNum = 1;
}
return sprintf('%s-%s-%04d', $prefix, $year, $nextNum);
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Support\ManagementDivisionFilter;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class UsageBillingController extends Controller
{
use ManagementDivisionFilter;
public function index(Request $request, Business $business)
{
$this->requireManagementSuite($business);
$divisions = $this->getChildDivisionsIfAny($business);
$selectedDivision = $this->getSelectedDivision($request, $business);
$includeChildren = $this->shouldIncludeChildren($request);
$businessIds = $this->getBusinessIdsForScope($business, $selectedDivision, $includeChildren);
// Collect usage data
$usage = $this->collectUsageData($business, $businessIds);
return view('seller.management.usage-billing.index', [
'business' => $business,
'divisions' => $divisions,
'selectedDivision' => $selectedDivision,
'includeChildren' => $includeChildren,
'usage' => $usage,
]);
}
protected function collectUsageData(Business $parentBusiness, array $businessIds): array
{
$startOfMonth = Carbon::now()->startOfMonth();
$endOfMonth = Carbon::now()->endOfMonth();
// Get suite limits from config
$defaults = config('suites.defaults.sales_suite', []);
// Count active brands
$brandCount = DB::table('brands')
->whereIn('business_id', $businessIds)
->where('is_active', true)
->count();
// Count active products (SKUs)
$skuCount = DB::table('products')
->join('brands', 'products.brand_id', '=', 'brands.id')
->whereIn('brands.business_id', $businessIds)
->where('products.is_active', true)
->count();
// Count messages sent this month
$messageCount = DB::table('messages')
->whereIn('business_id', $businessIds)
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
->count();
// Count menu sends this month
$menuSendCount = DB::table('menu_sends')
->whereIn('business_id', $businessIds)
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
->count();
// Count CRM contacts
$contactCount = DB::table('contacts')
->whereIn('business_id', $businessIds)
->count();
// Calculate limits based on number of brands
$brandLimit = $parentBusiness->brand_limit ?? $defaults['brand_limit'] ?? 1;
$skuLimitPerBrand = $defaults['sku_limit_per_brand'] ?? 15;
$messageLimitPerBrand = $defaults['message_limit_per_brand'] ?? 500;
$menuLimitPerBrand = $defaults['menu_limit_per_brand'] ?? 100;
$contactLimitPerBrand = $defaults['contact_limit_per_brand'] ?? 1000;
$totalSkuLimit = $brandCount * $skuLimitPerBrand;
$totalMessageLimit = $brandCount * $messageLimitPerBrand;
$totalMenuLimit = $brandCount * $menuLimitPerBrand;
$totalContactLimit = $brandCount * $contactLimitPerBrand;
// Is enterprise plan?
$isEnterprise = $parentBusiness->is_enterprise_plan ?? false;
// Get suites enabled
$enabledSuites = $this->getEnabledSuites($parentBusiness);
// Usage by division
$usageByDivision = [];
if (count($businessIds) > 1) {
$usageByDivision = DB::table('businesses')
->whereIn('businesses.id', $businessIds)
->leftJoin('brands', 'brands.business_id', '=', 'businesses.id')
->leftJoin('products', 'products.brand_id', '=', 'brands.id')
->select(
'businesses.id',
'businesses.name',
DB::raw('COUNT(DISTINCT brands.id) as brand_count'),
DB::raw('COUNT(DISTINCT products.id) as sku_count')
)
->groupBy('businesses.id', 'businesses.name')
->get();
}
return [
'brands' => [
'current' => $brandCount,
'limit' => $isEnterprise ? null : $brandLimit,
'percentage' => $brandLimit > 0 ? min(100, ($brandCount / $brandLimit) * 100) : 0,
],
'skus' => [
'current' => $skuCount,
'limit' => $isEnterprise ? null : $totalSkuLimit,
'percentage' => $totalSkuLimit > 0 ? min(100, ($skuCount / $totalSkuLimit) * 100) : 0,
],
'messages' => [
'current' => $messageCount,
'limit' => $isEnterprise ? null : $totalMessageLimit,
'percentage' => $totalMessageLimit > 0 ? min(100, ($messageCount / $totalMessageLimit) * 100) : 0,
],
'menu_sends' => [
'current' => $menuSendCount,
'limit' => $isEnterprise ? null : $totalMenuLimit,
'percentage' => $totalMenuLimit > 0 ? min(100, ($menuSendCount / $totalMenuLimit) * 100) : 0,
],
'contacts' => [
'current' => $contactCount,
'limit' => $isEnterprise ? null : $totalContactLimit,
'percentage' => $totalContactLimit > 0 ? min(100, ($contactCount / $totalContactLimit) * 100) : 0,
],
'is_enterprise' => $isEnterprise,
'enabled_suites' => $enabledSuites,
'usage_by_division' => $usageByDivision,
'billing_period' => [
'start' => $startOfMonth->format('M j, Y'),
'end' => $endOfMonth->format('M j, Y'),
],
];
}
protected function getEnabledSuites(Business $business): array
{
$suites = [];
if ($business->hasSalesSuite()) {
$suites[] = ['name' => 'Sales Suite', 'key' => 'sales'];
}
if ($business->hasProcessingSuite()) {
$suites[] = ['name' => 'Processing Suite', 'key' => 'processing'];
}
if ($business->hasManufacturingSuite()) {
$suites[] = ['name' => 'Manufacturing Suite', 'key' => 'manufacturing'];
}
if ($business->hasDeliverySuite()) {
$suites[] = ['name' => 'Delivery Suite', 'key' => 'delivery'];
}
if ($business->hasManagementSuite()) {
$suites[] = ['name' => 'Management Suite', 'key' => 'management'];
}
if ($business->hasDispensarySuite()) {
$suites[] = ['name' => 'Dispensary Suite', 'key' => 'dispensary'];
}
return $suites;
}
protected function getBusinessIdsForScope(Business $business, ?Business $selectedDivision, bool $includeChildren): array
{
if ($selectedDivision) {
if ($includeChildren) {
return $selectedDivision->divisions()->pluck('id')
->prepend($selectedDivision->id)
->toArray();
}
return [$selectedDivision->id];
}
if ($includeChildren && $business->hasChildBusinesses()) {
return $business->divisions()->pluck('id')
->prepend($business->id)
->toArray();
}
return [$business->id];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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