386 lines
18 KiB
PHP
Executable File
386 lines
18 KiB
PHP
Executable File
<?= $this->extend("layout/main_layout"); ?>
|
|
|
|
<?= $this->section("content"); ?>
|
|
<main class="flex-1 p-4 overflow-auto" x-data="controls()">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<div>
|
|
<h1 class="text-xl font-bold text-base-content tracking-tight">Controls</h1>
|
|
<p class="text-xs mt-0.5 opacity-70">Manage QC control standards</p>
|
|
</div>
|
|
<button
|
|
class="btn btn-sm gap-2 shadow-sm border-0 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-700 hover:to-blue-600 text-white transition-all duration-200"
|
|
@click="showForm()">
|
|
<i class="fa-solid fa-plus text-xs"></i> New Control
|
|
</button>
|
|
</div>
|
|
|
|
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-3 mb-4">
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex items-center gap-2">
|
|
<div class="relative flex-1 max-w-md">
|
|
<input type="text" placeholder="Search by name..."
|
|
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()" />
|
|
</div>
|
|
<select x-model="deptId" @change="setDeptId(deptId)" class="select select-bordered select-sm">
|
|
<option value="">All Departments</option>
|
|
<template x-if="departments">
|
|
<template x-for="dept in departments" :key="dept.deptId">
|
|
<option :value="dept.deptId" x-text="dept.deptName"></option>
|
|
</template>
|
|
</template>
|
|
<template x-if="!departments">
|
|
<option disabled>Loading...</option>
|
|
</template>
|
|
</select>
|
|
<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()">
|
|
<i class="fa-solid fa-magnifying-glass text-xs"></i> Search
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
class="btn btn-sm gap-1"
|
|
:class="statusFilter === '1' ? 'btn-primary' : 'btn-ghost'"
|
|
@click="setStatusFilter('1')">
|
|
<i class="fa-solid fa-check-circle text-xs"></i> Active
|
|
</button>
|
|
<button
|
|
class="btn btn-sm gap-1"
|
|
:class="statusFilter === '0' ? 'btn-error' : 'btn-ghost'"
|
|
@click="setStatusFilter('0')">
|
|
<i class="fa-solid fa-circle-xmark text-xs"></i> Inactive
|
|
</button>
|
|
<button
|
|
class="btn btn-sm gap-1"
|
|
:class="statusFilter === '' ? 'btn-neutral' : 'btn-ghost'"
|
|
@click="setStatusFilter('')">
|
|
<i class="fa-solid fa-list text-xs"></i> All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
|
|
<template x-if="loading && !list">
|
|
<div class="p-8 text-center">
|
|
<span class="loading loading-spinner loading-md text-primary"></span>
|
|
<p class="mt-2 text-base-content/60 text-xs font-medium">Fetching controls...</p>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="!loading && error">
|
|
<div class="p-8 text-center">
|
|
<div class="w-12 h-12 bg-error/10 text-error rounded-full flex items-center justify-center mx-auto mb-3">
|
|
<i class="fa-solid fa-triangle-exclamation text-xl"></i>
|
|
</div>
|
|
<h3 class="font-bold text-base-content">Something went wrong</h3>
|
|
<p class="text-error/80 mt-0.5 text-xs" x-text="error"></p>
|
|
<button @click="fetchList()" class="btn btn-sm btn-ghost mt-3 border border-base-300">Try Again</button>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="!loading && !error && list">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="bg-base-200/50 text-left border-b border-base-300">
|
|
<th class="py-3 px-4 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">Control Name</th>
|
|
<th class="py-3 px-3 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">Lot</th>
|
|
<th class="py-3 px-3 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">Producer</th>
|
|
<th class="py-3 px-3 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">Expiry</th>
|
|
<th class="py-3 px-3 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider">Status</th>
|
|
<th class="py-3 px-4 font-semibold text-base-content/70 text-[10px] uppercase tracking-wider text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-base-200">
|
|
<template x-for="item in list" :key="item.controlId">
|
|
<tr class="hover:bg-base-200/30 transition-colors">
|
|
<td class="py-2.5 px-4">
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-6 h-6 rounded bg-primary/10 text-primary flex items-center justify-center">
|
|
<i class="fa-solid fa-vial text-[10px]"></i>
|
|
</div>
|
|
<span class="font-medium text-sm" x-text="item.controlName"></span>
|
|
</div>
|
|
</td>
|
|
<td class="py-2.5 px-3">
|
|
<span class="font-mono text-[10px] bg-base-200 text-base-content/70 px-1.5 py-0.5 rounded border border-base-300" x-text="item.lot"></span>
|
|
</td>
|
|
<td class="py-2.5 px-3 text-xs text-base-content/70" x-text="item.producer || '-'">
|
|
</td>
|
|
<td class="py-2.5 px-3 text-xs text-base-content/70" x-text="item.expDate">
|
|
</td>
|
|
<td class="py-2.5 px-3">
|
|
<span
|
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider"
|
|
:class="getStatusBadgeClass(item.expDate)">
|
|
<span class="w-1 h-1 rounded-full" :class="getStatusDotClass(item.expDate)"></span>
|
|
<span x-text="getStatusLabel(item.expDate)"></span>
|
|
</span>
|
|
</td>
|
|
<td class="py-2.5 px-4 text-right">
|
|
<div class="flex justify-end items-center gap-1">
|
|
<button
|
|
class="p-1.5 text-amber-600 hover:bg-amber-50 rounded transition-colors tooltip tooltip-left"
|
|
data-tip="Edit" @click="showForm(item.controlId)">
|
|
<i class="fa-solid fa-pencil text-xs"></i>
|
|
</button>
|
|
<button
|
|
class="p-1.5 text-error hover:bg-error/10 rounded transition-colors tooltip tooltip-left"
|
|
data-tip="Delete" @click="deleteData(item.controlId)">
|
|
<i class="fa-solid fa-trash text-xs"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
|
|
<template x-if="list.length === 0">
|
|
<div class="p-8 text-center border-t border-base-200">
|
|
<div class="w-12 h-12 bg-base-200 text-base-content/30 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
<i class="fa-solid fa-box-open text-xl"></i>
|
|
</div>
|
|
<h3 class="font-bold text-base-content/70">No data found</h3>
|
|
<p class="text-base-content/50 mt-0.5 text-xs">Try adjusting your filters or add a new control.</p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<?= $this->include('master/control/dialog_control_form'); ?>
|
|
</main>
|
|
<?= $this->endSection(); ?>
|
|
|
|
<?= $this->section("script"); ?>
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data("controls", () => ({
|
|
loading: false,
|
|
showModal: false,
|
|
errors: {},
|
|
error: null,
|
|
keyword: "",
|
|
deptId: null,
|
|
statusFilter: "1",
|
|
departments: null,
|
|
loadingDepartments: false,
|
|
list: null,
|
|
form: {
|
|
controlId: null,
|
|
controlName: "",
|
|
lot: "",
|
|
producer: "",
|
|
expDate: "",
|
|
isActive: 1,
|
|
},
|
|
|
|
get uniqueControlNames() {
|
|
if (!this.list) return [];
|
|
return [...new Set(this.list.map(i => i.controlName))];
|
|
},
|
|
|
|
get uniqueProducers() {
|
|
if (!this.list) return [];
|
|
return [...new Set(this.list.map(i => i.producer))];
|
|
},
|
|
|
|
getStatusLabel(date) {
|
|
if (!date) return 'Unknown';
|
|
const exp = new Date(date);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
if (exp < today) return 'Expired';
|
|
|
|
const diffTime = exp - today;
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays <= 30) return 'Expiring Soon';
|
|
return 'Active';
|
|
},
|
|
|
|
getStatusBadgeClass(date) {
|
|
const status = this.getStatusLabel(date);
|
|
if (status === 'Expired') return 'bg-error/10 text-error border border-error/20';
|
|
if (status === 'Expiring Soon') return 'bg-warning/10 text-warning-content border border-warning/20';
|
|
return 'bg-success/10 text-success border border-success/20';
|
|
},
|
|
|
|
getStatusDotClass(date) {
|
|
const status = this.getStatusLabel(date);
|
|
if (status === 'Expired') return 'bg-error';
|
|
if (status === 'Expiring Soon') return 'bg-warning';
|
|
return 'bg-success';
|
|
},
|
|
|
|
init() {
|
|
this.fetchDepartments();
|
|
this.fetchList();
|
|
},
|
|
|
|
async fetchDepartments() {
|
|
this.loadingDepartments = true;
|
|
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 = [];
|
|
} finally {
|
|
this.loadingDepartments = false;
|
|
}
|
|
},
|
|
|
|
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);
|
|
}
|
|
if (this.statusFilter !== '') {
|
|
params.set('is_active', this.statusFilter);
|
|
}
|
|
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;
|
|
}
|
|
},
|
|
|
|
setDeptId(id) {
|
|
this.deptId = id;
|
|
this.list = null;
|
|
this.fetchList();
|
|
},
|
|
|
|
setStatusFilter(status) {
|
|
this.statusFilter = status;
|
|
this.list = null;
|
|
this.fetchList();
|
|
},
|
|
|
|
async loadData(id) {
|
|
this.loading = true;
|
|
try {
|
|
const response = await fetch(`${BASEURL}api/master/controls/${id}`, {
|
|
method: "GET",
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
if (!response.ok) throw new Error("Failed to load item");
|
|
const data = await response.json();
|
|
this.form = data.data[0];
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
this.form = {};
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async showForm(id = null) {
|
|
this.showModal = true;
|
|
this.errors = {};
|
|
if (id) {
|
|
await this.loadData(id);
|
|
} else {
|
|
this.form = { controlId: null, controlName: "", lot: "", producer: "", expDate: "", isActive: 1 };
|
|
}
|
|
},
|
|
|
|
closeModal() {
|
|
this.showModal = false;
|
|
this.form = { controlId: null, controlName: "", lot: "", producer: "", expDate: "", isActive: 1 };
|
|
},
|
|
|
|
validate() {
|
|
this.errors = {};
|
|
if (!this.form.controlName) this.errors.controlName = "Name is required.";
|
|
if (!this.form.lot) this.errors.lot = "Lot is required.";
|
|
return Object.keys(this.errors).length === 0;
|
|
},
|
|
|
|
async save() {
|
|
if (!this.validate()) return;
|
|
this.loading = true;
|
|
let method = '';
|
|
let url = '';
|
|
if (this.form.controlId) {
|
|
method = 'PATCH';
|
|
url = `${BASEURL}api/master/controls/${this.form.controlId}`;
|
|
} else {
|
|
method = 'POST';
|
|
url = `${BASEURL}api/master/controls`;
|
|
}
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(this.form),
|
|
});
|
|
const data = await res.json();
|
|
if (data.status === 'success') {
|
|
alert("Data saved successfully!");
|
|
this.closeModal();
|
|
this.fetchList();
|
|
} else {
|
|
alert(data.message || "Something went wrong.");
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to save data.");
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async deleteData(id) {
|
|
if (!confirm("Are you sure you want to delete this item?")) return;
|
|
this.loading = true;
|
|
try {
|
|
const res = await fetch(`${BASEURL}api/master/controls/${id}`, {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
const data = await res.json();
|
|
if (data.status === 'success') {
|
|
alert("Data deleted successfully!");
|
|
this.fetchList();
|
|
} else {
|
|
alert(data.message || "Failed to delete.");
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Failed to delete data.");
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
}));
|
|
});
|
|
</script>
|
|
<?= $this->endSection(); ?>
|