feat: Add department filtering to Controls, Tests, and Entry pages

Implemented comprehensive department filtering across multiple pages in the QC
system to enable users to filter data by laboratory department.

## Backend Changes

**Models:**
- MasterControlsModel: Enhanced search() method to accept optional dept_id
  parameter, added LEFT JOIN with master_depts to include department info
- MasterTestsModel: Updated search() to support dept_id filtering with JOIN

**Controllers:**
- MasterControlsController: Modified index() to accept and pass dept_id parameter
- MasterTestsController: Modified index() to accept and pass dept_id parameter
- EntryApiController: Updated getControls() to filter by dept_id using model search,
  added debug logging for troubleshooting

## Frontend Changes

**Views Updated:**
1. Controls page (/master/control)
   - Added department dropdown with DaisyUI styling
   - Active filter badge and clear button
   - Fetch controls filtered by selected department
   - Added department field to control form dialog

2. Tests page (/master/test)
   - Added department dropdown with active state indication
   - Filter tests by department
   - Clear button to reset filter

3. Daily Entry page (/entry/daily)
   - Added department dropdown in filter section
   - Resets control selection when department changes
   - Fetches controls and tests filtered by department

4. Monthly Entry page (/entry/monthly)
   - Added department dropdown with month selector
   - Resets test selection when department changes
   - Fetches tests filtered by department

## Key Features

- Dropdown UI shows "All Departments" as default
- Selected department name displayed in dropdown button
- Clear button appears when filter is active
- Active department highlighted in dropdown menu
- Loading state while fetching departments
- Automatic reset of dependent selections when department changes
- Consistent UI pattern across all pages using DaisyUI components

## Bug Fixes

- Fixed syntax error in MasterControlsModel search() method
- Removed duplicate/corrupted code that was causing incorrect results
- Added proper deptName field to SELECT query in MasterControlsModel

## Important Note: Department IDs Required

**ACTION REQUIRED**: Existing controls and tests in the database must be assigned
to departments for the filter to work correctly.

To update existing records, run:
  UPDATE master_controls SET dept_id = 1 WHERE dept_id IS NULL;
  UPDATE master_tests SET dept_id = 1 WHERE dept_id IS NULL;

Replace '1' with a valid department ID from master_depts table.

Alternatively, edit each control/test through the UI to assign a department.

## Technical Details

- Alpine.js data binding for reactive department selection
- API expects 'dept_id' query parameter (snake_case)
- Internal state uses camelCase (deptId)
- Departments loaded on init via /api/master/depts
- Search requests include both keyword and dept_id parameters
This commit is contained in:
mahdahar 2026-02-03 16:55:13 +07:00
parent c9c9e59316
commit 4ae2c75fdd
10 changed files with 407 additions and 25 deletions

View File

@ -37,14 +37,14 @@ class EntryApiController extends BaseController
public function getControls() public function getControls()
{ {
try { try {
$keyword = $this->request->getGet('keyword');
$deptId = $this->request->getGet('dept_id'); $deptId = $this->request->getGet('dept_id');
$date = $this->request->getGet('date'); $date = $this->request->getGet('date');
if ($deptId) { $controls = $this->controlModel->search($keyword, $deptId);
$controls = $this->controlModel->where('dept_id', $deptId)->where('deleted_at', null)->findAll();
} else { // Debug logging
$controls = $this->controlModel->where('deleted_at', null)->findAll(); log_message('debug', 'getControls: keyword=' . var_export($keyword, true) . ', deptId=' . var_export($deptId, true) . ', date=' . var_export($date, true) . ', found=' . count($controls));
}
// Filter expired controls if date provided // Filter expired controls if date provided
if ($date) { if ($date) {
@ -61,16 +61,20 @@ class EntryApiController extends BaseController
'controlName' => $c['controlName'], 'controlName' => $c['controlName'],
'lot' => $c['lot'], 'lot' => $c['lot'],
'producer' => $c['producer'], 'producer' => $c['producer'],
'expDate' => $c['expDate'] 'expDate' => $c['expDate'],
'deptName' => $c['deptName'] ?? null
]; ];
}, $controls); }, $controls);
log_message('debug', 'getControls: returning ' . count($data) . ' controls');
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'fetch success', 'message' => 'fetch success',
'data' => $data 'data' => $data
], 200); ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
log_message('error', 'getControls error: ' . $e->getMessage());
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
} }

View File

@ -19,10 +19,7 @@ class MasterControlsController extends BaseController {
]; ];
} }
public function index() { public function index() { $keyword = $this->request->getGet('keyword'); $deptId = $this->request->getGet('dept_id'); try { $rows = $this->model->search($keyword, $deptId);
$keyword = $this->request->getGet('keyword');
try {
$rows = $this->model->search($keyword);
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'fetch success', 'message' => 'fetch success',

View File

@ -18,10 +18,7 @@ class MasterTestsController extends BaseController {
]; ];
} }
public function index() { public function index() { $keyword = $this->request->getGet('keyword'); $deptId = $this->request->getGet('dept_id'); try { $rows = $this->model->search($keyword, $deptId);
$keyword = $this->request->getGet('keyword');
try {
$rows = $this->model->search($keyword);
return $this->respond([ return $this->respond([
'status' => 'success', 'status' => 'success',
'message' => 'fetch success', 'message' => 'fetch success',

View File

@ -19,14 +19,40 @@ class MasterControlsModel extends BaseModel {
protected $useTimestamps = true; protected $useTimestamps = true;
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
public function search($keyword = null) { public function search($keyword = null, $deptId = null) {
if ($keyword) { $builder = $this->builder();
return $this->groupStart() $builder->select('
->like('control_name', $keyword) master_controls.control_id as controlId,
->orLike('lot', $keyword) master_controls.control_name as controlName,
->groupEnd() master_controls.lot,
->findAll(); master_controls.producer,
master_controls.exp_date as expDate,
master_depts.dept_name as deptName
');
$builder->join('master_depts', 'master_depts.dept_id = master_controls.dept_id', 'left');
$builder->where('master_controls.deleted_at', null);
if ($deptId) {
$builder->where('master_controls.dept_id', $deptId);
} }
return $this->findAll();
if ($keyword) {
$builder->groupStart()
->like('master_controls.control_name', $keyword)
->orLike('master_controls.lot', $keyword)
->orLike('master_controls.producer', $keyword)
->groupEnd();
}
$builder->orderBy('master_controls.control_name', 'ASC');
$results = $builder->get()->getResultArray();
// Add deptName after camelCase conversion from BaseModel
foreach ($results as &$row) {
$row['deptName'] = $row['deptName'] ?? null;
}
return $results;
} }
} }

View File

@ -22,7 +22,7 @@ class MasterTestsModel extends BaseModel {
protected $useTimestamps = true; protected $useTimestamps = true;
protected $useSoftDeletes = true; protected $useSoftDeletes = true;
public function search($keyword = null) { public function search($keyword = null, $deptId = null) {
$builder = $this->builder(); $builder = $this->builder();
$builder->select(' $builder->select('
master_tests.test_id as testId, master_tests.test_id as testId,
@ -38,6 +38,10 @@ class MasterTestsModel extends BaseModel {
$builder->join('master_depts', 'master_depts.dept_id = master_tests.dept_id', 'left'); $builder->join('master_depts', 'master_depts.dept_id = master_tests.dept_id', 'left');
$builder->where('master_tests.deleted_at', null); $builder->where('master_tests.deleted_at', null);
if ($deptId) {
$builder->where('master_tests.dept_id', $deptId);
}
if ($keyword) { if ($keyword) {
$builder->groupStart() $builder->groupStart()
->like('master_tests.test_name', $keyword) ->like('master_tests.test_name', $keyword)

View File

@ -39,6 +39,44 @@
<button @click="setYesterday()" class="btn btn-sm btn-outline">Yesterday</button> <button @click="setYesterday()" class="btn btn-sm btn-outline">Yesterday</button>
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Department</span>
</label>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-sm gap-2 btn-outline w-48 justify-start">
<i class="fa-solid fa-building text-xs"></i>
<template x-if="deptId">
<span class="truncate" x-text="getDeptName()"></span>
</template>
<template x-if="!deptId">
<span class="opacity-70">All Departments</span>
</template>
<i class="fa-solid fa-caret-down text-xs ml-auto"></i>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56 border border-base-300">
<li>
<a @click="setDeptId(null)" :class="{'active': !deptId}">
<i class="fa-solid fa-building text-xs"></i> All Departments
</a>
</li>
<template x-if="departments">
<template x-for="dept in departments" :key="dept.deptId">
<li>
<a @click="setDeptId(dept.deptId)" :class="{'active': deptId === dept.deptId}" x-text="dept.deptName"></a>
</li>
</template>
</template>
<template x-if="!departments">
<li>
<a class="opacity-50">
<span class="loading loading-spinner loading-xs"></span> Loading...
</a>
</li>
</template>
</ul>
</div>
</div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text font-medium">Control</span> <span class="label-text font-medium">Control</span>
@ -147,19 +185,53 @@ document.addEventListener('alpine:init', () => {
saving: false, saving: false,
resultsData: {}, resultsData: {},
commentsData: {}, commentsData: {},
deptId: null,
departments: null,
init() { init() {
this.fetchDepartments();
this.fetchControls(); this.fetchControls();
this.setupKeyboard(); this.setupKeyboard();
}, },
async fetchDepartments() {
try {
const response = await fetch(`${BASEURL}api/master/depts`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load departments");
const json = await response.json();
this.departments = json.data || [];
} catch (e) {
console.error('Failed to fetch departments:', e);
}
},
get today() { get today() {
return new Date().toISOString().split('T')[0]; return new Date().toISOString().split('T')[0];
}, },
getDeptName() {
if (!this.deptId || !this.departments) return '';
const dept = this.departments.find(d => d.deptId === this.deptId);
return dept ? dept.deptName : '';
},
setDeptId(id) {
this.deptId = id;
this.selectedControl = null;
this.controls = [];
this.tests = [];
this.fetchControls();
},
async fetchControls() { async fetchControls() {
try { try {
const params = new URLSearchParams({ date: this.date }); const params = new URLSearchParams({ date: this.date });
if (this.deptId) {
params.set('dept_id', this.deptId);
}
const response = await fetch(`${BASEURL}api/entry/controls?${params}`); const response = await fetch(`${BASEURL}api/entry/controls?${params}`);
const json = await response.json(); const json = await response.json();
this.controls = json.data || []; this.controls = json.data || [];

View File

@ -49,6 +49,47 @@
<div class="divider divider-horizontal mx-0 hidden sm:flex"></div> <div class="divider divider-horizontal mx-0 hidden sm:flex"></div>
<div class="form-control">
<label class="label pt-0">
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Department</span>
</label>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-sm gap-2 btn-outline w-48 justify-start">
<i class="fa-solid fa-building text-xs"></i>
<template x-if="deptId">
<span class="truncate" x-text="getDeptName()"></span>
</template>
<template x-if="!deptId">
<span class="opacity-70">All Departments</span>
</template>
<i class="fa-solid fa-caret-down text-xs ml-auto"></i>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-56 border border-base-300">
<li>
<a @click="setDeptId(null)" :class="{'active': !deptId}">
<i class="fa-solid fa-building text-xs"></i> All Departments
</a>
</li>
<template x-if="departments">
<template x-for="dept in departments" :key="dept.deptId">
<li>
<a @click="setDeptId(dept.deptId)" :class="{'active': deptId === dept.deptId}" x-text="dept.deptName"></a>
</li>
</template>
</template>
<template x-if="!departments">
<li>
<a class="opacity-50">
<span class="loading loading-spinner loading-xs"></span> Loading...
</a>
</li>
</template>
</ul>
</div>
</div>
<div class="divider divider-horizontal mx-0 hidden sm:flex"></div>
<div class="form-control flex-1 max-w-xs"> <div class="form-control flex-1 max-w-xs">
<label class="label pt-0"> <label class="label pt-0">
<span class="label-text font-semibold opacity-60 uppercase text-[10px]">Select Test</span> <span class="label-text font-semibold opacity-60 uppercase text-[10px]">Select Test</span>
@ -267,6 +308,8 @@ document.addEventListener('alpine:init', () => {
commentsData: {}, commentsData: {},
originalComments: {}, originalComments: {},
month: '', month: '',
deptId: null,
departments: null,
commentModal: { commentModal: {
show: false, show: false,
controlId: null, controlId: null,
@ -278,13 +321,33 @@ document.addEventListener('alpine:init', () => {
init() { init() {
const now = new Date(); const now = new Date();
this.month = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0'); this.month = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
this.fetchDepartments();
this.fetchTests(); this.fetchTests();
this.setupKeyboard(); this.setupKeyboard();
}, },
async fetchDepartments() {
try {
const response = await fetch(`${BASEURL}api/master/depts`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load departments");
const json = await response.json();
this.departments = json.data || [];
} catch (e) {
console.error('Failed to fetch departments:', e);
}
},
async fetchTests() { async fetchTests() {
try { try {
const response = await fetch(`${BASEURL}api/master/tests`); const params = new URLSearchParams();
if (this.deptId) {
params.set('dept_id', this.deptId);
}
const url = `${BASEURL}api/master/tests${this.deptId ? '?' + params.toString() : ''}`;
const response = await fetch(url);
const json = await response.json(); const json = await response.json();
this.tests = json.data || []; this.tests = json.data || [];
} catch (e) { } catch (e) {
@ -292,6 +355,19 @@ document.addEventListener('alpine:init', () => {
} }
}, },
getDeptName() {
if (!this.deptId || !this.departments) return '';
const dept = this.departments.find(d => d.deptId === this.deptId);
return dept ? dept.deptName : '';
},
setDeptId(id) {
this.deptId = id;
this.selectedTest = null;
this.controls = [];
this.fetchTests();
},
onMonthChange() { onMonthChange() {
if (this.selectedTest) { if (this.selectedTest) {
this.fetchMonthlyData(); this.fetchMonthlyData();

View File

@ -6,6 +6,34 @@
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
<template x-if="!departmentsFetchComplete">
<div class="flex items-center justify-center py-2">
<span class="loading loading-spinner loading-sm text-primary"></span>
<span class="ml-2 text-xs opacity-50">Loading departments...</span>
</div>
</template>
<template x-if="departments">
<div class="form-control">
<label class="label py-1">
<span class="label-text-alt font-semibold text-base-content/70">Department</span>
</label>
<select
class="select select-bordered select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary bg-base-200 border-base-300 text-base-content"
:class="{'border-error': errors.deptId}"
x-model="form.deptId"
placeholder="Select department">
<option value="">Select Department</option>
<template x-for="dept in departments" :key="dept.deptId">
<option :value="dept.deptId" x-text="dept.deptName"></option>
</template>
</select>
<template x-if="errors.deptId">
<label class="label">
<span class="label-text-alt text-error" x-text="errors.deptId"></span>
</label>
</template>
</div>
</template>
<div class="form-control"> <div class="form-control">
<label class="label py-1"> <label class="label py-1">
<span class="label-text-alt font-semibold text-base-content/70">Control Name</span> <span class="label-text-alt font-semibold text-base-content/70">Control Name</span>

View File

@ -21,11 +21,49 @@
class="input input-bordered input-sm w-full px-3 py-2 text-sm bg-base-200 border border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all" class="input input-bordered input-sm w-full px-3 py-2 text-sm bg-base-200 border border-base-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all"
x-model="keyword" @keyup.enter="fetchList()" /> x-model="keyword" @keyup.enter="fetchList()" />
</div> </div>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-sm btn-neutral gap-2">
<i class="fa-solid fa-building text-xs"></i>
<template x-if="deptId">
<span x-text="getDeptName()"></span>
</template>
<template x-if="!deptId">
Department
</template>
<i class="fa-solid fa-caret-down text-xs ml-1"></i>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-300">
<li>
<a @click="setDeptId(null)" :class="{'active': !deptId}">
<i class="fa-solid fa-building text-xs"></i> All Departments
</a>
</li>
<template x-if="departments">
<template x-for="dept in departments" :key="dept.deptId">
<li>
<a @click="setDeptId(dept.deptId)" :class="{'active': deptId === dept.deptId}" x-text="dept.deptName"></a>
</li>
</template>
</template>
<template x-if="!departments">
<li>
<a class="opacity-50">
<span class="loading loading-spinner loading-xs"></span> Loading...
</a>
</li>
</template>
</ul>
</div>
<template x-if="deptId">
<button class="btn btn-sm gap-1" @click="setDeptId(null)">
<i class="fa-solid fa-xmark text-xs"></i> Clear
</button>
</template>
<button class="btn btn-sm btn-neutral gap-2" @click="fetchList()"> <button class="btn btn-sm btn-neutral gap-2" @click="fetchList()">
<i class="fa-solid fa-magnifying-glass text-xs"></i> Search <i class="fa-solid fa-magnifying-glass text-xs"></i> Search
</button> </button>
</div> </div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<template x-if="loading && !list"> <template x-if="loading && !list">
@ -164,6 +202,8 @@
errors: {}, errors: {},
error: null, error: null,
keyword: "", keyword: "",
deptId: null,
departments: null,
list: null, list: null,
form: { form: {
controlId: null, controlId: null,
@ -229,9 +269,62 @@
}, },
init() { init() {
this.fetchDepartments();
this.fetchList(); this.fetchList();
}, },
async fetchDepartments() {
try {
const response = await fetch(`${BASEURL}api/master/depts`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load departments");
const data = await response.json();
this.departments = data.data;
} catch (err) {
console.error(err);
this.departments = [];
}
},
async fetchList() {
this.loading = true;
this.error = null;
this.list = null;
try {
const params = new URLSearchParams({ keyword: this.keyword });
if (this.deptId) {
params.set('dept_id', this.deptId);
}
const response = await fetch(`${BASEURL}api/master/controls?${params}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load data");
const data = await response.json();
this.list = data.data;
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
getDeptName() {
if (!this.deptId || !this.departments) return '';
const dept = this.departments.find(d => d.deptId === this.deptId);
return dept ? dept.deptName : '';
},
setDeptId(id) {
this.deptId = id;
this.list = null;
this.fetchList();
},
async loadData(id) {
async fetchList() { async fetchList() {
this.loading = true; this.loading = true;
this.error = null; this.error = null;

View File

@ -27,6 +27,44 @@
@keyup.enter="fetchList()" @keyup.enter="fetchList()"
/> />
</div> </div>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-sm gap-2">
<i class="fa-solid fa-building text-xs"></i>
<template x-if="deptId">
<span x-text="getDeptName()"></span>
</template>
<template x-if="!deptId">
Department
</template>
<i class="fa-solid fa-caret-down text-xs ml-1"></i>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-300">
<li>
<a @click="setDeptId(null)" :class="{'active': !deptId}">
<i class="fa-solid fa-building text-xs"></i> All Departments
</a>
</li>
<template x-if="departments">
<template x-for="dept in departments" :key="dept.deptId">
<li>
<a @click="setDeptId(dept.deptId)" :class="{'active': deptId === dept.deptId}" x-text="dept.deptName"></a>
</li>
</template>
</template>
<template x-if="!departments">
<li>
<a class="opacity-50">
<span class="loading loading-spinner loading-xs"></span> Loading...
</a>
</li>
</template>
</ul>
</div>
<template x-if="deptId">
<button class="btn btn-sm gap-1" @click="setDeptId(null)">
<i class="fa-solid fa-xmark text-xs"></i> Clear
</button>
</template>
<button <button
class="px-4 py-2.5 text-sm font-medium bg-base-content text-base-100 rounded-lg hover:bg-base-content/90 transition-all duration-200 flex items-center gap-2" class="px-4 py-2.5 text-sm font-medium bg-base-content text-base-100 rounded-lg hover:bg-base-content/90 transition-all duration-200 flex items-center gap-2"
@click="fetchList()" @click="fetchList()"
@ -110,6 +148,8 @@
errors: {}, errors: {},
error: null, error: null,
keyword: "", keyword: "",
deptId: null,
departments: null,
list: null, list: null,
form: { form: {
testId: null, testId: null,
@ -123,15 +163,34 @@
}, },
init() { init() {
this.fetchDepartments();
this.fetchList(); this.fetchList();
}, },
async fetchDepartments() {
try {
const response = await fetch(`${BASEURL}api/master/depts`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load departments");
const data = await response.json();
this.departments = data.data;
} catch (err) {
console.error(err);
this.departments = [];
}
},
async fetchList() { async fetchList() {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
this.list = null; this.list = null;
try { try {
const params = new URLSearchParams({ keyword: this.keyword }); const params = new URLSearchParams({ keyword: this.keyword });
if (this.deptId) {
params.set('dept_id', this.deptId);
}
const response = await fetch(`${BASEURL}api/master/tests?${params}`, { const response = await fetch(`${BASEURL}api/master/tests?${params}`, {
method: "GET", method: "GET",
headers: { "Content-Type": "application/json" } headers: { "Content-Type": "application/json" }
@ -146,6 +205,32 @@
} }
}, },
getDeptName() {
if (!this.deptId || !this.departments) return '';
const dept = this.departments.find(d => d.deptId === this.deptId);
return dept ? dept.deptName : '';
},
setDeptId(id) {
this.deptId = id;
this.list = null;
this.fetchList();
},
async loadData(id) {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load data");
const data = await response.json();
this.list = data.data;
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
async loadData(id) { async loadData(id) {
this.loading = true; this.loading = true;
try { try {