diff --git a/app/Http/Controllers/Api/Inventory/InventoryCategoryController.php b/app/Http/Controllers/Api/Inventory/InventoryCategoryController.php
new file mode 100644
index 00000000..63ed63ff
--- /dev/null
+++ b/app/Http/Controllers/Api/Inventory/InventoryCategoryController.php
@@ -0,0 +1,65 @@
+validated();
+ $rows = $this->categories->list($payload['type'] ?? null);
+
+ return $this->success([
+ 'categories' => InventoryCategoryResource::collection($rows),
+ ]);
+ }
+
+ public function store(InventoryCategoryStoreRequest $request): JsonResponse
+ {
+ $result = $this->categories->create($request->validated());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to save category.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->respondCreated(new InventoryCategoryResource($result['category']));
+ }
+
+ public function update(InventoryCategoryUpdateRequest $request, int $id): JsonResponse
+ {
+ try {
+ $result = $this->categories->update($id, $request->validated());
+ } catch (\InvalidArgumentException $e) {
+ return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to update category.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success(new InventoryCategoryResource($result['category']));
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $result = $this->categories->delete($id);
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to delete category.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->respondDeleted();
+ }
+}
diff --git a/app/Http/Controllers/Api/Inventory/InventoryController.php b/app/Http/Controllers/Api/Inventory/InventoryController.php
new file mode 100644
index 00000000..e504cd13
--- /dev/null
+++ b/app/Http/Controllers/Api/Inventory/InventoryController.php
@@ -0,0 +1,154 @@
+items->list($request->validated());
+
+ return $this->success([
+ 'type' => $result['type'],
+ 'items' => InventoryItemResource::collection($result['items']),
+ 'categories' => $result['categories'],
+ 'conditionOptions' => $result['conditionOptions'],
+ 'schoolYears' => $result['schoolYears'],
+ 'selectedYear' => $result['selectedYear'],
+ 'currentYear' => $result['currentYear'],
+ 'semester' => $result['semester'],
+ 'userNames' => $result['userNames'],
+ ]);
+ }
+
+ public function show(int $id): JsonResponse
+ {
+ $item = $this->items->find($id);
+ if (!$item) {
+ return $this->error('Item not found.', Response::HTTP_NOT_FOUND);
+ }
+
+ return $this->success(new InventoryItemResource($item));
+ }
+
+ public function store(InventoryItemStoreRequest $request): JsonResponse
+ {
+ $result = $this->items->create($request->validated(), (int) auth()->id());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to create item.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->respondCreated(new InventoryItemResource($result['item']));
+ }
+
+ public function update(InventoryItemUpdateRequest $request, int $id): JsonResponse
+ {
+ $result = $this->items->update($id, $request->validated(), (int) auth()->id());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to update item.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success(new InventoryItemResource($result['item']));
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $result = $this->items->delete($id);
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to delete item.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->respondDeleted();
+ }
+
+ public function audit(InventoryAuditRequest $request, int $id): JsonResponse
+ {
+ $result = $this->items->auditClassroom($id, $request->validated(), (int) auth()->id());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to audit item.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success(new InventoryItemResource($result['item']));
+ }
+
+ public function adjust(InventoryAdjustRequest $request, int $id): JsonResponse
+ {
+ $result = $this->items->adjustStock($id, $request->validated(), (int) auth()->id());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to adjust stock.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success();
+ }
+
+ public function summary(string $type): JsonResponse
+ {
+ $payload = $this->summary->summary($type, request()->query('school_year'));
+ return $this->success($payload);
+ }
+
+ public function summaryAll(): JsonResponse
+ {
+ $payload = $this->summary->summaryAll(
+ request()->query('school_year'),
+ request()->query('type')
+ );
+ return $this->success($payload);
+ }
+
+ public function teacherDistribution(InventoryTeacherDistributionRequest $request): JsonResponse
+ {
+ $payload = $request->validated();
+ $result = $this->distribution->formData(
+ (int) auth()->id(),
+ $payload['class_section_id'] ?? null,
+ $payload['item_id'] ?? null
+ );
+
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Unable to load distribution form.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success($result);
+ }
+
+ public function teacherDistributionStore(InventoryTeacherDistributionStoreRequest $request): JsonResponse
+ {
+ try {
+ $result = $this->distribution->distribute((int) auth()->id(), $request->validated());
+ } catch (\Throwable $e) {
+ Log::error('Inventory distribution failed: ' . $e->getMessage());
+ return $this->error('Unable to distribute inventory.', Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Unable to distribute inventory.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success($result);
+ }
+}
diff --git a/app/Http/Controllers/Api/Inventory/InventoryMovementController.php b/app/Http/Controllers/Api/Inventory/InventoryMovementController.php
new file mode 100644
index 00000000..cbd19d78
--- /dev/null
+++ b/app/Http/Controllers/Api/Inventory/InventoryMovementController.php
@@ -0,0 +1,70 @@
+movements->list($request->validated());
+
+ return $this->success([
+ 'movements' => InventoryMovementResource::collection($rows),
+ ]);
+ }
+
+ public function store(InventoryMovementStoreRequest $request): JsonResponse
+ {
+ $result = $this->movements->create($request->validated(), (int) auth()->id());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to create movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->respondCreated();
+ }
+
+ public function update(InventoryMovementUpdateRequest $request, int $id): JsonResponse
+ {
+ $result = $this->movements->update($id, $request->validated());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to update movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success();
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $result = $this->movements->delete($id);
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to delete movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->respondDeleted();
+ }
+
+ public function bulkDelete(InventoryMovementBulkDeleteRequest $request): JsonResponse
+ {
+ $result = $this->movements->bulkDelete($request->validated()['ids']);
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Bulk delete failed.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success($result);
+ }
+}
diff --git a/app/Http/Controllers/Api/Inventory/SupplierController.php b/app/Http/Controllers/Api/Inventory/SupplierController.php
new file mode 100644
index 00000000..a839b925
--- /dev/null
+++ b/app/Http/Controllers/Api/Inventory/SupplierController.php
@@ -0,0 +1,62 @@
+validated();
+ $perPage = (int) ($payload['per_page'] ?? 20);
+ $result = $this->suppliers->list($payload, $perPage);
+
+ return $this->success([
+ 'suppliers' => SupplierResource::collection($result['items']),
+ 'pagination' => $result['pagination'],
+ ]);
+ }
+
+ public function store(SupplierStoreRequest $request): JsonResponse
+ {
+ $result = $this->suppliers->create($request->validated());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to save supplier.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->respondCreated(new SupplierResource($result['supplier']));
+ }
+
+ public function update(SupplierUpdateRequest $request, int $id): JsonResponse
+ {
+ $result = $this->suppliers->update($id, $request->validated());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to update supplier.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success(new SupplierResource($result['supplier']));
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $result = $this->suppliers->delete($id);
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to delete supplier.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->respondDeleted();
+ }
+}
diff --git a/app/Http/Controllers/Api/Inventory/SupplyCategoryController.php b/app/Http/Controllers/Api/Inventory/SupplyCategoryController.php
new file mode 100644
index 00000000..cbf92059
--- /dev/null
+++ b/app/Http/Controllers/Api/Inventory/SupplyCategoryController.php
@@ -0,0 +1,58 @@
+categories->list();
+
+ return $this->success([
+ 'categories' => SupplyCategoryResource::collection($rows),
+ ]);
+ }
+
+ public function store(SupplyCategoryStoreRequest $request): JsonResponse
+ {
+ $result = $this->categories->create($request->validated());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to save category.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->respondCreated(new SupplyCategoryResource($result['category']));
+ }
+
+ public function update(SupplyCategoryUpdateRequest $request, int $id): JsonResponse
+ {
+ $result = $this->categories->update($id, $request->validated());
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to update category.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->success(new SupplyCategoryResource($result['category']));
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $result = $this->categories->delete($id);
+ if (!($result['ok'] ?? false)) {
+ return $this->error($result['message'] ?? 'Failed to delete category.', Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+
+ return $this->respondDeleted();
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryAdjustRequest.php b/app/Http/Requests/Inventory/InventoryAdjustRequest.php
new file mode 100644
index 00000000..96efdae7
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryAdjustRequest.php
@@ -0,0 +1,23 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'mode' => ['required', 'in:in,out,adjust'],
+ 'quantity' => ['required', 'integer', 'min:1'],
+ 'reason' => ['nullable', 'string', 'max:120'],
+ 'note' => ['nullable', 'string'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryAuditRequest.php b/app/Http/Requests/Inventory/InventoryAuditRequest.php
new file mode 100644
index 00000000..7419450f
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryAuditRequest.php
@@ -0,0 +1,22 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'needs_repair_qty' => ['nullable', 'integer', 'min:0'],
+ 'need_replace_qty' => ['nullable', 'integer', 'min:0'],
+ 'cannot_find_qty' => ['nullable', 'integer', 'min:0'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryCategoryIndexRequest.php b/app/Http/Requests/Inventory/InventoryCategoryIndexRequest.php
new file mode 100644
index 00000000..144852ea
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryCategoryIndexRequest.php
@@ -0,0 +1,20 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'type' => ['nullable', 'string', 'max:20'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryCategoryStoreRequest.php b/app/Http/Requests/Inventory/InventoryCategoryStoreRequest.php
new file mode 100644
index 00000000..a48e6fa4
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryCategoryStoreRequest.php
@@ -0,0 +1,24 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'type' => ['required', 'string', 'max:20'],
+ 'name' => ['required', 'string', 'max:120'],
+ 'description' => ['nullable', 'string'],
+ 'grade_min' => ['nullable', 'integer', 'min:0', 'max:13'],
+ 'grade_max' => ['nullable', 'integer', 'min:0', 'max:13'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryCategoryUpdateRequest.php b/app/Http/Requests/Inventory/InventoryCategoryUpdateRequest.php
new file mode 100644
index 00000000..342f4f59
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryCategoryUpdateRequest.php
@@ -0,0 +1,24 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'type' => ['sometimes', 'string', 'max:20'],
+ 'name' => ['sometimes', 'string', 'max:120'],
+ 'description' => ['nullable', 'string'],
+ 'grade_min' => ['nullable', 'integer', 'min:0', 'max:13'],
+ 'grade_max' => ['nullable', 'integer', 'min:0', 'max:13'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryItemIndexRequest.php b/app/Http/Requests/Inventory/InventoryItemIndexRequest.php
new file mode 100644
index 00000000..ffa86245
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryItemIndexRequest.php
@@ -0,0 +1,23 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'type' => ['nullable', 'string', 'max:20'],
+ 'school_year' => ['nullable', 'string', 'max:20'],
+ 'semester' => ['nullable', 'string', 'max:20'],
+ 'q' => ['nullable', 'string', 'max:120'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryItemStoreRequest.php b/app/Http/Requests/Inventory/InventoryItemStoreRequest.php
new file mode 100644
index 00000000..2339330d
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryItemStoreRequest.php
@@ -0,0 +1,30 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'type' => ['required', 'string', 'max:20'],
+ 'category_id' => ['nullable', 'integer', 'min:1'],
+ 'name' => ['required', 'string', 'max:255'],
+ 'description' => ['nullable', 'string'],
+ 'quantity' => ['nullable', 'integer'],
+ 'unit' => ['nullable', 'string', 'max:50'],
+ 'condition' => ['nullable', 'string', 'max:50'],
+ 'isbn' => ['nullable', 'string', 'max:50'],
+ 'edition' => ['nullable', 'string', 'max:50'],
+ 'sku' => ['nullable', 'string', 'max:50'],
+ 'notes' => ['nullable', 'string'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryItemUpdateRequest.php b/app/Http/Requests/Inventory/InventoryItemUpdateRequest.php
new file mode 100644
index 00000000..11f34a1a
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryItemUpdateRequest.php
@@ -0,0 +1,29 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'category_id' => ['nullable', 'integer', 'min:1'],
+ 'name' => ['sometimes', 'string', 'max:255'],
+ 'description' => ['nullable', 'string'],
+ 'quantity' => ['nullable', 'integer'],
+ 'unit' => ['nullable', 'string', 'max:50'],
+ 'condition' => ['nullable', 'string', 'max:50'],
+ 'isbn' => ['nullable', 'string', 'max:50'],
+ 'edition' => ['nullable', 'string', 'max:50'],
+ 'sku' => ['nullable', 'string', 'max:50'],
+ 'notes' => ['nullable', 'string'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryMovementBulkDeleteRequest.php b/app/Http/Requests/Inventory/InventoryMovementBulkDeleteRequest.php
new file mode 100644
index 00000000..877e5263
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryMovementBulkDeleteRequest.php
@@ -0,0 +1,21 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'ids' => ['required', 'array', 'min:1'],
+ 'ids.*' => ['integer', 'min:1'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryMovementIndexRequest.php b/app/Http/Requests/Inventory/InventoryMovementIndexRequest.php
new file mode 100644
index 00000000..a3611294
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryMovementIndexRequest.php
@@ -0,0 +1,21 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'school_year' => ['nullable', 'string', 'max:20'],
+ 'semester' => ['nullable', 'string', 'max:20'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryMovementStoreRequest.php b/app/Http/Requests/Inventory/InventoryMovementStoreRequest.php
new file mode 100644
index 00000000..b1035e2e
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryMovementStoreRequest.php
@@ -0,0 +1,29 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'item_id' => ['required', 'integer', 'min:1'],
+ 'qty_change' => ['required', 'integer'],
+ 'movement_type' => ['required', 'in:initial,in,out,adjust,distribution'],
+ 'reason' => ['nullable', 'string', 'max:120'],
+ 'note' => ['nullable', 'string'],
+ 'semester' => ['nullable', 'string', 'max:20'],
+ 'school_year' => ['nullable', 'string', 'max:20'],
+ 'teacher_id' => ['nullable', 'integer', 'min:1'],
+ 'student_id' => ['nullable', 'integer', 'min:1'],
+ 'class_section_id' => ['nullable', 'integer', 'min:1'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryMovementUpdateRequest.php b/app/Http/Requests/Inventory/InventoryMovementUpdateRequest.php
new file mode 100644
index 00000000..dbf078ce
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryMovementUpdateRequest.php
@@ -0,0 +1,28 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'qty_change' => ['required', 'integer'],
+ 'movement_type' => ['required', 'in:initial,in,out,adjust,distribution'],
+ 'reason' => ['nullable', 'string', 'max:120'],
+ 'note' => ['nullable', 'string'],
+ 'semester' => ['nullable', 'string', 'max:20'],
+ 'school_year' => ['nullable', 'string', 'max:20'],
+ 'teacher_id' => ['nullable', 'integer', 'min:1'],
+ 'student_id' => ['nullable', 'integer', 'min:1'],
+ 'class_section_id' => ['nullable', 'integer', 'min:1'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryTeacherDistributionRequest.php b/app/Http/Requests/Inventory/InventoryTeacherDistributionRequest.php
new file mode 100644
index 00000000..a22bf6e8
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryTeacherDistributionRequest.php
@@ -0,0 +1,21 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'class_section_id' => ['nullable', 'integer', 'min:1'],
+ 'item_id' => ['nullable', 'integer', 'min:1'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/InventoryTeacherDistributionStoreRequest.php b/app/Http/Requests/Inventory/InventoryTeacherDistributionStoreRequest.php
new file mode 100644
index 00000000..d91bf51a
--- /dev/null
+++ b/app/Http/Requests/Inventory/InventoryTeacherDistributionStoreRequest.php
@@ -0,0 +1,24 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'item_id' => ['required', 'integer', 'min:1'],
+ 'class_section_id' => ['nullable', 'integer', 'min:1'],
+ 'student_ids' => ['required', 'array', 'min:1'],
+ 'student_ids.*' => ['integer', 'min:1'],
+ 'note' => ['nullable', 'string'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/SupplierIndexRequest.php b/app/Http/Requests/Inventory/SupplierIndexRequest.php
new file mode 100644
index 00000000..911179e1
--- /dev/null
+++ b/app/Http/Requests/Inventory/SupplierIndexRequest.php
@@ -0,0 +1,21 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'q' => ['nullable', 'string', 'max:120'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/SupplierStoreRequest.php b/app/Http/Requests/Inventory/SupplierStoreRequest.php
new file mode 100644
index 00000000..541a99d1
--- /dev/null
+++ b/app/Http/Requests/Inventory/SupplierStoreRequest.php
@@ -0,0 +1,24 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'name' => ['required', 'string', 'max:255'],
+ 'email' => ['nullable', 'email', 'max:255'],
+ 'phone' => ['nullable', 'string', 'max:50'],
+ 'address' => ['nullable', 'string', 'max:255'],
+ 'notes' => ['nullable', 'string'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/SupplierUpdateRequest.php b/app/Http/Requests/Inventory/SupplierUpdateRequest.php
new file mode 100644
index 00000000..54ae3f14
--- /dev/null
+++ b/app/Http/Requests/Inventory/SupplierUpdateRequest.php
@@ -0,0 +1,24 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'name' => ['sometimes', 'string', 'max:255'],
+ 'email' => ['nullable', 'email', 'max:255'],
+ 'phone' => ['nullable', 'string', 'max:50'],
+ 'address' => ['nullable', 'string', 'max:255'],
+ 'notes' => ['nullable', 'string'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/SupplyCategoryStoreRequest.php b/app/Http/Requests/Inventory/SupplyCategoryStoreRequest.php
new file mode 100644
index 00000000..1d6ee73f
--- /dev/null
+++ b/app/Http/Requests/Inventory/SupplyCategoryStoreRequest.php
@@ -0,0 +1,20 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'name' => ['required', 'string', 'max:255'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Inventory/SupplyCategoryUpdateRequest.php b/app/Http/Requests/Inventory/SupplyCategoryUpdateRequest.php
new file mode 100644
index 00000000..7c623d6f
--- /dev/null
+++ b/app/Http/Requests/Inventory/SupplyCategoryUpdateRequest.php
@@ -0,0 +1,20 @@
+check();
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'name' => ['required', 'string', 'max:255'],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Inventory/InventoryCategoryResource.php b/app/Http/Resources/Inventory/InventoryCategoryResource.php
new file mode 100644
index 00000000..fcb0c3f1
--- /dev/null
+++ b/app/Http/Resources/Inventory/InventoryCategoryResource.php
@@ -0,0 +1,20 @@
+ (int) ($this['id'] ?? 0),
+ 'type' => (string) ($this['type'] ?? ''),
+ 'name' => (string) ($this['name'] ?? ''),
+ 'description' => $this['description'] ?? null,
+ 'grade_min' => $this['grade_min'] ?? null,
+ 'grade_max' => $this['grade_max'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Inventory/InventoryItemResource.php b/app/Http/Resources/Inventory/InventoryItemResource.php
new file mode 100644
index 00000000..ff93000a
--- /dev/null
+++ b/app/Http/Resources/Inventory/InventoryItemResource.php
@@ -0,0 +1,33 @@
+ (int) ($this['id'] ?? 0),
+ 'type' => (string) ($this['type'] ?? ''),
+ 'category_id' => $this['category_id'] ?? null,
+ 'name' => (string) ($this['name'] ?? ''),
+ 'description' => $this['description'] ?? null,
+ 'quantity' => (int) ($this['quantity'] ?? 0),
+ 'good_qty' => $this['good_qty'] ?? null,
+ 'needs_repair_qty' => $this['needs_repair_qty'] ?? null,
+ 'need_replace_qty' => $this['need_replace_qty'] ?? null,
+ 'cannot_find_qty' => $this['cannot_find_qty'] ?? null,
+ 'unit' => $this['unit'] ?? null,
+ 'condition' => $this['condition'] ?? null,
+ 'isbn' => $this['isbn'] ?? null,
+ 'edition' => $this['edition'] ?? null,
+ 'sku' => $this['sku'] ?? null,
+ 'notes' => $this['notes'] ?? null,
+ 'semester' => $this['semester'] ?? null,
+ 'school_year' => $this['school_year'] ?? null,
+ 'updated_by' => $this['updated_by'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Inventory/InventoryMovementResource.php b/app/Http/Resources/Inventory/InventoryMovementResource.php
new file mode 100644
index 00000000..ba8ff17d
--- /dev/null
+++ b/app/Http/Resources/Inventory/InventoryMovementResource.php
@@ -0,0 +1,31 @@
+ (int) ($this['id'] ?? 0),
+ 'item_id' => (int) ($this['item_id'] ?? 0),
+ 'item_name' => $this['item_name'] ?? null,
+ 'qty_change' => (int) ($this['qty_change'] ?? 0),
+ 'movement_type' => (string) ($this['movement_type'] ?? ''),
+ 'reason' => $this['reason'] ?? null,
+ 'note' => $this['note'] ?? null,
+ 'semester' => $this['semester'] ?? null,
+ 'school_year' => $this['school_year'] ?? null,
+ 'performed_by' => $this['performed_by'] ?? null,
+ 'performed_by_name' => $this['performed_by_name'] ?? null,
+ 'teacher_id' => $this['teacher_id'] ?? null,
+ 'teacher_name' => $this['teacher_name'] ?? null,
+ 'student_id' => $this['student_id'] ?? null,
+ 'student_name' => $this['student_name'] ?? null,
+ 'class_section_id' => $this['class_section_id'] ?? null,
+ 'class_section_name' => $this['class_section_name'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Inventory/SupplierResource.php b/app/Http/Resources/Inventory/SupplierResource.php
new file mode 100644
index 00000000..d66ad664
--- /dev/null
+++ b/app/Http/Resources/Inventory/SupplierResource.php
@@ -0,0 +1,20 @@
+ (int) ($this['id'] ?? 0),
+ 'name' => (string) ($this['name'] ?? ''),
+ 'email' => $this['email'] ?? null,
+ 'phone' => $this['phone'] ?? null,
+ 'address' => $this['address'] ?? null,
+ 'notes' => $this['notes'] ?? null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Inventory/SupplyCategoryResource.php b/app/Http/Resources/Inventory/SupplyCategoryResource.php
new file mode 100644
index 00000000..6a614765
--- /dev/null
+++ b/app/Http/Resources/Inventory/SupplyCategoryResource.php
@@ -0,0 +1,16 @@
+ (int) ($this['id'] ?? 0),
+ 'name' => (string) ($this['name'] ?? ''),
+ ];
+ }
+}
diff --git a/app/Models/Supplier.php b/app/Models/Supplier.php
index 39aaee26..3bc40183 100644
--- a/app/Models/Supplier.php
+++ b/app/Models/Supplier.php
@@ -7,4 +7,16 @@ use App\Models\BaseModel;
class Supplier extends BaseModel
{
protected $table = 'suppliers';
+
+ public $timestamps = true;
+
+ protected $fillable = [
+ 'name',
+ 'email',
+ 'phone',
+ 'address',
+ 'notes',
+ 'created_at',
+ 'updated_at',
+ ];
}
diff --git a/app/Models/SupplyCategory.php b/app/Models/SupplyCategory.php
new file mode 100644
index 00000000..16f13a3a
--- /dev/null
+++ b/app/Models/SupplyCategory.php
@@ -0,0 +1,18 @@
+orderBy('name');
+ if ($type) {
+ $query->where('type', $this->normalizeType($type));
+ }
+ return $query->get()->toArray();
+ }
+
+ public function create(array $data): array
+ {
+ try {
+ $payload = $this->normalizePayload($data);
+ } catch (\InvalidArgumentException $e) {
+ return ['ok' => false, 'message' => $e->getMessage()];
+ }
+ $existing = InventoryCategory::query()
+ ->where('type', $payload['type'])
+ ->where('name', $payload['name'])
+ ->first();
+ if ($existing) {
+ return ['ok' => false, 'message' => 'This category already exists.'];
+ }
+
+ try {
+ $category = InventoryCategory::query()->create($payload);
+ } catch (\Throwable $e) {
+ Log::error('Inventory category create failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to save category.'];
+ }
+
+ return ['ok' => true, 'category' => $category->toArray()];
+ }
+
+ public function update(int $id, array $data): array
+ {
+ $category = InventoryCategory::query()->find($id);
+ if (!$category) {
+ return ['ok' => false, 'message' => 'Category not found.'];
+ }
+
+ try {
+ $payload = $this->normalizePayload($data, (string) $category->type);
+ } catch (\InvalidArgumentException $e) {
+ return ['ok' => false, 'message' => $e->getMessage()];
+ }
+ $existing = InventoryCategory::query()
+ ->where('type', $payload['type'])
+ ->where('name', $payload['name'])
+ ->where('id', '!=', $id)
+ ->first();
+ if ($existing) {
+ return ['ok' => false, 'message' => 'A category with this Type & Name already exists.'];
+ }
+
+ try {
+ $category->update($payload);
+ } catch (\Throwable $e) {
+ Log::error('Inventory category update failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to save category.'];
+ }
+
+ return ['ok' => true, 'category' => $category->toArray()];
+ }
+
+ public function delete(int $id): array
+ {
+ $category = InventoryCategory::query()->find($id);
+ if (!$category) {
+ return ['ok' => false, 'message' => 'Category not found.'];
+ }
+
+ try {
+ $category->delete();
+ } catch (\Throwable $e) {
+ Log::error('Inventory category delete failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to delete category.'];
+ }
+
+ return ['ok' => true];
+ }
+
+ private function normalizePayload(array $data, ?string $lockedType = null): array
+ {
+ $type = $lockedType ?? $this->normalizeType($data['type'] ?? 'office');
+ $name = preg_replace('/\\s+/', ' ', trim((string) ($data['name'] ?? '')));
+
+ $payload = [
+ 'type' => $type,
+ 'name' => $name,
+ 'description' => (string) ($data['description'] ?? ''),
+ 'grade_min' => null,
+ 'grade_max' => null,
+ ];
+
+ if ($type === 'book') {
+ $gmin = $data['grade_min'] ?? null;
+ $gmax = $data['grade_max'] ?? null;
+ $gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int) $gmin);
+ $gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int) $gmax);
+ if ($gmin !== null && $gmax !== null && $gmin > $gmax) {
+ throw new \InvalidArgumentException('Grade Min cannot be greater than Grade Max.');
+ }
+ if ($gmin !== null && $gmin > 13) {
+ $gmin = 13;
+ }
+ if ($gmax !== null && $gmax > 13) {
+ $gmax = 13;
+ }
+ $payload['grade_min'] = $gmin;
+ $payload['grade_max'] = $gmax;
+ }
+
+ return $payload;
+ }
+
+ private function normalizeType(?string $type): string
+ {
+ $type = strtolower((string) $type);
+ return in_array($type, $this->types, true) ? $type : 'office';
+ }
+}
diff --git a/app/Services/Inventory/InventoryContextService.php b/app/Services/Inventory/InventoryContextService.php
new file mode 100644
index 00000000..a7c2fb2f
--- /dev/null
+++ b/app/Services/Inventory/InventoryContextService.php
@@ -0,0 +1,18 @@
+normalizeType($filters['type'] ?? 'classroom');
+ $schoolYear = trim((string) ($filters['school_year'] ?? $this->context->schoolYear()));
+ $semester = trim((string) ($filters['semester'] ?? $this->context->semester()));
+ $q = trim((string) ($filters['q'] ?? ''));
+
+ $builder = InventoryItem::query()->where('type', $type);
+ if ($schoolYear !== '' && strtolower($schoolYear) !== 'all') {
+ $builder->where('school_year', $schoolYear);
+ }
+ if ($semester !== '') {
+ $builder->where('semester', $semester);
+ }
+ if ($q !== '') {
+ $builder->where('name', 'like', '%' . $q . '%');
+ }
+
+ $items = $builder->orderBy('name')->get()->toArray();
+ $categories = InventoryCategory::optionsForType($type);
+
+ $userIds = array_values(array_unique(array_map(static fn ($i) => (int) ($i['updated_by'] ?? 0), $items)));
+ $userNames = $this->userNamesByIds(array_values(array_filter($userIds)));
+
+ return [
+ 'type' => $type,
+ 'items' => $items,
+ 'categories' => $categories,
+ 'conditionOptions' => [
+ 'good' => 'Good condition',
+ 'needs_repair' => 'Needs repair',
+ 'need_replace' => 'Need replace',
+ 'cannot_find' => 'Cannot find',
+ ],
+ 'schoolYears' => $this->getSchoolYearsForType($type),
+ 'selectedYear' => $schoolYear,
+ 'currentYear' => $this->context->schoolYear(),
+ 'semester' => $semester,
+ 'userNames' => $userNames,
+ ];
+ }
+
+ public function find(int $id): ?array
+ {
+ $item = InventoryItem::query()->find($id);
+ return $item ? $item->toArray() : null;
+ }
+
+ public function create(array $data, ?int $userId = null): array
+ {
+ $payload = $this->filterItemData($data, null, $userId);
+
+ try {
+ return DB::transaction(function () use ($payload) {
+ $item = InventoryItem::query()->create($payload);
+ $initialQty = (int) ($payload['quantity'] ?? 0);
+ if ($initialQty !== 0) {
+ $this->movementService->recordMovement((int) $item->id, $initialQty, 'initial', 'Initial stock');
+ } else {
+ $this->movementService->recalcQuantity((int) $item->id);
+ }
+ return ['ok' => true, 'item' => $item->toArray()];
+ });
+ } catch (\Throwable $e) {
+ Log::error('Inventory item create failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to create item.'];
+ }
+ }
+
+ public function update(int $id, array $data, ?int $userId = null): array
+ {
+ $item = InventoryItem::query()->find($id);
+ if (!$item) {
+ return ['ok' => false, 'message' => 'Item not found.'];
+ }
+
+ $payload = $this->filterItemData($data, (string) $item->type, $userId);
+ unset($payload['id']);
+
+ try {
+ $item->update($payload);
+ } catch (\Throwable $e) {
+ Log::error('Inventory item update failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to update item.'];
+ }
+
+ return ['ok' => true, 'item' => $item->toArray()];
+ }
+
+ public function delete(int $id): array
+ {
+ $item = InventoryItem::query()->find($id);
+ if (!$item) {
+ return ['ok' => false, 'message' => 'Item not found.'];
+ }
+
+ try {
+ $item->delete();
+ } catch (\Throwable $e) {
+ Log::error('Inventory item delete failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to delete item.'];
+ }
+
+ return ['ok' => true];
+ }
+
+ public function auditClassroom(int $itemId, array $data, ?int $userId = null): array
+ {
+ $item = InventoryItem::query()->find($itemId);
+ if (!$item || $item->type !== 'classroom') {
+ return ['ok' => false, 'message' => 'Invalid classroom item.'];
+ }
+
+ $prevLoss = (int) ($item->need_replace_qty ?? 0);
+ $prevMiss = (int) ($item->cannot_find_qty ?? 0);
+
+ $newRepair = max(0, (int) ($data['needs_repair_qty'] ?? 0));
+ $newLoss = max(0, (int) ($data['need_replace_qty'] ?? 0));
+ $newMiss = max(0, (int) ($data['cannot_find_qty'] ?? 0));
+
+ $onHandNow = (int) ($item->quantity ?? 0);
+ $newGood = max(0, $onHandNow - $newRepair);
+
+ $deltaLoss = $newLoss - $prevLoss;
+ $deltaMiss = $newMiss - $prevMiss;
+
+ $totalOut = 0;
+ if ($deltaLoss > 0) {
+ $totalOut += $deltaLoss;
+ }
+ if ($deltaMiss > 0) {
+ $totalOut += $deltaMiss;
+ }
+
+ $totalIn = 0;
+ if ($deltaLoss < 0) {
+ $totalIn += -$deltaLoss;
+ }
+ if ($deltaMiss < 0) {
+ $totalIn += -$deltaMiss;
+ }
+
+ if ($totalOut > 0 && !$this->movementService->recordMovement($itemId, -$totalOut, 'out', 'Audit: loss/missing increased')) {
+ return ['ok' => false, 'message' => 'Not enough stock to apply audit.'];
+ }
+ if ($totalIn > 0) {
+ $this->movementService->recordMovement($itemId, $totalIn, 'in', 'Audit: loss/missing decreased');
+ }
+
+ $this->movementService->recalcQuantity($itemId);
+
+ $item->update([
+ 'good_qty' => $newGood,
+ 'needs_repair_qty' => $newRepair,
+ 'need_replace_qty' => $newLoss,
+ 'cannot_find_qty' => $newMiss,
+ 'updated_by' => $userId,
+ ]);
+
+ return ['ok' => true, 'item' => $item->toArray()];
+ }
+
+ public function adjustStock(int $itemId, array $data, ?int $userId = null): array
+ {
+ $item = InventoryItem::query()->find($itemId);
+ if (!$item) {
+ return ['ok' => false, 'message' => 'Item not found.'];
+ }
+
+ $mode = (string) ($data['mode'] ?? '');
+ $qty = (int) ($data['quantity'] ?? 0);
+ $reason = (string) ($data['reason'] ?? '');
+ $note = (string) ($data['note'] ?? '');
+
+ if ($qty <= 0) {
+ return ['ok' => false, 'message' => 'Quantity must be > 0.'];
+ }
+
+ $delta = ($mode === 'out') ? -abs($qty) : (($mode === 'in') ? abs($qty) : $qty);
+ $type = ($mode === 'out') ? 'out' : (($mode === 'in') ? 'in' : 'adjust');
+
+ $ok = $this->movementService->recordMovement($itemId, $delta, $type, $reason, $note, null, null, $userId);
+ if (!$ok) {
+ return ['ok' => false, 'message' => 'Not enough stock to apply adjustment.'];
+ }
+
+ return ['ok' => true];
+ }
+
+ private function filterItemData(array $data, ?string $lockedType = null, ?int $userId = null): array
+ {
+ $type = $lockedType ?? $this->normalizeType($data['type'] ?? 'classroom');
+ $qtyRaw = $data['quantity'] ?? 0;
+ $quantity = is_numeric($qtyRaw) ? (int) $qtyRaw : 0;
+
+ $base = [
+ 'type' => $type,
+ 'category_id' => isset($data['category_id']) && $data['category_id'] !== '' ? (int) $data['category_id'] : null,
+ 'name' => (string) ($data['name'] ?? ''),
+ 'description' => $data['description'] ?? null,
+ 'quantity' => $quantity,
+ 'unit' => $data['unit'] ?? null,
+ 'sku' => $data['sku'] ?? null,
+ 'notes' => $data['notes'] ?? null,
+ 'semester' => $this->context->semester(),
+ 'school_year' => $this->context->schoolYear(),
+ 'updated_by' => $userId,
+ ];
+
+ $base['condition'] = ($type === 'classroom') ? ($data['condition'] ?? null) : null;
+
+ if ($type === 'book') {
+ $base['isbn'] = $data['isbn'] ?? null;
+ $base['edition'] = $data['edition'] ?? null;
+ } else {
+ $base['isbn'] = null;
+ $base['edition'] = null;
+ }
+
+ return $base;
+ }
+
+ private function normalizeType(?string $type): string
+ {
+ $type = strtolower((string) $type);
+ return in_array($type, $this->types, true) ? $type : 'classroom';
+ }
+
+ private function userNamesByIds(array $ids): array
+ {
+ $ids = array_values(array_unique(array_filter($ids)));
+ if (empty($ids)) {
+ return [];
+ }
+
+ $cols = DB::getSchemaBuilder()->getColumnListing('users');
+ $hasFirst = in_array('firstname', $cols, true);
+ $hasLast = in_array('lastname', $cols, true);
+ $nameCol = in_array('name', $cols, true) ? 'name' : null;
+ $emailCol = in_array('email', $cols, true) ? 'email' : null;
+
+ if ($hasFirst || $hasLast) {
+ $select = DB::raw("id, TRIM(CONCAT(IFNULL(firstname,''),' ',IFNULL(lastname,''))) AS display_name");
+ } elseif ($nameCol) {
+ $select = DB::raw("id, {$nameCol} AS display_name");
+ } elseif ($emailCol) {
+ $select = DB::raw("id, {$emailCol} AS display_name");
+ } else {
+ $out = [];
+ foreach ($ids as $id) {
+ $out[$id] = 'User #' . $id;
+ }
+ return $out;
+ }
+
+ $rows = DB::table('users')->select($select)->whereIn('id', $ids)->get();
+ $map = [];
+ foreach ($rows as $r) {
+ $disp = trim((string) ($r->display_name ?? ''));
+ $map[(int) $r->id] = $disp !== '' ? $disp : ('User #' . $r->id);
+ }
+ return $map;
+ }
+
+ private function getSchoolYearsForType(string $type): array
+ {
+ $years = InventoryItem::query()
+ ->select('school_year')
+ ->where('type', $type)
+ ->whereNotNull('school_year')
+ ->groupBy('school_year')
+ ->orderByDesc('school_year')
+ ->pluck('school_year')
+ ->map(fn ($v) => trim((string) $v))
+ ->filter()
+ ->unique()
+ ->values()
+ ->all();
+
+ $currentYear = $this->context->schoolYear();
+ if ($currentYear && !in_array($currentYear, $years, true)) {
+ array_unshift($years, $currentYear);
+ }
+
+ return $years;
+ }
+}
diff --git a/app/Services/Inventory/InventoryMovementService.php b/app/Services/Inventory/InventoryMovementService.php
new file mode 100644
index 00000000..daa9e524
--- /dev/null
+++ b/app/Services/Inventory/InventoryMovementService.php
@@ -0,0 +1,341 @@
+context->schoolYear()));
+ $selectedSem = trim((string) ($filters['semester'] ?? $this->context->semester()));
+
+ $builder = DB::table('inventory_movements as m')
+ ->select([
+ 'm.*',
+ 'ii.name as item_name',
+ DB::raw("CONCAT(pb.firstname, ' ', pb.lastname) AS performed_by_name"),
+ DB::raw("CONCAT(t.firstname, ' ', t.lastname) AS teacher_name"),
+ DB::raw("CONCAT(s.firstname, ' ', s.lastname) AS student_name"),
+ 'cs.class_section_name',
+ ])
+ ->leftJoin('inventory_items as ii', 'ii.id', '=', 'm.item_id')
+ ->leftJoin('users as pb', 'pb.id', '=', 'm.performed_by')
+ ->leftJoin('users as t', 't.id', '=', 'm.teacher_id')
+ ->leftJoin('students as s', 's.id', '=', 'm.student_id')
+ ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'm.class_section_id')
+ ->orderBy('m.id', 'DESC');
+
+ if ($selectedYear !== '') {
+ $builder->where('m.school_year', $selectedYear);
+ }
+ if ($selectedSem !== '') {
+ $builder->where('m.semester', $selectedSem);
+ }
+
+ return $builder->get()->map(fn ($r) => (array) $r)->all();
+ }
+
+ public function create(array $data, int $performedBy = 0): array
+ {
+ $itemId = (int) ($data['item_id'] ?? 0);
+ $movementType = (string) ($data['movement_type'] ?? '');
+ $qtyChange = (int) ($data['qty_change'] ?? 0);
+
+ $item = InventoryItem::query()->find($itemId);
+ if (!$item) {
+ return ['ok' => false, 'message' => 'Selected item not found.'];
+ }
+
+ $qtyChange = $this->normalizeMovementQty($movementType, $qtyChange);
+ if ($this->wouldGoNegative($itemId, $qtyChange)) {
+ return ['ok' => false, 'message' => 'Not enough stock to apply this movement.'];
+ }
+
+ $payload = [
+ 'item_id' => $itemId,
+ 'qty_change' => $qtyChange,
+ 'movement_type' => $movementType,
+ 'reason' => trim((string) ($data['reason'] ?? '')),
+ 'note' => trim((string) ($data['note'] ?? '')),
+ 'semester' => $data['semester'] ?? $this->context->semester(),
+ 'school_year' => $data['school_year'] ?? $this->context->schoolYear(),
+ 'performed_by' => $performedBy ?: null,
+ 'teacher_id' => $this->nullableInt($data['teacher_id'] ?? null),
+ 'student_id' => $this->nullableInt($data['student_id'] ?? null),
+ 'class_section_id' => $this->nullableInt($data['class_section_id'] ?? null),
+ ];
+
+ try {
+ InventoryMovement::query()->create($payload);
+ $this->recalcQuantity($itemId);
+ } catch (\Throwable $e) {
+ Log::error('Inventory movement create failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Could not save movement.'];
+ }
+
+ return ['ok' => true];
+ }
+
+ public function update(int $id, array $data): array
+ {
+ $movement = InventoryMovement::query()->find($id);
+ if (!$movement) {
+ return ['ok' => false, 'message' => 'Movement not found.'];
+ }
+
+ $movementType = (string) ($data['movement_type'] ?? $movement->movement_type);
+ $qtyChange = (int) ($data['qty_change'] ?? $movement->qty_change);
+ $qtyChange = $this->normalizeMovementQty($movementType, $qtyChange);
+
+ $itemId = (int) $movement->item_id;
+ $delta = $qtyChange - (int) $movement->qty_change;
+ if ($delta !== 0 && $this->wouldGoNegative($itemId, $delta)) {
+ return ['ok' => false, 'message' => 'Not enough stock to apply this movement.'];
+ }
+
+ $payload = [
+ 'qty_change' => $qtyChange,
+ 'movement_type' => $movementType,
+ 'reason' => trim((string) ($data['reason'] ?? '')),
+ 'note' => trim((string) ($data['note'] ?? '')),
+ 'semester' => $data['semester'] ?? $this->context->semester(),
+ 'school_year' => $data['school_year'] ?? $this->context->schoolYear(),
+ 'teacher_id' => $this->nullableInt($data['teacher_id'] ?? null),
+ 'student_id' => $this->nullableInt($data['student_id'] ?? null),
+ 'class_section_id' => $this->nullableInt($data['class_section_id'] ?? null),
+ ];
+
+ try {
+ $movement->update($payload);
+ $this->recalcQuantity($itemId);
+ } catch (\Throwable $e) {
+ Log::error('Inventory movement update failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Could not update movement.'];
+ }
+
+ return ['ok' => true];
+ }
+
+ public function delete(int $id): array
+ {
+ $movement = InventoryMovement::query()->find($id);
+ if (!$movement) {
+ return ['ok' => false, 'message' => 'Movement not found.'];
+ }
+
+ $itemId = (int) $movement->item_id;
+ try {
+ $movement->delete();
+ if ($itemId > 0) {
+ $this->recalcQuantity($itemId);
+ }
+ } catch (\Throwable $e) {
+ Log::error('Inventory movement delete failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Could not delete movement.'];
+ }
+
+ return ['ok' => true];
+ }
+
+ public function bulkDelete(array $ids): array
+ {
+ $ids = array_values(array_filter(array_map('intval', $ids), static fn ($v) => $v > 0));
+ if (empty($ids)) {
+ return ['ok' => false, 'message' => 'No movements selected.'];
+ }
+
+ $itemIds = DB::table('inventory_movements')
+ ->select('item_id')
+ ->whereIn('id', $ids)
+ ->get()
+ ->map(fn ($r) => (int) ($r->item_id ?? 0))
+ ->filter(fn ($v) => $v > 0)
+ ->unique()
+ ->values()
+ ->all();
+
+ try {
+ InventoryMovement::query()->whereIn('id', $ids)->delete();
+ foreach ($itemIds as $itemId) {
+ $this->recalcQuantity($itemId);
+ }
+ } catch (\Throwable $e) {
+ Log::error('Inventory movement bulk delete failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Bulk delete failed.'];
+ }
+
+ return ['ok' => true, 'deleted' => count($ids)];
+ }
+
+ public function recordMovement(
+ int $itemId,
+ int $qtyChange,
+ string $type,
+ ?string $reason = null,
+ ?string $note = null,
+ ?int $classSectionId = null,
+ ?int $studentId = null,
+ ?int $performedBy = null
+ ): bool {
+ $isInitial = ($type === 'initial');
+
+ if (!$isInitial) {
+ $this->ensureInitialMovement($itemId);
+ $this->ensureInitialMovementForYear($itemId, $this->context->schoolYear());
+ $this->recalcQuantity($itemId);
+ }
+
+ if (in_array($type, ['out', 'distribution'], true) && $qtyChange > 0) {
+ $qtyChange = -abs($qtyChange);
+ }
+
+ if (in_array($type, ['out', 'distribution'], true) && $this->wouldGoNegative($itemId, $qtyChange)) {
+ return false;
+ }
+
+ InventoryMovement::query()->create([
+ 'item_id' => $itemId,
+ 'qty_change' => $qtyChange,
+ 'movement_type' => $type,
+ 'reason' => $reason,
+ 'note' => $note,
+ 'semester' => $this->context->semester(),
+ 'school_year' => $this->context->schoolYear(),
+ 'performed_by' => $performedBy,
+ 'teacher_id' => $performedBy,
+ 'student_id' => $studentId,
+ 'class_section_id' => $classSectionId,
+ ]);
+
+ $this->recalcQuantity($itemId);
+ return true;
+ }
+
+ public function recalcQuantity(int $itemId): void
+ {
+ $sumRow = InventoryMovement::query()
+ ->selectRaw('SUM(qty_change) as qty_change')
+ ->where('item_id', $itemId)
+ ->first();
+
+ $sum = (int) ($sumRow?->qty_change ?? 0);
+ if ($sum < 0) {
+ $sum = 0;
+ }
+
+ $item = InventoryItem::query()->select('id', 'type', 'quantity', 'needs_repair_qty', 'good_qty')->find($itemId);
+ if (!$item) {
+ return;
+ }
+
+ $updates = [];
+ if ((int) $item->quantity !== $sum) {
+ $updates['quantity'] = $sum;
+ }
+
+ if ($item->type === 'classroom') {
+ $needsRepair = (int) ($item->needs_repair_qty ?? 0);
+ $goodCalc = max(0, $sum - $needsRepair);
+ if ((int) ($item->good_qty ?? 0) !== $goodCalc) {
+ $updates['good_qty'] = $goodCalc;
+ }
+ }
+
+ if (!empty($updates)) {
+ $item->update($updates);
+ }
+ }
+
+ private function ensureInitialMovement(int $itemId): void
+ {
+ $hasMov = InventoryMovement::query()->where('item_id', $itemId)->exists();
+ if ($hasMov) {
+ return;
+ }
+
+ $item = InventoryItem::query()->select('quantity')->find($itemId);
+ $initialQty = (int) ($item?->quantity ?? 0);
+
+ InventoryMovement::query()->create([
+ 'item_id' => $itemId,
+ 'qty_change' => $initialQty,
+ 'movement_type' => 'initial',
+ 'semester' => $this->context->semester(),
+ 'school_year' => $this->context->schoolYear(),
+ ]);
+ }
+
+ private function ensureInitialMovementForYear(int $itemId, string $schoolYear): void
+ {
+ $hasAnyThisYear = InventoryMovement::query()
+ ->where('item_id', $itemId)
+ ->where('school_year', $schoolYear)
+ ->exists();
+
+ if (!$hasAnyThisYear) {
+ $onHand = (int) (InventoryItem::query()->where('id', $itemId)->value('quantity') ?? 0);
+ InventoryMovement::query()->create([
+ 'item_id' => $itemId,
+ 'qty_change' => $onHand,
+ 'movement_type' => 'initial',
+ 'school_year' => $schoolYear,
+ 'semester' => $this->context->semester() ?: null,
+ ]);
+ return;
+ }
+
+ $hasInitial = InventoryMovement::query()
+ ->where('item_id', $itemId)
+ ->where('school_year', $schoolYear)
+ ->where('movement_type', 'initial')
+ ->exists();
+
+ if (!$hasInitial) {
+ $onHand = (int) (InventoryItem::query()->where('id', $itemId)->value('quantity') ?? 0);
+ $netYear = (int) (InventoryMovement::query()
+ ->where('item_id', $itemId)
+ ->where('school_year', $schoolYear)
+ ->sum('qty_change') ?? 0);
+ $opening = $onHand - $netYear;
+
+ InventoryMovement::query()->create([
+ 'item_id' => $itemId,
+ 'qty_change' => $opening,
+ 'movement_type' => 'initial',
+ 'school_year' => $schoolYear,
+ 'semester' => $this->context->semester() ?: null,
+ ]);
+ }
+ }
+
+ private function normalizeMovementQty(string $movementType, int $qtyChange): int
+ {
+ if (in_array($movementType, ['out', 'distribution'], true) && $qtyChange > 0) {
+ return -abs($qtyChange);
+ }
+ return $qtyChange;
+ }
+
+ private function wouldGoNegative(int $itemId, int $delta): bool
+ {
+ $onHand = (int) (InventoryItem::query()->where('id', $itemId)->value('quantity') ?? 0);
+ return $onHand + $delta < 0;
+ }
+
+ private function nullableInt($val): ?int
+ {
+ if ($val === null || $val === '' || $val === '0') {
+ return null;
+ }
+ return (int) $val;
+ }
+}
diff --git a/app/Services/Inventory/InventorySummaryService.php b/app/Services/Inventory/InventorySummaryService.php
new file mode 100644
index 00000000..6d91e5e9
--- /dev/null
+++ b/app/Services/Inventory/InventorySummaryService.php
@@ -0,0 +1,191 @@
+context->schoolYear()));
+
+ $items = InventoryItem::query()
+ ->where('type', $type)
+ ->orderBy('name')
+ ->get()
+ ->toArray();
+
+ $itemIds = array_map(static fn ($i) => (int) ($i['id'] ?? 0), $items);
+ $agg = [];
+ if (!empty($itemIds)) {
+ $qb = DB::table('inventory_movements')
+ ->selectRaw('item_id,
+ SUM(CASE WHEN qty_change>0 THEN qty_change ELSE 0 END) AS received,
+ SUM(CASE WHEN qty_change<0 THEN -qty_change ELSE 0 END) AS issued')
+ ->whereIn('item_id', $itemIds)
+ ->groupBy('item_id');
+
+ if (strtolower($selectedYear) !== 'all' && $selectedYear !== '') {
+ $qb->where('school_year', $selectedYear);
+ }
+
+ foreach ($qb->get() as $row) {
+ $agg[(int) $row->item_id] = [
+ 'received' => (int) ($row->received ?? 0),
+ 'issued' => (int) ($row->issued ?? 0),
+ ];
+ }
+ }
+
+ return [
+ 'items' => $items,
+ 'agg' => $agg,
+ 'selectedYear' => $selectedYear,
+ 'currentYear' => $this->context->schoolYear(),
+ 'schoolYears' => $this->getSchoolYearsForType($type),
+ ];
+ }
+
+ public function summaryAll(?string $schoolYear, ?string $type): array
+ {
+ $selectedYear = trim((string) ($schoolYear ?? $this->context->schoolYear()));
+ $selectedType = strtolower((string) ($type ?? 'all'));
+ $isAllYears = strtolower($selectedYear) === 'all';
+
+ $qbItems = DB::table('inventory_items as i')
+ ->select('i.*', 'c.name AS category_name', DB::raw("CONCAT(u.firstname, ' ', u.lastname) AS updated_by_name"))
+ ->leftJoin('inventory_categories as c', 'c.id', '=', 'i.category_id')
+ ->leftJoin('users as u', 'u.id', '=', 'i.updated_by')
+ ->orderBy('i.name');
+
+ if (!$isAllYears) {
+ $qbItems->where('i.school_year', $selectedYear);
+ }
+
+ if (in_array($selectedType, ['book', 'classroom', 'office', 'kitchen'], true)) {
+ $qbItems->where('i.type', $selectedType);
+ }
+
+ $items = $qbItems->get()->map(fn ($r) => (array) $r)->all();
+
+ $rows = [];
+ $totals = [
+ 'opening' => 0,
+ 'received' => 0,
+ 'distributed' => 0,
+ 'other_out' => 0,
+ 'adjust_net' => 0,
+ 'ending' => 0,
+ 'onhand' => 0,
+ 'variance' => 0,
+ ];
+
+ if (!empty($items)) {
+ $itemIds = array_map('intval', array_column($items, 'id'));
+ $aggById = [];
+ $qb = DB::table('inventory_movements as m')
+ ->selectRaw("
+ m.item_id,
+ SUM(CASE WHEN m.movement_type = 'initial' THEN m.qty_change ELSE 0 END) AS opening,
+ SUM(CASE WHEN m.qty_change > 0 AND m.movement_type IN ('in','adjust') THEN m.qty_change ELSE 0 END) AS received,
+ SUM(CASE WHEN m.qty_change < 0 AND m.movement_type = 'distribution' THEN -m.qty_change ELSE 0 END) AS distributed,
+ SUM(CASE WHEN m.qty_change < 0 AND m.movement_type = 'out' THEN -m.qty_change ELSE 0 END) AS other_out,
+ SUM(CASE WHEN m.movement_type = 'adjust' THEN m.qty_change ELSE 0 END) AS adjust_net
+ ")
+ ->whereIn('m.item_id', $itemIds)
+ ->groupBy('m.item_id');
+
+ if (!$isAllYears) {
+ $qb->where('m.school_year', $selectedYear);
+ }
+
+ foreach ($qb->get()->map(fn ($r) => (array) $r)->all() as $r) {
+ $aggById[(int) $r['item_id']] = [
+ 'opening' => (int) ($r['opening'] ?? 0),
+ 'received' => (int) ($r['received'] ?? 0),
+ 'distributed' => (int) ($r['distributed'] ?? 0),
+ 'other_out' => (int) ($r['other_out'] ?? 0),
+ 'adjust_net' => (int) ($r['adjust_net'] ?? 0),
+ ];
+ }
+
+ foreach ($items as $it) {
+ $id = (int) $it['id'];
+ $a = $aggById[$id] ?? ['opening' => 0, 'received' => 0, 'distributed' => 0, 'other_out' => 0, 'adjust_net' => 0];
+
+ $opening = (int) $a['opening'];
+ $received = (int) $a['received'];
+ $distributed = (int) $a['distributed'];
+ $otherOut = (int) $a['other_out'];
+ $adjustNet = (int) $a['adjust_net'];
+
+ $ending = $opening + $received - $distributed - $otherOut + $adjustNet;
+ $onhand = (int) ($it['quantity'] ?? 0);
+ $variance = $onhand - $ending;
+
+ $rows[] = [
+ 'id' => $id,
+ 'name' => (string) ($it['name'] ?? ''),
+ 'type' => (string) ($it['type'] ?? ''),
+ 'category' => (string) ($it['category_name'] ?? ''),
+ 'opening' => $opening,
+ 'received' => $received,
+ 'distributed' => $distributed,
+ 'other_out' => $otherOut,
+ 'adjust_net' => $adjustNet,
+ 'ending' => $ending,
+ 'onhand' => $onhand,
+ 'variance' => $variance,
+ ];
+
+ $totals['opening'] += $opening;
+ $totals['received'] += $received;
+ $totals['distributed'] += $distributed;
+ $totals['other_out'] += $otherOut;
+ $totals['adjust_net'] += $adjustNet;
+ $totals['ending'] += $ending;
+ $totals['onhand'] += $onhand;
+ $totals['variance'] += $variance;
+ }
+ }
+
+ return [
+ 'selectedYear' => $selectedYear,
+ 'currentYear' => $this->context->schoolYear(),
+ 'schoolYears' => $this->getSchoolYearsForType('book') ?: [$this->context->schoolYear(), 'All'],
+ 'rows' => $rows,
+ 'totals' => $totals,
+ 'selectedType' => $selectedType,
+ ];
+ }
+
+ private function getSchoolYearsForType(string $type): array
+ {
+ $years = InventoryItem::query()
+ ->select('school_year')
+ ->where('type', $type)
+ ->whereNotNull('school_year')
+ ->groupBy('school_year')
+ ->orderByDesc('school_year')
+ ->pluck('school_year')
+ ->map(fn ($v) => trim((string) $v))
+ ->filter()
+ ->unique()
+ ->values()
+ ->all();
+
+ $currentYear = $this->context->schoolYear();
+ if ($currentYear && !in_array($currentYear, $years, true)) {
+ array_unshift($years, $currentYear);
+ }
+
+ return $years;
+ }
+}
diff --git a/app/Services/Inventory/InventoryTeacherDistributionService.php b/app/Services/Inventory/InventoryTeacherDistributionService.php
new file mode 100644
index 00000000..e5b76634
--- /dev/null
+++ b/app/Services/Inventory/InventoryTeacherDistributionService.php
@@ -0,0 +1,298 @@
+buildTeacherClassContext($userId);
+ if (!$ctx['ok']) {
+ return $ctx;
+ }
+
+ $classIds = $ctx['classSectionIds'];
+ $lockClassSection = count($classIds) === 1;
+ $selectedClassId = $lockClassSection
+ ? (int) ($ctx['defaultClassSectionId'] ?? 0)
+ : (int) ($classSectionId ?? ($ctx['defaultClassSectionId'] ?? 0));
+
+ $classID = null;
+ if (!empty($selectedClassId)) {
+ $classID = ClassSection::getClassId($selectedClassId);
+ $classID = $classID !== null ? (int) $classID : null;
+ }
+
+ $builder = DB::table('inventory_items as i')
+ ->select('i.id', 'i.name', 'i.isbn', 'i.edition', 'i.quantity', 'i.category_id', 'i.type', 'c.name as category_name', 'c.grade_min', 'c.grade_max')
+ ->leftJoin('inventory_categories as c', 'c.id', '=', 'i.category_id')
+ ->where('i.type', 'book');
+
+ if (!empty($classID)) {
+ $builder->where(function ($q) use ($classID) {
+ $q->where(function ($inner) use ($classID) {
+ $inner->whereNotNull('c.grade_min')
+ ->whereNotNull('c.grade_max')
+ ->where('c.grade_min', '<=', $classID)
+ ->where('c.grade_max', '>=', $classID);
+ })->orWhere(function ($inner) use ($classID) {
+ $inner->whereNotNull('c.grade_min')
+ ->whereNull('c.grade_max')
+ ->where('c.grade_min', $classID);
+ })->orWhere(function ($inner) use ($classID) {
+ $inner->whereNotNull('c.grade_max')
+ ->whereNull('c.grade_min')
+ ->where('c.grade_max', $classID);
+ });
+ });
+ }
+
+ $rows = $builder->orderBy('i.name')->get()->map(fn ($r) => (array) $r)->all();
+
+ $labelFor = static function (?array $r): string {
+ $name = $r['category_name'] ?? 'Uncategorized';
+ $gmin = $r['grade_min'] ?? null;
+ $gmax = $r['grade_max'] ?? null;
+ if ($gmin === null && $gmax === null) return $name;
+ if ($gmin !== null && $gmax !== null) return $name . ' (G' . (int) $gmin . '–G' . (int) $gmax . ')';
+ if ($gmin !== null) return $name . ' (G' . (int) $gmin . '+)';
+ return $name . ' (≤G' . (int) $gmax . ')';
+ };
+
+ $booksGrouped = [];
+ foreach ($rows as $r) {
+ $catId = (int) ($r['category_id'] ?? 0);
+ if (!isset($booksGrouped[$catId])) {
+ $booksGrouped[$catId] = [
+ 'label' => $labelFor($r),
+ 'items' => [],
+ ];
+ }
+ $display = $r['name'];
+ if (!empty($r['isbn'])) {
+ $display .= ' — ISBN ' . $r['isbn'];
+ }
+ if (!empty($r['edition'])) {
+ $display .= ' (' . $r['edition'] . ')';
+ }
+
+ $booksGrouped[$catId]['items'][] = [
+ 'id' => (int) $r['id'],
+ 'display' => $display,
+ 'quantity' => (int) ($r['quantity'] ?? 0),
+ ];
+ }
+
+ uasort($booksGrouped, fn ($a, $b) => strnatcasecmp($a['label'], $b['label']));
+ foreach ($booksGrouped as &$grp) {
+ usort($grp['items'], fn ($x, $y) => strnatcasecmp($x['display'], $y['display']));
+ }
+ unset($grp);
+
+ $students = $ctx['studentsData'][$selectedClassId] ?? [];
+
+ $already = [];
+ $itemId = (int) ($itemId ?? 0);
+ if ($itemId && $selectedClassId) {
+ $rowsHist = InventoryMovement::query()
+ ->selectRaw('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty')
+ ->where([
+ 'item_id' => $itemId,
+ 'movement_type' => 'distribution',
+ 'class_section_id' => $selectedClassId,
+ 'school_year' => $ctx['schoolYear'],
+ ])
+ ->groupBy('student_id')
+ ->get()
+ ->map(fn ($r) => (array) $r)
+ ->all();
+ foreach ($rowsHist as $r) {
+ $sid = (int) ($r['student_id'] ?? 0);
+ if ($sid) $already[$sid] = (int) ($r['qty'] ?? 0);
+ }
+ }
+
+ $onHand = 0;
+ if ($itemId) {
+ $book = InventoryItem::query()->find($itemId);
+ if ($book && $book->type === 'book') {
+ $onHand = (int) ($book->quantity ?? 0);
+ }
+ }
+
+ return [
+ 'ok' => true,
+ 'booksGrouped' => $booksGrouped,
+ 'items' => $rows,
+ 'classes' => $ctx['classes'],
+ 'class_section_id' => $selectedClassId,
+ 'lockClassSection' => $lockClassSection,
+ 'item_id' => $itemId,
+ 'students' => $students,
+ 'already' => $already,
+ 'onHand' => $onHand,
+ 'schoolYear' => $ctx['schoolYear'],
+ 'semester' => $ctx['semester'],
+ ];
+ }
+
+ public function distribute(int $userId, array $payload): array
+ {
+ $ctx = $this->buildTeacherClassContext($userId);
+ if (!$ctx['ok']) {
+ return $ctx;
+ }
+
+ $itemId = (int) ($payload['item_id'] ?? 0);
+ $postedClassId = (int) ($payload['class_section_id'] ?? 0);
+ $selectedIds = array_values(array_unique(array_map('intval', $payload['student_ids'] ?? [])));
+ $note = (string) ($payload['note'] ?? '');
+
+ $classIds = $ctx['classSectionIds'];
+ $classSectionId = (count($classIds) === 1)
+ ? (int) $classIds[0]
+ : (in_array($postedClassId, $classIds, true) ? $postedClassId : 0);
+
+ if (!$classSectionId) {
+ return ['ok' => false, 'message' => 'Invalid class selection.'];
+ }
+
+ $book = InventoryItem::query()->find($itemId);
+ if (!$book || $book->type !== 'book') {
+ return ['ok' => false, 'message' => 'Invalid book.'];
+ }
+
+ $students = $ctx['studentsData'][$classSectionId] ?? [];
+ $validIds = [];
+ foreach ($students as $st) {
+ $sid = $st['student_id'] ?? ($st['id'] ?? null);
+ if ($sid) $validIds[(int) $sid] = true;
+ }
+ $selectedIds = array_values(array_filter($selectedIds, fn ($sid) => isset($validIds[$sid])));
+
+ $already = [];
+ $rows = InventoryMovement::query()
+ ->selectRaw('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty')
+ ->where([
+ 'item_id' => $itemId,
+ 'movement_type' => 'distribution',
+ 'class_section_id' => $classSectionId,
+ 'school_year' => $ctx['schoolYear'],
+ ])
+ ->groupBy('student_id')
+ ->get()
+ ->map(fn ($r) => (array) $r)
+ ->all();
+ foreach ($rows as $r) {
+ $sid = (int) ($r['student_id'] ?? 0);
+ if ($sid) $already[$sid] = (int) ($r['qty'] ?? 0);
+ }
+
+ $toGive = [];
+ foreach ($selectedIds as $sid) {
+ if (($already[$sid] ?? 0) < 1) {
+ $toGive[] = $sid;
+ }
+ }
+
+ $needed = count($toGive);
+ $onHand = (int) ($book->quantity ?? 0);
+ if ($needed > $onHand) {
+ return ['ok' => false, 'message' => 'Not enough stock to complete distribution.'];
+ }
+
+ $okCount = 0;
+ foreach ($toGive as $sid) {
+ $ok = $this->movementService->recordMovement($itemId, -1, 'distribution', 'Teacher distribution', $note, $classSectionId, $sid, $userId);
+ if ($ok) {
+ $okCount++;
+ }
+ }
+
+ return [
+ 'ok' => true,
+ 'distributed' => $okCount,
+ 'already_assigned' => count($selectedIds) - $okCount,
+ ];
+ }
+
+ private function buildTeacherClassContext(int $userId): array
+ {
+ if ($userId <= 0) {
+ return ['ok' => false, 'message' => 'Please log in first.'];
+ }
+
+ $classes = TeacherClass::getClassAssignmentsByUserId($userId, $this->context->schoolYear());
+ if (empty($classes)) {
+ return ['ok' => false, 'message' => 'You do not have an assigned class yet.'];
+ }
+
+ $user = User::query()->find($userId);
+ if (!$user) {
+ return ['ok' => false, 'message' => 'User not found.'];
+ }
+
+ $roles = DB::table('user_roles as ur')
+ ->join('roles as r', 'r.id', '=', 'ur.role_id')
+ ->where('ur.user_id', $userId)
+ ->pluck('r.name')
+ ->toArray();
+
+ if (!in_array('teacher', $roles, true) && !in_array('teacher_assistant', $roles, true)) {
+ return ['ok' => false, 'message' => 'Access denied.'];
+ }
+
+ $classSectionIds = array_column($classes, 'class_section_id');
+ $studentsData = [];
+ if (!empty($classSectionIds)) {
+ $students = StudentClass::getStudentsByClassSectionIds($classSectionIds);
+ foreach ($students as $student) {
+ $studentsData[$student['class_section_id']][] = $student;
+ }
+ }
+
+ $taAssignments = TeacherClass::query()
+ ->whereIn('class_section_id', $classSectionIds)
+ ->where('school_year', $this->context->schoolYear())
+ ->where('position', 'ta')
+ ->get()
+ ->toArray();
+
+ $taNames = [];
+ foreach ($taAssignments as $ta) {
+ $taUser = User::query()->find($ta['teacher_id'] ?? 0);
+ if ($taUser) {
+ $csid = $ta['class_section_id'];
+ $taNames[$csid][] = trim(($taUser->firstname ?? '') . ' ' . ($taUser->lastname ?? ''));
+ }
+ }
+
+ $defaultClassSectionId = $classSectionIds[0] ?? null;
+
+ return [
+ 'ok' => true,
+ 'classes' => $classes,
+ 'classSectionIds' => $classSectionIds,
+ 'studentsData' => $studentsData,
+ 'taNames' => $taNames,
+ 'defaultClassSectionId' => $defaultClassSectionId,
+ 'schoolYear' => $this->context->schoolYear(),
+ 'semester' => $this->context->semester(),
+ ];
+ }
+}
diff --git a/app/Services/Inventory/SupplierService.php b/app/Services/Inventory/SupplierService.php
new file mode 100644
index 00000000..a852d638
--- /dev/null
+++ b/app/Services/Inventory/SupplierService.php
@@ -0,0 +1,91 @@
+where(function ($builder) use ($q) {
+ $builder->where('name', 'like', '%' . $q . '%')
+ ->orWhere('email', 'like', '%' . $q . '%')
+ ->orWhere('phone', 'like', '%' . $q . '%');
+ });
+ }
+
+ $paginator = $query->orderBy('name')->paginate($perPage);
+
+ return [
+ 'items' => $paginator->items(),
+ 'pagination' => [
+ 'current_page' => $paginator->currentPage(),
+ 'per_page' => $paginator->perPage(),
+ 'total' => $paginator->total(),
+ 'total_pages' => $paginator->lastPage(),
+ ],
+ ];
+ }
+
+ public function create(array $data): array
+ {
+ try {
+ $supplier = Supplier::query()->create($this->payload($data));
+ } catch (\Throwable $e) {
+ Log::error('Supplier create failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to save supplier.'];
+ }
+
+ return ['ok' => true, 'supplier' => $supplier->toArray()];
+ }
+
+ public function update(int $id, array $data): array
+ {
+ $supplier = Supplier::query()->find($id);
+ if (!$supplier) {
+ return ['ok' => false, 'message' => 'Supplier not found.'];
+ }
+
+ try {
+ $supplier->update($this->payload($data));
+ } catch (\Throwable $e) {
+ Log::error('Supplier update failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to update supplier.'];
+ }
+
+ return ['ok' => true, 'supplier' => $supplier->toArray()];
+ }
+
+ public function delete(int $id): array
+ {
+ $supplier = Supplier::query()->find($id);
+ if (!$supplier) {
+ return ['ok' => false, 'message' => 'Supplier not found.'];
+ }
+
+ try {
+ $supplier->delete();
+ } catch (\Throwable $e) {
+ Log::error('Supplier delete failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to delete supplier.'];
+ }
+
+ return ['ok' => true];
+ }
+
+ private function payload(array $data): array
+ {
+ return [
+ 'name' => (string) ($data['name'] ?? ''),
+ 'email' => $data['email'] ?? null,
+ 'phone' => $data['phone'] ?? null,
+ 'address' => $data['address'] ?? null,
+ 'notes' => $data['notes'] ?? null,
+ ];
+ }
+}
diff --git a/app/Services/Inventory/SupplyCategoryService.php b/app/Services/Inventory/SupplyCategoryService.php
new file mode 100644
index 00000000..b435ea67
--- /dev/null
+++ b/app/Services/Inventory/SupplyCategoryService.php
@@ -0,0 +1,62 @@
+orderBy('name')->get()->toArray();
+ }
+
+ public function create(array $data): array
+ {
+ try {
+ $category = SupplyCategory::query()->create([
+ 'name' => (string) ($data['name'] ?? ''),
+ ]);
+ } catch (\Throwable $e) {
+ Log::error('Supply category create failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to save category.'];
+ }
+
+ return ['ok' => true, 'category' => $category->toArray()];
+ }
+
+ public function update(int $id, array $data): array
+ {
+ $category = SupplyCategory::query()->find($id);
+ if (!$category) {
+ return ['ok' => false, 'message' => 'Category not found.'];
+ }
+
+ try {
+ $category->update(['name' => (string) ($data['name'] ?? '')]);
+ } catch (\Throwable $e) {
+ Log::error('Supply category update failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to update category.'];
+ }
+
+ return ['ok' => true, 'category' => $category->toArray()];
+ }
+
+ public function delete(int $id): array
+ {
+ $category = SupplyCategory::query()->find($id);
+ if (!$category) {
+ return ['ok' => false, 'message' => 'Category not found.'];
+ }
+
+ try {
+ $category->delete();
+ } catch (\Throwable $e) {
+ Log::error('Supply category delete failed: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'Failed to delete category.'];
+ }
+
+ return ['ok' => true];
+ }
+}
diff --git a/app/old/FamilyAdminController.php b/app/old/FamilyAdminController.php
deleted file mode 100644
index 10c1288f..00000000
--- a/app/old/FamilyAdminController.php
+++ /dev/null
@@ -1,487 +0,0 @@
-request->getGet('student_id') ?? 0);
- $guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
- $data = ['student' => null, 'families' => [], 'guardians' => [], 'students' => []];
-
- // Simple student list for select
- $data['students'] = $db->query("SELECT id, CONCAT(lastname, ', ', firstname) AS name FROM students ORDER BY lastname, firstname")->getResultArray();
- // Preload search datasets (students + guardians/parents)
- $data['searchStudents'] = $db->query("SELECT id, firstname, lastname FROM students ORDER BY lastname, firstname")->getResultArray();
- $data['searchGuardians'] = $db->query(
- "SELECT DISTINCT u.id, u.firstname, u.lastname, u.email, u.cellphone
- FROM family_guardians fg
- JOIN users u ON u.id = fg.user_id
- ORDER BY u.lastname, u.firstname"
- )->getResultArray();
-
- // If a guardian is provided, resolve one of their students and redirect to use existing flow
- if (!$studentId && $guardianId) {
- $row = $db->query(
- "SELECT s.id AS student_id
- FROM family_guardians fg
- JOIN family_students fs ON fs.family_id = fg.family_id
- JOIN students s ON s.id = fs.student_id
- WHERE fg.user_id = ?
- ORDER BY s.lastname, s.firstname
- LIMIT 1",
- [$guardianId]
- )->getRowArray();
- if (!$row) {
- $row = $db->query(
- "SELECT s.id AS student_id
- FROM students s
- WHERE s.parent_id = ?
- ORDER BY s.lastname, s.firstname
- LIMIT 1",
- [$guardianId]
- )->getRowArray();
- }
- if ($row && !empty($row['student_id'])) {
- return redirect()->to(site_url('family?student_id='.(int)$row['student_id']));
- }
- }
-
- if ($studentId) {
- $data['student'] = $db->query("SELECT id, firstname, lastname FROM students WHERE id = ?", [$studentId])->getRowArray();
-
- // Fetch all families for this student with extended fields
- $families = $db->query(
- "SELECT f.id, f.family_code, f.household_name, f.address_line1, f.address_line2, f.city, f.state, f.postal_code, f.country,
- f.primary_phone, f.preferred_lang, f.preferred_contact_method, f.is_active,
- fs.is_primary_home
- FROM family_students fs
- JOIN families f ON f.id = fs.family_id
- WHERE fs.student_id = ?
- ORDER BY fs.is_primary_home DESC, f.household_name",
- [$studentId]
- )->getResultArray();
-
- // Enrich each family with guardians, students, and financials
- $invoiceModel = new InvoiceModel();
- $paymentModel = new PaymentModel();
- $studentClassModel = new StudentClassModel();
- $configModel = new ConfigurationModel();
- $schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
-
- foreach ($families as &$fam) {
- $fid = (int) $fam['id'];
-
- // Guardians (with contact info)
- $guardians = $db->query(
- "SELECT u.id AS user_id, u.firstname, u.lastname, u.email, u.cellphone,
- u.address_street, u.city, u.state, u.zip,
- fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
- FROM family_guardians fg
- JOIN users u ON u.id = fg.user_id
- WHERE fg.family_id = ?
- ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
- [$fid]
- )->getResultArray();
- $fam['guardians'] = $guardians;
-
- // Students in this family
- $studentsRows = $db->query(
- "SELECT s.id, s.firstname, s.lastname
- FROM family_students fs
- JOIN students s ON s.id = fs.student_id
- WHERE fs.family_id = ?
- ORDER BY s.lastname, s.firstname",
- [$fid]
- )->getResultArray();
- // Enrich with grade label if available
- if (!empty($studentsRows)) {
- foreach ($studentsRows as &$sr) {
- $sid = (int) ($sr['id'] ?? 0);
- $sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
- }
- unset($sr);
- }
- $fam['students'] = $studentsRows;
-
- // Financials: invoices and payments for all guardians (by user_id)
- $parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
- $parentIds = array_values(array_filter($parentIds));
-
- $fam['invoices'] = [];
- $fam['payments'] = [];
- $fam['finance_summary'] = [
- 'invoices_count' => 0,
- 'total_amount' => 0.0,
- 'paid_amount' => 0.0,
- 'balance' => 0.0,
- ];
-
- if (!empty($parentIds)) {
- // Invoices
- $invRows = $db->table('invoices')
- ->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
- ->whereIn('parent_id', $parentIds)
- ->orderBy('issue_date', 'DESC')
- ->get()->getResultArray();
- $fam['invoices'] = $invRows;
- // Map invoice id -> number for payments table
- $invoiceMap = [];
- foreach ($invRows as $ir) {
- $invoiceMap[(int)$ir['id']] = (string)($ir['invoice_number'] ?? '');
- }
- $fam['invoice_map'] = $invoiceMap;
-
- // Aggregate summary
- foreach ($invRows as $ir) {
- $fam['finance_summary']['invoices_count']++;
- $fam['finance_summary']['total_amount'] += (float)($ir['total_amount'] ?? 0);
- $fam['finance_summary']['paid_amount'] += (float)($ir['paid_amount'] ?? 0);
- $fam['finance_summary']['balance'] += (float)($ir['balance'] ?? 0);
- }
-
- // Recent payments (limit 10)
- $payRows = $db->table('payments')
- ->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status')
- ->whereIn('parent_id', $parentIds)
- ->orderBy('payment_date', 'DESC')
- ->limit(10)
- ->get()->getResultArray();
- $fam['payments'] = $payRows;
- }
- }
- unset($fam);
-
- $data['families'] = $families;
-
- // Back-compat: also expose guardians of first family for old UI pieces
- if (!empty($families)) {
- $familyId = (int)$families[0]['id'];
- $data['guardians'] = $db->query(
- "SELECT u.id, u.firstname, u.lastname, u.email, fg.relation, fg.is_primary, fg.receive_emails
- FROM family_guardians fg JOIN users u ON u.id = fg.user_id
- WHERE fg.family_id = ? ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
- [$familyId]
- )->getResultArray();
- }
- }
-
- return service('response')->setBody(view('family/index', $data));
- }
-
- // GET /family/search?q=..
- public function search(): ResponseInterface
- {
- $db = \Config\Database::connect();
- $q = trim((string)($this->request->getGet('q') ?? ''));
- if ($q === '') {
- return $this->response->setJSON(['items' => []]);
- }
- $qs = '%' . $db->escapeLikeString($q) . '%';
-
- // Students suggestions
- $students = $db->query(
- "SELECT id, firstname, lastname
- FROM students
- WHERE CONCAT_WS(' ', firstname, lastname) LIKE ?
- ORDER BY lastname, firstname
- LIMIT 8",
- [$qs]
- )->getResultArray();
-
- // Parents/Guardians suggestions (users)
- $guardians = $db->query(
- "SELECT id, firstname, lastname, email, cellphone
- FROM users
- WHERE (
- CONCAT_WS(' ', firstname, lastname) LIKE ?
- OR email LIKE ?
- OR cellphone LIKE ?
- )
- ORDER BY lastname, firstname
- LIMIT 8",
- [$qs, $qs, $qs]
- )->getResultArray();
-
- $items = [];
- foreach ($students as $s) {
- $items[] = [
- 'type' => 'student',
- 'id' => (int)$s['id'],
- 'label'=> trim(($s['firstname'] ?? '').' '.($s['lastname'] ?? '')),
- 'sub' => 'Student',
- ];
- }
- foreach ($guardians as $g) {
- $items[] = [
- 'type' => 'guardian',
- 'id' => (int)$g['id'],
- 'label' => trim(($g['firstname'] ?? '').' '.($g['lastname'] ?? '')),
- 'sub' => trim(($g['email'] ?? '').' '.($g['cellphone'] ?? '')),
- ];
- }
-
- return $this->response->setJSON(['items' => $items]);
- }
-
- // GET /family/card?student_id=.. | guardian_id=.. | family_id=..
- public function card(): ResponseInterface
- {
- $db = \Config\Database::connect();
- $studentId = (int) ($this->request->getGet('student_id') ?? 0);
- $guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
- $familyId = (int) ($this->request->getGet('family_id') ?? 0);
-
- if (!$familyId) {
- if ($studentId) {
- $row = $db->query(
- "SELECT f.id
- FROM family_students fs
- JOIN families f ON f.id = fs.family_id
- WHERE fs.student_id = ?
- ORDER BY fs.is_primary_home DESC, f.household_name
- LIMIT 1",
- [$studentId]
- )->getRowArray();
- if (!empty($row['id'])) $familyId = (int) $row['id'];
- } elseif ($guardianId) {
- // 1) Try via guardians link
- $row = $db->query(
- "SELECT f.id
- FROM family_guardians fg
- JOIN families f ON f.id = fg.family_id
- WHERE fg.user_id = ?
- ORDER BY f.household_name
- LIMIT 1",
- [$guardianId]
- )->getRowArray();
- if (!empty($row['id'])) {
- $familyId = (int) $row['id'];
- } else {
- // 2) Fallback via students.parent_id → family_students
- $row = $db->query(
- "SELECT f.id
- FROM students s
- JOIN family_students fs ON fs.student_id = s.id
- JOIN families f ON f.id = fs.family_id
- WHERE s.parent_id = ?
- ORDER BY fs.is_primary_home DESC, f.household_name
- LIMIT 1",
- [$guardianId]
- )->getRowArray();
- if (!empty($row['id'])) $familyId = (int) $row['id'];
- }
- }
- }
-
- if (!$familyId) {
- return $this->response->setStatusCode(404)->setBody('
Family not found.
');
- }
-
- $family = $db->query(
- "SELECT f.id, f.family_code, f.household_name, f.address_line1, f.address_line2, f.city, f.state, f.postal_code, f.country,
- f.primary_phone, f.preferred_lang, f.preferred_contact_method, f.is_active
- FROM families f
- WHERE f.id = ?",
- [$familyId]
- )->getRowArray();
-
- if (!$family) {
- return $this->response->setStatusCode(404)->setBody('Family not found.
');
- }
-
- // Hydrate with guardians, students (+grades), invoices, payments
- $invoiceModel = new \App\Models\InvoiceModel();
- $paymentModel = new \App\Models\PaymentModel();
- $studentClassModel = new \App\Models\StudentClassModel();
- $configModel = new \App\Models\ConfigurationModel();
- $schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
-
- // Guardians
- $guardians = $db->query(
- "SELECT u.id AS user_id, u.firstname, u.lastname, u.email, u.cellphone,
- u.address_street, u.city, u.state, u.zip,
- fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
- FROM family_guardians fg
- JOIN users u ON u.id = fg.user_id
- WHERE fg.family_id = ?
- ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
- [$familyId]
- )->getResultArray();
- $family['guardians'] = $guardians;
-
- // Students
- $studentsRows = $db->query(
- "SELECT s.id, s.firstname, s.lastname
- FROM family_students fs
- JOIN students s ON s.id = fs.student_id
- WHERE fs.family_id = ?
- ORDER BY s.lastname, s.firstname",
- [$familyId]
- )->getResultArray();
- if (!empty($studentsRows)) {
- foreach ($studentsRows as &$sr) {
- $sid = (int) ($sr['id'] ?? 0);
- $sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
- }
- unset($sr);
- }
- $family['students'] = $studentsRows;
-
- // Financials
- $parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
- $parentIds = array_values(array_filter($parentIds));
-
- $family['invoices'] = [];
- $family['payments'] = [];
- $family['finance_summary'] = [
- 'invoices_count' => 0,
- 'total_amount' => 0.0,
- 'paid_amount' => 0.0,
- 'balance' => 0.0,
- ];
- // Emergency contacts (by guardian/parent)
- $family['emergency_contacts'] = [];
- if (!empty($parentIds)) {
- // Map guardian name by user_id
- $gmap = [];
- foreach ($guardians as $g) {
- $gid = (int)($g['user_id'] ?? 0);
- if ($gid > 0) $gmap[$gid] = trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? ''));
- }
- $ecRows = $db->table('emergency_contacts')
- ->select('id, parent_id, emergency_contact_name, relation, cellphone, email, school_year, semester, created_at, updated_at')
- ->whereIn('parent_id', $parentIds)
- ->orderBy('updated_at', 'DESC')
- ->get()->getResultArray();
- if (!empty($ecRows)) {
- foreach ($ecRows as &$ec) {
- $pid = (int)($ec['parent_id'] ?? 0);
- $ec['parent_label'] = $gmap[$pid] ?? ('Parent #'.$pid);
- }
- unset($ec);
- $family['emergency_contacts'] = $ecRows;
- }
- }
-
- if (!empty($parentIds)) {
- // Invoices
- $invRows = $db->table('invoices')
- ->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
- ->whereIn('parent_id', $parentIds)
- ->orderBy('issue_date', 'DESC')
- ->get()->getResultArray();
- $family['invoices'] = $invRows;
- $invoiceMap = [];
- foreach ($invRows as $ir) {
- $invoiceMap[(int)$ir['id']] = (string)($ir['invoice_number'] ?? '');
- }
- $family['invoice_map'] = $invoiceMap;
-
- foreach ($invRows as $ir) {
- $family['finance_summary']['invoices_count']++;
- $family['finance_summary']['total_amount'] += (float)($ir['total_amount'] ?? 0);
- $family['finance_summary']['paid_amount'] += (float)($ir['paid_amount'] ?? 0);
- $family['finance_summary']['balance'] += (float)($ir['balance'] ?? 0);
- }
-
- // Payments
- $payRows = $db->table('payments')
- ->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status')
- ->whereIn('parent_id', $parentIds)
- ->orderBy('payment_date', 'DESC')
- ->limit(10)
- ->get()->getResultArray();
- $family['payments'] = $payRows;
- }
-
- return service('response')->setBody(view('family/card', ['f' => $family]));
- }
-
- public function composeEmail()
- {
- $to = trim((string)$this->request->getGet('to'));
- $name = trim((string)$this->request->getGet('name'));
- $returnUrl = trim((string)$this->request->getGet('return_url'));
- if ($returnUrl === '') {
- $returnUrl = trim((string)$this->request->getServer('HTTP_REFERER'));
- }
- if ($returnUrl === '') {
- $returnUrl = site_url('family');
- }
-
- return view('family/compose_email', [
- 'to' => $to,
- 'name' => $name,
- 'return_url' => $returnUrl,
- ]);
- }
-
- public function sendComposeEmail()
- {
- if (!$this->request->is('post')) {
- return redirect()->to(site_url('family'));
- }
-
- $to = trim((string)$this->request->getPost('to'));
- $subject = trim((string)$this->request->getPost('subject'));
- $html = (string)($this->request->getPost('html') ?? '');
- $returnUrl = trim((string)$this->request->getPost('return_url'));
-
- if ($returnUrl === '' || !$this->isLocalReturnUrl($returnUrl)) {
- $returnUrl = site_url('family');
- }
-
- if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
- return redirect()->back()->withInput()->with('error', 'Please enter a valid email address.');
- }
- if ($subject === '') {
- return redirect()->back()->withInput()->with('error', 'Subject is required.');
- }
- if (trim($html) === '') {
- return redirect()->back()->withInput()->with('error', 'Email body is required.');
- }
-
- $wrapped = view('emails/custom_html', [
- 'subject' => $subject,
- 'body_html' => $html,
- ]);
-
- $mailer = new \App\Controllers\View\EmailController();
- $ok = $mailer->sendEmail($to, $subject, $wrapped, 'communication');
-
- if ($ok) {
- return redirect()->to($returnUrl)->with('status', 'Email sent.');
- }
-
- return redirect()->to($returnUrl)->with('error', 'Unable to send email.');
- }
-
- private function isLocalReturnUrl(string $url): bool
- {
- $url = trim($url);
- if ($url === '') return false;
-
- $parts = parse_url($url);
- if ($parts === false) return false;
-
- if (!empty($parts['scheme']) || !empty($parts['host'])) {
- $base = parse_url(site_url('/'));
- if (!$base || empty($base['host'])) return false;
- $hostMatch = strcasecmp((string)($parts['host'] ?? ''), (string)$base['host']) === 0;
- return $hostMatch;
- }
-
- // Relative URL (e.g., /family?x=1 or family?x=1)
- return true;
- }
-}
diff --git a/app/old/FamilyController.php b/app/old/FamilyController.php
deleted file mode 100644
index 93a84c69..00000000
--- a/app/old/FamilyController.php
+++ /dev/null
@@ -1,477 +0,0 @@
-families = new FamilyModel();
- $this->guardians = new FamilyGuardianModel();
- $this->familyStudents = new FamilyStudentModel();
-
- $this->students = new StudentModel();
- $this->users = new UserModel();
-
- $this->db = \Config\Database::connect();
- }
-
- /* =========================================================
- * SECTION A — APIs your UI uses
- * =======================================================*/
-
- // GET /api/students/{id}/families
- public function familiesByStudent(int $studentId)
- {
- $rows = $this->db->query(
- "SELECT f.*, fs.is_primary_home
- FROM family_students fs
- JOIN families f ON f.id = fs.family_id
- WHERE fs.student_id = ? AND f.is_active = 1
- ORDER BY fs.is_primary_home DESC, f.household_name",
- [$studentId]
- )->getResultArray();
-
- return $this->response->setJSON(['data' => $rows]);
- }
-
- // GET /api/families/{id}/guardians
- public function guardiansByFamily(int $familyId)
- {
- $rows = $this->db->query(
- "SELECT u.id AS user_id, u.firstname, u.lastname, u.email,
- fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
- FROM family_guardians fg
- JOIN users u ON u.id = fg.user_id
- WHERE fg.family_id = ?
- ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
- [$familyId]
- )->getResultArray();
-
- return $this->response->setJSON(['data' => $rows]);
- }
-
- /* =========================================================
- * SECTION B — Bootstrap & Linking helpers
- * =======================================================*/
-
- // POST /families/bootstrap
- // Creates/ensures families for every student with parent_id,
- // links the student to that family, and (optionally) links a second parent.
- public function bootstrap()
- {
- // Optionally restrict this to admins
- // if (! $this->userCan('families.bootstrap')) return $this->deny();
-
- // Guard: ensure required tables exist
- foreach (['families','family_students','family_guardians'] as $t) {
- if (! $this->db->tableExists($t)) {
- return $this->response->setStatusCode(500)->setJSON([
- 'status' => 'error',
- 'message' => "Missing required table '{$t}'. Run migrations: php spark migrate",
- ]);
- }
- }
-
- $this->db->transStart();
-
- // 1) Get all distinct primary parents from students.parent_id
- $primaryParents = $this->db->query(
- "SELECT DISTINCT parent_id FROM students WHERE parent_id IS NOT NULL"
- )->getResultArray();
-
- $created = 0;
- $linkedStudents = 0;
- $linkedGuardians = 0;
-
- foreach ($primaryParents as $row) {
- $primaryUserId = (int) $row['parent_id'];
- if ($primaryUserId <= 0) continue;
-
- $familyId = $this->ensureFamilyForPrimaryParent($primaryUserId, $created);
-
- // Link all students of this primary parent
- $students = $this->db->query(
- "SELECT id FROM students WHERE parent_id = ?",
- [$primaryUserId]
- )->getResultArray();
-
- foreach ($students as $s) {
- $linkedStudents += $this->attachStudentToFamily((int)$s['id'], $familyId);
- }
-
- // Ensure primary parent is guardian on that family
- $linkedGuardians += $this->attachGuardianUser($familyId, $primaryUserId, 'primary', true, 1, 0);
- }
-
- $this->db->transComplete();
-
- $payload = [
- 'status' => $this->db->transStatus() ? 'ok' : 'error',
- 'families_created' => $created,
- 'students_linked' => $linkedStudents,
- 'guardians_linked' => $linkedGuardians,
- ];
-
- // If accessed via GET (browser), redirect back with flash
- if (strtolower($this->request->getMethod()) === 'get') {
- $msg = json_encode($payload);
- return redirect()->to('/family')->with('message', "Bootstrap result: {$msg}");
- }
-
- return $this->response->setJSON($payload);
- }
-
- // POST /families/attach-second-by-user
- // body: student_id, user_id, relation='secondary'
- public function attachSecondByUser()
- {
- $studentId = (int) $this->request->getPost('student_id');
- $userId = (int) $this->request->getPost('user_id');
- $relation = (string) ($this->request->getPost('relation') ?? 'secondary');
-
- if (!$studentId || !$userId) return $this->bad('student_id and user_id required');
-
- $familyId = $this->familyIdFromStudentPrimary($studentId);
- if (!$familyId) return $this->bad('No primary family found for this student');
-
- $rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
-
- return $this->response->setJSON(['status' => 'ok', 'attached' => $rows, 'family_id' => $familyId]);
- }
-
- // POST /families/attach-second-by-email
- // body: student_id, email, firstname, lastname, relation='secondary'
- // Creates a stub user if email not in users, then links.
- public function attachSecondByEmail()
- {
- $studentId = (int) $this->request->getPost('student_id');
- $email = trim((string)$this->request->getPost('email'));
- $first = trim((string)$this->request->getPost('firstname'));
- $last = trim((string)$this->request->getPost('lastname'));
- $relation = (string) ($this->request->getPost('relation') ?? 'secondary');
-
- if (!$studentId || !$email) return $this->bad('student_id and email required');
-
- $familyId = $this->familyIdFromStudentPrimary($studentId);
- if (!$familyId) return $this->bad('No primary family found for this student');
-
- $user = $this->users->where('email', $email)->first();
- if (!$user) {
- // Create a minimal/stub user; adjust fields to your schema
- $this->users->insert([
- 'firstname' => $first ?: '',
- 'lastname' => $last ?: '',
- 'email' => $email,
- 'status' => 'Active',
- 'user_type' => 'secondary', // align with secondary guardian role
- ]);
- $userId = (int) $this->users->getInsertID();
- } else {
- $userId = (int) $user['id'];
- }
-
- $rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
-
- return $this->response->setJSON(['status' => 'ok', 'attached' => $rows, 'family_id' => $familyId, 'user_id' => $userId]);
- }
-
- /* =========================================================
- * SECTION C — Maintenance actions
- * =======================================================*/
-
- // POST /families/set-primary-home
- // body: family_id, student_id, is_primary_home (0/1)
- public function setPrimaryHome()
- {
- $familyId = (int) $this->request->getPost('family_id');
- $studentId = (int) $this->request->getPost('student_id');
- $flag = (int) $this->request->getPost('is_primary_home');
-
- if (!$familyId || !$studentId) return $this->bad('family_id and student_id required');
-
- $this->familyStudents
- ->where(['family_id' => $familyId, 'student_id' => $studentId])
- ->set(['is_primary_home' => $flag ? 1 : 0])
- ->update();
-
- return $this->response->setJSON(['status' => 'ok']);
- }
-
- // POST /families/set-guardian-flags
- // body: family_id, user_id, receive_emails(0/1), is_primary(0/1), receive_sms(0/1)
- public function setGuardianFlags()
- {
- $familyId = (int) $this->request->getPost('family_id');
- $userId = (int) $this->request->getPost('user_id');
-
- if (!$familyId || !$userId) return $this->bad('family_id and user_id required');
-
- $data = [];
- foreach (['receive_emails', 'is_primary', 'receive_sms', 'relation'] as $k) {
- if (null !== $this->request->getPost($k)) {
- $val = $this->request->getPost($k);
- $data[$k] = in_array($k, ['receive_emails', 'is_primary', 'receive_sms'])
- ? (int)$val
- : (string)$val;
- }
- }
- if (!$data) return $this->bad('No flags provided');
-
- $this->guardians->where(['family_id' => $familyId, 'user_id' => $userId])->set($data)->update();
-
- return $this->response->setJSON(['status' => 'ok']);
- }
-
- // POST /families/unlink-guardian
- // body: family_id, user_id
- public function unlinkGuardian()
- {
- $familyId = (int) $this->request->getPost('family_id');
- $userId = (int) $this->request->getPost('user_id');
- if (!$familyId || !$userId) return $this->bad('family_id and user_id required');
-
- $this->guardians->where(['family_id' => $familyId, 'user_id' => $userId])->delete();
- return $this->response->setJSON(['status' => 'ok']);
- }
-
- // POST /families/unlink-student
- // body: family_id, student_id
- public function unlinkStudent()
- {
- $familyId = (int) $this->request->getPost('family_id');
- $studentId = (int) $this->request->getPost('student_id');
- if (!$familyId || !$studentId) return $this->bad('family_id and student_id required');
-
- $this->familyStudents->where(['family_id' => $familyId, 'student_id' => $studentId])->delete();
- return $this->response->setJSON(['status' => 'ok']);
- }
-
- /* =========================================================
- * SECTION D — Private helpers
- * =======================================================*/
-
- // Ensure there is exactly one family per primary parent (users.id)
- // Returns $familyId and increments $created by reference if newly created.
- protected function ensureFamilyForPrimaryParent(int $primaryUserId, int &$createdCounter): int
- {
- $code = 'FAM-' . $primaryUserId;
- $row = $this->families->where('family_code', $code)->first();
- if ($row) return (int)$row['id'];
-
- $this->families->insert([
- 'family_code' => $code,
- 'household_name' => 'Family of User ' . $primaryUserId,
- 'is_active' => 1,
- ]);
- $createdCounter++;
- return (int)$this->families->getInsertID();
- }
-
- // Attach a student to a family (idempotent; returns rows affected >0 if inserted)
- protected function attachStudentToFamily(int $studentId, int $familyId): int
- {
- // INSERT IGNORE pattern
- $sql = "INSERT IGNORE INTO family_students (family_id, student_id, is_primary_home)
- VALUES (?, ?, 1)";
- $this->db->query($sql, [$familyId, $studentId]);
- return $this->db->affectedRows();
- }
-
- // Attach a guardian user to family (idempotent)
- protected function attachGuardianUser(
- int $familyId,
- int $userId,
- string $relation = 'primary',
- bool $isPrimary = false,
- int $receiveEmails = 1,
- int $receiveSms = 0
- ): int {
- $sql = "INSERT IGNORE INTO family_guardians (family_id, user_id, relation, is_primary, receive_emails, receive_sms)
- VALUES (?, ?, ?, ?, ?, ?)";
- $this->db->query($sql, [$familyId, $userId, $relation, $isPrimary ? 1 : 0, $receiveEmails, $receiveSms]);
- return $this->db->affectedRows();
- }
-
- // Find the “primary” family for a student = family created from parent_id
- protected function familyIdFromStudentPrimary(int $studentId): ?int
- {
- // 1) Get primary parent id for the student
- $st = $this->students->select('parent_id')->find($studentId);
- if (!$st || empty($st['parent_id'])) return null;
-
- // 2) The canonical family uses code FAM-{parent_id}
- $code = 'FAM-' . (int)$st['parent_id'];
- $row = $this->families->select('id')->where('family_code', $code)->first();
- if ($row) return (int)$row['id'];
-
- // If not found, fall back to any family linked already
- $row = $this->db->query(
- "SELECT family_id FROM family_students WHERE student_id = ? ORDER BY is_primary_home DESC LIMIT 1",
- [$studentId]
- )->getRowArray();
-
- return $row ? (int)$row['family_id'] : null;
- }
-
- // Simple 400
- protected function bad(string $msg)
- {
- return $this->response->setStatusCode(400)->setJSON(['status' => 'error', 'message' => $msg]);
- }
-
- // Example permission check hook (plug your own logic)
- protected function userCan(string $perm): bool
- {
- $perms = (array) (session('permissions') ?? []);
- return in_array($perm, $perms, true);
- }
-
- protected function deny()
- {
- return redirect()->to('/access_denied')->setStatusCode(403);
- }
-
- // Legacy import helper: map secondary parents from legacy 'parents' table
- public function importSecondParentsFromLegacy()
- {
- // Protect this route (e.g., admin only) via filter in routes
- $L = [
- 'table' => 'parents',
- 'firstparent_id' => 'firstparent_id', // users.id of primary (first) parent
- 'second_user_id' => 'secondparent_id', // users.id of second parent (optional)
- 'second_email' => 'secondparent_email',
- 'second_firstname' => 'secondparent_firstname',
- 'second_lastname' => 'secondparent_lastname',
- ];
-
- if (!$this->db->tableExists($L['table'])) {
- return $this->response->setJSON(['status' => 'error', 'message' => "Legacy table '{$L['table']}' not found"]);
- }
- foreach (['families','family_students','family_guardians'] as $t) {
- if (! $this->db->tableExists($t)) {
- return $this->response->setStatusCode(500)->setJSON([
- 'status' => 'error',
- 'message' => "Missing required table '{$t}'. Run migrations: php spark migrate",
- ]);
- }
- }
-
- $rows = $this->db->table($L['table'])
- ->select(
- "{$L['firstparent_id']} AS first_id, " .
- "{$L['second_user_id']} AS second_id, " .
- "{$L['second_email']} AS email, " .
- "{$L['second_firstname']} AS firstname, " .
- "{$L['second_lastname']} AS lastname",
- false
- )
- ->groupStart()
- ->where("{$L['second_user_id']} !=", 0)
- ->orWhere("{$L['second_email']} !=", '')
- ->groupEnd()
- ->get()->getResultArray();
-
- $createdUsers = 0;
- $linked = 0;
- $skipped = 0;
-
- foreach ($rows as $r) {
- $firstId = (int)($r['first_id'] ?? 0);
- $secondId = (int)($r['second_id'] ?? 0);
- $email = trim((string)($r['email'] ?? ''));
-
- if (!$firstId || (!$secondId && $email === '')) {
- $skipped++;
- continue;
- }
-
- // Ensure/find family by primary parent
- $code = 'FAM-' . $firstId;
- $fam = $this->families->select('id')->where('family_code', $code)->first();
- if ($fam) {
- $familyId = (int)$fam['id'];
- } else {
- $tmp = 0; // counter sink
- $familyId = $this->ensureFamilyForPrimaryParent($firstId, $tmp);
- }
-
- // Resolve/create user
- $userId = 0;
- if ($secondId > 0) {
- $userId = $secondId;
- } else {
- $user = $this->users->where('email', $email)->first();
- if (!$user) {
- $this->users->insert([
- 'firstname' => $r['firstname'] ?? '',
- 'lastname' => $r['lastname'] ?? '',
- 'email' => $email,
- 'status' => 'Active',
- 'user_type' => 'secondary',
- ]);
- $userId = (int)$this->users->getInsertID();
- $createdUsers++;
- } else {
- $userId = (int)$user['id'];
- }
- }
-
- // Ensure all students for this primary parent are linked to this family
- $stuRows = $this->db->query("SELECT id FROM students WHERE parent_id = ?", [$firstId])->getResultArray();
- foreach ($stuRows as $sr) {
- $sid = (int)($sr['id'] ?? 0);
- if ($sid > 0) {
- $this->attachStudentToFamily($sid, $familyId);
- }
- }
-
- // Link as guardian (idempotent)
- $linked += $this->attachGuardianUser($familyId, $userId, 'guardian', false, 1, 0);
- }
-
- $payload = [
- 'status' => 'ok',
- 'created_users' => $createdUsers,
- 'guardians_linked' => $linked,
- 'skipped' => $skipped,
- 'total_source' => count($rows),
- ];
-
- if (strtolower($this->request->getMethod()) === 'get') {
- $msg = json_encode($payload);
- return redirect()->to('/family')->with('message', "Import legacy result: {$msg}");
- }
-
- return $this->response->setJSON($payload);
- }
-}
diff --git a/app/old/InventoryController.php b/app/old/InventoryController.php
deleted file mode 100644
index 5540ea6c..00000000
--- a/app/old/InventoryController.php
+++ /dev/null
@@ -1,1670 +0,0 @@
-movModel = new InventoryMovementModel();
- $this->userModel = new UserModel();
- $this->configModel = new ConfigurationModel();
- $this->itemModel = new InventoryItemModel();
- $this->catModel = new InventoryCategoryModel();
- $this->teacherClassModel = new TeacherClassModel();
- $this->studentClassModel = new StudentClassModel();
- $this->schoolYear = $this->configModel->getConfig('school_year');
- $this->semester = $this->configModel->getConfig('semester');
- $this->classSectionModel = new ClassSectionModel();
- $this->db = \Config\Database::connect();
- $this->teacherModel = new TeacherModel();
- helper(['form', 'text']);
- }
-
- // app/Controllers/InventoryController.php (only the changed bits)
-
- private function viewForIndex(string $type): string
- {
- // inventory/classroom/index, inventory/book/index, inventory/office/index, inventory/kitchen/index
- return "inventory/{$type}/index";
- }
-
- private function viewForForm(string $type): string
- {
- // inventory/classroom/form, inventory/book/form, inventory/office/form, inventory/kitchen/form
- return "inventory/{$type}/form";
- }
-
- public function index(string $type = 'classroom')
- {
- $type = $this->normalizeType($type);
-
- $schoolYears = $this->getSchoolYearsForType($type);
- $requested = trim((string) $this->request->getGet('school_year'));
- $selectedYear = $requested !== '' ? $requested : $this->schoolYear;
- $selectedSemRaw = $this->request->getGet('semester');
- $selectedSem = $selectedSemRaw === null ? (string) $this->semester : trim((string) $selectedSemRaw);
-
- $builder = $this->itemModel->where('type', $type);
- if (strtolower($selectedYear) !== 'all') {
- $builder->where('school_year', $selectedYear);
- }
- if ($selectedSem !== '') {
- $builder->where('semester', $selectedSem);
- }
-
- $items = $builder->orderBy('name', 'ASC')->findAll();
- $categories = $this->catModel->optionsForType($type);
-
- // NEW: build UpdatedBy name map
- $userIds = array_values(array_unique(array_map(fn($i) => (int)($i['updated_by'] ?? 0), $items)));
- $userIds = array_values(array_filter($userIds));
- $userNames = $this->userNamesByIds($userIds);
-
- return view($this->viewForIndex($type), [
- 'type' => $type,
- 'items' => $items,
- 'categories' => $categories,
- 'conditionOptions' => ['good' => 'Good condition', 'needs_repair' => 'Needs repair', 'need_replace' => 'Need replace', 'cannot_find' => 'Cannot find'],
- 'schoolYears' => $schoolYears,
- 'selectedYear' => $selectedYear,
- 'currentYear' => $this->schoolYear,
- 'semester' => $selectedSem,
- 'userNames' => $userNames, // <-- pass to views
- ]);
- }
-
- public function edit(int $id)
- {
- $item = $this->itemModel->find($id);
- if (!$item) return redirect()->back()->with('error', 'Item not found.');
-
- // Load categories for THIS item type
- $categories = $this->catModel->optionsForType($item['type']);
-
- return view($this->viewForForm($item['type']), [
- 'type' => $item['type'],
- 'item' => $item,
- 'categories' => $categories,
- 'conditionOptions' => [
- 'good' => 'Good condition',
- 'needs_repair' => 'Needs repair',
- 'need_replace' => 'Need replace',
- 'cannot_find' => 'Cannot find'
- ],
- 'isEdit' => true, // <- tell the view we're editing
- ]);
- }
-
- public function update(int $id)
- {
- $item = $this->itemModel->find($id);
- if (!$item) return redirect()->back()->with('error', 'Item not found.');
-
- // lock to original type so users can’t switch books -> office, etc.
- $data = $this->filterItemData($this->request->getPost(), $item['type']);
-
- // Do not allow changing the PK via payload
- unset($data['id']);
-
- if (!$this->itemModel->update($id, $data)) {
- return redirect()->back()->withInput()->with('error', implode(', ', $this->itemModel->errors()));
- }
-
- return redirect()->to(site_url('inventory/' . $item['type']))->with('success', 'Item updated.');
- }
-
- public function store()
- {
- $data = $this->request->getPost();
-
- $itemData = $this->filterItemData($data);
- // Use insert() to get ID cleanly
- $itemId = $this->itemModel->insert($itemData, true);
- if ($itemId) {
- $initialQty = (int) ($itemData['quantity'] ?? 0);
- if ($initialQty !== 0) {
- $this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock');
- } else {
- // ensure quantity is synced even if zero
- $this->recalcQuantity($itemId);
- }
- return redirect()->to(site_url("inventory/{$itemData['type']}"))->with('success', 'Item added.');
- }
- return redirect()->back()->withInput()->with('error', implode(', ', $this->itemModel->errors()));
- }
-
- // Lazily ensure the very first movement = current on-hand, for legacy items
- private function ensureInitialMovement(int $itemId): void
- {
- // Any movement already?
- $hasMov = $this->movModel->where('item_id', $itemId)->limit(1)->countAllResults();
- if ($hasMov > 0) return; // legacy seed only
-
- $item = $this->itemModel->select('quantity')->find($itemId);
- $initialQty = (int)($item['quantity'] ?? 0);
-
- $this->movModel->insert([
- 'item_id' => $itemId,
- 'qty_change' => $initialQty,
- 'movement_type' => 'initial',
- 'semester' => $this->semester,
- 'school_year' => $this->schoolYear,
- 'performed_by' => $this->currentUserId(),
- ], false);
- }
-
- public function delete(int $id)
- {
- $item = $this->itemModel->find($id);
- if ($item) {
- $type = $item['type'];
- $this->itemModel->delete($id);
- return redirect()->to(site_url("inventory/{$type}"))->with('success', 'Item deleted.');
- }
- return redirect()->back()->with('error', 'Item not found.');
- }
-
- // Categories
- public function saveCategory()
- {
- // Normalize inputs
- $rawType = (string)($this->request->getPost('type') ?? '');
- $type = strtolower(trim($rawType));
- $id = $this->request->getPost('id');
-
- // Collapse multiple spaces in the name and trim
- $name = preg_replace('/\s+/', ' ', trim((string)$this->request->getPost('name')));
-
- $data = [
- 'type' => in_array($type, ['classroom', 'book', 'office', 'kitchen'], true) ? $type : 'office',
- 'name' => $name,
- 'description' => (string)$this->request->getPost('description'),
- ];
-
- // Only books have grade range
- if ($data['type'] === 'book') {
- $gmin = $this->request->getPost('grade_min');
- $gmax = $this->request->getPost('grade_max');
-
- $gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int)$gmin);
- $gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int)$gmax);
-
- if ($gmin !== null && $gmax !== null && $gmin > $gmax) {
- return redirect()->back()->withInput()->with('error', 'Grade Min cannot be greater than Grade Max.');
- }
- if ($gmin !== null && $gmin > 13) $gmin = 13;
- if ($gmax !== null && $gmax > 13) $gmax = 13;
-
- $data['grade_min'] = $gmin;
- $data['grade_max'] = $gmax;
- } else {
- $data['grade_min'] = null;
- $data['grade_max'] = null;
- }
-
- // --- Duplicate guard for (type, name) pair ---
- $existing = $this->catModel
- ->where('type', $data['type'])
- ->where('name', $data['name'])
- ->first();
-
- try {
- if ($id) {
- // Updating: allow if the found record is THIS record, block otherwise
- $pk = $this->catModel->primaryKey ?? 'id';
- if ($existing && (int)$existing[$pk] !== (int)$id) {
- return redirect()->back()->withInput()->with(
- 'error',
- 'A category with this Type & Name already exists. Please choose a different name or edit the existing one.'
- );
- }
- $ok = $this->catModel->update((int)$id, $data);
- } else {
- // Creating: block if already exists
- if ($existing) {
- // If you PREFER auto-converting this into an update, uncomment the two lines below:
- // $pk = $this->catModel->primaryKey ?? 'id';
- // return $this->catModel->update((int)$existing[$pk], $data)
- // ? redirect()->back()->with('success', 'Category already existed; details updated.')
- // : redirect()->back()->withInput()->with('error', 'Failed to update existing category.');
-
- return redirect()->back()->withInput()->with(
- 'error',
- 'This category already exists: ' . $data['type'] . ' — ' . $data['name'] . '.'
- );
- }
- $ok = (bool) $this->catModel->insert($data);
- }
- } catch (\Throwable $e) {
- // Handle duplicate at DB level (safety net)
- $msg = $e->getMessage();
- if (strpos($msg, 'Duplicate entry') !== false && strpos($msg, 'type_name') !== false) {
- return redirect()->back()->withInput()->with(
- 'error',
- 'Duplicate Type/Name. A category with this combination already exists.'
- );
- }
- log_message('critical', 'Failed to save category: {msg}', ['msg' => $msg]);
- return redirect()->back()->withInput()->with('error', 'Failed to save category.');
- }
-
- if (!$ok) {
- return redirect()->back()->withInput()->with('error', 'Failed to save category.');
- }
-
- return redirect()->back()->with('success', 'Category saved.');
- }
-
- public function optionsForType(string $type): array
- {
- return $this->asArray()
- ->select('id, name, description, grade_min, grade_max')
- ->where('type', $type)
- ->orderBy('name', 'ASC')
- ->findAll();
- }
-
- private function normalizeType(?string $type): string
- {
- $type = strtolower((string)$type);
- return in_array($type, $this->types, true) ? $type : 'classroom';
- }
-
- private function currentUserId(): ?int
- {
- foreach (['user_id', 'id', 'uid', 'userId'] as $k) {
- $v = session()->get($k);
- if ($v !== null && $v !== '') return (int)$v;
- }
- return null;
- }
-
-
- // add a tiny helper (optional, but keeps things tidy)
- private function post(string $key, $default = null)
- {
- $v = $this->request->getPost($key);
- if ($v === '' || $v === null) return $default;
- return is_string($v) ? trim($v) : $v;
- }
-
- private function filterItemData(array $data, ?string $lockedType = null): array
- {
- $type = $lockedType ?? $this->normalizeType($this->post('type', 'classroom'));
-
- $qtyRaw = $this->request->getPost('quantity');
- $quantity = is_numeric($qtyRaw) ? (int)$qtyRaw : 0;
-
- $base = [
- // 'id' should NOT be required; we pass PK separately on update()
- 'type' => $type,
- 'category_id' => ($this->post('category_id') !== null && $this->post('category_id') !== '') ? (int)$this->post('category_id') : null,
- 'name' => (string) $this->post('name', ''),
- 'description' => $this->post('description'),
- 'quantity' => $quantity,
- 'unit' => $this->post('unit'),
- 'sku' => $this->post('sku'),
- 'notes' => $this->post('notes'),
- 'semester' => $this->semester,
- 'school_year' => $this->schoolYear,
- 'updated_by' => $this->currentUserId(),
- ];
-
- $base['condition'] = ($type === 'classroom') ? $this->post('condition') : null;
-
- if ($type === 'book') {
- $base['isbn'] = $this->post('isbn');
- $base['edition'] = $this->post('edition');
- } else {
- $base['isbn'] = $base['edition'] = null;
- }
-
- return $base;
- }
-
- public function auditClassroomForm(int $itemId)
- {
- $item = $this->itemModel->find($itemId);
- if (!$item || $item['type'] !== 'classroom') {
- return redirect()->back()->with('error', 'Invalid classroom item.');
- }
-
- // Defaults if columns were just added
- $item['good_qty'] = (int)($item['good_qty'] ?? 0);
- $item['needs_repair_qty'] = (int)($item['needs_repair_qty'] ?? 0);
- $item['need_replace_qty'] = (int)($item['need_replace_qty'] ?? 0);
- $item['cannot_find_qty'] = (int)($item['cannot_find_qty'] ?? 0);
-
- return view('inventory/classroom/audit_form', ['item' => $item]);
- }
-
- public function auditClassroomStore(int $itemId)
- {
- $item = $this->itemModel->find($itemId);
- if (!$item || $item['type'] !== 'classroom') {
- return redirect()->back()->with('error', 'Invalid classroom item.');
- }
-
- // Previous buckets
- $prevGood = (int)($item['good_qty'] ?? 0);
- $prevRepair = (int)($item['needs_repair_qty'] ?? 0);
- $prevLoss = (int)($item['need_replace_qty'] ?? 0);
- $prevMiss = (int)($item['cannot_find_qty'] ?? 0);
-
- // New buckets (non-negative ints)
- $newRepair = max(0, (int)$this->request->getPost('needs_repair_qty'));
- $newLoss = max(0, (int)$this->request->getPost('need_replace_qty'));
- $newMiss = max(0, (int)$this->request->getPost('cannot_find_qty'));
-
- // Good is derived: keep total physical = good + repair (loss/missing are not on hand)
- // We base it on current on-hand (item.quantity) and newRepair.
- // If you want to enter "good" directly, you can switch to reading it from POST.
- $onHandNow = (int)$item['quantity'];
- $newGood = max(0, $onHandNow - $newRepair);
-
- // Movements for loss/missing deltas (affects physical on-hand)
- $deltaLoss = $newLoss - $prevLoss;
- $deltaMiss = $newMiss - $prevMiss;
-
- // Positive deltas mean more items are now total-loss/missing → stock OUT
- $totalOut = 0;
- if ($deltaLoss > 0) $totalOut += $deltaLoss;
- if ($deltaMiss > 0) $totalOut += $deltaMiss;
-
- // Negative deltas mean we recovered some previously lost/missing → stock IN
- $totalIn = 0;
- if ($deltaLoss < 0) $totalIn += -$deltaLoss;
- if ($deltaMiss < 0) $totalIn += -$deltaMiss;
-
- // Apply stock movements
- if ($totalOut > 0) {
- if (!$this->recordMovement($itemId, -$totalOut, 'out', 'Audit: loss/missing increased')) {
- return redirect()->back()->withInput(); // error flashed inside recordMovement
- }
- }
- if ($totalIn > 0) {
- $this->recordMovement($itemId, +$totalIn, 'in', 'Audit: loss/missing decreased');
- }
-
- // Recalc on-hand now that movements applied
- $this->recalcQuantity($itemId);
-
- // Persist buckets
- $this->itemModel->update($itemId, [
- 'good_qty' => $newGood,
- 'needs_repair_qty' => $newRepair,
- 'need_replace_qty' => $newLoss,
- 'cannot_find_qty' => $newMiss,
- // also stamp audit user/time
- 'updated_by' => $this->currentUserId(),
- 'updated_at' => utc_now(),
- ]);
-
- return redirect()->to(site_url('inventory/classroom'))->with('success', 'Audit saved.');
- }
-
- private function userNamesByIds(array $ids): array
- {
- $ids = array_values(array_unique(array_filter($ids, fn($v) => !empty($v))));
- if (!$ids) return [];
-
- if (!$this->db->tableExists('users')) return [];
-
- // Detect available columns to build a safe SELECT
- $cols = $this->db->getFieldNames('users'); // returns [] if empty
- $hasFirst = in_array('first_name', $cols, true) || in_array('firstname', $cols, true);
- $hasLast = in_array('last_name', $cols, true) || in_array('lastname', $cols, true);
- $firstCol = in_array('first_name', $cols, true) ? 'first_name' : (in_array('firstname', $cols, true) ? 'firstname' : null);
- $lastCol = in_array('last_name', $cols, true) ? 'last_name' : (in_array('lastname', $cols, true) ? 'lastname' : null);
- $nameCol = in_array('name', $cols, true) ? 'name' : null;
- $emailCol = in_array('email', $cols, true) ? 'email' : null;
-
- if ($firstCol || $lastCol) {
- $select = "id, TRIM(CONCAT(IFNULL($firstCol,''),' ',IFNULL($lastCol,''))) AS display_name";
- } elseif ($nameCol) {
- $select = "id, $nameCol AS display_name";
- } elseif ($emailCol) {
- $select = "id, $emailCol AS display_name";
- } else {
- // Last resort: just return IDs as labels
- $out = [];
- foreach ($ids as $id) $out[(int)$id] = 'User #' . $id;
- return $out;
- }
-
- $rows = $this->db->table('users')->select($select)->whereIn('id', $ids)->get()->getResultArray();
- $map = [];
- foreach ($rows as $r) {
- $disp = trim((string)($r['display_name'] ?? ''));
- $map[(int)$r['id']] = $disp !== '' ? $disp : ('User #' . $r['id']);
- }
- return $map;
- }
-
- // Distinct school years for a given inventory type (newest first).
- private function getSchoolYearsForType(string $type): array
- {
- // Pull distinct non-null school_year values for this type
- $years = $this->itemModel
- ->select('school_year')
- ->where('type', $type)
- ->where('school_year IS NOT NULL', null, false)
- ->groupBy('school_year')
- ->orderBy('school_year', 'DESC')
- ->findColumn('school_year') ?? [];
-
- // Normalize & de-dup
- $years = array_values(array_unique(array_filter(array_map('trim', $years))));
-
- // Ensure the current year (from config) appears as an option, even if no rows yet
- $currentYear = $this->schoolYear;
- if ($currentYear && !in_array($currentYear, $years, true)) {
- array_unshift($years, $currentYear);
- $years = array_values(array_unique($years));
- }
-
- return $years;
- }
-
- private function recordMovement(
- int $itemId,
- int $qtyChange,
- string $type, // initial|in|out|adjust|distribution
- ?string $reason = null,
- ?string $note = null,
- ?int $classSectionId = null,
- ?int $studentId = null
- ): bool {
- $me = $this->currentUserId();
- $isInitial = ($type === 'initial');
-
- // ✅ Only seed legacy/year opening for NON-initial inserts
- if (!$isInitial) {
- // Seed a one-time legacy opening movement if *no* movement exists for this item
- $this->ensureInitialMovement($itemId);
-
- // Ensure there is an opening for *this* school year
- $this->ensureInitialMovementForYear($itemId, $this->schoolYear);
-
- // Sync on-hand before validating outflow
- $this->recalcQuantity($itemId);
- }
-
- // Normalize sign for out/distribution
- if (in_array($type, ['out', 'distribution'], true) && $qtyChange > 0) {
- $qtyChange = -abs($qtyChange);
- }
-
- // Prevent going negative on out/distribution
- if (in_array($type, ['out', 'distribution'], true)) {
- $onHand = (int) ($this->itemModel->select('quantity')->where('id', $itemId)->first()['quantity'] ?? 0);
- if ($onHand + $qtyChange < 0) {
- session()->setFlashdata('error', 'Not enough stock to complete this deduction.');
- return false;
- }
- }
-
- $ok = $this->movModel->insert([
- 'item_id' => $itemId,
- 'qty_change' => $qtyChange,
- 'movement_type' => $type,
- 'reason' => $reason,
- 'note' => $note,
- 'semester' => $this->semester,
- 'school_year' => $this->schoolYear,
- 'performed_by' => $me,
- 'teacher_id' => $me,
- 'student_id' => $studentId,
- 'class_section_id' => $classSectionId,
- ], false);
-
- if ($ok) {
- $this->recalcQuantity($itemId);
- return true;
- }
- return false;
- }
-
- private function recalcQuantity(int $itemId): void
- {
- // Sum movements
- $sumRow = $this->movModel
- ->selectSum('qty_change')
- ->where('item_id', $itemId)
- ->first();
-
- $sum = (int)($sumRow['qty_change'] ?? 0);
- if ($sum < 0) $sum = 0;
-
- // Fetch current values we compare against
- $item = $this->itemModel
- ->select('id, type, quantity, needs_repair_qty, good_qty')
- ->find($itemId);
-
- if (!$item) return;
-
- $updates = [];
-
- // (optional) if you want quantity to mirror movement sum, keep this block
- // If you prefer to leave quantity as entered, just delete this IF.
- if ((int)$item['quantity'] !== $sum) {
- $updates['quantity'] = $sum;
- }
-
- // Keep classroom Good = OnHand − NeedsRepair (derived)
- if (($item['type'] ?? null) === 'classroom') {
- $needsRepair = (int)($item['needs_repair_qty'] ?? 0);
- $goodCalc = max(0, $sum - $needsRepair);
- if (!array_key_exists('good_qty', $item) || (int)$item['good_qty'] !== $goodCalc) {
- $updates['good_qty'] = $goodCalc;
- }
- }
-
- // ✅ Only update if there is something to update
- if (!empty($updates)) {
- try {
- $this->itemModel->update($itemId, $updates);
- } catch (\CodeIgniter\Database\Exceptions\DataException $e) {
- // This would happen if allowedFields stripped everything
- log_message('error', 'recalcQuantity() skipped update: ' . $e->getMessage());
- }
- }
- }
-
- public function summary(string $type = 'classroom')
- {
- $type = $this->normalizeType($type);
- $currentSem = $this->semester;
- $currentYear = $this->schoolYear;
- $selectedYear = trim((string) $this->request->getGet('school_year')) ?: $currentYear;
-
- // Items of this type
- $items = $this->itemModel->where('type', $type)->orderBy('name', 'ASC')->findAll();
- $itemIds = array_map(fn($i) => (int)$i['id'], $items);
-
- // Aggregate movements for the selected year (or all)
- $agg = [];
- if ($itemIds) {
- $qb = $this->movModel->select('item_id,
- SUM(CASE WHEN qty_change>0 THEN qty_change ELSE 0 END) AS received,
- SUM(CASE WHEN qty_change<0 THEN -qty_change ELSE 0 END) AS issued')
- ->whereIn('item_id', $itemIds)
- ->groupBy('item_id');
-
- if (strtolower($selectedYear) !== 'all') {
- $qb->where('school_year', $selectedYear);
- }
-
- foreach ($qb->findAll() as $row) {
- $agg[(int)$row['item_id']] = [
- 'received' => (int)$row['received'],
- 'issued' => (int)$row['issued'],
- ];
- }
- }
-
- // school year options
- $schoolYears = $this->getSchoolYearsForType($type);
-
- return view('inventory/summary', [
- 'type' => $type,
- 'items' => $items,
- 'agg' => $agg,
- 'selectedYear' => $selectedYear,
- 'currentYear' => $currentYear,
- 'schoolYears' => $schoolYears,
- ]);
- }
-
- public function adjustForm(int $itemId)
- {
- $item = $this->itemModel->find($itemId);
- if (!$item) return redirect()->back()->with('error', 'Item not found.');
- return view('inventory/adjust_form', ['item' => $item, 'type' => $item['type']]);
- }
-
- public function adjustStore(int $itemId)
- {
- $item = $this->itemModel->find($itemId);
- if (!$item) return redirect()->back()->with('error', 'Item not found.');
-
- $mode = $this->request->getPost('mode'); // 'in' or 'out' or 'adjust'
- $qty = (int) $this->request->getPost('quantity');
- $reason = (string) $this->request->getPost('reason');
- $note = (string) $this->request->getPost('note');
-
- if ($qty <= 0) return redirect()->back()->with('error', 'Quantity must be > 0.');
-
- $delta = ($mode === 'out') ? -abs($qty) : (($mode === 'in') ? abs($qty) : $qty);
- $type = ($mode === 'out') ? 'out' : (($mode === 'in') ? 'in' : 'adjust');
-
- if (!$this->recordMovement((int)$itemId, $delta, $type, $reason, $note)) {
- return redirect()->back()->withInput(); // error is flashed inside recordMovement
- }
-
- return redirect()->to(site_url('inventory/summary/' . $item['type']))->with('success', 'Stock updated.');
- }
-
- /**
- * Teacher book distribution page:
- * - Locks to teacher’s class section if only one, else uses ?class_section_id
- * - Filters BOOKS by category grade range (grade_min/grade_max) against class grade (class_id)
- * - Groups books by CATEGORY (with grade range in the group label) for an