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 -style select - * - Shows per-student prior distribution history for the selected book in the current school year - */ - public function teacherDistributeForm() - { - // 1) Build teacher/class context (auth/role/no-class handled inside) - $ctx = $this->buildTeacherClassContext(); - if ($ctx instanceof \CodeIgniter\HTTP\RedirectResponse) { - return $ctx; // early exit on redirect (not logged in / no class / invalid role) - } - - // 2) Decide selected class section (lock if teacher has only one) - $lockClassSection = \is_array($ctx['classSectionIds'] ?? null) && \count($ctx['classSectionIds']) === 1; - $classSectionId = $lockClassSection - ? (int) ($ctx['defaultClassSectionId'] ?? 0) - : (int) ($this->request->getGet('class_section_id') ?? ($ctx['defaultClassSectionId'] ?? 0)); - - // 3) Convert class section -> class grade (class_id as integer grade level) - $classID = null; - if (!empty($classSectionId)) { - $classID = $this->classSectionModel->getClassId($classSectionId); // e.g., 3 for Grade 3 - if ($classID !== null) $classID = (int) $classID; - } - - // 4) Selected book (optional, to render student history and on-hand) - $itemId = (int) ($this->request->getGet('item_id') ?? 0); - - // 5) Fetch BOOKS filtered by category grade range and GROUP BY CATEGORY - $db = \Config\Database::connect(); - $builder = $db->table('inventory_items 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') - ->join('inventory_categories c', 'c.id = i.category_id', 'left') - ->where('i.type', 'book'); - - if (!empty($classID)) { - $builder->groupStart() - // Case A: both bounds set and classID between [min..max] - ->groupStart() - ->where('c.grade_min IS NOT NULL', null, false) - ->where('c.grade_max IS NOT NULL', null, false) - ->where('c.grade_min <=', (int)$classID) - ->where('c.grade_max >=', (int)$classID) - ->groupEnd() - - // Case B: only min set AND equals min - ->orGroupStart() - ->where('c.grade_min IS NOT NULL', null, false) - ->where('c.grade_max', null) // IS NULL - ->where('c.grade_min', (int)$classID) // = - ->groupEnd() - - // Case C: only max set AND equals max - ->orGroupStart() - ->where('c.grade_max IS NOT NULL', null, false) - ->where('c.grade_min', null) // IS NULL - ->where('c.grade_max', (int)$classID) // = - ->groupEnd() - ->groupEnd(); - } - - $rows = $builder->orderBy('i.name', 'ASC')->get()->getResultArray(); - - // Build CATEGORY groups keyed by category_id - $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: [catId => ['label'=>string, 'items'=>[['id'=>..,'display'=>..,'quantity'=>..], ...]]] - $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), - ]; - } - - // Sort groups by label, and each group’s items by display - 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); - - // 6) Students of the selected class section (provided by context) - $students = $ctx['studentsData'][$classSectionId] ?? []; - - // 7) History map: how many of the selected book each student already received this school year - $already = []; - if ($itemId && $classSectionId) { - $rowsHist = $this->movModel - ->select('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') - ->findAll(); - - foreach ($rowsHist as $r) { - $sid = (int) ($r['student_id'] ?? 0); - if ($sid) $already[$sid] = (int) ($r['qty'] ?? 0); - } - } - - // 8) On-hand for the selected book (to show live available stock) - $onHand = 0; - if ($itemId) { - $book = $this->itemModel->find($itemId); - if ($book && ($book['type'] ?? '') === 'book') { - $onHand = (int) ($book['quantity'] ?? 0); - } - } - - // 9) Render view - return view('inventory/teacher_distribute', [ - 'booksGrouped' => $booksGrouped, // grouped by CATEGORY for - 'items' => $rows, // raw list if needed elsewhere (optional) - - 'classes' => $ctx['classes'], - 'class_section_id' => $classSectionId, - 'lockClassSection' => $lockClassSection, - - 'item_id' => $itemId, - 'students' => $students, - 'already' => $already, - 'onHand' => $onHand, - - 'schoolYear' => $ctx['schoolYear'], - 'semester' => $ctx['semester'], - ]); - } - - public function teacherDistributeStore() - { - $ctx = $this->buildTeacherClassContext(); - if ($ctx instanceof \CodeIgniter\HTTP\RedirectResponse) { - return $ctx; - } - - $itemId = (int)$this->request->getPost('item_id'); - $postedClassId = (int)$this->request->getPost('class_section_id'); - $selectedIds = (array)$this->request->getPost('student_ids'); - $note = (string)$this->request->getPost('note'); - - // Enforce teacher’s classes - $classIds = $ctx['classSectionIds']; - $classSectionId = (count($classIds) === 1) - ? (int)$classIds[0] - : (in_array($postedClassId, $classIds, true) ? $postedClassId : 0); - - if (!$classSectionId) { - return redirect()->back()->with('error', 'Invalid class selection.'); - } - - // Validate book - $book = $this->itemModel->find($itemId); - if (!$book || $book['type'] !== 'book') { - return redirect()->back()->with('error', 'Invalid book.'); - } - - // Validate students belong to the class - $students = $ctx['studentsData'][$classSectionId] ?? []; - $validIds = []; - foreach ($students as $st) { - $sid = $st['student_id'] ?? ($st['id'] ?? ($st['user_id'] ?? null)); - if ($sid) $validIds[(int)$sid] = true; - } - $selectedIds = array_values(array_unique(array_map('intval', $selectedIds))); - $selectedIds = array_values(array_filter($selectedIds, fn($sid) => isset($validIds[$sid]))); - - // Already distributed this year for this class/book - $already = []; - $rows = $this->movModel - ->select('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')->findAll(); - foreach ($rows as $r) { - $sid = (int)($r['student_id'] ?? 0); - if ($sid) $already[$sid] = (int)$r['qty']; - } - - // Only assign to students who don't already have it - $toGive = []; - foreach ($selectedIds as $sid) { - if (($already[$sid] ?? 0) < 1) $toGive[] = $sid; - } - - // Stock check - $needed = count($toGive); - $onHand = (int)$book['quantity']; - if ($needed > $onHand) { - return redirect()->back()->withInput()->with('error', 'Not enough stock: need ' . $needed . ', on hand ' . $onHand . '.'); - } - - // Record one movement per student - $okCount = 0; - foreach ($toGive as $sid) { - $ok = $this->recordMovement($itemId, -1, 'distribution', 'Teacher distribution', $note, $classSectionId, $sid); - if ($ok) $okCount++; - } - - if ($okCount === 0) { - return redirect() - ->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId)) - ->with('warning', 'No changes (everyone selected already has this book).'); - } - - return redirect() - ->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId)) - ->with('success', 'Distributed to ' . $okCount . ' student(s).'); - } - - - /** - * Builds the same context your TeacherController::classView() creates. - * Returns an array with: user, classes, roleNames, classSectionIds, studentsData, taNames, defaultClassSectionId - * Throws a redirect Response (return it) when access is invalid (not logged in / no classes / invalid role). - */ - private function buildTeacherClassContext() - { - // 1) Logged-in user? - $user_id = session()->get('user_id'); - if (!$user_id) { - return redirect()->to('/login')->with('error', 'Please log in first'); - } - - // 2) All assignments (main or TA) for current school year - $classes = $this->teacherClassModel->getClassAssignmentsByUserId($user_id, $this->schoolYear); - - if (empty($classes)) { - return redirect()->to('no-classes')->with('message', 'You do not have an assigned class yet. Please contact the administration.'); - } - - // 3) Identify user & role - $user = $this->userModel->find($user_id); - if (!$user) { - return redirect()->to('/error')->with('error', 'User not found'); - } - - $roles = $this->db->table('user_roles ur') - ->select('r.name') - ->join('roles r', 'r.id = ur.role_id') - ->where('ur.user_id', $user_id) - ->get()->getResultArray(); - - $roleNames = array_map(fn($r) => $r['name'], $roles); - - if (in_array('teacher', $roleNames, true)) { - $user['role_name'] = 'teacher'; - } elseif (in_array('teacher_assistant', $roleNames, true)) { - $user['role_name'] = 'teacher_assistant'; - } else { - return redirect()->to('/error')->with('error', 'Access denied'); - } - - // 4) Collect class_section IDs - $classSectionIds = array_column($classes, 'class_section_id'); - $studentsData = []; - - // 5) Group students by class_section_id - if (!empty($classSectionIds)) { - $students = $this->studentClassModel->getStudentsByClassSectionIds($classSectionIds); - foreach ($students as $student) { - $studentsData[$student['class_section_id']][] = $student; - } - } - - // 6) TA names per class_section (same as your code) - $taAssignments = $this->teacherClassModel - ->whereIn('class_section_id', $classSectionIds) - ->where('school_year', $this->schoolYear) - ->where('position', 'ta') - ->findAll(); - - $taNames = []; - foreach ($taAssignments as $ta) { - $taUser = $this->userModel->find($ta['teacher_id']); - if ($taUser) { - $csid = $ta['class_section_id']; - $taNames[$csid][] = ($taUser['firstname'] ?? '') . ' ' . ($taUser['lastname'] ?? ''); - } - } - - // 7) Default selection = active session choice (if valid) or first class - $sessionId = (int)(session()->get('class_section_id') ?? 0); - if ($sessionId && in_array($sessionId, $classSectionIds, true)) { - $defaultClassSectionId = $sessionId; - } else { - $defaultClassSectionId = $classSectionIds[0] ?? null; - if ($defaultClassSectionId) { - session()->set('class_section_id', $defaultClassSectionId); - } - } - - return [ - 'user' => $user, - 'roleNames' => $roleNames, - 'classes' => $classes, - 'classSectionIds' => $classSectionIds, - 'studentsData' => $studentsData, - 'taNames' => $taNames, - 'defaultClassSectionId' => $defaultClassSectionId, - 'schoolYear' => $this->schoolYear, - 'semester' => $this->semester, - 'user_id' => $user_id, - ]; - } - - // Ensure an initial movement exists for THIS school year, even if we started mid-year - private function ensureInitialMovementForYear(int $itemId, string $schoolYear): void - { - // If we already have any movement for this item in this year, make sure an 'initial' exists; else create one. - $hasAnyThisYear = $this->movModel - ->where('item_id', $itemId) - ->where('school_year', $schoolYear) - ->limit(1)->countAllResults(); - - // Already has any movement this year? - if ($hasAnyThisYear === 0) { - // No movement at all this year → opening = current on-hand - $onHand = (int) ($this->itemModel->select('quantity')->where('id', $itemId)->first()['quantity'] ?? 0); - $this->movModel->insert([ - 'item_id' => $itemId, - 'qty_change' => $onHand, - 'movement_type' => 'initial', - 'school_year' => $schoolYear, - 'semester' => $this->semester ?? null, - 'performed_by' => $this->currentUserId(), - ], false); - return; - } - - // We have some movement this year. If there's no 'initial' yet, backfill a correct opening. - $hasInitialThisYear = $this->movModel - ->where('item_id', $itemId) - ->where('school_year', $schoolYear) - ->where('movement_type', 'initial') - ->limit(1)->countAllResults(); - - if ($hasInitialThisYear === 0) { - // Opening = current_on_hand - net_year_movements - $onHand = (int) ($this->itemModel->select('quantity')->where('id', $itemId)->first()['quantity'] ?? 0); - $netYear = (int) ($this->movModel->selectSum('qty_change') - ->where('item_id', $itemId) - ->where('school_year', $schoolYear) - ->first()['qty_change'] ?? 0); - - $opening = $onHand - $netYear; // reconstruct beginning-of-year truth - $this->movModel->insert([ - 'item_id' => $itemId, - 'qty_change' => $opening, - 'movement_type' => 'initial', - 'school_year' => $schoolYear, - 'semester' => $this->semester ?? null, - 'performed_by' => $this->currentUserId(), - ], false); - } - } - - public function create(string $type = 'classroom') - { - $categories = $this->catModel->optionsForType($type); - return view("inventory/{$type}/form", [ - 'type' => $type, - 'item' => [], // important for create - 'categories' => $categories, - 'conditionOptions' => [ - 'good' => 'Good condition', - 'needs_repair' => 'Needs repair', - 'need_replace' => 'Need replace', - 'cannot_find' => 'Cannot find', - ], - ]); - } - - public function summaryAll() - { - // -------- Inputs -------- - $selectedYear = trim((string) $this->request->getGet('school_year')) ?: (string) $this->schoolYear; - $selectedType = strtolower((string) ($this->request->getGet('type') ?? 'all')); // optional: all|book|classroom|office|kitchen - $isAllYears = (strtolower($selectedYear) === 'all'); - - // -------- Items query (single source of truth) -------- - $qbItems = $this->db->table('inventory_items i') - ->select('i.*, c.name AS category_name, CONCAT(u.firstname, " ", u.lastname) AS updated_by_name') - ->join('inventory_categories c', 'c.id = i.category_id', 'left') - ->join('users u', 'u.id = i.updated_by', 'left') - ->orderBy('i.name', 'ASC'); - - // Filter by school year unless "all" - if (!$isAllYears) { - $qbItems->where('i.school_year', $selectedYear); - } - - // Optional: filter by type if provided - if (in_array($selectedType, ['book', 'classroom', 'office', 'kitchen'], true)) { - $qbItems->where('i.type', $selectedType); - } - - $items = $qbItems->get()->getResultArray(); - - // -------- Prepare output structures -------- - $rows = []; - $totals = [ - 'opening' => 0, - 'received' => 0, - 'distributed' => 0, - 'other_out' => 0, - 'adjust_net' => 0, - 'ending' => 0, - 'onhand' => 0, - 'variance' => 0, - ]; - - // If no items, render an empty table gracefully - if (empty($items)) { - return view('inventory/summary', [ - 'selectedYear' => $selectedYear, - 'currentYear' => $this->schoolYear, - 'schoolYears' => $this->getSchoolYearsForType('book') ?: [$this->schoolYear, 'All'], - 'rows' => $rows, - 'totals' => $totals, - // optionally pass the selected type if your view supports it - 'selectedType' => $selectedType, - ]); - } - - // -------- Aggregate movements for the fetched items -------- - $itemIds = array_map('intval', array_column($items, 'id')); - - $aggById = []; - $qb = $this->db->table('inventory_movements m') - ->select(" - 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()->getResultArray() 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), - ]; - } - - // -------- Build rows + totals -------- - 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']; - $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; - } - - // -------- Year dropdown options -------- - $schoolYears = $this->getSchoolYearsForType('book') ?: [$this->schoolYear, 'All']; - - return view('inventory/summary', [ - 'selectedYear' => $selectedYear, - 'currentYear' => $this->schoolYear, - 'schoolYears' => $schoolYears, - 'rows' => $rows, - 'totals' => $totals, - 'selectedType' => $selectedType, - ]); - } - - /** ========================= - * LIST - * ========================= */ - public function movementsIndex() - { - // Academic filters - $selectedYearRaw = $this->request->getGet('school_year'); - $selectedSemRaw = $this->request->getGet('semester'); - $selectedYear = $selectedYearRaw === null ? (string) $this->schoolYear : trim((string) $selectedYearRaw); - $selectedSem = $selectedSemRaw === null ? (string) $this->semester : trim((string) $selectedSemRaw); - - // Pull movements with friendly names when possible - $builder = $this->db->table('inventory_movements m') - ->select([ - 'm.*', - 'ii.name AS item_name', - - // Performed by (users) - "CONCAT(pb.firstname, ' ', pb.lastname) AS performed_by_name", - - // Teacher (users) - "CONCAT(t.firstname, ' ', t.lastname) AS teacher_name", - - // Student (students) - "CONCAT(s.firstname, ' ', s.lastname) AS student_name", - - // Class section - 'cs.class_section_name', - ]) - ->join('inventory_items ii', 'ii.id = m.item_id', 'left') - ->join('users pb', 'pb.id = m.performed_by', 'left') - ->join('users t', 't.id = m.teacher_id', 'left') - ->join('students s', 's.id = m.student_id', 'left') - ->join('classSection cs', 'cs.class_section_id = m.class_section_id', 'left') - ->orderBy('m.id', 'DESC'); - - if ($selectedYear !== '') $builder->where('m.school_year', $selectedYear); - if ($selectedSem !== '') $builder->where('m.semester', $selectedSem); - - $movements = $builder->get()->getResultArray(); - - return view('inventory/movements/index', [ - 'movements' => $movements, - 'schoolYear'=> $selectedYear, - 'semester' => $selectedSem, - ]); - } - - /** ========================= - * CREATE (form) - * ========================= */ - public function movementsCreate() - { - return view('inventory/movements/form', [ - 'movement' => $this->blankMovement(), - 'movementTypes' => $this->movementTypes, - 'semesters' => $this->semesters, - 'items' => $this->getItems(), - 'classSections' => $this->getClassSections(), - 'teachers' => $this->getTeachers(), - 'students' => $this->getStudents(), - 'action' => site_url('inventory/movements/store'), - 'title' => 'Create Inventory Movement', - 'isCreate' => true, - ]); - } - - /** ========================= - * STORE (POST) - * ========================= */ - public function movementsStore() - { - $rules = [ - 'item_id' => 'required|is_natural_no_zero', - 'qty_change' => 'required|integer', - 'movement_type' => 'required|in_list[' . implode(',', $this->movementTypes) . ']', - 'reason' => 'permit_empty|max_length[120]', - 'note' => 'permit_empty|string', - 'semester' => 'permit_empty|in_list[' . implode(',', $this->semesters) . ']', - 'school_year' => 'permit_empty|max_length[16]', - 'teacher_id' => 'permit_empty|is_natural_no_zero', - 'student_id' => 'permit_empty|is_natural_no_zero', - 'class_section_id' => 'permit_empty|is_natural_no_zero', - ]; - - if (! $this->validate($rules)) { - return redirect()->back()->withInput()->with('status', 'error') - ->with('message', implode(' ', $this->validator->getErrors())); - } - - $userId = (int) (session('user_id') ?? 0); - $itemId = (int) $this->request->getPost('item_id'); - $movementType = (string) $this->request->getPost('movement_type'); - - $item = $this->itemModel->find($itemId); - if (!$item) { - return redirect()->back()->withInput()->with('status', 'error') - ->with('message', 'Selected item not found.'); - } - - $qtyChange = $this->normalizeMovementQty($movementType, (int) $this->request->getPost('qty_change')); - if ($this->wouldGoNegative($itemId, $qtyChange)) { - return redirect()->back()->withInput(); - } - - $semester = trim((string) $this->request->getPost('semester')); - $schoolYear = trim((string) $this->request->getPost('school_year')); - if ($semester === '') $semester = (string) $this->semester; - if ($schoolYear === '') $schoolYear = (string) $this->schoolYear; - - $data = [ - 'item_id' => $itemId, - 'qty_change' => $qtyChange, - 'movement_type' => $movementType, - 'reason' => trim((string)$this->request->getPost('reason')), - 'note' => trim((string)$this->request->getPost('note')), - 'semester' => $semester ?: null, - 'school_year' => $schoolYear ?: null, - 'performed_by' => $userId ?: null, - 'teacher_id' => $this->nullableInt($this->request->getPost('teacher_id')), - 'student_id' => $this->nullableInt($this->request->getPost('student_id')), - 'class_section_id' => $this->nullableInt($this->request->getPost('class_section_id')), - 'created_at' => utc_now(), - 'updated_at' => utc_now(), - ]; - - $ok = $this->db->table('inventory_movements')->insert($data); - - if (! $ok) { - return redirect()->back()->withInput()->with('status', 'error') - ->with('message', 'Could not save movement.'); - } - - $this->recalcQuantity($itemId); - - return redirect()->to(site_url('inventory/movements')) - ->with('status', 'success') - ->with('message', 'Movement created.'); - } - - /** ========================= - * EDIT (form) - * ========================= */ - public function movementsEdit(int $id) - { - $movement = $this->db->table('inventory_movements')->where('id', $id)->get()->getRowArray(); - if (!$movement) { - return redirect()->to(site_url('inventory/movements')) - ->with('status', 'error')->with('message', 'Movement not found.'); - } - - // enrich for display (names) - $movement = $this->hydrateNames($movement); - - return view('inventory/movements/form', [ - 'movement' => $movement, // already hydrated by your helper - 'movementTypes' => $this->movementTypes, - 'semesters' => $this->semesters, - 'items' => $this->getItems(), - 'classSections' => $this->getClassSections(), - 'teachers' => $this->getTeachers(), - 'students' => $this->getStudents(), - 'action' => site_url('inventory/movements/update/' . $id), - 'title' => 'Edit Inventory Movement', - 'isCreate' => false, - ]); - } - - /** ========================= - * UPDATE (POST) - * ========================= */ - public function movementsUpdate(int $id) - { - $rules = [ - 'qty_change' => 'required|integer', - 'movement_type' => 'required|in_list[' . implode(',', $this->movementTypes) . ']', - 'reason' => 'permit_empty|max_length[120]', - 'note' => 'permit_empty|string', - 'semester' => 'permit_empty|in_list[' . implode(',', $this->semesters) . ']', - 'school_year' => 'permit_empty|max_length[16]', - 'teacher_id' => 'permit_empty|is_natural_no_zero', - 'student_id' => 'permit_empty|is_natural_no_zero', - 'class_section_id' => 'permit_empty|is_natural_no_zero', - ]; - - if (! $this->validate($rules)) { - return redirect()->back()->withInput()->with('status', 'error') - ->with('message', implode(' ', $this->validator->getErrors())); - } - - $existing = $this->db->table('inventory_movements')->where('id', $id)->get()->getRowArray(); - if (!$existing) { - return redirect()->to(site_url('inventory/movements')) - ->with('status', 'error')->with('message', 'Movement not found.'); - } - - $movementType = (string) $this->request->getPost('movement_type'); - $qtyChange = $this->normalizeMovementQty($movementType, (int) $this->request->getPost('qty_change')); - - $itemId = (int) ($existing['item_id'] ?? 0); - if ($itemId > 0) { - $delta = $qtyChange - (int) ($existing['qty_change'] ?? 0); - if ($delta !== 0 && $this->wouldGoNegative($itemId, $delta)) { - return redirect()->back()->withInput(); - } - } - - $semester = trim((string) $this->request->getPost('semester')); - $schoolYear = trim((string) $this->request->getPost('school_year')); - if ($semester === '') $semester = (string) $this->semester; - if ($schoolYear === '') $schoolYear = (string) $this->schoolYear; - - $data = [ - // item_id is often immutable after posting history; include if you want to allow editing: - // 'item_id' => (int) $this->request->getPost('item_id'), - 'qty_change' => $qtyChange, - 'movement_type' => $movementType, - 'reason' => trim((string)$this->request->getPost('reason')), - 'note' => trim((string)$this->request->getPost('note')), - 'semester' => $semester ?: null, - 'school_year' => $schoolYear ?: null, - 'teacher_id' => $this->nullableInt($this->request->getPost('teacher_id')), - 'student_id' => $this->nullableInt($this->request->getPost('student_id')), - 'class_section_id' => $this->nullableInt($this->request->getPost('class_section_id')), - 'updated_at' => utc_now(), - ]; - - $ok = $this->db->table('inventory_movements')->where('id', $id)->update($data); - - if (! $ok) { - return redirect()->back()->withInput()->with('status', 'error') - ->with('message', 'Could not update movement.'); - } - - if ($itemId > 0) { - $this->recalcQuantity($itemId); - } - - return redirect()->to(site_url('inventory/movements')) - ->with('status', 'success') - ->with('message', 'Movement updated.'); - } - - /** ========================= - * DELETE (POST) - * ========================= */ - public function movementsDelete(int $id) - { - // Optional: authorization checks here - log_message('info', 'Inventory: delete one attempt', ['id' => $id, 'user' => (int)(session('user_id') ?? 0)]); - $movement = $this->db->table('inventory_movements')->select('id, item_id')->where('id', $id)->get()->getRowArray(); - if (!$movement) { - return redirect()->back()->with('status', 'error')->with('message', 'Movement not found.'); - } - - $ok = $this->db->table('inventory_movements')->where('id', $id)->delete(); - log_message('info', 'Inventory: delete one result', ['id' => $id, 'ok' => (bool)$ok, 'affected' => $this->db->affectedRows()]); - - if (! $ok) { - return redirect()->back()->with('status', 'error') - ->with('message', 'Could not delete movement.'); - } - - if (!empty($movement['item_id'])) { - $this->recalcQuantity((int) $movement['item_id']); - } - return redirect()->to(site_url('inventory/movements')) - ->with('status', 'success')->with('message', 'Movement deleted.'); - } - - /* =========================== - * Helpers - * =========================== */ - - /** Default structure for create form */ - private function blankMovement(): array - { - return [ - 'id' => null, - 'item_id' => null, - 'qty_change' => 0, - 'movement_type' => 'adjust', - 'reason' => '', - 'note' => '', - 'semester' => null, - 'school_year' => null, - 'performed_by' => session('user_id') ?? null, - 'teacher_id' => null, - 'student_id' => null, - 'class_section_id' => null, - 'created_at' => null, - 'updated_at' => null, - ]; - } - - /** Coerce to nullable int */ - private function nullableInt($val): ?int - { - if ($val === null || $val === '' || $val === '0') { - return null; - } - return (int) $val; - } - - 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) ($this->itemModel->select('quantity')->where('id', $itemId)->first()['quantity'] ?? 0); - if ($onHand + $delta < 0) { - session()->setFlashdata('status', 'error'); - session()->setFlashdata('message', 'Not enough stock to apply this movement.'); - return true; - } - return false; - } - - /** Hydrate display names for a row (used in edit) */ - private function hydrateNames(array $m): array - { - // Item - if (!empty($m['item_id'])) { - $row = $this->db->table('inventory_items')->select('name')->where('id', $m['item_id'])->get()->getRowArray(); - $m['item_name'] = $row['name'] ?? null; - } - - // Performed By (users) - if (!empty($m['performed_by'])) { - $row = $this->db->table('users') - ->select("CONCAT(firstname, ' ', lastname) AS fullname") - ->where('id', $m['performed_by']) - ->get()->getRowArray(); - $m['performed_by_name'] = $row['fullname'] ?? null; - } - - // Teacher (users) - if (!empty($m['teacher_id'])) { - $row = $this->db->table('users') - ->select("CONCAT(firstname, ' ', lastname) AS fullname") - ->where('id', $m['teacher_id']) - ->get()->getRowArray(); - $m['teacher_name'] = $row['fullname'] ?? null; - } - - // Student (students) - if (!empty($m['student_id'])) { - $row = $this->db->table('students') - ->select("CONCAT(firstname, ' ', lastname) AS fullname") - ->where('id', $m['student_id']) - ->get()->getRowArray(); - $m['student_name'] = $row['fullname'] ?? null; - } - - // Class section (by CODE) - if (!empty($m['class_section_id'])) { - $row = $this->db->table('classSection') - ->select('class_section_name') - ->where('class_section_id', $m['class_section_id']) - ->get()->getRowArray(); - $m['class_section_name'] = $row['class_section_name'] ?? null; - } - - return $m; - } - - private function getTeachers(): array - { - $rows = $this->teacherModel->getTeachersAndTAs(); - - // Normalize to include a ready-to-use fullname - return array_map(static function ($r) { - $fullname = trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? '')); - return [ - 'id' => (int) $r['id'], - 'firstname' => $r['firstname'] ?? '', - 'lastname' => $r['lastname'] ?? '', - 'fullname' => $fullname, - 'email' => $r['email'] ?? '', - 'cellphone' => $r['cellphone'] ?? '', - 'role' => $r['role'] ?? '', - ]; - }, $rows); - } - - - /** Dropdowns */ - private function getItems(): array - { - return $this->db->table('inventory_items')->select('id, name')->orderBy('name', 'ASC')->get()->getResultArray(); - } - private function getClassSections(): array - { - return $this->db->table('classSection') - ->select('class_section_id, class_section_name') - ->orderBy('class_section_name', 'ASC')->get()->getResultArray(); - } - - - private function getStudents(): array -{ - // Returns: id, fullname - return $this->db->table('students') - ->select("id, CONCAT(firstname, ' ', lastname) AS fullname", false) - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->get() - ->getResultArray(); -} - - public function movementsBulkDelete() - { - $ids = (array) $this->request->getPost('ids'); - - // sanitize to ints and drop invalids - $ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0)); - - if (empty($ids)) { - log_message('warning', 'Inventory: bulk delete called with empty ids', ['user' => (int)(session('user_id') ?? 0)]); - return redirect()->back()->with('status','error')->with('message','No movements selected.'); - } - - log_message('info', 'Inventory: bulk delete attempt', ['count' => count($ids), 'ids' => $ids, 'user' => (int)(session('user_id') ?? 0)]); - $itemIds = $this->db->table('inventory_movements') - ->select('item_id') - ->whereIn('id', $ids) - ->get()->getResultArray(); - $itemIds = array_values(array_unique(array_filter(array_map( - static fn($r) => (int) ($r['item_id'] ?? 0), - $itemIds - )))); - - $ok = $this->db->table('inventory_movements')->whereIn('id', $ids)->delete(); - log_message('info', 'Inventory: bulk delete result', ['ok' => (bool)$ok, 'affected' => $this->db->affectedRows()]); - - if (! $ok) { - return redirect()->back()->with('status','error')->with('message','Bulk delete failed.'); - } - - foreach ($itemIds as $itemId) { - $this->recalcQuantity($itemId); - } - - return redirect()->to(site_url('inventory/movements')) - ->with('status','success') - ->with('message', count($ids) . ' movement(s) deleted.'); -} - -} diff --git a/app/old/SupplierController.php b/app/old/SupplierController.php deleted file mode 100644 index 7f58731c..00000000 --- a/app/old/SupplierController.php +++ /dev/null @@ -1,77 +0,0 @@ -model = new SupplierModel(); - } - - public function index() - { - $q = trim($this->request->getGet('q') ?? ''); - $builder = $this->model; - if ($q !== '') { - $builder = $builder->groupStart() - ->like('name', $q)->orLike('email', $q)->orLike('phone', $q) - ->groupEnd(); - } - return view('inventory/suppliers_index', [ - 'suppliers' => $builder->orderBy('name')->paginate(20), - 'pager' => $this->model->pager, - 'q' => $q, - ]); - } - - public function create() - { - return view('inventory/suppliers_form', [ - 'title' => 'Add Supplier', - 'action' => site_url('inventory/suppliers/store'), - 'supplier' => null, - ]); - } - - public function store() - { - $data = $this->request->getPost(['name','email','phone','address','notes']); - if (!$this->model->save($data)) { - return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors())); - } - return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier saved.'); - } - - public function edit($id) - { - $s = $this->model->find($id); - if (!$s) return redirect()->to('inventory/suppliers')->with('error', 'Not found.'); - return view('inventory/suppliers_form', [ - 'title' => 'Edit Supplier', - 'action' => site_url('inventory/suppliers/update/'.$id), - 'supplier' => $s, - ]); - } - - public function update($id) - { - $data = $this->request->getPost(['name','email','phone','address','notes']); - $data['id'] = $id; - if (!$this->model->save($data)) { - return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors())); - } - return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier updated.'); - } - - public function delete($id) - { - $this->model->delete($id); - return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier deleted.'); - } -} diff --git a/app/old/SupplyCategoryController.php b/app/old/SupplyCategoryController.php deleted file mode 100644 index fe358e78..00000000 --- a/app/old/SupplyCategoryController.php +++ /dev/null @@ -1,69 +0,0 @@ -model = new SupplyCategoryModel(); - } - - public function index() - { - return view('inventory/categories_index', [ - 'categories' => $this->model->orderBy('name')->findAll(), - ]); - } - - public function create() - { - return view('inventory/categories_form', [ - 'title' => 'Add Category', - 'action' => site_url('inventory/categories/store'), - 'category' => null, - ]); - } - - public function store() - { - $data = $this->request->getPost(['name']); - if (!$this->model->save($data)) { - return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors())); - } - return redirect()->to(site_url('inventory/categories'))->with('success', 'Category saved.'); - } - - public function edit($id) - { - $cat = $this->model->find($id); - if (!$cat) return redirect()->to('inventory/categories')->with('error', 'Not found.'); - return view('inventory/categories_form', [ - 'title' => 'Edit Category', - 'action' => site_url('inventory/categories/update/'.$id), - 'category' => $cat, - ]); - } - - public function update($id) - { - $data = $this->request->getPost(['name']); - $data['id'] = (int) $id; - if (!$this->model->save($data)) { - return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors())); - } - return redirect()->to(site_url('inventory/categories'))->with('success', 'Category updated.'); - } - - public function delete($id) - { - $this->model->delete($id); - return redirect()->to(site_url('inventory/categories'))->with('success', 'Category deleted.'); - } -} diff --git a/database/migrations/2026_03_09_070050_create_supply_categories_table.php b/database/migrations/2026_03_09_070050_create_supply_categories_table.php new file mode 100644 index 00000000..aa8d44cd --- /dev/null +++ b/database/migrations/2026_03_09_070050_create_supply_categories_table.php @@ -0,0 +1,22 @@ +id(); + $table->string('name')->unique(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('supply_categories'); + } +}; diff --git a/database/migrations/2026_03_09_070060_add_fields_to_suppliers_table.php b/database/migrations/2026_03_09_070060_add_fields_to_suppliers_table.php new file mode 100644 index 00000000..70d41362 --- /dev/null +++ b/database/migrations/2026_03_09_070060_add_fields_to_suppliers_table.php @@ -0,0 +1,44 @@ +string('email')->nullable()->after('name'); + } + if (!Schema::hasColumn('suppliers', 'phone')) { + $table->string('phone')->nullable()->after('email'); + } + if (!Schema::hasColumn('suppliers', 'address')) { + $table->string('address')->nullable()->after('phone'); + } + if (!Schema::hasColumn('suppliers', 'notes')) { + $table->text('notes')->nullable()->after('address'); + } + }); + } + + public function down(): void + { + Schema::table('suppliers', function (Blueprint $table) { + if (Schema::hasColumn('suppliers', 'notes')) { + $table->dropColumn('notes'); + } + if (Schema::hasColumn('suppliers', 'address')) { + $table->dropColumn('address'); + } + if (Schema::hasColumn('suppliers', 'phone')) { + $table->dropColumn('phone'); + } + if (Schema::hasColumn('suppliers', 'email')) { + $table->dropColumn('email'); + } + }); + } +}; diff --git a/resources/docs/openapi.json b/resources/docs/openapi.json index 5a3575c2..524eb3ae 100755 --- a/resources/docs/openapi.json +++ b/resources/docs/openapi.json @@ -1310,7 +1310,7 @@ } } }, - "/api/login-activity": { + "/api/v1/users/login-activity": { "get": { "operationId": "getLoginActivity", "tags": ["Users"], diff --git a/resources/views/docs/swagger.blade.php b/resources/views/docs/swagger.blade.php index a876d01b..7d3fcdc1 100755 --- a/resources/views/docs/swagger.blade.php +++ b/resources/views/docs/swagger.blade.php @@ -19,6 +19,8 @@ window.addEventListener('load', () => { SwaggerUIBundle({ url: "{{ url('/api/documentation/swagger.json') }}", dom_id: '#swagger-ui', + operationsSorter: 'alpha', + tagsSorter: 'alpha', docExpansion: 'none', defaultModelsExpandDepth: -1, persistAuthorization: true diff --git a/routes/api.php b/routes/api.php index dfc46d98..1e674c4f 100755 --- a/routes/api.php +++ b/routes/api.php @@ -51,6 +51,8 @@ use App\Http\Controllers\Api\System\SemesterRangeController; use App\Http\Controllers\Api\Scores\HomeworkController; use App\Http\Controllers\Api\Frontend\InfoIconController; use App\Http\Controllers\Api\Inventory\InventoryController; +use App\Http\Controllers\Api\Inventory\InventoryCategoryController; +use App\Http\Controllers\Api\Inventory\InventoryMovementController; use App\Http\Controllers\Api\Auth\IpBanController; use App\Http\Controllers\Api\Frontend\LandingPageController; use App\Http\Controllers\Api\Attendance\LateSlipLogsController; @@ -86,6 +88,7 @@ use App\Http\Controllers\Api\Auth\SessionTimeoutController; use App\Http\Controllers\Api\Reports\SlipPrinterController; use App\Http\Controllers\Api\Staff\StaffController; use App\Http\Controllers\Api\Inventory\SupplierController; +use App\Http\Controllers\Api\Inventory\SupplyCategoryController; use App\Http\Controllers\Api\Staff\TeacherController; use App\Http\Controllers\Api\StatsController; use App\Http\Controllers\Api\Frontend\UiController; @@ -306,6 +309,41 @@ Route::prefix('v1')->group(function () { Route::post('send', [CommunicationController::class, 'send']); }); + Route::prefix('inventory')->group(function () { + Route::get('items', [InventoryController::class, 'index']); + Route::get('items/{id}', [InventoryController::class, 'show']); + Route::post('items', [InventoryController::class, 'store']); + Route::patch('items/{id}', [InventoryController::class, 'update']); + Route::delete('items/{id}', [InventoryController::class, 'destroy']); + Route::post('items/{id}/audit', [InventoryController::class, 'audit']); + Route::post('items/{id}/adjust', [InventoryController::class, 'adjust']); + Route::get('summary/{type}', [InventoryController::class, 'summary']); + Route::get('summary-all', [InventoryController::class, 'summaryAll']); + Route::get('teacher-distribution', [InventoryController::class, 'teacherDistribution']); + Route::post('teacher-distribution', [InventoryController::class, 'teacherDistributionStore']); + + Route::get('categories', [InventoryCategoryController::class, 'index']); + Route::post('categories', [InventoryCategoryController::class, 'store']); + Route::patch('categories/{id}', [InventoryCategoryController::class, 'update']); + Route::delete('categories/{id}', [InventoryCategoryController::class, 'destroy']); + + Route::get('movements', [InventoryMovementController::class, 'index']); + Route::post('movements', [InventoryMovementController::class, 'store']); + Route::patch('movements/{id}', [InventoryMovementController::class, 'update']); + Route::delete('movements/{id}', [InventoryMovementController::class, 'destroy']); + Route::post('movements/bulk-delete', [InventoryMovementController::class, 'bulkDelete']); + + Route::get('suppliers', [SupplierController::class, 'index']); + Route::post('suppliers', [SupplierController::class, 'store']); + Route::patch('suppliers/{id}', [SupplierController::class, 'update']); + Route::delete('suppliers/{id}', [SupplierController::class, 'destroy']); + + Route::get('supply-categories', [SupplyCategoryController::class, 'index']); + Route::post('supply-categories', [SupplyCategoryController::class, 'store']); + Route::patch('supply-categories/{id}', [SupplyCategoryController::class, 'update']); + Route::delete('supply-categories/{id}', [SupplyCategoryController::class, 'destroy']); + }); + Route::prefix('family-admin')->group(function () { Route::get('/', [FamilyAdminController::class, 'index']); Route::get('search', [FamilyAdminController::class, 'search']); diff --git a/school_api b/school_api index 0663b112..29209878 100644 Binary files a/school_api and b/school_api differ diff --git a/tests/Feature/Api/V1/Inventory/InventoryCategoriesControllerTest.php b/tests/Feature/Api/V1/Inventory/InventoryCategoriesControllerTest.php new file mode 100644 index 00000000..f36cdb71 --- /dev/null +++ b/tests/Feature/Api/V1/Inventory/InventoryCategoriesControllerTest.php @@ -0,0 +1,109 @@ +seedUser(); + $this->seedCategory('classroom'); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/inventory/categories?type=classroom'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.categories')); + } + + public function test_store_creates_category(): void + { + $user = $this->seedUser(); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/inventory/categories', [ + 'type' => 'office', + 'name' => 'Supplies', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('inventory_categories', ['name' => 'Supplies']); + } + + public function test_update_changes_category(): void + { + $user = $this->seedUser(); + $categoryId = $this->seedCategory('office'); + + Sanctum::actingAs($user); + $response = $this->patchJson('/api/v1/inventory/categories/' . $categoryId, [ + 'name' => 'Updated', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('inventory_categories', ['id' => $categoryId, 'name' => 'Updated']); + } + + public function test_destroy_deletes_category(): void + { + $user = $this->seedUser(); + $categoryId = $this->seedCategory('office'); + + Sanctum::actingAs($user); + $response = $this->deleteJson('/api/v1/inventory/categories/' . $categoryId); + + $response->assertOk(); + $this->assertDatabaseMissing('inventory_categories', ['id' => $categoryId]); + } + + public function test_validation_fails(): void + { + $user = $this->seedUser(); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/inventory/categories', []); + + $response->assertStatus(422); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'User', + 'lastname' => 'One', + 'cellphone' => '9999999999', + 'email' => 'user@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedCategory(string $type): int + { + return DB::table('inventory_categories')->insertGetId([ + 'type' => $type, + 'name' => ucfirst($type) . ' Category', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Feature/Api/V1/Inventory/InventoryItemsControllerTest.php b/tests/Feature/Api/V1/Inventory/InventoryItemsControllerTest.php new file mode 100644 index 00000000..4250285a --- /dev/null +++ b/tests/Feature/Api/V1/Inventory/InventoryItemsControllerTest.php @@ -0,0 +1,339 @@ +seedUser(); + $this->seedConfig(); + $categoryId = $this->seedInventoryCategory('classroom'); + $this->seedInventoryItem($categoryId); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/inventory/items?type=classroom'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.items')); + } + + public function test_show_returns_item(): void + { + $user = $this->seedUser(); + $this->seedConfig(); + $categoryId = $this->seedInventoryCategory('classroom'); + $itemId = $this->seedInventoryItem($categoryId); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/inventory/items/' . $itemId); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $response->assertJsonPath('data.id', $itemId); + } + + public function test_store_creates_item(): void + { + $user = $this->seedUser(); + $this->seedConfig(); + $categoryId = $this->seedInventoryCategory('classroom'); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/inventory/items', [ + 'type' => 'classroom', + 'category_id' => $categoryId, + 'name' => 'Markers', + 'quantity' => 5, + 'condition' => 'good', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('inventory_items', ['name' => 'Markers']); + $this->assertDatabaseHas('inventory_movements', ['movement_type' => 'initial']); + } + + public function test_update_changes_item(): void + { + $user = $this->seedUser(); + $this->seedConfig(); + $categoryId = $this->seedInventoryCategory('classroom'); + $itemId = $this->seedInventoryItem($categoryId); + + Sanctum::actingAs($user); + $response = $this->patchJson('/api/v1/inventory/items/' . $itemId, [ + 'name' => 'Updated Item', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('inventory_items', ['id' => $itemId, 'name' => 'Updated Item']); + } + + public function test_destroy_deletes_item(): void + { + $user = $this->seedUser(); + $this->seedConfig(); + $categoryId = $this->seedInventoryCategory('classroom'); + $itemId = $this->seedInventoryItem($categoryId); + + Sanctum::actingAs($user); + $response = $this->deleteJson('/api/v1/inventory/items/' . $itemId); + + $response->assertOk(); + $this->assertDatabaseMissing('inventory_items', ['id' => $itemId]); + } + + public function test_audit_updates_classroom_item(): void + { + $user = $this->seedUser(); + $this->seedConfig(); + $categoryId = $this->seedInventoryCategory('classroom'); + $itemId = $this->seedInventoryItem($categoryId); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/inventory/items/' . $itemId . '/audit', [ + 'needs_repair_qty' => 1, + 'need_replace_qty' => 1, + 'cannot_find_qty' => 0, + ]); + + $response->assertOk(); + $this->assertDatabaseHas('inventory_items', ['id' => $itemId, 'needs_repair_qty' => 1]); + } + + public function test_adjust_creates_movement(): void + { + $user = $this->seedUser(); + $this->seedConfig(); + $categoryId = $this->seedInventoryCategory('classroom'); + $itemId = $this->seedInventoryItem($categoryId, 0); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/inventory/items/' . $itemId . '/adjust', [ + 'mode' => 'in', + 'quantity' => 2, + 'reason' => 'Restock', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('inventory_movements', ['item_id' => $itemId, 'movement_type' => 'in']); + } + + public function test_summary_returns_aggregates(): void + { + $user = $this->seedUser(); + $this->seedConfig(); + $categoryId = $this->seedInventoryCategory('classroom'); + $this->seedInventoryItem($categoryId); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/inventory/summary/classroom'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.items')); + } + + public function test_summary_all_returns_rows(): void + { + $user = $this->seedUser(); + $this->seedConfig(); + $categoryId = $this->seedInventoryCategory('classroom'); + $this->seedInventoryItem($categoryId); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/inventory/summary-all'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + } + + public function test_teacher_distribution_endpoints(): void + { + $user = $this->seedUser(); + $this->seedConfig(); + $this->seedTeacherRole($user->id); + $classSectionId = $this->seedClassSection(); + $this->seedTeacherClass($user->id, $classSectionId); + $studentId = $this->seedStudent(100, $user->id); + $this->seedStudentClass($studentId, $classSectionId); + $categoryId = $this->seedInventoryCategory('book'); + $bookId = $this->seedInventoryItem($categoryId, 5, 'book'); + + Sanctum::actingAs($user); + $formResponse = $this->getJson('/api/v1/inventory/teacher-distribution?class_section_id=' . $classSectionId . '&item_id=' . $bookId); + $formResponse->assertOk(); + + $storeResponse = $this->postJson('/api/v1/inventory/teacher-distribution', [ + 'item_id' => $bookId, + 'class_section_id' => $classSectionId, + 'student_ids' => [$studentId], + ]); + $storeResponse->assertOk(); + $this->assertDatabaseHas('inventory_movements', ['item_id' => $bookId, 'movement_type' => 'distribution']); + } + + public function test_validation_fails_for_store(): void + { + $user = $this->seedUser(); + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/inventory/items', []); + + $response->assertStatus(422); + $response->assertJsonPath('message', 'Validation failed.'); + } + + public function test_requires_authentication(): void + { + $response = $this->getJson('/api/v1/inventory/items'); + + $response->assertStatus(401); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Teacher', + 'lastname' => 'User', + 'cellphone' => '9999999999', + 'email' => 'teacher@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedInventoryCategory(string $type): int + { + return DB::table('inventory_categories')->insertGetId([ + 'type' => $type, + 'name' => ucfirst($type) . ' Category', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedInventoryItem(int $categoryId, int $quantity = 3, string $type = 'classroom'): int + { + return DB::table('inventory_items')->insertGetId([ + 'type' => $type, + 'category_id' => $categoryId, + 'name' => 'Item ' . $type, + 'quantity' => $quantity, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedTeacherRole(int $userId): void + { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'teacher', + 'dashboard_route' => 'teacher', + 'priority' => 1, + 'is_active' => 1, + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $userId, + 'role_id' => $roleId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedClassSection(): int + { + DB::table('classes')->insert([ + 'id' => 1, + 'class_name' => 'Grade 1', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('classSection')->insert([ + 'class_section_id' => 200, + 'class_section_name' => 'Grade 1', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return 200; + } + + private function seedTeacherClass(int $userId, int $classSectionId): void + { + DB::table('teacher_class')->insert([ + 'teacher_id' => $userId, + 'class_section_id' => $classSectionId, + 'position' => 'main', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedStudent(int $id, int $parentId): int + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => 'SCH1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 10, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025-2026', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_active' => 1, + ]); + + return $id; + } + + private function seedStudentClass(int $studentId, int $classSectionId): void + { + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_event_only' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Feature/Api/V1/Inventory/InventoryMovementsControllerTest.php b/tests/Feature/Api/V1/Inventory/InventoryMovementsControllerTest.php new file mode 100644 index 00000000..6989113b --- /dev/null +++ b/tests/Feature/Api/V1/Inventory/InventoryMovementsControllerTest.php @@ -0,0 +1,146 @@ +seedUser(); + $itemId = $this->seedItem(); + $this->seedMovement($itemId); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/inventory/movements'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.movements')); + } + + public function test_store_creates_movement(): void + { + $user = $this->seedUser(); + $itemId = $this->seedItem(); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/inventory/movements', [ + 'item_id' => $itemId, + 'qty_change' => 2, + 'movement_type' => 'in', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('inventory_movements', ['item_id' => $itemId, 'movement_type' => 'in']); + } + + public function test_update_changes_movement(): void + { + $user = $this->seedUser(); + $itemId = $this->seedItem(); + $movementId = $this->seedMovement($itemId); + + Sanctum::actingAs($user); + $response = $this->patchJson('/api/v1/inventory/movements/' . $movementId, [ + 'qty_change' => 3, + 'movement_type' => 'in', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('inventory_movements', ['id' => $movementId, 'qty_change' => 3]); + } + + public function test_destroy_deletes_movement(): void + { + $user = $this->seedUser(); + $itemId = $this->seedItem(); + $movementId = $this->seedMovement($itemId); + + Sanctum::actingAs($user); + $response = $this->deleteJson('/api/v1/inventory/movements/' . $movementId); + + $response->assertOk(); + $this->assertDatabaseMissing('inventory_movements', ['id' => $movementId]); + } + + public function test_bulk_delete_removes_movements(): void + { + $user = $this->seedUser(); + $itemId = $this->seedItem(); + $first = $this->seedMovement($itemId); + $second = $this->seedMovement($itemId); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/inventory/movements/bulk-delete', [ + 'ids' => [$first, $second], + ]); + + $response->assertOk(); + $this->assertDatabaseMissing('inventory_movements', ['id' => $first]); + $this->assertDatabaseMissing('inventory_movements', ['id' => $second]); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'User', + 'lastname' => 'One', + 'cellphone' => '9999999999', + 'email' => 'movement@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedItem(): int + { + $categoryId = DB::table('inventory_categories')->insertGetId([ + 'type' => 'office', + 'name' => 'Office', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return DB::table('inventory_items')->insertGetId([ + 'type' => 'office', + 'category_id' => $categoryId, + 'name' => 'Printer', + 'quantity' => 10, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedMovement(int $itemId): int + { + return DB::table('inventory_movements')->insertGetId([ + 'item_id' => $itemId, + 'qty_change' => 1, + 'movement_type' => 'in', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Feature/Api/V1/Inventory/SuppliersControllerTest.php b/tests/Feature/Api/V1/Inventory/SuppliersControllerTest.php new file mode 100644 index 00000000..4e926aad --- /dev/null +++ b/tests/Feature/Api/V1/Inventory/SuppliersControllerTest.php @@ -0,0 +1,98 @@ +seedUser(); + $this->seedSupplier('Supplier A'); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/inventory/suppliers'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.suppliers')); + } + + public function test_store_creates_supplier(): void + { + $user = $this->seedUser(); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/inventory/suppliers', [ + 'name' => 'Supplier B', + 'email' => 'b@example.com', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('suppliers', ['name' => 'Supplier B']); + } + + public function test_update_changes_supplier(): void + { + $user = $this->seedUser(); + $supplierId = $this->seedSupplier('Supplier C'); + + Sanctum::actingAs($user); + $response = $this->patchJson('/api/v1/inventory/suppliers/' . $supplierId, [ + 'name' => 'Supplier Updated', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('suppliers', ['id' => $supplierId, 'name' => 'Supplier Updated']); + } + + public function test_destroy_deletes_supplier(): void + { + $user = $this->seedUser(); + $supplierId = $this->seedSupplier('Supplier D'); + + Sanctum::actingAs($user); + $response = $this->deleteJson('/api/v1/inventory/suppliers/' . $supplierId); + + $response->assertOk(); + $this->assertDatabaseMissing('suppliers', ['id' => $supplierId]); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'User', + 'lastname' => 'One', + 'cellphone' => '9999999999', + 'email' => 'supplier@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedSupplier(string $name): int + { + return DB::table('suppliers')->insertGetId([ + 'name' => $name, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Feature/Api/V1/Inventory/SupplyCategoriesControllerTest.php b/tests/Feature/Api/V1/Inventory/SupplyCategoriesControllerTest.php new file mode 100644 index 00000000..aec624fa --- /dev/null +++ b/tests/Feature/Api/V1/Inventory/SupplyCategoriesControllerTest.php @@ -0,0 +1,97 @@ +seedUser(); + $this->seedCategory('Paper'); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/inventory/supply-categories'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.categories')); + } + + public function test_store_creates_category(): void + { + $user = $this->seedUser(); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/inventory/supply-categories', [ + 'name' => 'Cleaning', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('supply_categories', ['name' => 'Cleaning']); + } + + public function test_update_changes_category(): void + { + $user = $this->seedUser(); + $categoryId = $this->seedCategory('Pens'); + + Sanctum::actingAs($user); + $response = $this->patchJson('/api/v1/inventory/supply-categories/' . $categoryId, [ + 'name' => 'Pens Updated', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('supply_categories', ['id' => $categoryId, 'name' => 'Pens Updated']); + } + + public function test_destroy_deletes_category(): void + { + $user = $this->seedUser(); + $categoryId = $this->seedCategory('Markers'); + + Sanctum::actingAs($user); + $response = $this->deleteJson('/api/v1/inventory/supply-categories/' . $categoryId); + + $response->assertOk(); + $this->assertDatabaseMissing('supply_categories', ['id' => $categoryId]); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'User', + 'lastname' => 'One', + 'cellphone' => '9999999999', + 'email' => 'supply@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedCategory(string $name): int + { + return DB::table('supply_categories')->insertGetId([ + 'name' => $name, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/Services/Inventory/InventoryCategoryServiceTest.php b/tests/Unit/Services/Inventory/InventoryCategoryServiceTest.php new file mode 100644 index 00000000..3c6fad94 --- /dev/null +++ b/tests/Unit/Services/Inventory/InventoryCategoryServiceTest.php @@ -0,0 +1,41 @@ +create([ + 'type' => 'office', + 'name' => 'Supplies', + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('inventory_categories', ['name' => 'Supplies']); + } + + public function test_update_category(): void + { + $categoryId = DB::table('inventory_categories')->insertGetId([ + 'type' => 'office', + 'name' => 'Office', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new InventoryCategoryService(); + $result = $service->update($categoryId, ['name' => 'Updated']); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('inventory_categories', ['id' => $categoryId, 'name' => 'Updated']); + } +} diff --git a/tests/Unit/Services/Inventory/InventoryItemServiceTest.php b/tests/Unit/Services/Inventory/InventoryItemServiceTest.php new file mode 100644 index 00000000..56e063d1 --- /dev/null +++ b/tests/Unit/Services/Inventory/InventoryItemServiceTest.php @@ -0,0 +1,78 @@ +seedConfig(); + $categoryId = $this->seedCategory(); + $service = new InventoryItemService(new InventoryContextService(), new InventoryMovementService(new InventoryContextService())); + + $result = $service->create([ + 'type' => 'classroom', + 'category_id' => $categoryId, + 'name' => 'Markers', + 'quantity' => 2, + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('inventory_items', ['name' => 'Markers']); + $this->assertDatabaseHas('inventory_movements', ['movement_type' => 'initial']); + } + + public function test_update_updates_item(): void + { + $this->seedConfig(); + $categoryId = $this->seedCategory(); + $itemId = $this->seedItem($categoryId); + $service = new InventoryItemService(new InventoryContextService(), new InventoryMovementService(new InventoryContextService())); + + $result = $service->update($itemId, ['name' => 'Updated'], 1); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('inventory_items', ['id' => $itemId, 'name' => 'Updated']); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedCategory(): int + { + return DB::table('inventory_categories')->insertGetId([ + 'type' => 'classroom', + 'name' => 'Classroom', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedItem(int $categoryId): int + { + return DB::table('inventory_items')->insertGetId([ + 'type' => 'classroom', + 'category_id' => $categoryId, + 'name' => 'Item', + 'quantity' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/Services/Inventory/InventoryMovementServiceTest.php b/tests/Unit/Services/Inventory/InventoryMovementServiceTest.php new file mode 100644 index 00000000..f5b30e49 --- /dev/null +++ b/tests/Unit/Services/Inventory/InventoryMovementServiceTest.php @@ -0,0 +1,59 @@ +seedConfig(); + $itemId = $this->seedItem(); + $service = new InventoryMovementService(new InventoryContextService()); + + $result = $service->create([ + 'item_id' => $itemId, + 'qty_change' => 2, + 'movement_type' => 'in', + ], 1); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('inventory_movements', ['item_id' => $itemId]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedItem(): int + { + $categoryId = DB::table('inventory_categories')->insertGetId([ + 'type' => 'office', + 'name' => 'Office', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return DB::table('inventory_items')->insertGetId([ + 'type' => 'office', + 'category_id' => $categoryId, + 'name' => 'Printer', + 'quantity' => 10, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/Services/Inventory/InventorySummaryServiceTest.php b/tests/Unit/Services/Inventory/InventorySummaryServiceTest.php new file mode 100644 index 00000000..8b1f7dfe --- /dev/null +++ b/tests/Unit/Services/Inventory/InventorySummaryServiceTest.php @@ -0,0 +1,54 @@ +seedConfig(); + $this->seedItem(); + + $service = new InventorySummaryService(new InventoryContextService()); + $result = $service->summary('classroom', '2025-2026'); + + $this->assertNotEmpty($result['items']); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedItem(): void + { + $categoryId = DB::table('inventory_categories')->insertGetId([ + 'type' => 'classroom', + 'name' => 'Classroom', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('inventory_items')->insert([ + 'type' => 'classroom', + 'category_id' => $categoryId, + 'name' => 'Item', + 'quantity' => 2, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/Services/Inventory/InventoryTeacherDistributionServiceTest.php b/tests/Unit/Services/Inventory/InventoryTeacherDistributionServiceTest.php new file mode 100644 index 00000000..ebe74742 --- /dev/null +++ b/tests/Unit/Services/Inventory/InventoryTeacherDistributionServiceTest.php @@ -0,0 +1,193 @@ +seedConfig(); + $userId = $this->seedUser(); + + $service = new InventoryTeacherDistributionService( + new InventoryContextService(), + new InventoryMovementService(new InventoryContextService()) + ); + + $result = $service->formData($userId, null, null); + + $this->assertFalse($result['ok']); + } + + public function test_distribute_success(): void + { + $this->seedConfig(); + $userId = $this->seedUser(); + $this->seedTeacherRole($userId); + $classSectionId = $this->seedClassSection(); + $this->seedTeacherClass($userId, $classSectionId); + $studentId = $this->seedStudent(100, $userId); + $this->seedStudentClass($studentId, $classSectionId); + $categoryId = $this->seedCategory(); + $bookId = $this->seedItem($categoryId); + + $service = new InventoryTeacherDistributionService( + new InventoryContextService(), + new InventoryMovementService(new InventoryContextService()) + ); + + $result = $service->distribute($userId, [ + 'item_id' => $bookId, + 'class_section_id' => $classSectionId, + 'student_ids' => [$studentId], + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('inventory_movements', ['item_id' => $bookId, 'movement_type' => 'distribution']); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedUser(): int + { + return DB::table('users')->insertGetId([ + 'firstname' => 'Teacher', + 'lastname' => 'User', + 'cellphone' => '9999999999', + 'email' => 'teacher@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + } + + private function seedTeacherRole(int $userId): void + { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'teacher', + 'dashboard_route' => 'teacher', + 'priority' => 1, + 'is_active' => 1, + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $userId, + 'role_id' => $roleId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedClassSection(): int + { + DB::table('classes')->insert([ + 'id' => 1, + 'class_name' => 'Grade 1', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('classSection')->insert([ + 'class_section_id' => 200, + 'class_section_name' => 'Grade 1', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return 200; + } + + private function seedTeacherClass(int $userId, int $classSectionId): void + { + DB::table('teacher_class')->insert([ + 'teacher_id' => $userId, + 'class_section_id' => $classSectionId, + 'position' => 'main', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedStudent(int $id, int $parentId): int + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => 'SCH1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 10, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025-2026', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_active' => 1, + ]); + + return $id; + } + + private function seedStudentClass(int $studentId, int $classSectionId): void + { + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_event_only' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedCategory(): int + { + return DB::table('inventory_categories')->insertGetId([ + 'type' => 'book', + 'name' => 'Books', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedItem(int $categoryId): int + { + return DB::table('inventory_items')->insertGetId([ + 'type' => 'book', + 'category_id' => $categoryId, + 'name' => 'Book', + 'quantity' => 2, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/Services/Inventory/SupplierServiceTest.php b/tests/Unit/Services/Inventory/SupplierServiceTest.php new file mode 100644 index 00000000..a3192461 --- /dev/null +++ b/tests/Unit/Services/Inventory/SupplierServiceTest.php @@ -0,0 +1,40 @@ +create([ + 'name' => 'Supplier A', + 'email' => 'a@example.com', + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('suppliers', ['name' => 'Supplier A']); + } + + public function test_update_supplier(): void + { + $id = DB::table('suppliers')->insertGetId([ + 'name' => 'Supplier B', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new SupplierService(); + $result = $service->update($id, ['name' => 'Supplier Updated']); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('suppliers', ['id' => $id, 'name' => 'Supplier Updated']); + } +} diff --git a/tests/Unit/Services/Inventory/SupplyCategoryServiceTest.php b/tests/Unit/Services/Inventory/SupplyCategoryServiceTest.php new file mode 100644 index 00000000..738b810d --- /dev/null +++ b/tests/Unit/Services/Inventory/SupplyCategoryServiceTest.php @@ -0,0 +1,37 @@ +create(['name' => 'Paper']); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('supply_categories', ['name' => 'Paper']); + } + + public function test_update_category(): void + { + $id = DB::table('supply_categories')->insertGetId([ + 'name' => 'Pens', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new SupplyCategoryService(); + $result = $service->update($id, ['name' => 'Pens Updated']); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('supply_categories', ['id' => $id, 'name' => 'Pens Updated']); + } +}