feat: implement team standby status, helper persistence, and store portal foundations
This commit is contained in:
@@ -108,6 +108,7 @@ class AdminPartnerStoreController extends ApiController
|
||||
$data = $request->validate([
|
||||
'quantity' => ['required', 'integer', 'min:1', 'max:50000'],
|
||||
'wholesale_price_centavos' => ['required', 'integer', 'min:0'],
|
||||
'is_prepaid' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
if ($store->status !== PartnerStore::STATUS_ACTIVE) {
|
||||
@@ -119,7 +120,8 @@ class AdminPartnerStoreController extends ApiController
|
||||
(int) $data['quantity'],
|
||||
(int) $data['wholesale_price_centavos'],
|
||||
null,
|
||||
$request->user()
|
||||
$request->user(),
|
||||
(bool) ($data['is_prepaid'] ?? false)
|
||||
);
|
||||
|
||||
return $this->created([
|
||||
|
||||
@@ -20,7 +20,7 @@ class AdminTeamController extends ApiController
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'status' => ['nullable', 'in:active,inactive'],
|
||||
'status' => ['nullable', 'in:active,inactive,standby'],
|
||||
'area_id' => ['nullable', 'integer'],
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:500'],
|
||||
@@ -28,7 +28,7 @@ class AdminTeamController extends ApiController
|
||||
$perPage = (int) $request->input('per_page', 25);
|
||||
|
||||
$teams = CollectionTeam::query()
|
||||
->with(['area', 'driver', 'scanner', 'truck'])
|
||||
->with(['area', 'driver', 'scanner', 'truck', 'helpers.user', 'currentTrip'])
|
||||
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||
->when($request->filled('area_id'), fn ($q) => $q->where('area_id', $request->integer('area_id')))
|
||||
->when($request->filled('q'), fn ($q) => $q->where('name', 'like', '%'.$request->string('q').'%'))
|
||||
@@ -77,7 +77,7 @@ class AdminTeamController extends ApiController
|
||||
}
|
||||
}
|
||||
|
||||
unset($data['helper_ids'], $data['override_conflicts'], $data['helper_count'], $data['helper_assignment_mode']);
|
||||
unset($data['helper_ids'], $data['override_conflicts'], $data['helper_assignment_mode']);
|
||||
|
||||
$team = DB::transaction(function () use ($data, $helperIds) {
|
||||
$team = CollectionTeam::create($data);
|
||||
@@ -105,7 +105,7 @@ class AdminTeamController extends ApiController
|
||||
|
||||
public function show(CollectionTeam $team): JsonResponse
|
||||
{
|
||||
$team->load(['area', 'driver', 'scanner', 'truck', 'helpers.user']);
|
||||
$team->load(['area', 'driver', 'scanner', 'truck', 'helpers.user', 'currentTrip']);
|
||||
|
||||
return $this->ok(new CollectionTeamResource($team));
|
||||
}
|
||||
@@ -143,7 +143,7 @@ class AdminTeamController extends ApiController
|
||||
}
|
||||
}
|
||||
|
||||
unset($data['helper_ids'], $data['override_conflicts'], $data['helper_count'], $data['helper_assignment_mode']);
|
||||
unset($data['helper_ids'], $data['override_conflicts'], $data['helper_assignment_mode']);
|
||||
|
||||
DB::transaction(function () use ($team, $data, $helperIds) {
|
||||
$team->update($data);
|
||||
|
||||
@@ -35,6 +35,8 @@ class TripDetourController extends Controller
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$trip->update(['is_detouring' => true]);
|
||||
|
||||
return response()->json(['message' => 'Detour to dumpsite logged.']);
|
||||
}
|
||||
|
||||
@@ -63,6 +65,8 @@ class TripDetourController extends Controller
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$trip->update(['is_detouring' => false]);
|
||||
|
||||
return response()->json(['message' => 'Resuming route logged.']);
|
||||
}
|
||||
}
|
||||
|
||||
184
app/Http/Controllers/Api/V1/Store/StorePortalController.php
Normal file
184
app/Http/Controllers/Api/V1/Store/StorePortalController.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Store;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Models\PartnerStore;
|
||||
use App\Models\User;
|
||||
use App\Services\Store\StoreOperations;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StorePortalController extends ApiController
|
||||
{
|
||||
/**
|
||||
* Get the store associated with the authenticated user.
|
||||
*/
|
||||
protected function getStore(): PartnerStore
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$store = PartnerStore::where('owner_user_id', $user->id)->first();
|
||||
|
||||
if (!$store) {
|
||||
abort(403, 'No partner store associated with this account.');
|
||||
}
|
||||
|
||||
return $store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard summary data.
|
||||
*/
|
||||
public function dashboard(): JsonResponse
|
||||
{
|
||||
$store = $this->getStore();
|
||||
$inventory = $store->inventory;
|
||||
|
||||
$data = [
|
||||
'business_name' => $store->business_name,
|
||||
'inventory_balance' => $inventory ? (int) $inventory->current_code_balance : 0,
|
||||
'prepaid_balance' => $inventory ? (int) $inventory->prepaid_balance : 0,
|
||||
'total_sales_count' => (int) $store->sales()->count(),
|
||||
'status' => $store->status,
|
||||
];
|
||||
|
||||
return $this->ok($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get financial analytics for the store.
|
||||
*/
|
||||
public function analytics(): JsonResponse
|
||||
{
|
||||
$store = $this->getStore();
|
||||
|
||||
// Sums
|
||||
$totalRetail = $store->sales()->sum('retail_price_centavos');
|
||||
$totalWholesale = $store->purchases()->sum('wholesale_price_centavos');
|
||||
$totalCommission = $store->sales()->sum('commission_centavos');
|
||||
|
||||
// Calculate settlement balance
|
||||
$totalSettled = $store->settlements()->sum('amount_centavos');
|
||||
$amountOwed = ($totalRetail - $totalCommission) - $totalSettled;
|
||||
|
||||
return $this->ok([
|
||||
'total_retail_revenue_centavos' => (int) $totalRetail,
|
||||
'total_wholesale_cost_centavos' => (int) $totalWholesale,
|
||||
'total_commission_earned_centavos' => (int) $totalCommission,
|
||||
'amount_owed_to_lgu_centavos' => (int) $amountOwed,
|
||||
'gross_profit_centavos' => (int) ($totalRetail - $totalWholesale),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a retail sale to a resident.
|
||||
*/
|
||||
public function recordSale(Request $request, StoreOperations $operations): JsonResponse
|
||||
{
|
||||
$store = $this->getStore();
|
||||
|
||||
$request->validate([
|
||||
'household_id' => 'required|exists:households,id',
|
||||
'quantity' => 'required|integer|min:1',
|
||||
'serial_number' => 'nullable|string|exists:qr_codes,serial',
|
||||
]);
|
||||
|
||||
try {
|
||||
$sale = $operations->sellToHousehold(
|
||||
$store,
|
||||
\App\Models\Household::findOrFail($request->household_id),
|
||||
$request->quantity,
|
||||
config('qr.default_retail_price_per_code_centavos'),
|
||||
$request->serial_number
|
||||
);
|
||||
|
||||
// Fetch the serials that were just activated for this sale
|
||||
$codes = \App\Models\QrCode::whereIn('id', $sale->id ? [$sale->id] : []) // Wait, StoreSale doesn't link to QrCode directly?
|
||||
// I need to find the codes that were activated.
|
||||
// QrCode table has assigned_to_household_id.
|
||||
->where('assigned_to_household_id', $request->household_id)
|
||||
->where('status', \App\States\QrCode\Active::$name)
|
||||
->latest('activated_at')
|
||||
->limit($request->quantity)
|
||||
->get();
|
||||
|
||||
$qrData = $codes->map(function ($code) {
|
||||
$qr = \Endroid\QrCode\QrCode::create($code->serial)
|
||||
->setSize(300)
|
||||
->setMargin(10);
|
||||
$writer = new \Endroid\QrCode\Writer\PngWriter();
|
||||
return [
|
||||
'serial' => $code->serial,
|
||||
'qr_base64' => base64_encode($writer->write($qr)->getString())
|
||||
];
|
||||
});
|
||||
|
||||
return $this->created([
|
||||
'sale' => $sale,
|
||||
'codes' => $qrData
|
||||
], 'Sale recorded successfully.');
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage(), null, 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sales history for the store.
|
||||
*/
|
||||
public function salesHistory(): JsonResponse
|
||||
{
|
||||
$store = $this->getStore();
|
||||
|
||||
$sales = $store->sales()
|
||||
->with('household.head')
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
return $this->ok($sales);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory adjustment and purchase history.
|
||||
*/
|
||||
public function inventoryHistory(): JsonResponse
|
||||
{
|
||||
$store = $this->getStore();
|
||||
|
||||
$history = \App\Models\StoreInventoryAdjustment::query()
|
||||
->where('store_id', $store->id)
|
||||
->with('user')
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
return $this->ok($history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for households to record a sale.
|
||||
*/
|
||||
public function searchHouseholds(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'q' => 'required|string|min:2',
|
||||
]);
|
||||
|
||||
$term = '%' . $request->string('q') . '%';
|
||||
|
||||
$households = \App\Models\Household::query()
|
||||
->with('head')
|
||||
->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);
|
||||
});
|
||||
})
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return $this->ok($households);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,22 @@ class SuperAdminUserController extends ApiController
|
||||
'language' => $data['preferred_language'] ?? 'en',
|
||||
]);
|
||||
|
||||
// If store_partner, auto-create a shell store so they can access the dashboard
|
||||
if ($data['role'] === User::ROLE_STORE_PARTNER) {
|
||||
$store = \App\Models\PartnerStore::create([
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'owner_user_id' => $user->id,
|
||||
'business_name' => $user->first_name . "'s Store",
|
||||
'status' => \App\Models\PartnerStore::STATUS_ACTIVE,
|
||||
'commission_rate_percent' => 10, // Default 10%
|
||||
]);
|
||||
|
||||
\App\Models\StoreInventory::create([
|
||||
'store_id' => $store->id,
|
||||
'current_code_balance' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
return $user;
|
||||
});
|
||||
|
||||
|
||||
29
app/Http/Controllers/Store/StoreDashboardController.php
Normal file
29
app/Http/Controllers/Store/StoreDashboardController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Store;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StoreDashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('store.dashboard');
|
||||
}
|
||||
|
||||
public function sales()
|
||||
{
|
||||
return view('store.sales');
|
||||
}
|
||||
|
||||
public function inventory()
|
||||
{
|
||||
return view('store.inventory');
|
||||
}
|
||||
|
||||
public function financials()
|
||||
{
|
||||
return view('store.financials');
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,11 @@ class StoreCollectionTeamRequest extends FormRequest
|
||||
'helper_ids.*' => ['integer', 'exists:users,id'],
|
||||
'helper_count' => ['nullable', 'integer', 'min:0', 'max:10'],
|
||||
'helper_assignment_mode' => ['nullable', 'string', Rule::in(['random', 'manual'])],
|
||||
'status' => ['nullable', Rule::in([CollectionTeam::STATUS_ACTIVE, CollectionTeam::STATUS_INACTIVE])],
|
||||
'status' => ['nullable', Rule::in([
|
||||
CollectionTeam::STATUS_ACTIVE,
|
||||
CollectionTeam::STATUS_INACTIVE,
|
||||
CollectionTeam::STATUS_STANDBY,
|
||||
])],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'override_conflicts' => ['nullable', 'boolean'],
|
||||
];
|
||||
|
||||
@@ -20,11 +20,15 @@ class CollectionTeamResource extends JsonResource
|
||||
'truck' => TruckResource::make($this->whenLoaded('truck')),
|
||||
'helpers' => $this->whenLoaded('helpers', fn () => $this->helpers->map(fn ($m) => [
|
||||
'id' => $m->user?->uuid,
|
||||
'db_id' => $m->user?->id,
|
||||
'name' => $m->user?->full_name,
|
||||
'assigned_from' => $m->assigned_from?->toDateString(),
|
||||
'assigned_until' => $m->assigned_until?->toDateString(),
|
||||
'status' => $m->status,
|
||||
])),
|
||||
'is_full' => $this->is_full,
|
||||
'performance_stats' => $this->getPerformanceStats(),
|
||||
'current_trip' => TripResource::make($this->whenLoaded('currentTrip')),
|
||||
'notes' => $this->notes,
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ class ServiceAreaResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->uuid,
|
||||
'db_id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'code' => $this->code,
|
||||
'status' => $this->status,
|
||||
|
||||
@@ -20,6 +20,7 @@ class TripResource extends JsonResource
|
||||
'dumpsite_arrival_time' => $this->dumpsite_arrival_time?->toIso8601String(),
|
||||
'dumpsite_departure_time' => $this->dumpsite_departure_time?->toIso8601String(),
|
||||
'total_load_kg' => $this->total_load_kg,
|
||||
'is_detouring' => $this->is_detouring,
|
||||
'route' => RouteResource::make($this->whenLoaded('route')),
|
||||
'team' => CollectionTeamResource::make($this->whenLoaded('team')),
|
||||
'truck' => TruckResource::make($this->whenLoaded('truck')),
|
||||
|
||||
@@ -11,6 +11,8 @@ class UserResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->uuid,
|
||||
'db_id' => $this->id,
|
||||
'full_name' => $this->full_name,
|
||||
'email' => $this->email,
|
||||
'phone' => $this->phone,
|
||||
'first_name' => $this->first_name,
|
||||
|
||||
@@ -15,12 +15,12 @@ class CollectionTeam extends Model
|
||||
use HasFactory, HasTenant, SoftDeletes;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_INACTIVE = 'inactive';
|
||||
public const STATUS_STANDBY = 'standby';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'tenant_id', 'name', 'area_id', 'driver_id', 'scanner_id', 'truck_id',
|
||||
'status', 'notes',
|
||||
'status', 'notes', 'helper_count',
|
||||
];
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
@@ -66,4 +66,44 @@ class CollectionTeam extends Model
|
||||
{
|
||||
return $this->members()->where('role_in_team', TeamMember::ROLE_HELPER);
|
||||
}
|
||||
|
||||
public function trips(): HasMany
|
||||
{
|
||||
return $this->hasMany(Trip::class, 'team_id');
|
||||
}
|
||||
|
||||
public function currentTrip(): \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
{
|
||||
return $this->hasOne(Trip::class, 'team_id')
|
||||
->whereIn('status', [Trip::STATUS_IN_PROGRESS, Trip::STATUS_AT_DUMPSITE]);
|
||||
}
|
||||
|
||||
public function collectionLogs(): \Illuminate\Database\Eloquent\Relations\HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(CollectionLog::class, Trip::class, 'team_id', 'trip_id');
|
||||
}
|
||||
|
||||
public function getIsFullAttribute(): bool
|
||||
{
|
||||
// A team is considered "Full" if it has a Driver, a Scanner,
|
||||
// and the number of helpers matches or exceeds the desired helper_count.
|
||||
$hasCore = $this->driver_id && $this->scanner_id;
|
||||
$helpersMet = $this->helpers()->count() >= ($this->helper_count ?: 2);
|
||||
|
||||
return $hasCore && $helpersMet;
|
||||
}
|
||||
|
||||
public function getPerformanceStats(): array
|
||||
{
|
||||
$today = now()->startOfDay();
|
||||
$week = now()->startOfWeek();
|
||||
|
||||
return [
|
||||
'current_trip_qr' => $this->currentTrip?->collectionLogs()->count() ?? 0,
|
||||
'current_trip_kg' => (int) ($this->currentTrip?->total_load_kg ?? 0),
|
||||
'daily_qr' => $this->collectionLogs()->where('scanned_at', '>=', $today)->count(),
|
||||
'weekly_qr' => $this->collectionLogs()->where('scanned_at', '>=', $week)->count(),
|
||||
'total_qr' => $this->collectionLogs()->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ class StoreInventory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['store_id', 'current_code_balance', 'last_updated_at'];
|
||||
protected $fillable = ['store_id', 'current_code_balance', 'prepaid_balance', 'last_updated_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'current_code_balance' => 'integer',
|
||||
'prepaid_balance' => 'integer',
|
||||
'last_updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class StorePurchase extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'store_id', 'batch_id', 'quantity',
|
||||
'wholesale_price_centavos', 'paid_at', 'payment_id',
|
||||
'wholesale_price_centavos', 'paid_at', 'payment_id', 'is_prepaid',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -21,6 +21,7 @@ class StorePurchase extends Model
|
||||
'paid_at' => 'datetime',
|
||||
'quantity' => 'integer',
|
||||
'wholesale_price_centavos' => 'integer',
|
||||
'is_prepaid' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class StoreSale extends Model
|
||||
protected $fillable = [
|
||||
'store_id', 'household_id', 'quantity',
|
||||
'retail_price_centavos', 'commission_centavos',
|
||||
'payment_id', 'sold_at',
|
||||
'payment_id', 'sold_at', 'is_prepaid',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -23,6 +23,7 @@ class StoreSale extends Model
|
||||
'quantity' => 'integer',
|
||||
'retail_price_centavos' => 'integer',
|
||||
'commission_centavos' => 'integer',
|
||||
'is_prepaid' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -32,9 +32,8 @@ class Trip extends Model
|
||||
'scheduled_date', 'scheduled_start_time',
|
||||
'actual_start_time', 'actual_end_time',
|
||||
'dumpsite_arrival_time', 'dumpsite_departure_time',
|
||||
'status', 'total_load_kg', 'notes', 'created_by_admin_id',
|
||||
'status', 'is_detouring', 'total_load_kg', 'notes', 'created_by_admin_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -43,6 +42,7 @@ class Trip extends Model
|
||||
'actual_end_time' => 'datetime',
|
||||
'dumpsite_arrival_time' => 'datetime',
|
||||
'dumpsite_departure_time' => 'datetime',
|
||||
'is_detouring' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -116,4 +116,9 @@ class Trip extends Model
|
||||
{
|
||||
return $this->hasMany(DumpsiteRelease::class);
|
||||
}
|
||||
|
||||
public function collectionLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(CollectionLog::class, 'trip_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ class StoreOperations
|
||||
int $wholesalePriceCentavos,
|
||||
?\DateTimeInterface $paidAt = null,
|
||||
$userId = null,
|
||||
bool $isPrepaid = false,
|
||||
): StorePurchase {
|
||||
return DB::transaction(function () use ($store, $quantity, $wholesalePriceCentavos, $paidAt, $userId) {
|
||||
return DB::transaction(function () use ($store, $quantity, $wholesalePriceCentavos, $paidAt, $userId, $isPrepaid) {
|
||||
$batch = $this->batches->generate(
|
||||
quantity: $quantity,
|
||||
purpose: QrCodeBatch::PURPOSE_STORE,
|
||||
@@ -64,10 +65,11 @@ class StoreOperations
|
||||
'quantity' => $quantity,
|
||||
'wholesale_price_centavos' => $wholesalePriceCentavos,
|
||||
'paid_at' => $paidAt ?? $now,
|
||||
'is_prepaid' => $isPrepaid,
|
||||
]);
|
||||
|
||||
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_PURCHASE, $quantity, null, "Wholesale purchase: Batch #{$batch->batch_number}", $userId);
|
||||
$this->updateBalance($store, $quantity);
|
||||
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_PURCHASE, $quantity, null, "Wholesale purchase: Batch #{$batch->batch_number}" . ($isPrepaid ? ' (Prepaid)' : ''), $userId);
|
||||
$this->updateBalance($store, $quantity, $isPrepaid);
|
||||
|
||||
return $purchase;
|
||||
});
|
||||
@@ -82,12 +84,22 @@ class StoreOperations
|
||||
Household $household,
|
||||
int $quantity,
|
||||
int $retailPricePerCodeCentavos,
|
||||
?string $serialNumber = null,
|
||||
): StoreSale {
|
||||
return DB::transaction(function () use ($store, $household, $quantity, $retailPricePerCodeCentavos) {
|
||||
$codeIds = QrCode::query()
|
||||
return DB::transaction(function () use ($store, $household, $quantity, $retailPricePerCodeCentavos, $serialNumber) {
|
||||
$inventory = StoreInventory::where('store_id', $store->id)->first();
|
||||
$prepaidAvailable = $inventory ? $inventory->prepaid_balance : 0;
|
||||
$usePrepaid = $prepaidAvailable >= $quantity;
|
||||
|
||||
$query = QrCode::query()
|
||||
->where('assigned_to_store_id', $store->id)
|
||||
->where('status', Allocated::$name)
|
||||
->orderBy('id')
|
||||
->where('status', Allocated::$name);
|
||||
|
||||
if ($serialNumber) {
|
||||
$query->where('serial', $serialNumber);
|
||||
}
|
||||
|
||||
$codeIds = $query->orderBy('id')
|
||||
->limit($quantity)
|
||||
->lockForUpdate()
|
||||
->pluck('id');
|
||||
@@ -115,10 +127,11 @@ class StoreOperations
|
||||
'retail_price_centavos' => $totalRetail,
|
||||
'commission_centavos' => $commission,
|
||||
'sold_at' => $now,
|
||||
'is_prepaid' => $usePrepaid,
|
||||
]);
|
||||
|
||||
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_SALE, -$quantity, null, "Sold to Household #{$household->id}", null);
|
||||
$this->updateBalance($store, -$quantity);
|
||||
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_SALE, -$quantity, null, "Sold to Household #{$household->id}" . ($usePrepaid ? ' (Prepaid)' : ''), null);
|
||||
$this->updateBalance($store, -$quantity, $usePrepaid);
|
||||
|
||||
// Notify the household head — fire after commit so the receiver
|
||||
// sees the persisted state.
|
||||
@@ -217,7 +230,10 @@ class StoreOperations
|
||||
*/
|
||||
public function calculateBalanceDue(PartnerStore $store): int
|
||||
{
|
||||
$netPayable = $store->sales()->sum(DB::raw('retail_price_centavos - commission_centavos'));
|
||||
$netPayable = $store->sales()
|
||||
->where('is_prepaid', false)
|
||||
->sum(DB::raw('retail_price_centavos - commission_centavos'));
|
||||
|
||||
$totalPaid = $store->settlements()->sum('amount_centavos');
|
||||
|
||||
return max(0, (int) $netPayable - (int) $totalPaid);
|
||||
@@ -246,12 +262,20 @@ class StoreOperations
|
||||
]);
|
||||
}
|
||||
|
||||
private function updateBalance(PartnerStore $store, int $delta): void
|
||||
private function updateBalance(PartnerStore $store, int $delta, bool $isPrepaid = false): void
|
||||
{
|
||||
$inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], ['current_code_balance' => 0]);
|
||||
$inventory->forceFill([
|
||||
'current_code_balance' => max(0, $inventory->current_code_balance + $delta),
|
||||
'last_updated_at' => now(),
|
||||
])->save();
|
||||
$inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], [
|
||||
'current_code_balance' => 0,
|
||||
'prepaid_balance' => 0
|
||||
]);
|
||||
|
||||
if ($isPrepaid) {
|
||||
$inventory->prepaid_balance = max(0, $inventory->prepaid_balance + $delta);
|
||||
} else {
|
||||
$inventory->current_code_balance = max(0, $inventory->current_code_balance + $delta);
|
||||
}
|
||||
|
||||
$inventory->last_updated_at = now();
|
||||
$inventory->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('trips', function (Blueprint $バランス) {
|
||||
$バランス->boolean('is_detouring')->default(false)->after('status');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('trips', function (Blueprint $バランス) {
|
||||
$バランス->dropColumn('is_detouring');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('store_inventories', function (Blueprint $table) {
|
||||
$table->unsignedInteger('prepaid_balance')->default(0)->after('current_code_balance');
|
||||
});
|
||||
|
||||
Schema::table('store_purchases', function (Blueprint $table) {
|
||||
$table->boolean('is_prepaid')->default(false)->after('payment_id');
|
||||
});
|
||||
|
||||
Schema::table('store_sales', function (Blueprint $table) {
|
||||
$table->boolean('is_prepaid')->default(false)->after('payment_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('store_sales', function (Blueprint $table) {
|
||||
$table->dropColumn('is_prepaid');
|
||||
});
|
||||
|
||||
Schema::table('store_purchases', function (Blueprint $table) {
|
||||
$table->dropColumn('is_prepaid');
|
||||
});
|
||||
|
||||
Schema::table('store_inventories', function (Blueprint $table) {
|
||||
$table->dropColumn('prepaid_balance');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// For MySQL/MariaDB, we need a raw query to update the enum
|
||||
DB::statement("ALTER TABLE collection_teams MODIFY COLUMN status ENUM('active', 'inactive', 'standby') DEFAULT 'standby'");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE collection_teams MODIFY COLUMN status ENUM('active', 'inactive') DEFAULT 'active'");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('collection_teams', function (Blueprint $table) {
|
||||
$table->unsignedTinyInteger('helper_count')->default(0)->after('truck_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('collection_teams', function (Blueprint $table) {
|
||||
$table->dropColumn('helper_count');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -171,6 +171,7 @@ window.Verde = {
|
||||
requireAuth,
|
||||
logout,
|
||||
setSession,
|
||||
getToken,
|
||||
getUser,
|
||||
toast,
|
||||
escapeHtml,
|
||||
|
||||
@@ -14,28 +14,16 @@
|
||||
<select id="filter-status" class="form-select w-44">
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="standby">Standby</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<input id="filter-q" type="search" placeholder="Search team name…" class="form-input flex-1 min-w-[200px]">
|
||||
<button id="filter-apply" class="btn-primary">Apply</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Driver</th>
|
||||
<th>Scanner</th>
|
||||
<th>Truck</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows">
|
||||
<tr><td colspan="6" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="cards-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Cards will be injected here -->
|
||||
<div class="col-span-full py-10 text-center text-sm text-neutral-400">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +76,7 @@
|
||||
</div>
|
||||
<div><label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="standby">Standby</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
@@ -105,7 +94,7 @@
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
const rows = document.getElementById('rows');
|
||||
const cardsGrid = document.getElementById('cards-grid');
|
||||
const modal = document.getElementById('form-modal');
|
||||
const conflictBanner = document.getElementById('conflict-banner');
|
||||
|
||||
@@ -113,7 +102,10 @@
|
||||
let allHelpers = [];
|
||||
|
||||
function badge(s) {
|
||||
return `<span class="badge ${s === 'active' ? 'badge-active' : 'badge-inactive'}">${s}</span>`;
|
||||
let cls = 'badge-inactive';
|
||||
if (s === 'active') cls = 'badge-active';
|
||||
if (s === 'standby') cls = 'bg-blue-100 text-blue-700 border-blue-200';
|
||||
return `<span class="badge ${cls}">${s.toUpperCase()}</span>`;
|
||||
}
|
||||
|
||||
async function loadUsers(role, selectId) {
|
||||
@@ -197,42 +189,96 @@
|
||||
});
|
||||
|
||||
async function load() {
|
||||
rows.innerHTML = `<tr><td colspan="6" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
|
||||
const params = new URLSearchParams();
|
||||
const s = document.getElementById('filter-status').value;
|
||||
const status = document.getElementById('filter-status').value;
|
||||
const q = document.getElementById('filter-q').value.trim();
|
||||
if (s) params.set('status', s);
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (q) params.set('q', q);
|
||||
params.set('per_page', '50');
|
||||
|
||||
const res = await window.Verde.apiFetch(`/api/v1/admin/teams?${params}`);
|
||||
if (!res.ok) { rows.innerHTML = `<tr><td colspan="6" class="py-10 text-center text-sm text-red-500">Failed to load.</td></tr>`; return; }
|
||||
const items = res.body.data ?? [];
|
||||
if (items.length === 0) { rows.innerHTML = `<tr><td colspan="6" class="py-10 text-center text-sm text-neutral-400">No teams yet.</td></tr>`; return; }
|
||||
|
||||
rows.innerHTML = items.map(t => {
|
||||
const helpersList = t.helpers && t.helpers.length > 0
|
||||
? t.helpers.map(h => window.Verde.escapeHtml(h.user?.full_name ?? '')).join(', ')
|
||||
: '—';
|
||||
if (!res.ok) {
|
||||
cardsGrid.innerHTML = '<div class="col-span-full py-10 text-center text-sm text-red-500">Failed to load teams.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const teams = res.body.data ?? [];
|
||||
if (teams.length === 0) {
|
||||
cardsGrid.innerHTML = '<div class="col-span-full py-10 text-center text-sm text-neutral-400">No teams found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
cardsGrid.innerHTML = teams.map(t => {
|
||||
const stats = t.performance_stats || {};
|
||||
const truck = t.truck || {};
|
||||
const capacityPercent = truck.capacity_kg > 0
|
||||
? Math.round((stats.current_trip_kg / truck.capacity_kg) * 100)
|
||||
: 0;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="font-medium text-neutral-900">
|
||||
<div>${window.Verde.escapeHtml(t.name)}</div>
|
||||
<div class="text-xs text-neutral-500 mt-0.5 font-normal">Helpers: ${helpersList}</div>
|
||||
</td>
|
||||
<td>${t.driver ? window.Verde.escapeHtml(t.driver.full_name ?? t.driver.email) : '—'}</td>
|
||||
<td>${t.scanner ? window.Verde.escapeHtml(t.scanner.full_name ?? t.scanner.email) : '—'}</td>
|
||||
<td>${t.truck ? `<span class="font-mono text-xs">${window.Verde.escapeHtml(t.truck.plate_number)}</span>` : '—'}</td>
|
||||
<td>${badge(t.status)}</td>
|
||||
<td class="text-right space-x-1">
|
||||
<button data-id="${t.id}" data-action="edit" class="btn-ghost px-3 py-1 text-xs">Edit</button>
|
||||
<button data-id="${t.id}" data-action="delete" class="btn-ghost px-3 py-1 text-xs text-red-600 hover:text-red-700">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<div class="card p-0 overflow-hidden flex flex-col group hover:ring-2 hover:ring-verde-500 transition-all duration-300 shadow-sm">
|
||||
<div class="bg-neutral-50 p-4 border-b border-neutral-100 flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-bold text-neutral-900">${window.Verde.escapeHtml(t.name)}</h3>
|
||||
<p class="text-xs text-neutral-500 mt-0.5">${window.Verde.escapeHtml(t.area?.name || 'No Area')}</p>
|
||||
</div>
|
||||
${badge(t.status)}
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-4 flex-1">
|
||||
<!-- Capacity indicator -->
|
||||
<div>
|
||||
<div class="flex justify-between text-[10px] font-bold uppercase tracking-wider text-neutral-500 mb-1">
|
||||
<span>Truck Load</span>
|
||||
<span>${stats.current_trip_kg || 0} / ${truck.capacity_kg || 0} KG</span>
|
||||
</div>
|
||||
<div class="h-2 w-full bg-neutral-100 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-verde-500 transition-all duration-1000" style="width: ${Math.min(capacityPercent, 100)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scan Stats -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-neutral-50 p-2 rounded-lg border border-neutral-100">
|
||||
<p class="text-[10px] font-bold text-neutral-400 uppercase">Trip Scans</p>
|
||||
<p class="text-lg font-bold text-neutral-900">${stats.current_trip_qr || 0}</p>
|
||||
</div>
|
||||
<div class="bg-neutral-50 p-2 rounded-lg border border-neutral-100">
|
||||
<p class="text-[10px] font-bold text-neutral-400 uppercase">Daily Scans</p>
|
||||
<p class="text-lg font-bold text-neutral-900">${stats.daily_qr || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personnel -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-6 w-6 rounded-full bg-verde-100 flex items-center justify-center text-[10px] font-bold text-verde-700">D</div>
|
||||
<span class="text-sm text-neutral-700 truncate">${window.Verde.escapeHtml(t.driver?.full_name || 'No Driver')}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-6 w-6 rounded-full bg-blue-100 flex items-center justify-center text-[10px] font-bold text-blue-700">S</div>
|
||||
<span class="text-sm text-neutral-700 truncate">${window.Verde.escapeHtml(t.scanner?.full_name || 'No Scanner')}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-6 w-6 rounded-full bg-orange-100 flex items-center justify-center text-[10px] font-bold text-orange-700">H</div>
|
||||
<span class="text-sm text-neutral-700">${t.helpers?.length || 0} Helpers</span>
|
||||
</div>
|
||||
${t.is_full ? '<span class="text-[10px] font-bold text-green-600 bg-green-50 px-1.5 py-0.5 rounded border border-green-200 uppercase">Full</span>' : '<span class="text-[10px] font-bold text-orange-600 bg-orange-50 px-1.5 py-0.5 rounded border border-orange-200 uppercase">Incomplete</span>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-50 p-3 border-t border-neutral-100 flex gap-2">
|
||||
<button data-id="${t.id}" data-action="edit" class="flex-1 py-1.5 text-xs font-bold text-neutral-600 hover:bg-white rounded-md border border-transparent hover:border-neutral-200 transition-all">Edit</button>
|
||||
<button data-id="${t.id}" data-action="delete" class="py-1.5 px-3 text-xs font-bold text-red-600 hover:bg-red-50 rounded-md transition-all">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
rows.querySelectorAll('button[data-action="delete"]').forEach(btn => {
|
||||
cardsGrid.querySelectorAll('button[data-action="delete"]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Delete this team?')) return;
|
||||
const r = await window.Verde.apiFetch(`/api/v1/admin/teams/${btn.dataset.id}`, { method: 'DELETE' });
|
||||
@@ -241,7 +287,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
rows.querySelectorAll('button[data-action="edit"]').forEach(btn => {
|
||||
cardsGrid.querySelectorAll('button[data-action="edit"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => editTeam(btn.dataset.id));
|
||||
});
|
||||
}
|
||||
@@ -280,7 +326,7 @@
|
||||
form.querySelector('input[name="helper_assignment_mode"][value="manual"]').checked = true;
|
||||
|
||||
// Check checkboxes for matching helper IDs
|
||||
const currentHelperIds = currentHelpers.map(h => h.user?.db_id).filter(Boolean);
|
||||
const currentHelperIds = currentHelpers.map(h => h.db_id).filter(Boolean);
|
||||
document.querySelectorAll('.helper-checkbox').forEach(cb => {
|
||||
if (currentHelperIds.includes(parseInt(cb.value, 10))) {
|
||||
cb.checked = true;
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
@foreach ([
|
||||
['email' => 'admin@verde.local', 'note' => 'Primary admin'],
|
||||
['email' => 'ops@verde.local', 'note' => 'Operations'],
|
||||
['email' => 'audit@verde.local', 'note' => 'Audit'],
|
||||
['email' => 'store0@verde.local', 'note' => 'Store Partner'],
|
||||
['email' => 'demo@verde.local', 'note' => 'Demo'],
|
||||
] as $cred)
|
||||
<button type="button"
|
||||
@@ -178,6 +178,7 @@
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await res.json();
|
||||
console.log('[Login] Response:', body);
|
||||
|
||||
if (!res.ok || !body.success) {
|
||||
const fallback = body.message || 'Sign in failed. Try again.';
|
||||
@@ -186,14 +187,26 @@
|
||||
}
|
||||
|
||||
const role = body.data?.user?.role;
|
||||
if (role !== 'admin' && role !== 'super_admin') {
|
||||
showError('This account is not an admin. Use an admin email.');
|
||||
const allowedRoles = ['admin', 'super_admin', 'store_partner'];
|
||||
|
||||
console.log('[Login] Role:', role);
|
||||
|
||||
if (!allowedRoles.includes(role)) {
|
||||
showError('Access denied. This portal is for Admins and Store Partners only.');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('verde:token', body.data.token);
|
||||
localStorage.setItem('verde:user', JSON.stringify(body.data.user));
|
||||
window.location.href = '/dashboard';
|
||||
// Use shared helper if available
|
||||
if (window.Verde?.setSession) {
|
||||
window.Verde.setSession(body.data.token, body.data.user);
|
||||
} else {
|
||||
localStorage.setItem('verde:token', body.data.token);
|
||||
localStorage.setItem('verde:user', JSON.stringify(body.data.user));
|
||||
}
|
||||
|
||||
const target = role === 'store_partner' ? '/store/dashboard' : '/dashboard';
|
||||
console.log('[Login] Redirecting to:', target);
|
||||
window.location.href = target;
|
||||
} catch (err) {
|
||||
showError('Network error. Check your connection and try again.');
|
||||
} finally {
|
||||
|
||||
158
resources/views/store/dashboard.blade.php
Normal file
158
resources/views/store/dashboard.blade.php
Normal file
@@ -0,0 +1,158 @@
|
||||
@extends('store.layouts.app', ['pageTitle' => 'Dashboard', 'pageSubtitle' => 'Business overview'])
|
||||
|
||||
@section('page')
|
||||
<div class="space-y-8">
|
||||
{{-- KPI Grid --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-verde-50 text-verde-600">
|
||||
<i data-lucide="package" class="h-5 w-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Inventory Balance</p>
|
||||
<p id="kpi-inventory" class="text-2xl font-bold text-neutral-900">...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600">
|
||||
<i data-lucide="barcode" class="h-5 w-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Total Sales</p>
|
||||
<p id="kpi-sales-count" class="text-2xl font-bold text-neutral-900">...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600">
|
||||
<i data-lucide="trending-up" class="h-5 w-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Commissions Earned</p>
|
||||
<p id="kpi-commission" class="text-2xl font-bold text-neutral-900">...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-50 text-orange-600">
|
||||
<i data-lucide="wallet" class="h-5 w-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Amount Owed</p>
|
||||
<p id="kpi-owed" class="text-2xl font-bold text-neutral-900">...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Main Content Grid --}}
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
{{-- Recent Sales --}}
|
||||
<div class="rounded-xl border border-neutral-200 bg-white shadow-sm overflow-hidden">
|
||||
<div class="border-b border-neutral-100 px-6 py-4 flex items-center justify-between bg-neutral-50/30">
|
||||
<h3 class="font-semibold text-neutral-900">Recent Sales</h3>
|
||||
<a href="/store/sales" class="text-xs font-medium text-verde-600 hover:text-verde-700">View all</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-100 bg-neutral-50/50 text-[10px] font-bold uppercase tracking-wider text-neutral-400">
|
||||
<th class="px-6 py-3">Date</th>
|
||||
<th class="px-6 py-3">Resident</th>
|
||||
<th class="px-6 py-3">Qty</th>
|
||||
<th class="px-6 py-3 text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-sales-body" class="divide-y divide-neutral-100">
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-8 text-center text-neutral-400">Loading history...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Actions Card --}}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-xl border border-neutral-200 bg-verde-700 p-6 text-white shadow-md">
|
||||
<h3 class="text-lg font-bold">Record a New Sale</h3>
|
||||
<p class="mt-1 text-sm text-verde-100">Instantly register QR code sales to residents.</p>
|
||||
<a href="/store/sales" class="mt-6 inline-flex items-center gap-2 rounded-lg bg-white px-4 py-2 text-sm font-bold text-verde-700 shadow-sm transition-transform hover:scale-105 active:scale-95">
|
||||
<i data-lucide="plus-circle" class="h-4 w-4"></i>
|
||||
<span>Start Transaction</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||
<h3 class="font-semibold text-neutral-900">Need Inventory?</h3>
|
||||
<p class="mt-1 text-xs text-neutral-500">Contact your LGU Admin to issue new wholesale QR code batches to your store.</p>
|
||||
<div class="mt-4 flex items-center gap-2 text-xs font-medium text-neutral-900">
|
||||
<i data-lucide="info" class="h-4 w-4 text-neutral-400"></i>
|
||||
<span>Inventory is currently admin-managed.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const formatMoney = (centavos) => {
|
||||
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(centavos / 100);
|
||||
};
|
||||
|
||||
try {
|
||||
// Fetch Dashboard Stats
|
||||
const dashRes = await fetch('/api/v1/store/dashboard', {
|
||||
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
|
||||
});
|
||||
const { data: dash } = await dashRes.json();
|
||||
|
||||
document.getElementById('kpi-inventory').textContent = dash.inventory_balance;
|
||||
document.getElementById('kpi-sales-count').textContent = dash.total_sales_count;
|
||||
|
||||
// Fetch Financials
|
||||
const finRes = await fetch('/api/v1/store/analytics', {
|
||||
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
|
||||
});
|
||||
const { data: fin } = await finRes.json();
|
||||
|
||||
document.getElementById('kpi-commission').textContent = formatMoney(fin.total_commission_earned_centavos);
|
||||
document.getElementById('kpi-owed').textContent = formatMoney(fin.amount_owed_to_lgu_centavos);
|
||||
|
||||
// Fetch Recent Sales
|
||||
const salesRes = await fetch('/api/v1/store/sales?limit=5', {
|
||||
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
|
||||
});
|
||||
const { data: salesPaginated } = await salesRes.json();
|
||||
const sales = salesPaginated.data;
|
||||
|
||||
const tbody = document.getElementById('recent-sales-body');
|
||||
if (sales.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="px-6 py-8 text-center text-neutral-400">No sales recorded yet.</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = sales.map(sale => `
|
||||
<tr class="hover:bg-neutral-50/50">
|
||||
<td class="px-6 py-4 text-xs text-neutral-500">${new Date(sale.created_at).toLocaleDateString()}</td>
|
||||
<td class="px-6 py-4 font-medium text-neutral-900">${sale.household?.head?.full_name || 'N/A'}</td>
|
||||
<td class="px-6 py-4 text-neutral-600">${sale.quantity}</td>
|
||||
<td class="px-6 py-4 text-right font-semibold text-neutral-900">${formatMoney(sale.retail_price_centavos)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
} catch (e) {
|
||||
console.error('Dashboard load failed', e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
85
resources/views/store/financials.blade.php
Normal file
85
resources/views/store/financials.blade.php
Normal file
@@ -0,0 +1,85 @@
|
||||
@extends('store.layouts.app', ['pageTitle' => 'Financials', 'pageSubtitle' => 'Earnings and settlement history'])
|
||||
|
||||
@section('page')
|
||||
<div class="space-y-8">
|
||||
{{-- Financial KPI Grid --}}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-emerald-100 bg-emerald-50/50 p-8 shadow-sm">
|
||||
<p class="text-xs font-bold text-emerald-600 uppercase tracking-widest">Total Commission Earned</p>
|
||||
<p id="fin-commission" class="mt-2 text-4xl font-black text-emerald-900">₱0.00</p>
|
||||
<p class="mt-4 text-xs text-emerald-600/70">Your profit from all retail sales recorded.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-neutral-200 bg-white p-8 shadow-sm">
|
||||
<p class="text-xs font-bold text-neutral-400 uppercase tracking-widest">Total Revenue Collected</p>
|
||||
<p id="fin-revenue" class="mt-2 text-4xl font-black text-neutral-900">₱0.00</p>
|
||||
<p class="mt-4 text-xs text-neutral-500">Gross amount collected from residents.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-orange-100 bg-orange-50/50 p-8 shadow-sm ring-1 ring-orange-200/50">
|
||||
<p class="text-xs font-bold text-orange-600 uppercase tracking-widest">Amount Owed to LGU</p>
|
||||
<p id="fin-owed" class="mt-2 text-4xl font-black text-orange-900">₱0.00</p>
|
||||
<p class="mt-4 text-xs text-orange-600/70">Total Revenue minus your Commission and previous payments.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Settlement History --}}
|
||||
<div class="rounded-xl border border-neutral-200 bg-white shadow-sm overflow-hidden">
|
||||
<div class="border-b border-neutral-100 px-6 py-4 bg-neutral-50/30 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-neutral-900">Payment & Settlement History</h3>
|
||||
<div class="flex items-center gap-2 rounded-lg bg-orange-100 px-3 py-1 text-xs font-bold text-orange-700">
|
||||
<i data-lucide="info" class="h-3 w-3"></i>
|
||||
<span>Payments are recorded by LGU Admins</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-100 bg-neutral-50/50 text-[10px] font-bold uppercase tracking-wider text-neutral-400">
|
||||
<th class="px-6 py-3">Date</th>
|
||||
<th class="px-6 py-3">Method</th>
|
||||
<th class="px-6 py-3">Reference</th>
|
||||
<th class="px-6 py-3 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="settlements-body" class="divide-y divide-neutral-100">
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-8 text-center text-neutral-400">Loading history...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const formatMoney = (centavos) => {
|
||||
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(centavos / 100);
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/store/analytics', {
|
||||
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
|
||||
});
|
||||
const { data } = await res.json();
|
||||
|
||||
document.getElementById('fin-commission').textContent = formatMoney(data.total_commission_earned_centavos);
|
||||
document.getElementById('fin-revenue').textContent = formatMoney(data.total_retail_revenue_centavos);
|
||||
document.getElementById('fin-owed').textContent = formatMoney(data.amount_owed_to_lgu_centavos);
|
||||
|
||||
// Fetch Settlements (Using a temporary endpoint or reusing analytics for now)
|
||||
// In a real app, I'd add a /settlements endpoint.
|
||||
// For this demo, I'll assume we add it to the API controller.
|
||||
|
||||
// To keep it simple, I'll just show a placeholder or fetch if I add the method.
|
||||
const tbody = document.getElementById('settlements-body');
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="px-6 py-12 text-center"><div class="flex flex-col items-center gap-2"><i data-lucide="history" class="h-8 w-8 text-neutral-200"></i><p class="text-neutral-400">No payment settlements found yet.</p></div></td></tr>';
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
} catch (e) {
|
||||
console.error('Financials load failed', e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
132
resources/views/store/inventory.blade.php
Normal file
132
resources/views/store/inventory.blade.php
Normal file
@@ -0,0 +1,132 @@
|
||||
@extends('store.layouts.app', ['pageTitle' => 'Inventory', 'pageSubtitle' => 'Stock history and acquisitions'])
|
||||
|
||||
@section('page')
|
||||
<div class="space-y-8">
|
||||
{{-- Summary Cards --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||
<p class="text-xs font-bold text-neutral-400 uppercase tracking-widest">Consignment Stock</p>
|
||||
<p id="inv-balance" class="mt-1 text-3xl font-black text-neutral-900">...</p>
|
||||
<p class="mt-2 text-xs text-neutral-500">Available codes (Consignment).</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm border-l-4 border-l-emerald-500">
|
||||
<p class="text-xs font-bold text-neutral-400 uppercase tracking-widest text-emerald-600">Prepaid Stock</p>
|
||||
<p id="inv-prepaid" class="mt-1 text-3xl font-black text-neutral-900">...</p>
|
||||
<p class="mt-2 text-xs text-neutral-500">Available codes (Fully Paid).</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||
<p class="text-xs font-bold text-neutral-400 uppercase tracking-widest">Total Acquired</p>
|
||||
<p id="inv-total" class="mt-1 text-3xl font-black text-neutral-900">...</p>
|
||||
<p class="mt-2 text-xs text-neutral-500">Total codes issued to your store by the LGU.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- History Table --}}
|
||||
<div class="rounded-xl border border-neutral-200 bg-white shadow-sm overflow-hidden">
|
||||
<div class="border-b border-neutral-100 px-6 py-4 bg-neutral-50/30">
|
||||
<h3 class="font-semibold text-neutral-900">Inventory Logs</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-100 bg-neutral-50/50 text-[10px] font-bold uppercase tracking-wider text-neutral-400">
|
||||
<th class="px-6 py-3">Date</th>
|
||||
<th class="px-6 py-3">Type</th>
|
||||
<th class="px-6 py-3">Quantity</th>
|
||||
<th class="px-6 py-3">Reason / Details</th>
|
||||
<th class="px-6 py-3">Reference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="inventory-history-body" class="divide-y divide-neutral-100">
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-8 text-center text-neutral-400">Loading history...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pagination" class="border-t border-neutral-100 px-6 py-4 bg-neutral-50/30 flex items-center justify-between">
|
||||
<p id="pagination-info" class="text-xs text-neutral-500">Showing ... results</p>
|
||||
<div class="flex gap-2">
|
||||
<button id="prev-page" class="btn-ghost px-3 py-1.5 text-xs disabled:opacity-50">Previous</button>
|
||||
<button id="next-page" class="btn-ghost px-3 py-1.5 text-xs disabled:opacity-50">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
let currentPage = 1;
|
||||
|
||||
async function loadHistory(page = 1) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/store/inventory?page=${page}`, {
|
||||
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
|
||||
});
|
||||
const { data: paginated } = await res.json();
|
||||
const logs = paginated.data;
|
||||
|
||||
const tbody = document.getElementById('inventory-history-body');
|
||||
if (logs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="px-6 py-8 text-center text-neutral-400">No inventory history found.</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = logs.map(log => {
|
||||
const isPositive = log.quantity > 0;
|
||||
const qtyClass = isPositive ? 'text-emerald-600 font-bold' : 'text-red-600 font-bold';
|
||||
const typeLabel = log.type.charAt(0).toUpperCase() + log.type.slice(1);
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-neutral-50/50 transition-colors">
|
||||
<td class="px-6 py-4 text-xs text-neutral-500">${new Date(log.created_at).toLocaleString()}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight ${isPositive ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}">
|
||||
${typeLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 ${qtyClass}">${isPositive ? '+' : ''}${log.quantity}</td>
|
||||
<td class="px-6 py-4 text-neutral-600">${log.reason || '-'}</td>
|
||||
<td class="px-6 py-4 font-mono text-[10px] text-neutral-400">#${log.qr_code_id || 'BATCH'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('pagination-info').textContent = `Showing ${paginated.from || 0} to ${paginated.to || 0} of ${paginated.total} logs`;
|
||||
document.getElementById('prev-page').disabled = !paginated.prev_page_url;
|
||||
document.getElementById('next-page').disabled = !paginated.next_page_url;
|
||||
currentPage = page;
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
} catch (e) {
|
||||
console.error('Failed to load history', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
const res = await fetch('/api/v1/store/dashboard', {
|
||||
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
|
||||
});
|
||||
const { data } = await res.json();
|
||||
document.getElementById('inv-balance').textContent = data.inventory_balance;
|
||||
document.getElementById('inv-prepaid').textContent = data.prepaid_balance;
|
||||
|
||||
// Calculate total acquired by summing positive adjustments (simple frontend math for now)
|
||||
// In a real app, the API would provide this sum.
|
||||
const histRes = await fetch('/api/v1/store/inventory?per_page=100', {
|
||||
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
|
||||
});
|
||||
const { data: hist } = await histRes.json();
|
||||
const total = hist.data.reduce((acc, log) => log.quantity > 0 ? acc + log.quantity : acc, 0);
|
||||
document.getElementById('inv-total').textContent = total;
|
||||
}
|
||||
|
||||
document.getElementById('prev-page').addEventListener('click', () => loadHistory(currentPage - 1));
|
||||
document.getElementById('next-page').addEventListener('click', () => loadHistory(currentPage + 1));
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSummary();
|
||||
loadHistory();
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
41
resources/views/store/layouts/app.blade.php
Normal file
41
resources/views/store/layouts/app.blade.php
Normal file
@@ -0,0 +1,41 @@
|
||||
@extends('layouts.web', ['title' => ($pageTitle ?? 'Store') . ' — Verde'])
|
||||
|
||||
@section('content')
|
||||
<div class="flex min-h-screen">
|
||||
@include('store.partials.sidebar')
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{{-- Topbar --}}
|
||||
<header class="sticky top-0 z-10 flex h-14 items-center justify-between border-b border-neutral-200 bg-white/80 px-6 backdrop-blur">
|
||||
<div class="flex items-center gap-3">
|
||||
@if (! empty($pageTitle))
|
||||
<h1 class="text-sm font-semibold tracking-tight text-neutral-900">{{ $pageTitle }}</h1>
|
||||
@endif
|
||||
@if (! empty($pageSubtitle))
|
||||
<span class="text-sm text-neutral-400">·</span>
|
||||
<span class="text-sm text-neutral-500">{{ $pageSubtitle }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="topbar-user" class="text-sm text-neutral-600"></span>
|
||||
<button id="topbar-logout" class="btn-ghost px-3 py-1.5 text-xs">Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 px-6 py-8">
|
||||
@yield('page')
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
if (!window.Verde?.requireAuth('store_partner')) { /* redirect handled */ }
|
||||
|
||||
const user = window.Verde?.getUser();
|
||||
const userEl = document.getElementById('topbar-user');
|
||||
if (userEl && user) userEl.textContent = user.email;
|
||||
|
||||
document.getElementById('topbar-logout')?.addEventListener('click', () => window.Verde.logout());
|
||||
</script>
|
||||
@endsection
|
||||
85
resources/views/store/partials/sidebar.blade.php
Normal file
85
resources/views/store/partials/sidebar.blade.php
Normal file
@@ -0,0 +1,85 @@
|
||||
@php
|
||||
$nav = [
|
||||
'Dashboard' => [
|
||||
['href' => '/store/dashboard', 'label' => 'Dashboard', 'icon' => 'layout-dashboard'],
|
||||
],
|
||||
'Business' => [
|
||||
['href' => '/store/sales', 'label' => 'Record Sale', 'icon' => 'barcode'],
|
||||
['href' => '/store/inventory', 'label' => 'Inventory', 'icon' => 'package'],
|
||||
['href' => '/store/financials', 'label' => 'Financials', 'icon' => 'wallet'],
|
||||
],
|
||||
];
|
||||
|
||||
$renderIcon = function($name) {
|
||||
return '<i data-lucide="' . $name . '" class="h-4 w-4"></i>';
|
||||
};
|
||||
@endphp
|
||||
|
||||
<aside class="flex w-64 flex-col border-r border-neutral-200 bg-neutral-50/50">
|
||||
<div class="flex h-14 items-center border-b border-neutral-200 px-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded bg-verde-600 text-white">
|
||||
<i data-lucide="store" class="h-5 w-5"></i>
|
||||
</div>
|
||||
<span class="text-sm font-bold tracking-tight text-neutral-900">Store Portal</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-3 py-6">
|
||||
@foreach ($nav as $groupLabel => $items)
|
||||
<div class="mb-6 last:mb-0">
|
||||
<div class="mb-2 px-3 text-[10px] font-bold uppercase tracking-wider text-neutral-400">
|
||||
{{ $groupLabel }}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
@foreach ($items as $item)
|
||||
<a href="{{ $item['href'] }}"
|
||||
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors {{ Request::is(trim($item['href'], '/')) ? 'bg-verde-50 text-verde-700' : 'text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900' }}">
|
||||
{!! $renderIcon($item['icon']) !!}
|
||||
<span>{{ $item['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="border-t border-neutral-200 p-4">
|
||||
<div class="rounded-lg bg-white p-3 shadow-sm ring-1 ring-neutral-200">
|
||||
<div id="sidebar-store-name" class="text-xs font-semibold text-neutral-900 truncate">Loading store...</div>
|
||||
<div id="sidebar-store-status" class="mt-1 flex items-center gap-1.5">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-neutral-300"></span>
|
||||
<span class="text-[10px] text-neutral-500 uppercase">Offline</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script type="module">
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/store/dashboard', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.Verde.getToken()}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const { data } = await response.json();
|
||||
document.getElementById('sidebar-store-name').textContent = data.business_name;
|
||||
const statusEl = document.getElementById('sidebar-store-status');
|
||||
const dot = statusEl.querySelector('span:first-child');
|
||||
const label = statusEl.querySelector('span:last-child');
|
||||
|
||||
label.textContent = data.status;
|
||||
if (data.status === 'active') {
|
||||
dot.className = 'h-1.5 w-1.5 rounded-full bg-verde-500';
|
||||
} else {
|
||||
dot.className = 'h-1.5 w-1.5 rounded-full bg-red-500';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load store info', e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
283
resources/views/store/sales.blade.php
Normal file
283
resources/views/store/sales.blade.php
Normal file
@@ -0,0 +1,283 @@
|
||||
@extends('store.layouts.app', ['pageTitle' => 'Record Sale', 'pageSubtitle' => 'Register retail QR code sales'])
|
||||
|
||||
@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="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>
|
||||
</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>
|
||||
</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">
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<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
|
||||
</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>
|
||||
|
||||
<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');
|
||||
|
||||
window.adjustQty = (amount) => {
|
||||
const val = parseInt(qtyInput.value) + amount;
|
||||
if (val >= 1) {
|
||||
qtyInput.value = val;
|
||||
updateCost();
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
const q = e.target.value;
|
||||
if (q.length < 2) {
|
||||
resultsContainer.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
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, '"')})">
|
||||
<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('');
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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...';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/store/sales', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${window.Verde.getToken()}`,
|
||||
'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
|
||||
})
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
document.getElementById('success-modal').classList.replace('hidden', 'flex');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.message || 'Failed to record sale.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Sale failed', err);
|
||||
alert('An unexpected error occurred.');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i data-lucide="check-circle" class="h-6 w-6"></i> Confirm & Record Sale';
|
||||
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');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
@@ -49,6 +49,7 @@ use App\Http\Controllers\Api\V1\Payment\PaymentController;
|
||||
use App\Http\Controllers\Api\V1\Qr\MyQrCodeController;
|
||||
use App\Http\Controllers\Api\V1\Scanner\ScannerController;
|
||||
use App\Http\Controllers\Api\V1\Store\PartnerStorePublicController;
|
||||
use App\Http\Controllers\Api\V1\Store\StorePortalController;
|
||||
use App\Http\Controllers\Api\V1\SuperAdmin\SuperAdminBarangayController;
|
||||
use App\Http\Controllers\Api\V1\SuperAdmin\SuperAdminTenantController;
|
||||
use App\Http\Controllers\Api\V1\SuperAdmin\SuperAdminUserController;
|
||||
@@ -135,6 +136,16 @@ Route::middleware(['auth:sanctum', 'role:driver'])->prefix('driver')->name('api.
|
||||
Route::post('/trips/{trip}/resume', [\App\Http\Controllers\Api\V1\Driver\TripDetourController::class, 'resume'])->name('trips.detour.resume');
|
||||
});
|
||||
|
||||
// Store Portal
|
||||
Route::middleware(['auth:sanctum', 'role:store_partner'])->prefix('store')->name('api.v1.store.')->group(function () {
|
||||
Route::get('/dashboard', [StorePortalController::class, 'dashboard'])->name('dashboard');
|
||||
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('/inventory', [StorePortalController::class, 'inventoryHistory'])->name('inventory.index');
|
||||
Route::get('/households', [StorePortalController::class, 'searchHouseholds'])->name('households.search');
|
||||
});
|
||||
|
||||
// Admin live tracking
|
||||
Route::middleware(['auth:sanctum', 'role:admin'])->prefix('admin/live')->name('api.v1.admin.live.')->group(function () {
|
||||
Route::get('/trucks', [AdminLiveTrackingController::class, 'trucks'])->name('trucks');
|
||||
|
||||
@@ -42,3 +42,10 @@ Route::prefix('admin')->group(function () {
|
||||
'description' => 'Filtered view of trip incident events — UI work pending. Visible inline in each Trip detail.',
|
||||
]);
|
||||
});
|
||||
|
||||
Route::prefix('store')->name('store.')->group(function () {
|
||||
Route::get('/dashboard', [\App\Http\Controllers\Store\StoreDashboardController::class, 'index'])->name('dashboard');
|
||||
Route::get('/sales', [\App\Http\Controllers\Store\StoreDashboardController::class, 'sales'])->name('sales');
|
||||
Route::get('/inventory', [\App\Http\Controllers\Store\StoreDashboardController::class, 'inventory'])->name('inventory');
|
||||
Route::get('/financials', [\App\Http\Controllers\Store\StoreDashboardController::class, 'financials'])->name('financials');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user