feat(store): complete partner store overhaul with mapping, household search, financial analytics, and resident notification system

This commit is contained in:
Developer
2026-07-02 12:47:50 +08:00
parent 647af45b99
commit c4cbdb42c7
20 changed files with 692 additions and 227 deletions

View File

@@ -347,7 +347,7 @@ class AdminPartnerStoreController extends ApiController
// For now, we'll return the data needed to render a printable QR.
return $this->ok([
'serial' => $qrCode->serial,
'qr_data' => route('qr.verify', ['serial' => $qrCode->serial]),
'qr_data' => route('api.v1.admin.qr-codes.show', ['serial' => $qrCode->serial]),
]);
}
@@ -365,6 +365,7 @@ class AdminPartnerStoreController extends ApiController
'total_commission_pesos' => number_format($totalCommission / 100, 2),
'total_settled_pesos' => number_format($totalSettled / 100, 2),
'balance_due_pesos' => number_format($balanceDue / 100, 2),
'gross_profit_pesos' => number_format(($totalRetailRevenue - $totalWholesaleCost) / 100, 2),
'total_issued_codes' => (int) $store->purchases()->sum('quantity'),
'total_sold_codes' => (int) $store->sales()->sum('quantity'),
]);

View File

@@ -4,11 +4,14 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Responses\ApiResponse;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
abstract class ApiController extends Controller
{
use AuthorizesRequests;
protected function ok(mixed $data = null, ?string $message = null, array $meta = []): JsonResponse
{
return ApiResponse::success($data, $message, $meta);

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api\V1\Me;
use App\Http\Controllers\Api\V1\ApiController;
use App\Models\Household;
use App\Models\StoreSale;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class MySalesController extends ApiController
{
/**
* Get the household associated with the authenticated user.
*/
private function getHousehold(): ?Household
{
return Household::where('head_user_id', auth()->id())
->orWhereHas('members', function ($q) {
$q->where('user_id', auth()->id());
})
->first();
}
/**
* List sales history for the user's household.
*/
public function index(): JsonResponse
{
$household = $this->getHousehold();
if (!$household) {
return $this->ok([], 'No household associated with this account.');
}
$sales = StoreSale::where('household_id', $household->id)
->with('store')
->latest('sold_at')
->paginate(15);
return $this->ok(
$sales->map(fn($s) => [
'id' => $s->id,
'store_name' => $s->store?->business_name ?? 'Verde Partner Store',
'quantity' => $s->quantity,
'retail_price_pesos' => number_format($s->retail_price_centavos / 100, 2),
'sold_at' => $s->sold_at?->toIso8601String() ?? $s->created_at->toIso8601String(),
'receipt_url' => route('api.v1.me.sales.receipt', $s->id),
]),
null,
[
'page' => $sales->currentPage(),
'per_page' => $sales->perPage(),
'total' => $sales->total(),
]
);
}
/**
* Download a PDF receipt for a specific sale.
*/
public function downloadReceipt(StoreSale $sale): Response
{
$household = $this->getHousehold();
if (!$household || $sale->household_id !== $household->id) {
abort(403, 'Unauthorized access to this receipt.');
}
$sale->load(['store', 'household.head', 'household.barangay']);
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdfs.receipt', [
'sale' => $sale
]);
return $pdf->download("receipt-{$sale->id}.pdf");
}
}

View File

@@ -4,10 +4,12 @@ namespace App\Http\Controllers\Api\V1\Store;
use App\Http\Controllers\Api\V1\ApiController;
use App\Models\PartnerStore;
use App\Models\StoreSale;
use App\Models\User;
use App\Services\Store\StoreOperations;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class StorePortalController extends ApiController
{
@@ -155,11 +157,26 @@ class StorePortalController extends ApiController
return $this->ok($history);
}
public function downloadReceipt(\App\Models\StoreSale $sale): Response
{
$this->authorize('view', $sale);
$sale->load(['store', 'household.head', 'household.barangay']);
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdfs.receipt', [
'sale' => $sale
]);
return $pdf->download("receipt-{$sale->id}.pdf");
}
/**
* Search for households to record a sale.
* Search for households to record a sale. Scoped to the store's LGU.
*/
public function searchHouseholds(Request $request): JsonResponse
{
$store = $this->getStore();
$request->validate([
'q' => 'required|string|min:2',
]);
@@ -167,18 +184,46 @@ class StorePortalController extends ApiController
$term = '%' . $request->string('q') . '%';
$households = \App\Models\Household::query()
->with('head')
->with(['head', 'barangay'])
->where('tenant_id', $store->tenant_id)
->where('verification_status', \App\Models\Household::VERIFICATION_APPROVED)
->where(function($q) use ($term) {
$q->where('address_line', 'like', $term)
->orWhereHas('head', function($h) use ($term) {
$h->where('first_name', 'like', $term)
->orWhere('last_name', 'like', $term);
})
->orWhereHas('members', function($m) use ($term) {
$m->where('first_name', 'like', $term)
->orWhere('last_name', 'like', $term);
});
})
->limit(10)
->limit(15)
->get();
return $this->ok($households);
return $this->ok($households->map(fn($h) => [
'id' => $h->id,
'uuid' => $h->uuid,
'head_name' => $h->head?->full_name ?? 'Unknown Head',
'address' => $h->address_line,
'barangay' => $h->barangay?->name,
'lat' => $h->coordinates?->latitude,
'lng' => $h->coordinates?->longitude,
]));
}
/**
* Get store settings and pricing.
*/
public function getSettings(): JsonResponse
{
$store = $this->getStore();
$tenant = $store->tenant;
return $this->ok([
'retail_price_centavos' => (int) ($tenant->qr_retail_price_centavos ?? 1000),
'retail_price_pesos' => ($tenant->qr_retail_price_centavos ?? 1000) / 100,
'commission_rate_percent' => (int) $store->commission_rate_percent,
]);
}
}

View File

@@ -36,6 +36,7 @@ class ResolveTenant
if ($headerCode) {
$tenant = Tenant::where('code', strtoupper((string) $headerCode))->first();
if (! $tenant) {
\Log::info("ResolveTenant: Header code not found: {$headerCode}");
return response()->json([
'success' => false,
'data' => null,
@@ -46,6 +47,9 @@ class ResolveTenant
}
} elseif ($headerId) {
$tenant = Tenant::where('uuid', $headerId)->first();
if (! $tenant) {
\Log::info("ResolveTenant: Header ID not found: {$headerId}");
}
}
// Fall back to the authenticated user's tenant.

View File

@@ -31,6 +31,7 @@ class QrCode extends Model
'scanned_by_user_id',
'expires_at',
'metadata',
'replacement_for_id',
];
protected function casts(): array

View File

@@ -28,6 +28,7 @@ class Tenant extends Model
'timezone', 'theme_color', 'logo_path',
'contact_email', 'contact_phone',
'status',
'qr_retail_price_centavos',
];
protected function casts(): array
@@ -100,4 +101,9 @@ class Tenant extends Model
{
return 'uuid';
}
public function getQrPriceAttribute(): float
{
return $this->qr_retail_price_centavos / 100;
}
}

View File

@@ -16,6 +16,7 @@ class CodesPurchased extends Notification
public readonly int $quantity,
public readonly int $totalCentavos,
public readonly string $sourceName,
public readonly int $saleId,
) {}
public function toArray(mixed $notifiable): array
@@ -25,7 +26,9 @@ class CodesPurchased extends Notification
'quantity' => $this->quantity,
'total_centavos' => $this->totalCentavos,
'source' => $this->sourceName,
'sale_id' => $this->saleId,
'message' => "{$this->quantity} QR codes added to your wallet from {$this->sourceName}.",
'receipt_url' => route('api.v1.me.sales.receipt', $this->saleId),
];
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Policies;
use App\Models\StoreSale;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class StoreSalePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, StoreSale $storeSale): bool
{
if ($user->role === User::ROLE_ADMIN) {
return $user->tenant_id === $storeSale->store?->tenant_id;
}
if ($user->role === User::ROLE_STORE_PARTNER) {
return $user->id === $storeSale->store?->owner_user_id;
}
return false;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, StoreSale $storeSale): bool
{
return false;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, StoreSale $storeSale): bool
{
return false;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, StoreSale $storeSale): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, StoreSale $storeSale): bool
{
return false;
}
}

View File

@@ -138,7 +138,7 @@ class StoreOperations
if ($household->head) {
Notification::send(
$household->head,
new CodesPurchased($quantity, $totalRetail, $store->business_name),
new CodesPurchased($quantity, $totalRetail, $store->business_name, $sale->id),
);
}

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->unsignedBigInteger('qr_retail_price_centavos')
->default(1000)
->after('status')
->comment('Default retail price per QR sticker for this LGU in centavos');
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('qr_retail_price_centavos');
});
}
};

View File

@@ -20,9 +20,11 @@
</script>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@stack('styles')
</head>
<body class="font-sans">
{{ $slot ?? '' }}
@yield('content')
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Receipt #{{ $sale->id }}</title>
<style>
body { font-family: sans-serif; color: #333; margin: 0; padding: 20px; font-size: 14px; }
.header { text-align: center; margin-bottom: 30px; border-bottom: 2px solid #eee; padding-bottom: 10px; }
.logo { color: #16a34a; font-weight: bold; font-size: 24px; letter-spacing: -1px; }
.info-grid { width: 100%; margin-bottom: 30px; }
.info-grid td { padding: 5px 0; vertical-align: top; }
.label { color: #666; font-size: 12px; font-weight: bold; text-transform: uppercase; }
.value { font-weight: bold; }
.table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
.table th { background: #f9fafb; text-align: left; padding: 10px; border-bottom: 1px solid #eee; font-size: 12px; }
.table td { padding: 10px; border-bottom: 1px solid #eee; }
.total-section { text-align: right; margin-top: 20px; }
.total-label { font-size: 16px; color: #666; }
.total-amount { font-size: 24px; font-weight: bold; color: #000; }
.footer { text-align: center; color: #999; font-size: 11px; margin-top: 50px; border-top: 1px solid #eee; padding-top: 10px; }
</style>
</head>
<body>
<div class="header">
<div class="logo">VERDE</div>
<div style="font-size: 12px; color: #666; margin-top: 5px;">Transaction Receipt</div>
</div>
<table class="info-grid">
<tr>
<td width="50%">
<div class="label">Issued By</div>
<div class="value">{{ $sale->store?->name }}</div>
<div style="font-size: 12px;">{{ $sale->store?->address }}</div>
</td>
<td width="50%" style="text-align: right;">
<div class="label">Receipt Number</div>
<div class="value">#{{ str_pad($sale->id, 8, '0', STR_PAD_LEFT) }}</div>
<div class="label" style="margin-top: 10px;">Date</div>
<div class="value">{{ $sale->sold_at?->format('M d, Y h:i A') }}</div>
</td>
</tr>
</table>
<div class="label" style="margin-bottom: 5px;">Customer Details</div>
<div class="value">{{ $sale->household?->head?->full_name }}</div>
<div style="font-size: 12px; margin-bottom: 30px;">{{ $sale->household?->address_line }}, {{ $sale->household?->barangay?->name }}</div>
<table class="table">
<thead>
<tr>
<th>Description</th>
<th style="text-align: center;">Qty</th>
<th style="text-align: right;">Unit Price</th>
<th style="text-align: right;">Total</th>
</tr>
</thead>
<tbody>
<tr>
<td>Verde QR Code Stickers (Retail)</td>
<td style="text-align: center;">{{ $sale->quantity }}</td>
<td style="text-align: right;">{{ number_format($sale->retail_price_centavos / 100, 2) }}</td>
<td style="text-align: right;">{{ number_format(($sale->retail_price_centavos * $sale->quantity) / 100, 2) }}</td>
</tr>
</tbody>
</table>
<div class="total-section">
<span class="total-label">Total Amount Paid:</span>
<div class="total-amount">{{ number_format(($sale->retail_price_centavos * $sale->quantity) / 100, 2) }}</div>
</div>
<div class="footer">
<p>Thank you for choosing Verde. Let's keep our environment clean together!</p>
<p>This is a computer-generated receipt and does not require a signature.</p>
</div>
</body>
</html>

View File

@@ -1,208 +1,207 @@
@extends('store.layouts.app', ['pageTitle' => 'Record Sale', 'pageSubtitle' => 'Register retail QR code sales'])
@push('styles')
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
.ts-control { border-radius: 0.75rem !important; padding: 0.75rem 1rem !important; font-size: 1.125rem !important; }
#resident-map { height: 250px; border-radius: 0.75rem; z-index: 1; }
.verde-receipt-card { background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%); }
</style>
@endpush
@section('page')
<div class="mx-auto max-w-4xl space-y-8">
<div class="rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
<div class="rounded-2xl border border-neutral-200 bg-white p-8 shadow-sm">
<div class="mb-8">
<h2 class="text-xl font-bold text-neutral-900">Search Household</h2>
<p class="mt-1 text-sm text-neutral-500">Search by resident name or address to start a transaction.</p>
<h2 class="text-2xl font-black text-neutral-900 tracking-tight">Record New Sale</h2>
<p class="mt-1 text-sm text-neutral-500 font-medium">Link QR stickers to a verified household in your LGU.</p>
</div>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4 text-neutral-400">
<i data-lucide="search" class="h-5 w-5"></i>
</div>
<input type="text"
id="household-search"
class="block w-full rounded-xl border-neutral-200 pl-12 py-4 text-lg focus:border-verde-500 focus:ring-verde-500"
placeholder="Enter name or address...">
{{-- Results Dropdown --}}
<div id="search-results" class="absolute z-20 mt-2 w-full hidden rounded-xl border border-neutral-200 bg-white shadow-xl">
<div class="divide-y divide-neutral-100 max-h-96 overflow-y-auto" id="results-list">
{{-- Dynamically filled --}}
</div>
</div>
</div>
</div>
{{-- Selected Household Card (Hidden by default) --}}
<div id="selected-household-card" class="hidden rounded-xl border border-verde-200 bg-verde-50/30 p-8 shadow-sm ring-1 ring-verde-100">
<div class="flex items-start justify-between">
<div class="flex gap-6">
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-verde-600 text-white shadow-lg shadow-verde-200/50">
<i data-lucide="home" class="h-8 w-8"></i>
</div>
<div>
<h3 id="sel-head-name" class="text-2xl font-bold text-neutral-900">...</h3>
<p id="sel-address" class="mt-1 text-neutral-600">...</p>
<div class="mt-4 flex items-center gap-3">
<span id="sel-barangay" class="rounded-full bg-verde-100 px-3 py-1 text-xs font-bold text-verde-800">...</span>
<span id="sel-size" class="text-xs text-neutral-400 font-medium">... members</span>
<div class="space-y-6">
{{-- Search & Map --}}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div class="space-y-6">
<div>
<label class="block text-xs font-bold uppercase tracking-widest text-neutral-400 mb-2">Search Resident or Household</label>
<select id="household-select" placeholder="Start typing name or address..." class="w-full"></select>
</div>
</div>
</div>
<button id="cancel-selection" class="text-xs font-bold text-neutral-400 hover:text-neutral-600 uppercase tracking-widest">Change</button>
</div>
<hr class="my-8 border-verde-200/50">
<div id="selected-info" class="hidden animate-in fade-in slide-in-from-top-4">
<div class="rounded-xl bg-verde-50/50 p-4 ring-1 ring-verde-100">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-verde-600 text-white">
<i data-lucide="user" class="h-5 w-5"></i>
</div>
<div>
<h4 id="display-name" class="font-bold text-neutral-900">...</h4>
<p id="display-address" class="text-xs text-neutral-500">...</p>
</div>
</div>
</div>
</div>
<form id="sale-form" class="space-y-6">
<input type="hidden" id="sel-household-id">
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<div>
<label class="block text-sm font-bold text-neutral-700">Quantity of QR Codes</label>
<div class="mt-2 flex items-center gap-4">
<button type="button" onclick="adjustQty(-1)" class="flex h-12 w-12 items-center justify-center rounded-lg border border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50 active:scale-95">
<i data-lucide="minus" class="h-5 w-5"></i>
</button>
<input type="number" id="sale-qty" value="1" min="1" class="h-12 w-24 rounded-lg border-neutral-200 text-center text-xl font-bold focus:border-verde-500 focus:ring-verde-500">
<button type="button" onclick="adjustQty(1)" class="flex h-12 w-12 items-center justify-center rounded-lg border border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50 active:scale-95">
<i data-lucide="plus" class="h-5 w-5"></i>
</button>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold uppercase tracking-widest text-neutral-400 mb-2">Quantity</label>
<div class="flex items-center gap-2">
<button type="button" onclick="adjustQty(-1)" class="flex h-12 w-12 items-center justify-center rounded-xl border border-neutral-200 bg-white hover:bg-neutral-50 active:scale-95 transition-all">
<i data-lucide="minus" class="h-4 w-4"></i>
</button>
<input type="number" id="sale-qty" value="1" min="1" class="h-12 w-20 rounded-xl border-neutral-200 text-center font-bold text-lg focus:ring-verde-500 focus:border-verde-500">
<button type="button" onclick="adjustQty(1)" class="flex h-12 w-12 items-center justify-center rounded-xl border border-neutral-200 bg-white hover:bg-neutral-50 active:scale-95 transition-all">
<i data-lucide="plus" class="h-4 w-4"></i>
</button>
</div>
</div>
<div class="flex flex-col justify-end items-end">
<p class="text-[10px] font-black uppercase tracking-tighter text-neutral-400">Total Amount</p>
<p id="total-price" class="text-3xl font-black text-neutral-900">₱0.00</p>
</div>
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-widest text-neutral-400 mb-2">Manual Serial (Optional)</label>
<input type="text" id="serial-entry" placeholder="VERDE-XXXX-XXXX" class="w-full rounded-xl border-neutral-200 py-3 font-mono text-sm uppercase tracking-wider focus:ring-verde-500 focus:border-verde-500">
</div>
</div>
</div>
<div>
<label class="block text-sm font-bold text-neutral-700">Manual Serial Entry (Optional)</label>
<input type="text" id="serial-number"
class="mt-2 block w-full rounded-lg border-neutral-200 py-3 focus:border-verde-500 focus:ring-verde-500 font-mono"
placeholder="Enter Serial from Sticker">
<p class="mt-1 text-[10px] text-neutral-400">Leave blank to use the next available code.</p>
<div class="relative">
<div id="resident-map" class="bg-neutral-100 flex items-center justify-center text-neutral-400 border border-neutral-200">
<div class="text-center p-8">
<i data-lucide="map-pin" class="h-8 w-8 mx-auto mb-2 opacity-20"></i>
<p class="text-xs">Select a resident to see their location</p>
</div>
</div>
</div>
</div>
<div class="flex flex-col items-end pt-4">
<p class="text-xs font-bold text-neutral-400 uppercase tracking-widest">Total Resident Cost</p>
<p id="total-cost" class="text-3xl font-black text-neutral-900">₱0.00</p>
</div>
<div class="pt-6">
<button type="submit" id="submit-sale" class="flex w-full items-center justify-center gap-3 rounded-xl bg-verde-600 py-4 text-lg font-bold text-white shadow-xl shadow-verde-600/20 transition-all hover:bg-verde-700 hover:shadow-verde-600/30 active:scale-[0.98]">
<i data-lucide="check-circle" class="h-6 w-6"></i>
<span>Confirm & Record Sale</span>
<div class="pt-8">
<button type="button" id="btn-submit" class="w-full flex items-center justify-center gap-3 rounded-2xl bg-verde-600 py-5 text-lg font-black text-white shadow-2xl shadow-verde-600/30 hover:bg-verde-700 active:scale-[0.99] transition-all disabled:opacity-50">
<i data-lucide="credit-card" class="h-6 w-6"></i>
<span>CONFIRM SALE & PRINT RECEIPT</span>
</button>
</div>
</form>
</div>
</div>
</div>
{{-- Success Modal --}}
<div id="success-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-neutral-900/60 backdrop-blur-sm p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-8 text-center shadow-2xl">
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-verde-50 text-verde-600">
<div id="success-modal" class="fixed inset-0 z-[100] hidden items-center justify-center bg-neutral-900/80 backdrop-blur-md p-4">
<div class="w-full max-w-md rounded-3xl bg-white p-8 shadow-2xl animate-in zoom-in-95 duration-200">
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-verde-100 text-verde-600 mb-6">
<i data-lucide="check" class="h-10 w-10"></i>
</div>
<h2 class="mt-6 text-2xl font-bold text-neutral-900">Sale Recorded!</h2>
<p class="mt-2 text-neutral-500">The transaction has been successfully logged to the household.</p>
<h3 class="text-2xl font-black text-neutral-900 text-center">Sale Recorded!</h3>
<p class="mt-2 text-center text-neutral-500">The transaction was successful. The resident has been notified and can view their receipt in their app.</p>
<div id="qr-print-container" class="mt-6 hidden space-y-4">
<div class="mx-auto w-40 h-40 border border-neutral-200 rounded-lg p-2 bg-white">
<img id="printable-qr" src="" class="w-full h-full object-contain">
</div>
<p id="printable-serial" class="font-mono text-sm font-bold text-neutral-600">...</p>
<button onclick="printQRCode()" class="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-neutral-900 py-3 font-bold text-neutral-900 hover:bg-neutral-50">
<i data-lucide="printer" class="h-5 w-5"></i>
Print Sticker
<div class="mt-8 space-y-3">
<a id="download-receipt" href="#" target="_blank" class="flex w-full items-center justify-center gap-3 rounded-2xl bg-neutral-900 py-4 font-bold text-white hover:bg-neutral-800 transition-colors">
<i data-lucide="file-text" class="h-5 w-5"></i>
Download PDF Receipt
</a>
<button onclick="location.reload()" class="flex w-full items-center justify-center gap-3 rounded-2xl border-2 border-neutral-200 py-4 font-bold text-neutral-600 hover:bg-neutral-50 transition-colors">
<i data-lucide="plus" class="h-5 w-5"></i>
Record Another Sale
</button>
</div>
<button onclick="resetPage()" class="mt-8 w-full rounded-xl bg-neutral-900 py-3 font-bold text-white transition-transform hover:scale-105">Record Another</button>
</div>
</div>
@endsection
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module">
const SRP_CENTAVOS = {{ config('qr.default_retail_price_per_code_centavos', 1000) }};
const searchInput = document.getElementById('household-search');
const resultsContainer = document.getElementById('search-results');
const resultsList = document.getElementById('results-list');
const selectedSection = document.getElementById('selected-household-card');
const qtyInput = document.getElementById('sale-qty');
const costDisplay = document.getElementById('total-cost');
let map, marker;
let retailPrice = 0;
const token = window.Verde.getToken();
window.adjustQty = (amount) => {
const val = parseInt(qtyInput.value) + amount;
if (val >= 1) {
qtyInput.value = val;
updateCost();
}
// Init Map
const initMap = () => {
map = L.map('resident-map').setView([14.5995, 120.9842], 13); // Manila default
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
};
const updateCost = () => {
const qty = parseInt(qtyInput.value) || 0;
const total = (qty * SRP_CENTAVOS) / 100;
costDisplay.textContent = new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(total);
// Fetch Settings
const fetchSettings = async () => {
const res = await fetch('/api/v1/store/settings', {
headers: { 'Authorization': `Bearer ${token}` }
});
const { data } = await res.json();
retailPrice = data.retail_price_centavos;
updateTotal();
};
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const q = e.target.value;
if (q.length < 2) {
resultsContainer.classList.add('hidden');
return;
}
const updateTotal = () => {
const qty = parseInt(document.getElementById('sale-qty').value) || 0;
const total = (qty * retailPrice) / 100;
document.getElementById('total-price').textContent = new Intl.NumberFormat('en-PH', {
style: 'currency', currency: 'PHP'
}).format(total);
};
searchTimeout = setTimeout(async () => {
try {
const res = await fetch(`/api/v1/store/households?q=${encodeURIComponent(q)}`, {
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
});
const { data: households } = await res.json();
if (households.length === 0) {
resultsList.innerHTML = '<div class="p-6 text-center text-neutral-400">No matching households found.</div>';
} else {
resultsList.innerHTML = households.map(h => `
<button type="button" class="flex w-full items-center gap-4 px-6 py-4 text-left transition-colors hover:bg-neutral-50" onclick="selectHousehold(${JSON.stringify(h).replace(/"/g, '&quot;')})">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500">
<i data-lucide="home" class="h-5 w-5"></i>
</div>
<div>
<div class="font-bold text-neutral-900">${h.head?.full_name || 'N/A'}</div>
<div class="text-xs text-neutral-500">${h.address_line}</div>
</div>
</button>
`).join('');
window.adjustQty = (delta) => {
const input = document.getElementById('sale-qty');
input.value = Math.max(1, parseInt(input.value) + delta);
updateTotal();
};
document.getElementById('sale-qty').addEventListener('input', updateTotal);
// Init Autocomplete
const initSearch = () => {
new TomSelect('#household-select', {
valueField: 'id',
labelField: 'head_name',
searchField: ['head_name', 'address'],
load: async (query, callback) => {
const url = `/api/v1/store/households?q=${encodeURIComponent(query)}`;
fetch(url, { headers: { 'Authorization': `Bearer ${token}` }})
.then(r => r.json())
.then(json => callback(json.data))
.catch(() => callback());
},
render: {
option: (data, escape) => `
<div class="px-3 py-2">
<div class="font-bold text-neutral-900">${escape(data.head_name)}</div>
<div class="text-[10px] text-neutral-500 uppercase tracking-wide">${escape(data.address)} · ${escape(data.barangay)}</div>
</div>
`,
item: (data, escape) => `<div>${escape(data.head_name)}</div>`
},
onChange: (id) => {
const ts = document.getElementById('household-select').tomselect;
const data = ts.options[id];
if (!data) return;
document.getElementById('display-name').textContent = data.head_name;
document.getElementById('display-address').textContent = `${data.address}, ${data.barangay}`;
document.getElementById('selected-info').classList.remove('hidden');
if (data.lat && data.lng) {
if (marker) map.removeLayer(marker);
const pos = [data.lat, data.lng];
marker = L.marker(pos).addTo(map);
map.setView(pos, 16);
}
resultsContainer.classList.remove('hidden');
if (window.lucide) window.lucide.createIcons();
} catch (err) {
console.error('Search failed', err);
}
}, 300);
});
window.selectHousehold = (h) => {
searchInput.value = '';
resultsContainer.classList.add('hidden');
document.getElementById('sel-household-id').value = h.id;
document.getElementById('sel-head-name').textContent = h.head?.full_name || 'N/A';
document.getElementById('sel-address').textContent = h.address_line;
document.getElementById('sel-barangay').textContent = h.barangay?.name || 'Unknown Barangay';
document.getElementById('sel-size').textContent = h.members_count || 0;
selectedSection.classList.remove('hidden');
searchInput.closest('.rounded-xl').classList.add('hidden');
updateCost();
if (window.lucide) window.lucide.createIcons();
});
};
document.getElementById('cancel-selection').addEventListener('click', () => {
selectedSection.classList.add('hidden');
searchInput.closest('.rounded-xl').classList.remove('hidden');
});
// Form Submission
document.getElementById('btn-submit').addEventListener('click', async () => {
const id = document.getElementById('household-select').value;
if (!id) return alert('Please select a household first.');
qtyInput.addEventListener('input', updateCost);
document.getElementById('sale-form').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submit-sale');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i data-lucide="loader-2" class="h-6 w-6 animate-spin"></i> Processing...';
const btn = document.getElementById('btn-submit');
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="h-6 w-6 animate-spin"></i> PROCESSING...';
if (window.lucide) window.lucide.createIcons();
try {
@@ -210,74 +209,39 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.Verde.getToken()}`,
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
},
body: JSON.stringify({
household_id: document.getElementById('sel-household-id').value,
quantity: qtyInput.value,
serial_number: document.getElementById('serial-number').value || null
household_id: id,
quantity: document.getElementById('sale-qty').value,
serial_number: document.getElementById('serial-entry').value || null
})
});
if (res.ok) {
const result = await res.json();
if (result.data.codes && result.data.codes.length > 0) {
const firstCode = result.data.codes[0];
document.getElementById('printable-qr').src = `data:image/png;base64,${firstCode.qr_base64}`;
document.getElementById('printable-serial').textContent = firstCode.serial;
document.getElementById('qr-print-container').classList.remove('hidden');
}
const { data } = await res.json();
document.getElementById('download-receipt').href = `/api/v1/store/sales/${data.sale_id}/receipt`;
document.getElementById('success-modal').classList.replace('hidden', 'flex');
} else {
const err = await res.json();
alert(err.message || 'Failed to record sale.');
alert(err.message || 'Transaction failed.');
}
} catch (err) {
console.error('Sale failed', err);
alert('An unexpected error occurred.');
} catch (e) {
alert('A network error occurred.');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i data-lucide="check-circle" class="h-6 w-6"></i> Confirm & Record Sale';
btn.disabled = false;
btn.innerHTML = '<i data-lucide="credit-card" class="h-6 w-6"></i> CONFIRM SALE & PRINT RECEIPT';
if (window.lucide) window.lucide.createIcons();
}
});
window.printQRCode = () => {
const qrContainer = document.getElementById('qr-print-container');
const printWindow = window.open('', '_blank');
const imgHtml = document.getElementById('printable-qr').outerHTML;
const serial = document.getElementById('printable-serial').textContent;
printWindow.document.write(`
<html>
<head>
<title>Print QR Sticker</title>
<style>
body { margin: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; font-family: monospace; }
img { width: 150px; height: 150px; }
.serial { margin-top: 5px; font-weight: bold; font-size: 14px; }
@page { size: auto; margin: 0; }
</style>
</head>
<body onload="window.print(); window.close();">
${imgHtml}
<div class="serial">${serial}</div>
</body>
</html>
`);
printWindow.document.close();
};
window.resetPage = () => {
location.reload();
};
// Close results when clicking outside
document.addEventListener('click', (e) => {
if (!searchInput.contains(e.target) && !resultsContainer.contains(e.target)) {
resultsContainer.classList.add('hidden');
}
// Lifecycle
document.addEventListener('DOMContentLoaded', () => {
initMap();
initSearch();
fetchSettings();
});
</script>
@endsection
@endpush

View File

@@ -113,6 +113,9 @@ Route::middleware('auth:sanctum')->prefix('me')->name('api.v1.me.')->group(funct
Route::post('/notifications/mark-all-read', [MyNotificationsController::class, 'markAllRead'])->name('notifications.mark-all-read');
Route::post('/notifications/{id}/read', [MyNotificationsController::class, 'markRead'])->name('notifications.mark-read');
Route::get('/sales', [\App\Http\Controllers\Api\V1\Me\MySalesController::class, 'index'])->name('sales.index');
Route::get('/sales/{sale}/receipt', [\App\Http\Controllers\Api\V1\Me\MySalesController::class, 'downloadReceipt'])->name('sales.receipt');
Route::get('/live/trucks', [MyLiveTrucksController::class, 'trucks'])->name('live.trucks');
});
@@ -142,8 +145,10 @@ Route::middleware(['auth:sanctum', 'role:store_partner'])->prefix('store')->name
Route::get('/analytics', [StorePortalController::class, 'analytics'])->name('analytics');
Route::post('/sales', [StorePortalController::class, 'recordSale'])->name('sales.store');
Route::get('/sales', [StorePortalController::class, 'salesHistory'])->name('sales.index');
Route::get('/sales/{sale}', [StorePortalController::class, 'downloadReceipt'])->name('sales.receipt');
Route::get('/inventory', [StorePortalController::class, 'inventoryHistory'])->name('inventory.index');
Route::get('/households', [StorePortalController::class, 'searchHouseholds'])->name('households.search');
Route::get('/settings', [StorePortalController::class, 'getSettings'])->name('settings');
});
// Admin live tracking

View File

@@ -0,0 +1,80 @@
<?php
namespace Tests\Feature\Api\V1\Me;
use App\Models\Household;
use App\Models\PartnerStore;
use App\Models\StoreSale;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MySalesControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed();
}
public function test_it_can_list_my_sales_history()
{
$user = User::factory()->create(['role' => 'resident']);
$household = Household::factory()->create(['head_user_id' => $user->id]);
$store = PartnerStore::factory()->create();
StoreSale::factory()->count(3)->create([
'household_id' => $household->id,
'store_id' => $store->id,
'retail_price_centavos' => 2500,
'quantity' => 1
]);
$response = $this->actingAs($user)
->getJson(route('api.v1.me.sales.index'));
$response->assertOk()
->assertJsonCount(3, 'data');
$this->assertNotNull($response->json('data.0.receipt_url'));
}
public function test_it_can_download_my_receipt()
{
$user = User::factory()->create(['role' => 'resident']);
$household = Household::factory()->create(['head_user_id' => $user->id]);
$store = PartnerStore::factory()->create();
$sale = StoreSale::factory()->create([
'household_id' => $household->id,
'store_id' => $store->id
]);
$response = $this->actingAs($user)
->getJson(route('api.v1.me.sales.receipt', $sale->id));
$response->assertOk()
->assertHeader('Content-Type', 'application/pdf');
}
public function test_it_cannot_download_others_receipt()
{
$user1 = User::factory()->create(['role' => 'resident']);
$household1 = Household::factory()->create(['head_user_id' => $user1->id]);
$user2 = User::factory()->create(['role' => 'resident']);
$household2 = Household::factory()->create(['head_user_id' => $user2->id]);
$store = PartnerStore::factory()->create();
$saleOfUser2 = StoreSale::factory()->create([
'household_id' => $household2->id,
'store_id' => $store->id
]);
$response = $this->actingAs($user1)
->getJson(route('api.v1.me.sales.receipt', $saleOfUser2->id));
$response->assertStatus(403);
}
}

View File

@@ -23,10 +23,9 @@ class PartnerStoreSettlementTest extends TestCase
protected function setUp(): void
{
parent::setUp();
// $this->defaultTenant is already created and set in parent::setUp()
$this->admin = User::factory()->create(['tenant_id' => $this->defaultTenant->id, 'role' => 'admin']);
$owner = User::factory()->create(['tenant_id' => $this->defaultTenant->id, 'role' => 'store_partner']);
$this->store = PartnerStore::factory()->create([
'tenant_id' => $this->defaultTenant->id,
'owner_user_id' => $owner->id,

View File

@@ -211,7 +211,7 @@ class PartnerStoreTest extends TestCase
]);
$response->assertOk()
->assertJsonPath('data.inventory_balance', 10);
->assertJsonPath('data.inventory_balance', 10); // 10 - 1 + 1 (replacement) = 10
$this->assertEquals('voided', (string) $qrCode->fresh()->status);
$this->assertNull($qrCode->fresh()->assigned_to_store_id);
@@ -267,10 +267,10 @@ class PartnerStoreTest extends TestCase
$response->assertOk()
->assertJsonStructure(['data', 'meta'])
->assertJsonCount(4, 'data'); // Purchase, Sale, Defective, Manual Replacement
->assertJsonCount(4, 'data'); // Purchase, Sale, Defective, Replacement
// Latest first (descending created_at)
$this->assertEquals('manual', $response->json('data.0.type'));
$this->assertEquals('manual', $response->json('data.0.type')); // Replacement
$this->assertEquals(1, $response->json('data.0.quantity'));
$this->assertEquals('defective', $response->json('data.1.type'));
$this->assertEquals(-1, $response->json('data.1.quantity'));

View File

@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature\Api\V1\Store;
use App\Models\PartnerStore;
use App\Models\StoreSale;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class StorePortalEnhancementsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed();
}
public function test_it_can_get_tenant_specific_pricing_settings()
{
$tenant = Tenant::factory()->create(['qr_retail_price_centavos' => 2500]);
$user = User::factory()->create(['role' => User::ROLE_STORE_PARTNER, 'tenant_id' => $tenant->id]);
$store = PartnerStore::factory()->create(['owner_user_id' => $user->id, 'tenant_id' => $tenant->id, 'commission_rate_percent' => 15]);
$response = $this->actingAs($user)
->withHeader('X-Tenant-Code', $tenant->code)
->getJson(route('api.v1.store.settings'));
$response->assertOk()
->assertJsonPath('data.retail_price_centavos', 2500)
->assertJsonPath('data.retail_price_pesos', 25)
->assertJsonPath('data.commission_rate_percent', 15);
}
public function test_it_can_download_sale_receipt()
{
$user = User::factory()->create(['role' => User::ROLE_STORE_PARTNER]);
$store = PartnerStore::factory()->create(['owner_user_id' => $user->id, 'tenant_id' => $user->tenant_id]);
$sale = StoreSale::factory()->create(['store_id' => $store->id]);
$response = $this->actingAs($user)
->getJson(route('api.v1.store.sales.receipt', $sale));
$response->assertOk()
->assertHeader('Content-Type', 'application/pdf');
}
public function test_it_cannot_download_receipt_from_another_store()
{
$user1 = User::factory()->create(['role' => User::ROLE_STORE_PARTNER]);
$store1 = PartnerStore::factory()->create(['owner_user_id' => $user1->id, 'tenant_id' => $user1->tenant_id]);
$user2 = User::factory()->create(['role' => User::ROLE_STORE_PARTNER]);
$store2 = PartnerStore::factory()->create(['owner_user_id' => $user2->id, 'tenant_id' => $user2->tenant_id]);
$saleFromStore2 = StoreSale::factory()->create(['store_id' => $store2->id]);
$response = $this->actingAs($user1)
->getJson(route('api.v1.store.sales.receipt', $saleFromStore2));
$response->assertStatus(403);
}
}

View File

@@ -20,6 +20,33 @@ We have successfully implemented the ability to edit and delete trucks from the
---
## Walkthrough - Partner Store Portal Overhaul
I have successfully enhanced the Partner Store Portal with advanced search, LGU-scoped pricing, and professional PDF receipts.
## Key Changes
### 1. Dynamic LGU Pricing
- Added `qr_retail_price_centavos` to the `tenants` table.
- Each LGU can now set its own retail price for QR stickers.
- The Store Portal automatically fetches and displays the correct price for the current store's LGU.
### 2. Enhanced Household Search
- Integrated **TomSelect** for a lightning-fast, searchable dropdown.
- Search results are strictly scoped to the store's LGU.
- Added **Leaflet.js** map verification to show the resident's location upon selection.
### 3. Professional PDF Receipts
- Implemented professional receipt generation using **DomPDF**.
- Receipts include store details, transaction numbers, and branded formatting.
- Added a "Download Receipt" flow immediately after a successful sale.
## Technical Implementation
- **Migrations**: Added pricing field to `tenants`.
- **Controllers**: Updated `StorePortalController` with `settings`, `search`, and `downloadReceipt` methods.
- **Frontend**: Overhauled `sales.blade.php` with modern JS libraries and premium styling.
- **Security**: Implemented `StoreSalePolicy` to prevent unauthorized receipt downloads.
## Verification Results
### Automated Tests