mahdahar 5cae572916 Refactor application structure, unify entry management, and overhaul UI with DaisyUI
- Reorganized Architecture: Moved Master data management (Controls, Departments, Tests) to a dedicated Master namespace/directory for better modularity.
- Unified Data Entry: Consolidated daily and monthly QC entry views and logic into a single streamlined system.
- UI/UX Modernization:
    - Integrated DaisyUI and Alpine.js for a responsive, themeable, and interactive experience.
    - Replaced legacy layouts with a new DaisyUI-based template.
    - Updated branding with new logo and favicon assets.
    - Cleaned up deprecated JavaScript assets (app.js, charts.js, tables.js).
- Backend Enhancements:
    - Added `ControlEntryModel` and `ResultCommentsController` for improved data handling and auditing.
    - Updated routing to support the new controller structure and consolidated API endpoints.
- Documentation & Assets:
    - Added [docs/PRD.md](cci:7://file:///c:/www/tinyqc/docs/PRD.md:0:0-0:0) and [docs/llms.txt](cci:7://file:///c:/www/tinyqc/docs/llms.txt:0:0-0:0) for project context and requirements.
    - Included database schema scripts and backups in the `backup/` directory.
- Cleanup: Removed legacy TUI progress files and unused commands.
2026-01-19 06:37:37 +07:00

243 lines
10 KiB
PHP

<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto" x-data="controls()">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-base-content tracking-tight">Controls</h1>
<p class="text-sm mt-1 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"></i> New Control
</button>
</div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm p-4 mb-6">
<div class="flex items-center gap-3">
<div class="relative flex-1 max-w-md">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-sm"></i>
<input
type="text"
placeholder="Search by name..."
class="w-full pl-10 pr-4 py-2.5 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>
<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"
@click="fetchList()"
>
<i class="fa-solid fa-magnifying-glass text-xs"></i> Search
</button>
</div>
</div>
<div class="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden">
<template x-if="loading">
<div class="p-8 text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-2 text-base-content/60">Loading...</p>
</div>
</template>
<template x-if="!loading && error">
<div class="p-8 text-center">
<i class="fa-solid fa-triangle-exclamation text-4xl text-error mb-2"></i>
<p class="text-error" x-text="error"></p>
</div>
</template>
<template x-if="!loading && !error && list">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="uppercase tracking-wider font-semibold bg-base-200 text-base-content/70 text-xs">
<tr>
<th class="py-3 px-5 font-semibold">Control Name</th>
<th class="py-3 px-5 font-semibold">Lot Number</th>
<th class="py-3 px-5 font-semibold">Producer</th>
<th class="py-3 px-5 font-semibold">Expiry Date</th>
<th class="py-3 px-5 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="text-base-content/80 divide-y divide-base-300">
<template x-for="item in list" :key="item.controlId">
<tr class="hover:bg-base-200 transition-colors">
<td class="py-3 px-5 font-medium text-base-content" x-text="item.controlName"></td>
<td class="py-3 px-5">
<span class="font-mono text-xs bg-base-200 text-base-content/70 px-2 py-1 rounded" x-text="item.lotNumber"></span>
</td>
<td class="py-3 px-5" x-text="item.producer"></td>
<td class="py-3 px-5" x-text="item.expDate"></td>
<td class="py-3 px-5 text-right">
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors"
@click="showForm(item.controlId)"
>
<i class="fa-solid fa-pencil"></i> Edit
</button>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-error bg-error/10 hover:bg-error/20 rounded-lg transition-colors ml-1"
@click="deleteData(item.controlId)"
>
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</template>
<template x-if="list.length === 0">
<tr>
<td colspan="5" class="py-8 text-center text-base-content/60">No data available</td>
</tr>
</template>
</tbody>
</table>
</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: "",
list: null,
form: {
controlId: null,
controlName: "",
lotNumber: "",
producer: "",
expDate: "",
},
async fetchList() {
this.loading = true;
this.error = null;
this.list = null;
try {
const params = new URLSearchParams({ keyword: this.keyword });
const response = await fetch(`${window.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;
}
},
async loadData(id) {
this.loading = true;
try {
const response = await fetch(`${window.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: "", lotNumber: "", producer: "", expDate: "" };
}
},
closeModal() {
this.showModal = false;
this.form = { controlId: null, controlName: "", lotNumber: "", producer: "", expDate: "" };
},
validate() {
this.errors = {};
if (!this.form.controlName) this.errors.controlName = "Name 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 = `${window.BASEURL}api/master/controls/${this.form.controlId}`;
} else {
method = 'POST';
url = `${window.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(`${window.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(); ?>