Files
hub/app/Http/Controllers/Seller/Management/ApVendorsController.php
kelly c7d6ee5e21 feat: multiple UI/UX improvements and case-insensitive search
- Refactor New Quote page to enterprise data-entry layout (2-column, dense)
- Add Payment Terms dropdown (COD, NET 15, NET 30, NET 60)
- Fix sidebar menu active states and route names
- Fix brand filter badge visibility on brands page
- Remove company_name references (use business instead)
- Polish Promotions page layout
- Fix double-click issue on sidebar menu collapse
- Make all searches case-insensitive (like -> ilike for PostgreSQL)
2025-12-14 15:36:00 -07:00

329 lines
11 KiB
PHP

<?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', 'ilike', "%{$search}%")
->orWhere('code', 'ilike', "%{$search}%")
->orWhere('contact_email', 'ilike', "%{$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', 'ilike', "{$prefix}%")
->count();
return $count > 0 ? "{$prefix}-{$count}" : $prefix;
}
}