[Update] UI Brankas dan nampan

This commit is contained in:
Baghaztra 2025-09-11 15:11:20 +07:00
parent 679ffc504c
commit 3e96a158e5
7 changed files with 589 additions and 391 deletions

View File

@ -14,7 +14,7 @@ class NampanController extends Controller
public function index() public function index()
{ {
return response()->json( return response()->json(
Nampan::with('items.produk.foto')->withCount('items')->get() Nampan::with('items.produk.foto', 'items.produk.kategori')->withCount('items')->get()
); );
} }
@ -24,10 +24,12 @@ class NampanController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$validated = $request->validate([ $validated = $request->validate([
'nama' => 'required|string|max:10', 'nama' => 'required|string|max:10|unique:nampans,nama',
], ],
[ [
'nama' => 'Nama nampan harus diisi.' 'nama.required' => 'Nama nampan harus diisi.',
'nama.unique' => 'Nampan dengan nama yang sama sudah ada.',
'nama.max' => 'Nama nampan maksimal 10 karakter.'
]); ]);
Nampan::create($validated); Nampan::create($validated);
@ -54,7 +56,7 @@ class NampanController extends Controller
public function update(Request $request, int $id) public function update(Request $request, int $id)
{ {
$validated = $request->validate([ $validated = $request->validate([
'nama' => 'required|string|max:10', 'nama' => 'required|string|max:10|unique:nampans,nama,'.$id,
], ],
[ [
'nama' => 'Nama nampan harus diisi.' 'nama' => 'Nama nampan harus diisi.'

View File

@ -1,49 +1,83 @@
// brankas list
<template> <template>
<div v-if="loading" class="flex justify-center items-center h-screen"> <div v-if="loading" class="flex justify-center items-center h-screen">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"></div> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"></div>
<span class="ml-2 text-gray-600">Memuat data...</span> <span class="ml-2 text-gray-600">Memuat data...</span>
</div> </div>
<div>
<div v-else>
<!-- Alert Section -->
<div class="mb-4" v-if="alert">
<div v-if="alert.error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
<strong class="font-bold">Error!</strong>
<span class="block sm:inline">{{ alert.error }}</span>
</div>
<div v-if="alert.success" class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4" role="alert">
<strong class="font-bold">Success!</strong>
<span class="block sm:inline">{{ alert.success }}</span>
</div>
</div>
<!-- Statistik Brankas -->
<div class="bg-A border border-C rounded-xl p-4 mb-6">
<div class="flex flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-A rounded-lg">
<i class="fas fa-archive text-D"></i>
</div>
<div class="flex flex-col sm:flex-row sm:gap-6 text-sm text-gray-600">
<span>Total Item di brankas: {{ filteredItems.length }}</span>
</div>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-D">{{ totalWeight }}g</div>
<div class="text-sm text-gray-500">Total Berat</div>
</div>
</div>
</div>
<!-- Daftar Item --> <!-- Daftar Item -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div v-if="filteredItems.length === 0" class="text-center text-gray-500 py-[120px]">
<div {{ props.search ? 'Item tidak ditemukan.' : 'Brankas kosong.' }}
v-for="item in filteredItems" </div>
:key="item.id"
class="flex justify-between items-center border rounded-lg p-3 shadow-sm hover:shadow-md transition cursor-pointer" <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@click="openMovePopup(item)" <div v-for="item in filteredItems" :key="item.id"
> class="flex justify-between items-center border border-C rounded-lg p-3 shadow-sm hover:shadow-md transition cursor-pointer"
@click="openMovePopup(item)">
<!-- Gambar & Info Produk --> <!-- Gambar & Info Produk -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img <img v-if="item.produk.foto?.length" :src="item.produk.foto[0].url"
v-if="item.produk.foto?.length" class="size-12 object-cover rounded"
:src="item.produk.foto[0].url" @error="handleImageError" />
class="size-12 object-contain" <div class="size-12 bg-gray-200 rounded flex items-center justify-center" v-else>
/> <i class="fas fa-image text-gray-400"></i>
</div>
<div> <div>
<p class="font-semibold">{{ item.produk.nama }}</p> <p class="font-semibold text-D">{{ item.produk.nama }}</p>
<p class="text-sm text-gray-500">ID: {{ item.id }}</p> <p class="text-sm text-gray-500 font-semibold">{{ item.kode_item }}</p>
</div> </div>
</div> </div>
<!-- Berat --> <!-- Berat -->
<span class="font-medium">{{ item.produk.berat }}g</span> <span class="font-medium text-D">{{ item.produk.berat }}g</span>
</div> </div>
</div> </div>
<!-- Modal Pindah Nampan --> <!-- Modal Pindah Nampan -->
<div <div v-if="isPopupVisible" class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
v-if="isPopupVisible" <div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
>
<div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative">
<!-- QR Code --> <!-- QR Code -->
<div class="flex justify-center mb-4"> <div class="flex justify-center mb-4">
<div class="p-2 border rounded-lg"> <div class="p-2 border border-C rounded-lg">
<img :src="qrCodeUrl" alt="QR Code" class="size-36" /> <img :src="qrCodeUrl" alt="QR Code" class="size-36" />
</div> </div>
</div> </div>
<!-- Info Produk --> <!-- Info Produk -->
<div class="text-center text-D font-bold text-lg mb-1">
{{ selectedItem?.kode_item }}
</div>
<div class="text-center text-gray-700 font-medium mb-1"> <div class="text-center text-gray-700 font-medium mb-1">
{{ selectedItem?.produk?.nama }} {{ selectedItem?.produk?.nama }}
</div> </div>
@ -51,38 +85,63 @@
{{ selectedItem?.produk?.kategori }} {{ selectedItem?.produk?.kategori }}
</div> </div>
<!-- Tombol Cetak -->
<div class="flex justify-center mb-4">
<button @click="printQR" class="bg-D text-A px-4 py-2 rounded hover:bg-D/80 transition">
<i class="fas fa-print mr-2"></i>Cetak
</button>
</div>
<!-- Dropdown pilih nampan --> <!-- Dropdown pilih nampan -->
<div class="mb-4"> <div class="mb-4">
<label for="tray-select" class="block text-sm font-medium mb-1"> <label for="tray-select" class="block text-sm font-medium text-D mb-2">
Nama Nampan Pindah ke Nampan
</label> </label>
<select <select id="tray-select" v-model="selectedTrayId"
id="tray-select" class="w-full px-3 py-2 border border-C rounded-md shadow-sm focus:border-D focus:ring focus:ring-D/20 focus:ring-opacity-50">
v-model="selectedTrayId" <option value="" disabled>Pilih Nampan</option>
class="w-full rounded-md border shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
>
<option value="" disabled>Brankas</option>
<option v-for="tray in trays" :key="tray.id" :value="tray.id"> <option v-for="tray in trays" :key="tray.id" :value="tray.id">
{{ tray.nama }} {{ tray.nama }}
</option> </option>
</select> </select>
<p v-if="errorMove" class="text-red-500 text-sm mt-1">{{ errorMove }}</p>
</div> </div>
<!-- Tombol --> <!-- Tombol -->
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button @click="closePopup" class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
@click="closePopup"
class="px-4 py-2 rounded border text-gray-700 hover:bg-gray-100 transition"
>
Batal Batal
</button> </button>
<button <button @click="saveMove" :disabled="!selectedTrayId || isMoving"
@click="saveMove" class="px-4 py-2 rounded text-D transition flex items-center"
:disabled="!selectedTrayId" :class="(selectedTrayId && !isMoving) ? 'bg-C hover:bg-C/80' : 'bg-gray-400 cursor-not-allowed'">
class="px-4 py-2 rounded text-white transition" <div v-if="isMoving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
:class="selectedTrayId ? 'bg-orange-500 hover:bg-orange-600' : 'bg-gray-400 cursor-not-allowed'" {{ isMoving ? 'Memindahkan...' : 'Pindahkan' }}
> </button>
Simpan </div>
</div>
</div>
<!-- Confirm Modal untuk aksi berbahaya (jika diperlukan di masa depan) -->
<div v-if="isConfirmModalVisible" class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
<div class="bg-white rounded-xl shadow-lg max-w-md w-full p-6 transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
<div class="flex items-center mb-4">
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-red-100 rounded-full flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-600"></i>
</div>
<div class="ml-3">
<h3 class="text-lg font-medium text-D">{{ confirmModalTitle }}</h3>
</div>
</div>
<div class="mb-4">
<p class="text-sm text-gray-500" v-html="confirmModalMessage"></p>
</div>
<div class="flex justify-end gap-2">
<button @click="closeConfirmModal" class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
{{ cancelText }}
</button>
<button @click="handleConfirmAction" class="px-4 py-2 rounded bg-red-500 hover:bg-red-600 text-white transition">
{{ confirmText }}
</button> </button>
</div> </div>
</div> </div>
@ -90,8 +149,6 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import axios from "axios"; import axios from "axios";
@ -107,44 +164,68 @@ const items = ref([]);
const trays = ref([]); const trays = ref([]);
const loading = ref(true); const loading = ref(true);
const error = ref(null); const error = ref(null);
const alert = ref(null);
const timer = ref(null);
// --- state modal // State modal pindah
const isPopupVisible = ref(false); const isPopupVisible = ref(false);
const selectedItem = ref(null); const selectedItem = ref(null);
const selectedTrayId = ref(""); const selectedTrayId = ref("");
const errorMove = ref("");
const isMoving = ref(false);
// State modal konfirmasi
const isConfirmModalVisible = ref(false);
const confirmModalTitle = ref("");
const confirmModalMessage = ref("");
const confirmText = ref("Ya, Konfirmasi");
const cancelText = ref("Batal");
// QR Code generator // QR Code generator
const qrCodeUrl = computed(() => { const qrCodeUrl = computed(() => {
if (selectedItem.value) { if (selectedItem.value) {
const data = `ITM-${selectedItem.value.id}-${selectedItem.value.produk.nama.replace(/\s/g, "")}`; const data = `ITM-${selectedItem.value.id}-${selectedItem.value.produk.nama.replace(/\s/g, "")}`;
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent( return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
data
)}`;
} }
return ""; return "";
}); });
// --- fungsi modal // Computed untuk statistik
const totalWeight = computed(() => {
const total = filteredItems.value.reduce((sum, item) => sum + (item.produk.berat || 0), 0);
return total.toFixed(2);
});
const filteredItems = computed(() => {
if (!props.search) return items.value;
return items.value.filter((item) =>
item.produk?.nama?.toLowerCase().includes(props.search.toLowerCase()) ||
item.kode_item?.toLowerCase().includes(props.search.toLowerCase())
);
});
// Fungsi modal pindah
const openMovePopup = (item) => { const openMovePopup = (item) => {
selectedItem.value = item; selectedItem.value = item;
if (item.id_nampan) { selectedTrayId.value = "";
// sudah ada di nampan tertentu errorMove.value = "";
selectedTrayId.value = item.id_nampan;
} else {
// 🗄 kalau belum ada, default ke "Brankas"
const brankas = trays.value.find(t => t.nama.toLowerCase() === "brankas");
selectedTrayId.value = brankas ? brankas.id : "";
}
isPopupVisible.value = true; isPopupVisible.value = true;
}; };
const closePopup = () => { const closePopup = () => {
isPopupVisible.value = false; isPopupVisible.value = false;
selectedItem.value = null; selectedItem.value = null;
selectedTrayId.value = ""; selectedTrayId.value = "";
errorMove.value = "";
isMoving.value = false;
}; };
const saveMove = async () => { const saveMove = async () => {
if (!selectedTrayId.value || !selectedItem.value) return; if (!selectedTrayId.value || !selectedItem.value || isMoving.value) return;
errorMove.value = "";
isMoving.value = true;
try { try {
await axios.put( await axios.put(
`/api/item/${selectedItem.value.id}`, `/api/item/${selectedItem.value.id}`,
@ -157,15 +238,87 @@ const saveMove = async () => {
} }
); );
// Tampilkan alert sukses
const trayName = trays.value.find(t => t.id === selectedTrayId.value)?.nama;
alert.value = { success: `Item berhasil dipindahkan ke nampan "${trayName}"` };
await refreshData(); await refreshData();
closePopup(); closePopup();
// Auto hide alert
clearTimeout(timer.value);
timer.value = setTimeout(() => { alert.value = null; }, 3000);
} catch (err) { } catch (err) {
console.error("Gagal memindahkan item:", err.response?.data || err); console.error("Gagal memindahkan item:", err.response?.data || err);
alert("Gagal memindahkan item. Silakan coba lagi."); errorMove.value = err.response?.data?.message || "Gagal memindahkan item. Silakan coba lagi.";
} finally {
isMoving.value = false;
} }
}; };
// --- ambil data // Fungsi modal konfirmasi
const closeConfirmModal = () => {
isConfirmModalVisible.value = false;
confirmModalTitle.value = "";
confirmModalMessage.value = "";
confirmText.value = "Ya, Konfirmasi";
cancelText.value = "Batal";
};
const handleConfirmAction = async () => {
// Implementasi aksi konfirmasi jika diperlukan
closeConfirmModal();
};
// Fungsi utilitas
const printQR = () => {
if (qrCodeUrl.value) {
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<html>
<head>
<title>Print QR Code - ${selectedItem.value.kode_item}</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 20px;
}
.qr-container {
border: 2px solid #ccc;
padding: 20px;
display: inline-block;
margin: 20px;
}
.item-info {
margin-top: 10px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="qr-container">
<img src="${qrCodeUrl.value}" alt="QR Code" style="width: 200px; height: 200px;" />
<div class="item-info">
<div style="font-weight: bold; margin-bottom: 5px;">${selectedItem.value.kode_item}</div>
<div>${selectedItem.value.produk.nama}</div>
<div style="color: #666; margin-top: 5px;">${selectedItem.value.produk.berat}g</div>
</div>
</div>
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
}
};
const handleImageError = (event) => {
event.target.style.display = 'none';
};
// Ambil data
const refreshData = async () => { const refreshData = async () => {
try { try {
const [itemRes, trayRes] = await Promise.all([ const [itemRes, trayRes] = await Promise.all([
@ -176,21 +329,30 @@ const refreshData = async () => {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` }, headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
}), }),
]); ]);
items.value = itemRes.data;
// Filter hanya item yang ada di brankas (id_nampan = null atau tidak ada)
items.value = itemRes.data.filter(item => !item.id_nampan);
trays.value = trayRes.data; trays.value = trayRes.data;
} catch (err) { } catch (err) {
error.value = err.message || "Gagal mengambil data"; console.error("Error fetching data:", err);
alert.value = { error: err.response?.data?.message || "Gagal mengambil data" };
clearTimeout(timer.value);
timer.value = setTimeout(() => { alert.value = null; }, 5000);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
onMounted(refreshData); onMounted(refreshData);
const filteredItems = computed(() => {
if (!props.search) return items.value;
return items.value.filter((item) =>
item.produk?.nama?.toLowerCase().includes(props.search.toLowerCase())
);
});
</script> </script>
<style scoped>
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.animate-fadeIn {
animation: fadeIn 0.25s ease-out forwards;
}
</style>

View File

@ -1,32 +1,20 @@
<template> <template>
<div <div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
v-if="isOpen" <div class="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 transform transition-all">
class="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]" <h2 class="text-xl font-bold text-gray-800 mb-3 text-center">
> {{ title }}
<div </h2>
class="bg-white rounded-lg shadow-lg p-6 w-[350px] text-center relative"
>
<!-- Judul -->
<p class="text-lg font-semibold mb-2">{{ props.title }}?</p>
<!-- Deskripsi tambahan --> <p class="text-gray-600 text-sm mb-6 text-center leading-relaxed" v-html="message"></p>
<p class="text-sm text-gray-600 mb-4">
{{ props.message }}
</p>
<!-- Tombol aksi -->
<div class="flex justify-center gap-3"> <div class="flex justify-center gap-3">
<button <button @click="$emit('cancel')"
@click="$emit('cancel')" class="px-5 py-2 rounded-lg font-semibold border border-gray-300 text-gray-700 hover:bg-gray-100 transition">
class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400" {{ cancelText }}
>
Batal
</button> </button>
<button <button @click="$emit('confirm')"
@click="$emit('confirm')" class="px-5 py-2 rounded-lg font-semibold bg-red-500 text-white hover:bg-red-600 transition">
class="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600" {{ confirmText }}
>
Hapus
</button> </button>
</div> </div>
</div> </div>
@ -35,8 +23,38 @@
<script setup> <script setup>
const props = defineProps({ const props = defineProps({
isOpen: Boolean, isOpen: {
title:String, type: Boolean,
message:String required: true,
},
title: {
type: String,
required: true,
},
message: {
type: String,
required: true,
},
confirmText: {
type: String,
default: 'Ya, Konfirmasi',
},
cancelText: {
type: String,
default: 'Batal',
},
}); });
// Mendefinisikan events yang dapat di-emit oleh komponen
defineEmits(['confirm', 'cancel']);
</script> </script>
<style scoped>
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.animate-fadeIn {
animation: fadeIn 0.25s ease-out forwards;
}
</style>

View File

@ -1,9 +1,29 @@
<template> <template>
<div> <div>
<!-- Tampilkan berat rata-rata -->
<div class="bg-A border border-C rounded-xl p-4 mx-6 mb-6">
<div class="flex flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-A rounded-lg">
<i class="fas fa-weight text-D"></i>
</div>
<div class="flex flex-col sm:flex-row sm:gap-6 text-sm text-gray-600">
<span>Total: {{ totalTrays }}</span>
<span>Berisi: {{ nonEmptyTrays }}</span>
<span>Kosong: {{ emptyTrays }}</span>
</div>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-D">{{ averageWeight }}g</div>
<div class="text-sm text-gray-500">Rata-rata</div>
</div>
</div>
</div>
<div v-if="loading" class="flex justify-center items-center h-screen"> <div v-if="loading" class="flex justify-center items-center h-screen">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"></div> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"></div>
<span class="ml-2 text-gray-600">Memuat data...</span> <span class="ml-2 text-gray-600">Memuat data...</span>
</div> </div>
<div v-else-if="error" class="text-center text-red-500 py-6">{{ error }}</div> <div v-else-if="error" class="text-center text-red-500 py-6">{{ error }}</div>
@ -12,16 +32,13 @@
</div> </div>
<!-- Grid Card --> <!-- Grid Card -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-stretch"> <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-stretch px-6">
<div <div v-for="tray in filteredTrays" :key="tray.id"
v-for="tray in filteredTrays" class="border border-C rounded-xl p-4 shadow-sm hover:shadow-md transition flex flex-col h-full">
:key="tray.id"
class="border border-C rounded-xl p-4 shadow-sm hover:shadow-md transition flex flex-col h-full"
>
<!-- Header Card --> <!-- Header Card -->
<div class="flex justify-between items-center mb-3"> <div class="flex justify-between items-center mb-3">
<h2 class="font-bold text-lg text-[#102C57]">{{ tray.nama }}</h2> <h2 class="font-bold text-lg text-[#102C57]">{{ tray.nama }}</h2>
<div class="flex gap-2"> <div class="flex gap-2" v-if="isAdmin">
<button class="p-1 rounded" @click="emit('edit', tray)"> <button class="p-1 rounded" @click="emit('edit', tray)">
<i class="fa fa-pen fa-sm text-yellow-500 hover:text-yellow-600"></i> <i class="fa fa-pen fa-sm text-yellow-500 hover:text-yellow-600"></i>
</button> </button>
@ -32,27 +49,16 @@
</div> </div>
<!-- Isi Card (Max tinggi 3 item + scroll kalau lebih) --> <!-- Isi Card (Max tinggi 3 item + scroll kalau lebih) -->
<div <div v-if="tray.items && tray.items.length" class="space-y-2 flex-1 overflow-y-auto h-[160px] pr-1">
v-if="tray.items && tray.items.length" <div v-for="item in tray.items" :key="item.id"
class="space-y-2 flex-1 overflow-y-auto max-h-[160px] pr-1"
>
<div
v-for="item in tray.items"
:key="item.id"
class="flex justify-between items-center border border-C rounded-lg p-2 cursor-pointer hover:bg-gray-50" class="flex justify-between items-center border border-C rounded-lg p-2 cursor-pointer hover:bg-gray-50"
@click="openMovePopup(item)" @click="openMovePopup(item)">
>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img <img v-if="item.produk.foto && item.produk.foto.length > 0" :src="item.produk.foto[0].url"
v-if="item.produk.foto && item.produk.foto.length > 0" alt="foto produk" class="size-12 object-cover rounded" />
:src="item.produk.foto[0].url" <div class="text-D">
alt="foto produk"
class="size-12 object-cover rounded"
/>
<div class="text-[#102C57]">
<p class="text-sm">{{ item.produk.nama }}</p> <p class="text-sm">{{ item.produk.nama }}</p>
<p class="text-sm">{{ item.produk.kategori }}</p> <p class="text-sm font-medium">{{ item.kode_item }}</p>
<p class="text-sm">{{ item.produk.harga_jual.toLocaleString() }}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -69,64 +75,54 @@
<!-- Footer Card --> <!-- Footer Card -->
<div class="border-t border-C mt-3 pt-2 text-right font-semibold"> <div class="border-t border-C mt-3 pt-2 text-right font-semibold">
Berat Total: {{ totalWeight(tray) }}g <div class="flex justify-between items-center">
<span class="text-sm text-gray-500">{{ tray.items?.length || 0 }} item</span>
<span class="text-lg">Berat Total: {{ totalWeight(tray) }}g</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Pop-up pindah item --> <!-- Pop-up pindah item -->
<div <div v-if="isPopupVisible" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
v-if="isPopupVisible"
class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
>
<div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative"> <div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative">
<div class="flex justify-center mb-4"> <div class="flex justify-center mb-2">
<div class="p-2 border rounded-lg"> <div class="p-2 border rounded-lg">
<img :src="qrCodeUrl" alt="QR Code" class="size-36" /> <img :src="qrCodeUrl" alt="QR Code" class="size-36" />
</div> </div>
</div> </div>
<div class="text-center text-gray-700 font-medium mb-1"> <div class="text-center text-D font-bold text-lg">
{{ selectedItem.produk.nama }} {{ selectedItem.kode_item }}
</div> </div>
<div class="text-center text-gray-500 text-sm mb-4"> <div class="text-center text-gray-700 font-medium mb-3">
{{ selectedItem.produk.kategori }} {{ selectedItem.produk.nama }}
</div> </div>
<div class="flex justify-center mb-4"> <div class="flex justify-center mb-4">
<button class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition"> <button @click="printQR" class="bg-D text-A px-4 py-2 rounded hover:bg-D/80 transition">
Cetak <i class="fas fa-print mr-2"></i>Cetak
</button> </button>
</div> </div>
<!-- Dropdown --> <!-- Dropdown -->
<div class="mb-4"> <div class="mb-4">
<label for="tray-select" class="block text-sm font-medium mb-1">Nama Nampan</label> <label for="tray-select" class="block text-sm font-medium mb-1">Nama Nampan</label>
<select <InputSelect v-if="isAdmin" v-model="selectedTrayId"
id="tray-select" :options="trays.map(tray => ({ label: tray.nama, value: tray.id }))" placeholder="Pilih Nampan"
v-model="selectedTrayId" class="mt-2" />
class="w-full rounded-md border shadow-sm focus:outline-none focus:ring focus:ring-indigo-200" <div class="bg-A px-3 py-2 rounded text-D font-medium" v-else>
> {{trays.find(tray => tray.id === selectedTrayId)?.nama}}
<option v-for="tray in trays" :key="tray.id" :value="tray.id"> </div>
{{ tray.nama }}
</option>
</select>
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button @click="closePopup" class="px-4 py-2 rounded bg-gray-400 hover:bg-gray-500 text-white transition">
@click="closePopup" {{ isAdmin ? 'Batal' : 'Tutup' }}
class="px-4 py-2 rounded border text-gray-700 hover:bg-gray-100 transition"
>
Batal
</button> </button>
<button <button v-if="isAdmin" @click="saveMove" :disabled="!selectedTrayId" class="px-4 py-2 rounded transition"
@click="saveMove" :class="selectedTrayId ? 'bg-C hover:bg-C/80 text-D' : 'bg-gray-400 cursor-not-allowed'">
:disabled="!selectedTrayId"
class="px-4 py-2 rounded text-white transition"
:class="selectedTrayId ? 'bg-orange-500 hover:bg-orange-600' : 'bg-gray-400 cursor-not-allowed'"
>
Simpan Simpan
</button> </button>
</div> </div>
@ -137,10 +133,14 @@
<script setup> <script setup>
import { ref, onMounted, computed } from "vue"; import { ref, onMounted, computed } from "vue";
import axios from "axios"; import axios from "axios";
import InputSelect from "./InputSelect.vue";
const isAdmin = localStorage.getItem("role") === "owner";
const props = defineProps({ const props = defineProps({
search: { type: String, default: "" }, search: { type: String, default: "" },
}); });
const emit = defineEmits(["edit", "delete"]); const emit = defineEmits(["edit", "delete"]);
const trays = ref([]); const trays = ref([]);
const loading = ref(true); const loading = ref(true);
@ -160,6 +160,48 @@ const qrCodeUrl = computed(() => {
return ""; return "";
}); });
const printQR = () => {
if (qrCodeUrl.value) {
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<html>
<head>
<title>Print QR Code - ${selectedItem.value.kode_item}</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 20px;
}
.qr-container {
border: 2px solid #ccc;
padding: 20px;
display: inline-block;
margin: 20px;
}
.item-info {
margin-top: 10px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="qr-container">
<img src="${qrCodeUrl.value}" alt="QR Code" style="width: 200px; height: 200px;" />
<div class="item-info">
<div style="font-weight: bold; margin-bottom: 5px;">${selectedItem.value.kode_item}</div>
<div>${selectedItem.value.produk.nama}</div>
<div style="color: #666; margin-top: 5px;">${selectedItem.value.produk.berat}g</div>
</div>
</div>
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
}
};
// --- Fungsi Pop-up --- // --- Fungsi Pop-up ---
const openMovePopup = (item) => { const openMovePopup = (item) => {
selectedItem.value = item; selectedItem.value = item;
@ -190,32 +232,6 @@ const saveMove = async () => {
closePopup(); closePopup();
} catch (err) { } catch (err) {
console.error("Gagal memindahkan item:", err.response?.data || err); console.error("Gagal memindahkan item:", err.response?.data || err);
alert("Gagal memindahkan item. Silakan coba lagi.");
}
};
// --- Ambil data nampan + item ---
const refreshData = async () => {
try {
const [nampanRes, itemRes] = await Promise.all([
axios.get("/api/nampan", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
}),
axios.get("/api/item", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
}),
]);
const nampans = nampanRes.data;
const items = itemRes.data;
trays.value = nampans.map((tray) => ({
...tray,
items: items.filter((item) => Number(item.id_nampan) === Number(tray.id)),
}));
} catch (err) {
error.value = err.message || "Gagal mengambil data";
} finally {
loading.value = false;
} }
}; };
@ -226,6 +242,48 @@ const totalWeight = (tray) => {
return total.toFixed(2); return total.toFixed(2);
}; };
// Computed untuk statistik berat rata-rata
const averageWeight = computed(() => {
const nonEmptyTraysData = trays.value.filter(tray => {
const weight = parseFloat(totalWeight(tray));
return weight > 0;
});
if (nonEmptyTraysData.length === 0) return "0.00";
const totalWeightSum = nonEmptyTraysData.reduce((sum, tray) => {
return sum + parseFloat(totalWeight(tray));
}, 0);
const average = totalWeightSum / nonEmptyTraysData.length;
return average.toFixed(2);
});
// Computed untuk statistik tambahan
const totalTrays = computed(() => trays.value.length);
const nonEmptyTrays = computed(() => {
return trays.value.filter(tray => parseFloat(totalWeight(tray)) > 0).length;
});
const emptyTrays = computed(() => {
return trays.value.filter(tray => parseFloat(totalWeight(tray)) === 0).length;
});
// --- Ambil data nampan + item ---
const refreshData = async () => {
try {
const nampanRes = await axios.get("/api/nampan", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
trays.value = nampanRes.data;
} catch (err) {
error.value = err.message || "Gagal mengambil data";
} finally {
loading.value = false;
}
};
// Filter nampan // Filter nampan
const filteredTrays = computed(() => { const filteredTrays = computed(() => {
if (!props.search) return trays.value; if (!props.search) return trays.value;
@ -237,4 +295,4 @@ const filteredTrays = computed(() => {
onMounted(() => { onMounted(() => {
refreshData(); refreshData();
}); });
</script> </script>

View File

@ -1,12 +1,10 @@
<template> <template>
<div class="flex justify-end mb-3"> <div class="border border-C bg-A rounded-md w-full relative items-center">
<div class="border border-C bg-A rounded-md w-full sm:w-64 relative items-center"> <input v-model="searchText" type="text" placeholder="Cari ..."
<input v-model="searchText" type="text" placeholder="Cari ..." class="focus:outline-none focus:ring-2 focus:ring-blue-400 rounded-md w-full px-3 py-2 "
class="focus:outline-none focus:ring-2 focus:ring-blue-400 rounded-md w-full px-3 py-2 " @input="$emit('update:search', searchText)" />
@input="$emit('update:search', searchText)" /> <div class="absolute right-3 top-1/2 -translate-y-1/2 text-C">
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-C"> <i class="fas fa-search"></i>
<i class="fas fa-search"></i>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -2,7 +2,11 @@
<mainLayout> <mainLayout>
<div class="p-6"> <div class="p-6">
<p class="font-serif italic text-[25px] text-D">BRANKAS</p> <p class="font-serif italic text-[25px] text-D">BRANKAS</p>
<searchbar v-model:search="searchQuery" /> <div class="flex justify-end">
<div class="w-full sm:w-64 my-3">
<searchbar v-model:search="searchQuery"/>
</div>
</div>
<BrankasList :search="searchQuery" /> <BrankasList :search="searchQuery" />
</div> </div>
</mainLayout> </mainLayout>

View File

@ -1,239 +1,195 @@
<template> <template>
<mainLayout> <mainLayout>
<!-- Header --> <div class="p-6 flex flex-col sm:flex-row justify-between items-start gap-3">
<div class="p-6"> <p class="font-serif italic text-[25px] text-D">NAMPAN</p>
<!-- Judul -->
<p class="font-serif italic text-[25px] text-D">NAMPAN</p>
<!-- Searchbar --> <div class="flex flex-col gap-3 justify-end w-full sm:w-auto">
<div class="flex justify-end mt-2"> <Searchbar v-model:search="searchQuery" />
<div class="w-64"> <div class="flex w-full gap-2" v-if="isAdmin">
<searchbar v-model:search="searchQuery" /> <button @click="openModal" class="px-4 py-2 sm:px-2 sm:py-1 hover:bg-B bg-C rounded-md shadow w-full">
</div> Tambah Nampan
</div> </button>
<button @click="promptEmptyAllTrays" class="px-4 py-2 sm:px-2 sm:py-1 bg-red-500 hover:bg-red-600 text-white rounded-md w-full">
<!-- Tombol --> Kosongkan Semua Nampan
<div class="flex gap-2 mt-3 justify-end"> </button>
<!-- Tambah Nampan -->
<button
@click="openModal"
class="px-4 py-2 hover:bg-B bg-C rounded-md shadow font-semibold"
>
Tambah Nampan
</button>
<!-- Kosongkan -->
<button
@click="openConfirmModal"
class="px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md"
>
Kosongkan
</button>
</div>
</div> </div>
</div>
<!-- Search + List -->
<TrayList :search="searchQuery" @edit="editTray" @delete="deleteTray" />
<!-- Modal Tambah/Edit Nampan -->
<div
v-if="showModal"
class="fixed inset-0 bg-black/75 flex justify-center items-center z-50 backdrop-blur-sm"
>
<div
class="bg-white rounded-lg shadow-lg p-6 w-96 transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn"
>
<h2 class="text-lg font-semibold mb-4 text-[#102c57]">
{{ editingTrayId ? "Edit Nampan" : "Tambah Nampan" }}
</h2>
<label class="block mb-2 text-sm font-medium text-[#102c57]">
Nama Nampan
</label>
<input
v-model="trayName"
type="text"
placeholder="Contoh: A4"
class="w-full border rounded-md p-2 mb-4"
/>
<div class="flex justify-end gap-2">
<button
@click="closeModal"
class="px-4 py-2 bg-gray-400 hover:bg-gray-500 text-white rounded-md"
>
Cancel
</button>
<button
@click="saveTray"
class="px-4 py-2 bg-[#DAC0A3] hover:bg-[#C9A77E] rounded-md text-[#102c57]"
>
Save
</button>
</div> </div>
</div>
</div>
<!-- Modal Konfirmasi Kosongkan --> <div class="px-6" v-if="alert">
<div <div v-if="alert.error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
v-if="showConfirmModal" <strong class="font-bold">Error!</strong>
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" <span class="block sm:inline">{{ alert.error }}</span>
> </div>
<div <div v-if="alert.success" class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4" role="alert">
class="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn" <strong class="font-bold">Success!</strong>
> <span class="block sm:inline">{{ alert.success }}</span>
<!-- Judul --> </div>
<h2 class="text-xl font-bold text-[#102c57] mb-3 text-center"> </div>
Kosongkan semua nampan?
</h2>
<!-- Deskripsi --> <TrayList :search="searchQuery" @edit="editTray" @delete="promptDeleteTray" />
<p class="text-gray-600 text-sm mb-6 text-center leading-relaxed">
Semua item akan dimasukkan ke <span class="font-semibold">Brankas</span>.<br />
Masuk ke menu <b>Brankas</b> untuk mengembalikan item ke nampan.
</p>
<!-- Tombol --> <!-- Modal untuk tambah/edit nampan -->
<div class="flex justify-center gap-3"> <div v-if="showModal" class="fixed inset-0 bg-black/75 flex justify-center items-center z-50 backdrop-blur-sm">
<button <div class="bg-white rounded-lg shadow-lg p-6 w-96 transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
@click="closeConfirmModal" <h2 class="text-lg font-semibold mb-4 text-D">
class="px-5 py-2 rounded-lg font-semibold border border-gray-300 text-gray-700 hover:bg-gray-100 transition" {{ editingTrayId ? "Edit Nampan" : "Tambah Nampan" }}
> </h2>
Batal <label class="block mb-2 text-sm font-medium text-D" for="tray-name">Nama Nampan</label>
</button> <InputField id="tray-name" v-model="trayName" type="text" placeholder="Contoh: A1" class="mb-1" />
<button <p v-if="errorCreate" class="text-red-500 text-sm mb-4">{{ errorCreate }}</p>
@click="confirmEmptyTray" <div class="flex justify-end mt-3 gap-2">
class="px-5 py-2 rounded-lg font-semibold bg-red-500 text-white hover:bg-red-600 transition" <button @click="closeModal" class="px-4 py-2 bg-gray-400 hover:bg-gray-500 text-white rounded-md">Batal</button>
> <button @click="saveTray" class="px-4 py-2 bg-C hover:bg-C/80 rounded-md text-D">Simpan</button>
Ya, Kosongkan
</button>
</div>
</div>
</div> </div>
</mainLayout> </div>
</div>
<!-- Komponen ConfirmDeleteModal yang diperbaiki -->
<ConfirmDeleteModal
:isOpen="isConfirmModalVisible"
:title="confirmModalTitle"
:message="confirmModalMessage"
:confirmText="confirmText"
:cancelText="cancelText"
@confirm="handleConfirmAction"
@cancel="closeConfirmModal"
/>
</mainLayout>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref } from "vue";
import axios from "axios"; import axios from "axios";
import mainLayout from "../layouts/mainLayout.vue"; import mainLayout from "../layouts/mainLayout.vue";
import searchbar from "../components/Searchbar.vue"; import Searchbar from "../components/Searchbar.vue";
import TrayList from "../components/TrayList.vue"; import TrayList from "../components/TrayList.vue";
import InputField from "../components/InputField.vue";
import ConfirmDeleteModal from "../components/ConfirmDeleteModal.vue";
const isAdmin = localStorage.getItem("role") === "owner";
const searchQuery = ref(""); const searchQuery = ref("");
const showModal = ref(false); const showModal = ref(false);
const showConfirmModal = ref(false);
const trayName = ref(""); const trayName = ref("");
const editingTrayId = ref(null); const editingTrayId = ref(null);
const errorCreate = ref("");
const timer = ref(null);
const alert = ref(null);
// State untuk modal konfirmasi
const isConfirmModalVisible = ref(false);
const confirmModalTitle = ref("");
const confirmModalMessage = ref("");
const confirmText = ref("Ya, Konfirmasi");
const cancelText = ref("Batal");
const trayToDeleteId = ref(null);
// buka modal tambah/edit const openModal = () => { showModal.value = true; };
const openModal = () => {
showModal.value = true;
};
const closeModal = () => { const closeModal = () => {
trayName.value = ""; trayName.value = "";
editingTrayId.value = null; editingTrayId.value = null;
showModal.value = false; showModal.value = false;
}; };
// simpan nampan
const saveTray = async () => { const saveTray = async () => {
if (!trayName.value.trim()) { if (!trayName.value.trim()) {
alert("Nama Nampan tidak boleh kosong"); errorCreate.value = "Nama nampan tidak boleh kosong.";
return; clearTimeout(timer.value);
} timer.value = setTimeout(() => { errorCreate.value = ""; }, 3000);
try { return;
if (editingTrayId.value) { }
await axios.put( try {
`/api/nampan/${editingTrayId.value}`, const token = localStorage.getItem("token");
{ nama: trayName.value }, const headers = { Authorization: `Bearer ${token}` };
{ if (editingTrayId.value) {
headers: { await axios.put(`/api/nampan/${editingTrayId.value}`, { nama: trayName.value }, { headers });
Authorization: `Bearer ${localStorage.getItem("token")}`, alert.value = { success: "Nampan berhasil diperbarui" };
}, } else {
} await axios.post("/api/nampan", { nama: trayName.value }, { headers });
); alert.value = { success: "Nampan berhasil ditambahkan" };
alert("Nampan berhasil diupdate");
} else {
await axios.post(
"/api/nampan",
{ nama: trayName.value },
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
}
);
alert("Nampan berhasil ditambahkan");
}
closeModal();
location.reload();
} catch (error) {
console.error(error);
alert("Gagal menyimpan nampan");
} }
closeModal();
location.reload();
} catch (error) {
console.error(error);
errorCreate.value = error.response?.data?.message || "Gagal menyimpan nampan.";
clearTimeout(timer.value);
timer.value = setTimeout(() => { errorCreate.value = ""; }, 3000);
}
}; };
// === Konfirmasi kosongkan nampan ===
const openConfirmModal = () => {
showConfirmModal.value = true;
};
const closeConfirmModal = () => { const closeConfirmModal = () => {
showConfirmModal.value = false; isConfirmModalVisible.value = false;
trayToDeleteId.value = null;
confirmModalTitle.value = "";
confirmModalMessage.value = "";
confirmText.value = "Ya, Konfirmasi";
cancelText.value = "Batal";
}; };
const confirmEmptyTray = async () => { const promptEmptyAllTrays = () => {
confirmModalTitle.value = "Kosongkan semua nampan?";
confirmModalMessage.value = `Semua item akan dimasukkan ke <span class="font-semibold">Brankas</span>.<br />Masuk ke menu <b>Brankas</b> untuk mengembalikan item ke nampan.`;
confirmText.value = "Ya, Kosongkan";
cancelText.value = "Batal";
trayToDeleteId.value = null;
isConfirmModalVisible.value = true;
};
const promptDeleteTray = (tray) => {
confirmModalTitle.value = `Hapus Nampan "${tray.nama}"?`;
confirmModalMessage.value = "Semua item di dalam nampan ini juga akan dipindahkan ke Brankas. Aksi ini tidak dapat dibatalkan.";
confirmText.value = "Ya, Hapus";
cancelText.value = "Batal";
trayToDeleteId.value = tray.id;
isConfirmModalVisible.value = true;
};
const handleConfirmAction = async () => {
if (trayToDeleteId.value) {
// Hapus nampan spesifik
try { try {
await axios.delete("/api/kosongkan-nampan", { await axios.delete(`/api/nampan/${trayToDeleteId.value}`, {
headers: { headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
Authorization: `Bearer ${localStorage.getItem("token")}`, });
}, alert.value = { success: "Nampan berhasil dihapus" };
}); location.reload();
alert("Semua item berhasil dipindahkan ke Brankas");
closeConfirmModal();
location.reload();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
alert("Gagal mengosongkan nampan"); alert.value = { error: "Gagal menghapus nampan. Silakan coba lagi." };
} }
} else {
// Kosongkan semua nampan
try {
await axios.delete("/api/kosongkan-nampan", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
alert.value = { success: "Semua nampan berhasil dikosongkan" };
location.reload();
} catch (error) {
console.error(error);
alert.value = { error: "Gagal mengosongkan nampan. Silakan coba lagi." };
}
}
closeConfirmModal();
clearTimeout(timer.value);
timer.value = setTimeout(() => { alert.value = null }, 3000);
}; };
// Fungsi untuk edit nampan
const editTray = (tray) => { const editTray = (tray) => {
trayName.value = tray.nama; trayName.value = tray.nama;
editingTrayId.value = tray.id; editingTrayId.value = tray.id;
showModal.value = true; showModal.value = true;
};
const deleteTray = async (id) => {
if (!confirm("Yakin ingin menghapus nampan ini?")) return;
try {
await axios.delete(`/api/nampan/${id}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
alert("Nampan berhasil dihapus");
location.reload();
} catch (error) {
console.error(error);
alert("Gagal menghapus nampan");
}
}; };
</script> </script>
<style> <style scoped>
@keyframes fadeIn { @keyframes fadeIn {
from { from { opacity: 0; transform: scale(0.95); }
opacity: 0; to { opacity: 1; transform: scale(1); }
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
} }
.animate-fadeIn { .animate-fadeIn {
animation: fadeIn 0.25s ease-out forwards; animation: fadeIn 0.25s ease-out forwards;
} }