Kasir/resources/js/pages/InputProduk.vue
2025-10-20 17:28:57 +07:00

507 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<mainLayout>
<!-- Modal Buat Item -->
<CreateItemModal :isOpen="openItemModal" :product="createdProduct" @close="closeItemModal" />
<div class="p-6">
<p class="font-serif italic text-[25px] text-D">Produk Baru</p>
<div class="flex flex-col md:flex-row mt-5 gap-6">
<!-- Form Section -->
<div class="flex-1">
<div class="mb-3">
<label class="block text-D mb-1">Nama Produk</label>
<InputField v-model="form.nama" type="text" placeholder="Masukkan nama produk" @input="errors.nama = null" />
<p v-if="errors.nama" class="text-sm text-red-500 mt-1">{{ errors.nama[0] }}</p>
</div>
<div class="mb-3">
<label class="block text-D mb-1">Kategori</label>
<InputSelect v-model="form.id_kategori" :options="category" placeholder="Pilih kategori" />
</div>
<div class="mb-3 flex flex-row w-full gap-3">
<div class="flex-1">
<label class="block text-D mb-1">Berat (g)</label>
<InputField v-model="form.berat" type="number" step="0.01" placeholder="Masukkan berat"
@input="calculateHargaJual" />
</div>
<div class="flex-1">
<label class="block text-D mb-1">Kadar (K)</label>
<InputField v-model="form.kadar" type="number" placeholder="Masukkan kadar" />
</div>
</div>
<div class="mb-3 flex flex-row w-full gap-3">
<div class="flex-1">
<label class="block text-D mb-1">Harga per Gram</label>
<InputField
v-model="hargaPerGramFormatted"
type="text"
placeholder="Masukkan harga per gram"
@input="formatHargaPerGramInput"
@keypress="onlyNumbers"
/>
</div>
<div class="flex-1">
<label class="block text-D mb-1">Harga Jual</label>
<InputField
v-model="hargaJualFormatted"
type="text"
placeholder="Masukkan harga jual"
@input="formatHargaJualInput"
@keypress="onlyNumbers"
/>
</div>
</div>
</div>
<!-- Image Upload Section -->
<div class="flex-1">
<label class="block text-D mb-1">Foto</label>
<div class="grid grid-cols-3 gap-3 relative">
<!-- Uploaded Images -->
<div v-for="(image, index) in uploadedImages" :key="`img-${image.id}`" class="relative group aspect-square">
<div class="w-full h-full bg-gray-100 rounded-lg border-2 border-gray-200 overflow-hidden">
<img :src="image.url" :alt="`Foto ${index + 1}`" class="w-full h-full object-cover" />
<button @click="removeImage(image.id)" :disabled="uploadLoading"
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold shadow-lg hover:bg-red-600 transition-colors disabled:bg-gray-400">
×
</button>
</div>
</div>
<!-- Upload Button -->
<div v-if="uploadedImages.length < 6" class="relative aspect-square">
<div @drop="handleDrop" @dragover.prevent @dragenter.prevent="isDragging = true"
@dragleave.prevent="isDragging = false" @click="toggleUploadMenu"
class="w-full h-full bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-D hover:bg-blue-50 transition-colors group"
:class="{ 'border-blue-400 bg-blue-50': isDragging, 'cursor-not-allowed opacity-50': uploadLoading }">
<div class="text-center">
<div v-if="!uploadLoading"
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2 group-hover:bg-D transition-colors">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</div>
<div v-else class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="animate-spin w-6 h-6 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</div>
<p class="text-xs text-gray-600 font-medium"
v-html="uploadLoading ? 'Uploading...' : 'Unggah<br/>Foto'"></p>
</div>
</div>
<!-- Dropdown Menu -->
<div v-if="showUploadMenu"
class="absolute top-full left-0 mt-2 w-60 bg-white border border-gray-200 rounded-lg shadow-lg z-20">
<button @click="triggerFileUpload"
class="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-100">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<div>
<div class="font-medium text-gray-900">Upload dari File</div>
<div class="text-sm text-gray-500">Pilih foto dari galeri</div>
</div>
</button>
<button @click="openCameraModal"
class="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<div>
<div class="font-medium text-gray-900">Ambil dari Kamera</div>
<div class="text-sm text-gray-500">Foto langsung dengan kamera</div>
</div>
</button>
</div>
</div>
</div>
<!-- Hidden File Input -->
<input ref="fileInput" type="file" multiple accept="image/jpeg,image/jpg,image/png"
@change="handleFileSelect" class="hidden" />
<p class="text-xs text-gray-500 mt-2">Format: JPG, JPEG, PNG (Max: 2MB per file, Max: 6 foto)</p>
<div v-if="uploadError" class="mt-2 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-600">
{{ uploadError }}
</div>
</div>
</div>
<div class="mt-6 flex justify-end flex-row gap-3">
<button @click="back" class="px-6 py-2 rounded-md bg-gray-400 hover:bg-gray-500 text-white">Batal</button>
<button @click="submitForm(true)" :disabled="loading || !isFormValid"
class="bg-C text-D px-6 py-2 rounded-md hover:bg-B disabled:bg-B disabled:text-white disabled:cursor-not-allowed">
{{ loading ? 'Menyimpan...' : 'Tambah Item' }}
</button>
<button @click="submitForm(false)" :disabled="loading || !isFormValid"
class="bg-C text-D px-6 py-2 rounded-md hover:bg-B disabled:bg-B disabled:text-white disabled:cursor-not-allowed">
{{ loading ? 'Menyimpan...' : 'Simpan' }}
</button>
</div>
</div>
<!-- Overlay -->
<div v-if="showUploadMenu" @click="showUploadMenu = false" class="fixed inset-0 z-10"></div>
<!-- Camera Modal -->
<div v-if="showCamera" class="fixed inset-0 bg-black/75 flex items-center justify-center z-[9999]">
<div class="bg-white w-[480px] rounded-lg shadow-lg p-4 relative">
<video ref="video" autoplay playsinline class="w-full h-64 bg-black rounded"></video>
<canvas ref="canvas" class="hidden"></canvas>
<div class="mt-3 flex justify-between">
<button @click="closeCamera" class="px-4 py-2 bg-gray-400 text-white rounded">Batal</button>
<button @click="capturePhoto" class="px-4 py-2 bg-blue-500 text-white rounded">Ambil Foto</button>
</div>
</div>
</div>
</mainLayout>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import axios from "axios";
import mainLayout from "../layouts/mainLayout.vue";
import InputField from "../components/InputField.vue";
import InputSelect from "../components/InputSelect.vue";
import CreateItemModal from "../components/CreateItemModal.vue";
const router = useRouter();
const form = ref({
nama: '',
id_kategori: null,
berat: null,
kadar: null,
harga_per_gram: null,
harga_jual: null,
});
const category = ref([]);
const showUploadMenu = ref(false);
const fileInput = ref(null);
const loading = ref(false);
const uploadLoading = ref(false);
const uploadedImages = ref([]);
const isDragging = ref(false);
const uploadError = ref('');
const errors = ref({});
const openItemModal = ref(false);
const createdProduct = ref(null);
// Camera states
const showCamera = ref(false);
const video = ref(null);
const canvas = ref(null);
let stream = null;
// Formatted values for harga_per_gram and harga_jual
const hargaPerGramFormatted = ref("");
const hargaJualFormatted = ref("");
// Format angka dengan pemisah ribuan
const formatNumber = (num) => {
if (!num) return "";
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
};
// Menghapus format dan mengambil angka asli
const unformatNumber = (str) => {
if (!str) return null;
const cleaned = str.replace(/\./g, "");
const number = parseFloat(cleaned);
return isNaN(number) ? null : number;
};
// Handler untuk format input harga per gram
const formatHargaPerGramInput = (event) => {
const value = event.target.value;
const cleanValue = value.replace(/\D/g, "");
if (cleanValue) {
const formatted = formatNumber(cleanValue);
hargaPerGramFormatted.value = formatted;
form.value.harga_per_gram = parseFloat(cleanValue);
calculateHargaJual();
} else {
hargaPerGramFormatted.value = "";
form.value.harga_per_gram = null;
calculateHargaJual();
}
};
// Handler untuk format input harga jual
const formatHargaJualInput = (event) => {
const value = event.target.value;
const cleanValue = value.replace(/\D/g, "");
if (cleanValue) {
const formatted = formatNumber(cleanValue);
hargaJualFormatted.value = formatted;
form.value.harga_jual = parseFloat(cleanValue);
} else {
hargaJualFormatted.value = "";
form.value.harga_jual = null;
}
};
// Hanya izinkan angka saat mengetik
const onlyNumbers = (event) => {
const char = String.fromCharCode(event.which);
if (!/[0-9]/.test(char)) {
event.preventDefault();
}
};
const isFormValid = computed(() => {
return (
form.value.nama &&
form.value.id_kategori &&
form.value.berat > 0 &&
form.value.kadar > 0 &&
form.value.harga_per_gram > 0 &&
form.value.harga_jual > 0 &&
uploadedImages.value.length > 0
);
});
const calculateHargaJual = () => {
const berat = parseFloat(form.value.berat) || 0;
const hargaPerGram = parseFloat(form.value.harga_per_gram) || 0;
if (berat > 0 && hargaPerGram > 0) {
const hargaJual = berat * hargaPerGram;
form.value.harga_jual = hargaJual;
hargaJualFormatted.value = formatNumber(hargaJual.toFixed(0));
} else {
form.value.harga_jual = null;
hargaJualFormatted.value = "";
}
};
const loadKategori = async () => {
try {
const response = await axios.get('/api/kategori', {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
if (response.data && Array.isArray(response.data)) {
category.value = response.data.map((cat) => ({ value: cat.id, label: cat.nama }));
}
} catch (error) {
console.error('Error loading categories:', error);
}
};
const loadFoto = async () => {
try {
const response = await axios.get(`/api/foto`, {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
uploadedImages.value = response.data;
} catch (e) {
console.error(e);
uploadError.value = "Gagal memuat foto";
}
};
const toggleUploadMenu = () => {
if (!uploadLoading.value && uploadedImages.value.length < 6) {
showUploadMenu.value = !showUploadMenu.value;
}
};
const triggerFileUpload = () => {
showUploadMenu.value = false;
fileInput.value?.click();
};
const handleFileSelect = (event) => {
const files = Array.from(event.target.files);
uploadFiles(files);
};
const handleDrop = (event) => {
event.preventDefault();
isDragging.value = false;
if (uploadLoading.value || uploadedImages.value.length >= 6) return;
const files = Array.from(event.dataTransfer.files);
uploadFiles(files);
};
const uploadFiles = async (files) => {
uploadError.value = '';
if (uploadedImages.value.length + files.length > 6) {
uploadError.value = 'Maksimal 6 foto yang dapat diupload';
return;
}
const validFiles = files.filter((file) => {
const isValidType = ['image/jpeg', 'image/jpg', 'image/png'].includes(file.type);
const isValidSize = file.size <= 2 * 1024 * 1024;
if (!isValidType) {
uploadError.value = 'Format file harus JPG, JPEG, atau PNG';
return false;
}
if (!isValidSize) {
uploadError.value = 'Ukuran file maksimal 2MB';
return false;
}
return true;
});
if (validFiles.length === 0) return;
uploadLoading.value = true;
try {
for (const file of validFiles) {
const formData = new FormData();
formData.append('foto', file);
const response = await axios.post('/api/foto', formData, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
'Content-Type': 'multipart/form-data',
},
});
uploadedImages.value.push(response.data);
}
if (fileInput.value) fileInput.value.value = '';
} catch (error) {
console.error('Upload error:', error);
uploadError.value = error.response?.data?.message || 'Gagal mengupload foto';
} finally {
uploadLoading.value = false;
}
};
const removeImage = async (id) => {
try {
await axios.delete(`/api/foto/${id}`, {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
uploadedImages.value = uploadedImages.value.filter((i) => i.id !== id);
} catch {
uploadError.value = "Gagal menghapus foto";
}
};
// CAMERA FUNCTIONS
const openCameraModal = async () => {
showUploadMenu.value = false;
showCamera.value = true;
try {
stream = await navigator.mediaDevices.getUserMedia({ video: true });
video.value.srcObject = stream;
} catch (err) {
console.error("Gagal akses kamera:", err);
alert("Tidak bisa mengakses kamera, cek izin browser!");
closeCamera();
}
};
const closeCamera = () => {
showCamera.value = false;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
};
const capturePhoto = () => {
const ctx = canvas.value.getContext("2d");
canvas.value.width = video.value.videoWidth;
canvas.value.height = video.value.videoHeight;
ctx.drawImage(video.value, 0, 0);
canvas.value.toBlob(async (blob) => {
if (!blob) return;
await uploadFiles([new File([blob], "camera_photo.png", { type: "image/png" })]);
closeCamera();
}, "image/png");
};
const submitForm = async (addItem) => {
if (!isFormValid.value) {
alert('Mohon lengkapi semua field yang diperlukan');
return;
}
loading.value = true;
try {
const response = await axios.post('/api/produk', form.value, {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
const createdProductData = response.data.data;
form.value = {
nama: '',
id_kategori: '',
berat: 0,
kadar: 0,
harga_per_gram: 0,
harga_jual: 0,
};
hargaPerGramFormatted.value = "";
hargaJualFormatted.value = "";
uploadedImages.value = [];
uploadError.value = '';
showUploadMenu.value = false;
if (fileInput.value) fileInput.value.value = '';
if (addItem) {
openCreateItemModal(createdProductData);
} else {
window.location.href = '/produk?message=Produk berhasil disimpan';
}
} catch (error) {
console.error('Submit error:', error);
if (error.response?.status === 422) {
errors.value = error.response.data.errors || {};
} else {
errors.value = { general: "Terjadi kesalahan saat menyimpan produk" };
}
} finally {
loading.value = false;
}
};
const back = async () => {
loading.value = true;
try{
console.log(localStorage.getItem("token"));
await axios.delete('/api/all/foto', {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
router.push('/produk');
} catch (e){
console.error("Error image ", e);
} finally {
loading.value = false;
}
};
const openCreateItemModal = (product) => {
createdProduct.value = product;
openItemModal.value = true;
};
const closeItemModal = () => {
openItemModal.value = false;
createdProduct.value = null;
router.push('/produk');
};
onMounted(() => {
loadFoto();
loadKategori();
});
</script>