diff --git a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php
index 1109af4..39f1d61 100644
--- a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php
+++ b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php
@@ -128,19 +128,23 @@ class AdminPartnerStoreController extends ApiController
public function recordSale(Request $request, PartnerStore $store): JsonResponse
{
+ $globalPrice = config('qr.default_retail_price_per_code_centavos', 1000);
+
$data = $request->validate([
'household_id' => ['required', 'integer', 'exists:households,id'],
'quantity' => ['required', 'integer', 'min:1', 'max:1000'],
- 'retail_price_per_code_centavos' => ['required', 'integer', 'min:0'],
+ 'retail_price_per_code_centavos' => ['nullable', 'integer', 'in:' . $globalPrice],
]);
+ $retailPrice = (int) ($data['retail_price_per_code_centavos'] ?? $globalPrice);
+
try {
$household = Household::findOrFail($data['household_id']);
$sale = $this->ops->sellToHousehold(
$store,
$household,
(int) $data['quantity'],
- (int) $data['retail_price_per_code_centavos'],
+ $retailPrice,
);
} catch (\DomainException $e) {
return $this->fail($e->getMessage(), null, 422);
@@ -153,4 +157,73 @@ class AdminPartnerStoreController extends ApiController
'commission_centavos' => $sale->commission_centavos,
], 'Sale recorded');
}
+
+ public function purchases(Request $request, PartnerStore $store): JsonResponse
+ {
+ $purchases = $store->purchases()
+ ->with('batch')
+ ->latest()
+ ->paginate(15);
+
+ return $this->ok(
+ $purchases->map(fn ($p) => [
+ 'id' => $p->id,
+ 'batch_number' => $p->batch?->batch_number ?? '—',
+ 'quantity' => $p->quantity,
+ 'wholesale_price_pesos' => number_format($p->wholesale_price_centavos / 100, 2),
+ 'paid_at' => $p->paid_at?->toIso8601String() ?? $p->created_at?->toIso8601String(),
+ ]),
+ null,
+ [
+ 'page' => $purchases->currentPage(),
+ 'per_page' => $purchases->perPage(),
+ 'total' => $purchases->total(),
+ 'last_page' => $purchases->lastPage(),
+ ]
+ );
+ }
+
+ public function salesHistory(Request $request, PartnerStore $store): JsonResponse
+ {
+ $sales = $store->sales()
+ ->with(['household.head'])
+ ->latest()
+ ->paginate(15);
+
+ return $this->ok(
+ $sales->map(fn ($s) => [
+ 'id' => $s->id,
+ 'household_name' => $s->household?->head?->full_name ?? '—',
+ 'household_email' => $s->household?->head?->email ?? '—',
+ 'quantity' => $s->quantity,
+ 'retail_price_pesos' => number_format($s->retail_price_centavos / 100, 2),
+ 'commission_pesos' => number_format($s->commission_centavos / 100, 2),
+ 'sold_at' => $s->sold_at?->toIso8601String() ?? $s->created_at?->toIso8601String(),
+ ]),
+ null,
+ [
+ 'page' => $sales->currentPage(),
+ 'per_page' => $sales->perPage(),
+ 'total' => $sales->total(),
+ 'last_page' => $sales->lastPage(),
+ ]
+ );
+ }
+
+ public function analytics(PartnerStore $store): JsonResponse
+ {
+ $totalWholesaleCost = (int) $store->purchases()->sum('wholesale_price_centavos');
+ $totalRetailRevenue = (int) $store->sales()->sum('retail_price_centavos');
+ $totalCommission = (int) $store->sales()->sum('commission_centavos');
+ $grossProfit = $totalRetailRevenue - $totalWholesaleCost;
+
+ return $this->ok([
+ 'total_wholesale_cost_pesos' => number_format($totalWholesaleCost / 100, 2),
+ 'total_retail_revenue_pesos' => number_format($totalRetailRevenue / 100, 2),
+ 'total_commission_pesos' => number_format($totalCommission / 100, 2),
+ 'gross_profit_pesos' => number_format($grossProfit / 100, 2),
+ 'total_issued_codes' => (int) $store->purchases()->sum('quantity'),
+ 'total_sold_codes' => (int) $store->sales()->sum('quantity'),
+ ]);
+ }
}
diff --git a/resources/views/admin/partner-stores.blade.php b/resources/views/admin/partner-stores.blade.php
index bfc84ba..c28bd55 100644
--- a/resources/views/admin/partner-stores.blade.php
+++ b/resources/views/admin/partner-stores.blade.php
@@ -73,6 +73,7 @@
@@ -134,6 +135,86 @@
Quick Actions
+
+
+
Wholesale Purchase History
+
+
+
+
+ | Date |
+ Batch # |
+ Qty |
+ Cost (₱) |
+
+
+
+ | No purchases found. |
+
+
+
+
+
+
+
+
+
+ {{-- TAB: Analytics (edit mode only) --}}
+
+ {{-- KPI Grid --}}
+
+
+
Gross Revenue
+
₱0.00
+
+
+
Wholesale Cost
+
₱0.00
+
+
+
+
Commission Earned
+
₱0.00
+
+
+
+
+
+
Total Issued Codes
+
0
+
+
+
+
+ {{-- Retail Sales History --}}
+
+
Resident Retail Sales History
+
+
+
+
+ | Date |
+ Resident |
+ Qty |
+ Price (₱) |
+ Comm. (₱) |
+
+
+
+ | No sales recorded. |
+
+
+
+
+
+
+
@@ -238,9 +319,133 @@
});
}
+ let purchasesPage = 1;
+ let salesPage = 1;
+
+ async function fetchPurchases(storeId, page = 1) {
+ if (page === 1) {
+ document.getElementById('inventory-purchases-rows').innerHTML = `| Loading history… |
`;
+ document.getElementById('inventory-purchases-more').classList.add('hidden');
+ } else {
+ const btn = document.getElementById('inventory-purchases-more-btn');
+ btn.textContent = 'Loading…';
+ btn.disabled = true;
+ }
+
+ const res = await window.Verde.apiFetch(`/api/v1/admin/partner-stores/${storeId}/purchases?page=${page}`);
+ if (!res.ok) {
+ if (page === 1) {
+ document.getElementById('inventory-purchases-rows').innerHTML = `| Failed to load history. |
`;
+ }
+ return;
+ }
+
+ const data = res.body.data ?? [];
+ const meta = res.body.meta ?? {};
+ const tbody = document.getElementById('inventory-purchases-rows');
+
+ const rowsHtml = data.map(p => {
+ const dateStr = p.paid_at ? new Date(p.paid_at).toLocaleString() : '—';
+ return `
+ | ${dateStr} |
+ ${window.Verde.escapeHtml(p.batch_number)} |
+ ${p.quantity} |
+ ₱${p.wholesale_price_pesos} |
+
`;
+ }).join('');
+
+ if (page === 1) {
+ tbody.innerHTML = rowsHtml.length > 0 ? rowsHtml : `| No purchases found. |
`;
+ } else {
+ tbody.insertAdjacentHTML('beforeend', rowsHtml);
+ }
+
+ const btn = document.getElementById('inventory-purchases-more-btn');
+ btn.textContent = 'Load More';
+ btn.disabled = false;
+
+ const hasMore = meta.page < meta.last_page;
+ document.getElementById('inventory-purchases-more').classList.toggle('hidden', !hasMore);
+ }
+
+ async function fetchSales(storeId, page = 1) {
+ if (page === 1) {
+ document.getElementById('analytics-sales-rows').innerHTML = `| Loading history… |
`;
+ document.getElementById('analytics-sales-more').classList.add('hidden');
+ } else {
+ const btn = document.getElementById('analytics-sales-more-btn');
+ btn.textContent = 'Loading…';
+ btn.disabled = true;
+ }
+
+ const res = await window.Verde.apiFetch(`/api/v1/admin/partner-stores/${storeId}/sales-history?page=${page}`);
+ if (!res.ok) {
+ if (page === 1) {
+ document.getElementById('analytics-sales-rows').innerHTML = `| Failed to load history. |
`;
+ }
+ return;
+ }
+
+ const data = res.body.data ?? [];
+ const meta = res.body.meta ?? {};
+ const tbody = document.getElementById('analytics-sales-rows');
+
+ const rowsHtml = data.map(s => {
+ const dateStr = s.sold_at ? new Date(s.sold_at).toLocaleString() : '—';
+ return `
+ | ${dateStr} |
+
+ ${window.Verde.escapeHtml(s.household_name)}
+ ${window.Verde.escapeHtml(s.household_email)}
+ |
+ ${s.quantity} |
+ ₱${s.retail_price_pesos} |
+ ₱${s.commission_pesos} |
+
`;
+ }).join('');
+
+ if (page === 1) {
+ tbody.innerHTML = rowsHtml.length > 0 ? rowsHtml : `| No sales recorded. |
`;
+ } else {
+ tbody.insertAdjacentHTML('beforeend', rowsHtml);
+ }
+
+ const btn = document.getElementById('analytics-sales-more-btn');
+ btn.textContent = 'Load More';
+ btn.disabled = false;
+
+ const hasMore = meta.page < meta.last_page;
+ document.getElementById('analytics-sales-more').classList.toggle('hidden', !hasMore);
+ }
+
+ async function fetchAnalytics(storeId) {
+ document.getElementById('analytics-revenue').textContent = '₱...';
+ document.getElementById('analytics-cost').textContent = '₱...';
+ document.getElementById('analytics-profit').textContent = '₱...';
+ document.getElementById('analytics-commission').textContent = '₱...';
+ document.getElementById('analytics-codes-issued').textContent = '...';
+ document.getElementById('analytics-codes-sold').textContent = '...';
+
+ const res = await window.Verde.apiFetch(`/api/v1/admin/partner-stores/${storeId}/analytics`);
+ if (!res.ok) return;
+
+ const data = res.body;
+ document.getElementById('analytics-revenue').textContent = `₱${data.total_retail_revenue_pesos}`;
+ document.getElementById('analytics-cost').textContent = `₱${data.total_wholesale_cost_pesos}`;
+
+ const profitVal = parseFloat(data.gross_profit_pesos.replace(/,/g, ''));
+ const profitEl = document.getElementById('analytics-profit');
+ profitEl.textContent = `₱${data.gross_profit_pesos}`;
+ profitEl.className = profitVal >= 0 ? 'text-lg font-bold text-green-700 mt-1' : 'text-lg font-bold text-red-600 mt-1';
+
+ document.getElementById('analytics-commission').textContent = `₱${data.total_commission_pesos}`;
+ document.getElementById('analytics-codes-issued').textContent = data.total_issued_codes;
+ document.getElementById('analytics-codes-sold').textContent = data.total_sold_codes;
+ }
+
// Tab switching (edit mode only)
function switchTab(name) {
- document.querySelectorAll('#tab-details, #tab-inventory').forEach(p => p.classList.add('hidden'));
+ document.querySelectorAll('#tab-details, #tab-inventory, #tab-analytics').forEach(p => p.classList.add('hidden'));
document.getElementById(`tab-${name}`).classList.remove('hidden');
document.querySelectorAll('.tab-btn').forEach(b => {
const active = b.dataset.tab === name;
@@ -249,6 +454,13 @@
});
if (name === 'details' && pickerMap) {
setTimeout(() => pickerMap.invalidateSize(), 50);
+ } else if (name === 'inventory' && editingStoreId) {
+ purchasesPage = 1;
+ fetchPurchases(editingStoreId, 1);
+ } else if (name === 'analytics' && editingStoreId) {
+ salesPage = 1;
+ fetchAnalytics(editingStoreId);
+ fetchSales(editingStoreId, 1);
}
}
@@ -618,6 +830,18 @@
}
});
+ document.getElementById('inventory-purchases-more-btn').addEventListener('click', () => {
+ if (!editingStoreId) return;
+ purchasesPage += 1;
+ fetchPurchases(editingStoreId, purchasesPage);
+ });
+
+ document.getElementById('analytics-sales-more-btn').addEventListener('click', () => {
+ if (!editingStoreId) return;
+ salesPage += 1;
+ fetchSales(editingStoreId, salesPage);
+ });
+
loadOwners();
load();
diff --git a/routes/api.php b/routes/api.php
index dd50170..adfa39f 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -307,6 +307,9 @@ Route::prefix('admin/partner-stores')
Route::patch('/{store}', [AdminPartnerStoreController::class, 'update'])->name('update');
Route::post('/{store}/issue-inventory', [AdminPartnerStoreController::class, 'issueInventory'])->name('issue-inventory');
Route::post('/{store}/sales', [AdminPartnerStoreController::class, 'recordSale'])->name('sales');
+ Route::get('/{store}/purchases', [AdminPartnerStoreController::class, 'purchases'])->name('purchases');
+ Route::get('/{store}/sales-history', [AdminPartnerStoreController::class, 'salesHistory'])->name('sales-history');
+ Route::get('/{store}/analytics', [AdminPartnerStoreController::class, 'analytics'])->name('analytics');
});
Route::prefix('admin/reports')
diff --git a/tests/Feature/Api/V1/Store/PartnerStoreTest.php b/tests/Feature/Api/V1/Store/PartnerStoreTest.php
index 001bcfb..9e3a552 100644
--- a/tests/Feature/Api/V1/Store/PartnerStoreTest.php
+++ b/tests/Feature/Api/V1/Store/PartnerStoreTest.php
@@ -116,4 +116,83 @@ class PartnerStoreTest extends TestCase
$response->assertStatus(422);
}
+
+ public function test_sale_omitting_price_uses_global_srp(): void
+ {
+ Sanctum::actingAs($this->admin);
+ $store = PartnerStore::factory()->create(['status' => 'active', 'commission_rate_percent' => 10]);
+ app(StoreOperations::class)->issueWholesale($store, 10, 50000);
+ $household = Household::factory()->create();
+
+ $response = $this->postJson("/api/v1/admin/partner-stores/{$store->uuid}/sales", [
+ 'household_id' => $household->id,
+ 'quantity' => 5,
+ ]);
+
+ $response->assertCreated()
+ ->assertJsonPath('data.quantity', 5)
+ ->assertJsonPath('data.retail_price_centavos', 5000)
+ ->assertJsonPath('data.commission_centavos', 500);
+ }
+
+ public function test_sale_with_incorrect_price_fails_validation(): void
+ {
+ Sanctum::actingAs($this->admin);
+ $store = PartnerStore::factory()->create(['status' => 'active']);
+ app(StoreOperations::class)->issueWholesale($store, 10, 50000);
+ $household = Household::factory()->create();
+
+ $response = $this->postJson("/api/v1/admin/partner-stores/{$store->uuid}/sales", [
+ 'household_id' => $household->id,
+ 'quantity' => 5,
+ 'retail_price_per_code_centavos' => 1200,
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['retail_price_per_code_centavos']);
+ }
+
+ public function test_admin_can_view_analytics_and_history_endpoints(): void
+ {
+ Sanctum::actingAs($this->admin);
+ $store = PartnerStore::factory()->create(['status' => 'active', 'commission_rate_percent' => 10]);
+
+ // Issue wholesale (creates purchase)
+ app(StoreOperations::class)->issueWholesale($store, 20, 100000);
+
+ // Sell to household (creates sale)
+ $household = Household::factory()->create();
+ app(StoreOperations::class)->sellToHousehold($store, $household, 5, 1000);
+
+ // Test purchases endpoint
+ $response = $this->getJson("/api/v1/admin/partner-stores/{$store->uuid}/purchases");
+ $response->assertOk()
+ ->assertJsonStructure(['data', 'meta'])
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.quantity', 20)
+ ->assertJsonPath('data.0.wholesale_price_pesos', '1,000.00');
+
+ // Test sales-history endpoint
+ $response = $this->getJson("/api/v1/admin/partner-stores/{$store->uuid}/sales-history");
+ $response->assertOk()
+ ->assertJsonStructure(['data', 'meta'])
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.quantity', 5)
+ ->assertJsonPath('data.0.retail_price_pesos', '50.00')
+ ->assertJsonPath('data.0.commission_pesos', '5.00');
+
+ // Test analytics endpoint
+ $response = $this->getJson("/api/v1/admin/partner-stores/{$store->uuid}/analytics");
+ $response->assertOk()
+ ->assertJson([
+ 'data' => [
+ 'total_wholesale_cost_pesos' => '1,000.00',
+ 'total_retail_revenue_pesos' => '50.00',
+ 'total_commission_pesos' => '5.00',
+ 'gross_profit_pesos' => '-950.00',
+ 'total_issued_codes' => 20,
+ 'total_sold_codes' => 5,
+ ]
+ ]);
+ }
}