Compare commits
	
		
			No commits in common. "e1103f1819793bc12d98c93289406604a603b57a" and "edeb3ba932c6fa9bac7262c6711287c0e8f25f76" have entirely different histories.
		
	
	
		
			e1103f1819
			...
			edeb3ba932
		
	
		
							
								
								
									
										2
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|     "name": "Kasir", |     "name": "html", | ||||||
|     "lockfileVersion": 3, |     "lockfileVersion": 3, | ||||||
|     "requires": true, |     "requires": true, | ||||||
|     "packages": { |     "packages": { | ||||||
|  | |||||||
| @ -1,344 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <ConfirmDeleteModal v-if="showDeleteModal" :isOpen="showDeleteModal" title="Konfirmasi" |  | ||||||
|     message="Yakin ingin menghapus item ini?" @confirm="hapusPesanan" @cancel="closeDeleteModal" /> |  | ||||||
| 
 |  | ||||||
|   <!-- Struk Input Overlay --> |  | ||||||
|   <StrukOverlay |  | ||||||
|     v-if="showStruk" |  | ||||||
|     :isOpen="showStruk" |  | ||||||
|     :pesanan="pesanan" |  | ||||||
|     :total="total" |  | ||||||
|     @close="closeStruk" |  | ||||||
|     @transaksi-saved="handleTransaksiSaved" |  | ||||||
|   /> |  | ||||||
| 
 |  | ||||||
|   <!-- Struk View (Print) Overlay --> |  | ||||||
|   <StrukView |  | ||||||
|     v-if="showStrukView" |  | ||||||
|     :isOpen="showStrukView" |  | ||||||
|     :transaksi="savedTransaksi" |  | ||||||
|     @close="closeStrukView" |  | ||||||
|   /> |  | ||||||
| 
 |  | ||||||
|   <div class="p-2 sm:p-4 h-120 relative"> |  | ||||||
|     <!-- Grid Form & Total --> |  | ||||||
|     <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> |  | ||||||
|       <!-- Input Form --> |  | ||||||
|       <div class="order-2 md:order-1 flex flex-col gap-4"> |  | ||||||
|         <!-- Input Kode Item --> |  | ||||||
|         <div> |  | ||||||
|           <label class="block text-sm font-medium text-D">Kode Item *</label> |  | ||||||
|           <div class="flex flex-row justify-between mt-1 w-full rounded-md bg-A shadow-sm sm:text-sm border-B"> |  | ||||||
|             <input type="text" v-model="kodeItem" @keyup.enter="inputItem" placeholder="Scan atau masukkan kode item" |  | ||||||
|               class="bg-A focus:outline-none focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2 w-full rounded-l-md" /> |  | ||||||
|             <button v-if="!loadingItem" @click="inputItem" class="px-3 bg-D hover:bg-D/80 text-A rounded-r-md"> |  | ||||||
|               <i class="fas fa-arrow-right"></i> |  | ||||||
|             </button> |  | ||||||
|             <div v-else class="flex items-center justify-center px-3"> |  | ||||||
|               <div class="rounded-full h-5 w-5 border-b-2 border-A flex items-center justify-center"> |  | ||||||
|                 <i class="fas fa-spinner"></i> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         <!-- Input Harga Jual --> |  | ||||||
|         <div> |  | ||||||
|           <label class="block text-sm font-medium text-D">Harga Jual</label> |  | ||||||
|           <input |  | ||||||
|             type="text" |  | ||||||
|             v-model="hargaJualFormatted" |  | ||||||
|             @input="formatHargaInput" |  | ||||||
|             @keypress="onlyNumbers" |  | ||||||
|             placeholder="Masukkan Harga Jual" |  | ||||||
|             class="bg-A focus:outline-none focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2 w-full rounded-md border border-B shadow-sm sm:text-sm" |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         <!-- Tombol Aksi --> |  | ||||||
|         <div class="flex flex-col sm:flex-row justify-between gap-2"> |  | ||||||
|           <button @click="tambahItem" |  | ||||||
|             class="w-full sm:w-auto px-4 py-2 rounded-md bg-C text-D font-medium hover:bg-C/80 transition"> |  | ||||||
|             Tambah Item |  | ||||||
|           </button> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       <!-- Total --> |  | ||||||
|       <div class="order-1 md:order-2 flex flex-col md:flex-row md:items-center md:justify-center gap-1"> |  | ||||||
|         <div class="text-left md:text-start"> |  | ||||||
|           <span class="block text-gray-600 font-medium">Total:</span> |  | ||||||
|           <span class="text-2xl sm:text-3xl font-bold text-D"> |  | ||||||
|             Rp{{ total.toLocaleString() }},- |  | ||||||
|           </span> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
| 
 |  | ||||||
|     <!-- Error & Info --> |  | ||||||
|     <div class="mb-4"> |  | ||||||
|       <p v-if="error" :class="{ 'animate-shake': error }" class="text-sm text-red-600 mt-1"> |  | ||||||
|         {{ error }} |  | ||||||
|       </p> |  | ||||||
|       <p v-if="info" class="text-sm text-C mt-1">{{ info }}</p> |  | ||||||
|     </div> |  | ||||||
| 
 |  | ||||||
|     <!-- Table Responsive --> |  | ||||||
|     <div class="overflow-x-auto"> |  | ||||||
|       <table class="w-full border border-B rounded-lg overflow-hidden text-xs sm:text-sm"> |  | ||||||
|         <thead class="bg-A text-D"> |  | ||||||
|           <tr> |  | ||||||
|             <th class="border border-B p-2 w-8">No</th> |  | ||||||
|             <th class="border border-B p-2">Nama Produk</th> |  | ||||||
|             <th class="border border-B p-2">Posisi</th> |  | ||||||
|             <th class="border border-B p-2">Harga</th> |  | ||||||
|             <th class="border border-B p-2 w-10"></th> |  | ||||||
|           </tr> |  | ||||||
|         </thead> |  | ||||||
|         <tbody> |  | ||||||
|           <tr v-if="pesanan.length == 0" class="text-center text-D/70"> |  | ||||||
|             <td colspan="5" class="h-16 border border-B text-xs sm:text-sm"> |  | ||||||
|               Belum ada item dipesan |  | ||||||
|             </td> |  | ||||||
|           </tr> |  | ||||||
|           <tr v-else v-for="(item, index) in pesanan" :key="index" class="hover:bg-gray-50 text-center"> |  | ||||||
|             <td class="border border-B p-2">{{ index + 1 }}</td> |  | ||||||
|             <td class="border border-B p-2 text-left truncate max-w-[120px] sm:max-w-none"> |  | ||||||
|               {{ item.produk.nama }} |  | ||||||
|             </td> |  | ||||||
|             <td class="border border-B p-2 truncate max-w-[80px]"> |  | ||||||
|               {{ item.nampan ? item.nampan.nama : "Brankas" }} |  | ||||||
|             </td> |  | ||||||
|             <td class="border border-B p-2 whitespace-nowrap"> |  | ||||||
|               Rp{{ item.harga_deal.toLocaleString() }} |  | ||||||
|             </td> |  | ||||||
|             <td class="border border-B p-2 text-center"> |  | ||||||
|               <button @click="openDeleteModal(index)" class="text-red-500 hover:text-red-700"> |  | ||||||
|                 <i class="fas fa-trash"></i> |  | ||||||
|               </button> |  | ||||||
|             </td> |  | ||||||
|           </tr> |  | ||||||
|         </tbody> |  | ||||||
|       </table> |  | ||||||
|     </div> |  | ||||||
|     <div class="absolute bottom-0 right-1"> |  | ||||||
|       <button @click="konfirmasiPenjualan" |  | ||||||
|         class="w-full sm:w-auto px-6 py-2 rounded-md bg-D text-A font-semibold hover:bg-D/80 transition"> |  | ||||||
|         Lanjut |  | ||||||
|       </button> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| 
 |  | ||||||
| <script setup> |  | ||||||
| import { ref, computed } from "vue"; |  | ||||||
| import axios from "axios"; |  | ||||||
| import ConfirmDeleteModal from "./ConfirmDeleteModal.vue"; |  | ||||||
| import StrukOverlay from "./StrukOverlay.vue"; |  | ||||||
| import StrukView from "./StrukView.vue"; |  | ||||||
| 
 |  | ||||||
| // Emit untuk komunikasi dengan parent |  | ||||||
| const emit = defineEmits(['transaksi-saved']); |  | ||||||
| 
 |  | ||||||
| const kodeItem = ref(""); |  | ||||||
| const info = ref(""); |  | ||||||
| const error = ref(""); |  | ||||||
| const hargaJual = ref(null); |  | ||||||
| const hargaJualFormatted = ref(""); |  | ||||||
| const item = ref(null); |  | ||||||
| const loadingItem = ref(false); |  | ||||||
| const pesanan = ref([]); |  | ||||||
| const showDeleteModal = ref(false); |  | ||||||
| const deleteIndex = ref(null); |  | ||||||
| 
 |  | ||||||
| const showStruk = ref(false); |  | ||||||
| const showStrukView = ref(false); |  | ||||||
| const savedTransaksi = ref(null); |  | ||||||
| 
 |  | ||||||
| let errorTimeout = null; |  | ||||||
| let infoTimeout = null; |  | ||||||
| 
 |  | ||||||
| const formatNumber = (num) => { |  | ||||||
|   if (!num) return ""; |  | ||||||
|   return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "."); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const unformatNumber = (str) => { |  | ||||||
|   if (!str) return null; |  | ||||||
|   const cleaned = str.replace(/\./g, ""); |  | ||||||
|   const number = parseInt(cleaned); |  | ||||||
|   return isNaN(number) ? null : number; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const formatHargaInput = (event) => { |  | ||||||
|   const value = event.target.value; |  | ||||||
|   const cleanValue = value.replace(/\D/g, ""); |  | ||||||
| 
 |  | ||||||
|   if (cleanValue) { |  | ||||||
|     const formatted = formatNumber(cleanValue); |  | ||||||
|     hargaJualFormatted.value = formatted; |  | ||||||
|     hargaJual.value = parseInt(cleanValue); |  | ||||||
|   } else { |  | ||||||
|     hargaJualFormatted.value = ""; |  | ||||||
|     hargaJual.value = null; |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const onlyNumbers = (event) => { |  | ||||||
|   const char = String.fromCharCode(event.which); |  | ||||||
|   if (!/[0-9]/.test(char)) { |  | ||||||
|     event.preventDefault(); |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const inputItem = async () => { |  | ||||||
|   if (!kodeItem.value) return; |  | ||||||
| 
 |  | ||||||
|   info.value = ""; |  | ||||||
|   error.value = ""; |  | ||||||
|   clearTimeout(infoTimeout); |  | ||||||
|   clearTimeout(errorTimeout); |  | ||||||
| 
 |  | ||||||
|   loadingItem.value = true; |  | ||||||
| 
 |  | ||||||
|   try { |  | ||||||
|     const response = await axios.get(`/api/item/${kodeItem.value}`, { |  | ||||||
|       headers: { |  | ||||||
|         Authorization: `Bearer ${localStorage.getItem("token")}`, |  | ||||||
|       }, |  | ||||||
|     }); |  | ||||||
|     item.value = response.data; |  | ||||||
|     hargaJual.value = item.value.produk.harga_jual; |  | ||||||
|     hargaJualFormatted.value = formatNumber(item.value.produk.harga_jual); |  | ||||||
| 
 |  | ||||||
|     if (item.value.is_sold) { |  | ||||||
|       throw new Error("Item sudah terjual"); |  | ||||||
|     } |  | ||||||
|     if (pesanan.value.some((p) => p.id === item.value.id)) { |  | ||||||
|       throw new Error("Item sedang dipesan"); |  | ||||||
|     } |  | ||||||
|     info.value = `Item dipilih: ${item.value.produk.nama} dari ${item.value.nampan ? 'Nampan ' + item.value.nampan.nama : "Brankas"}`; |  | ||||||
| 
 |  | ||||||
|     infoTimeout = setTimeout(() => { |  | ||||||
|       info.value = ""; |  | ||||||
|     }, 5000); |  | ||||||
|   } catch (err) { |  | ||||||
|     error.value = err.message || "Item tidak ditemukan"; |  | ||||||
|     info.value = ""; |  | ||||||
|     hargaJual.value = null; |  | ||||||
|     hargaJualFormatted.value = ""; |  | ||||||
|     item.value = null; |  | ||||||
| 
 |  | ||||||
|     errorTimeout = setTimeout(() => { |  | ||||||
|       error.value = ""; |  | ||||||
|     }, 5000); |  | ||||||
|   } finally { |  | ||||||
|     loadingItem.value = false; |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const tambahItem = () => { |  | ||||||
|   if (!item.value || !hargaJual.value) { |  | ||||||
|     error.value = "Scan atau masukkan kode item untuk dijual."; |  | ||||||
|     if (kodeItem.value) { |  | ||||||
|       error.value = "Masukkan harga jual, atau input dari kode item lagi."; |  | ||||||
|     } |  | ||||||
|     clearTimeout(errorTimeout); |  | ||||||
|     errorTimeout = setTimeout(() => { |  | ||||||
|       error.value = ""; |  | ||||||
|     }, 5000); |  | ||||||
|     return; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // Check if pesanan already has an item |  | ||||||
|   if (pesanan.value.length >= 1) { |  | ||||||
|     error.value = "Hanya satu item yang dapat ditambahkan per transaksi. Hapus item sebelumnya untuk menambahkan yang baru."; |  | ||||||
|     clearTimeout(errorTimeout); |  | ||||||
|     errorTimeout = setTimeout(() => { |  | ||||||
|       error.value = ""; |  | ||||||
|     }, 5000); |  | ||||||
|     return; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   item.value.kode_item = kodeItem.value; |  | ||||||
|   item.value.harga_deal = Number(hargaJual.value); |  | ||||||
|   item.value.posisi = item.value.nampan ? item.value.nampan.nama : "Brankas"; |  | ||||||
| 
 |  | ||||||
|   pesanan.value = [item.value]; // Replace any existing item |  | ||||||
| 
 |  | ||||||
|   console.log("Pesanan:", item.value); |  | ||||||
| 
 |  | ||||||
|   kodeItem.value = ""; |  | ||||||
|   hargaJual.value = null; |  | ||||||
|   hargaJualFormatted.value = ""; |  | ||||||
|   item.value = null; |  | ||||||
|   info.value = ""; |  | ||||||
|   clearTimeout(infoTimeout); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const openDeleteModal = (index) => { |  | ||||||
|   deleteIndex.value = index; |  | ||||||
|   showDeleteModal.value = true; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const closeDeleteModal = () => { |  | ||||||
|   showDeleteModal.value = false; |  | ||||||
|   deleteIndex.value = null; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const hapusPesanan = () => { |  | ||||||
|   if (deleteIndex.value !== null) { |  | ||||||
|     pesanan.value = []; // Clear the pesanan array |  | ||||||
|   } |  | ||||||
|   closeDeleteModal(); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const konfirmasiPenjualan = () => { |  | ||||||
|   if (pesanan.value.length === 0) { |  | ||||||
|     error.value = "Belum ada item yang dipesan."; |  | ||||||
|     clearTimeout(errorTimeout); |  | ||||||
|     errorTimeout = setTimeout(() => { |  | ||||||
|       error.value = ""; |  | ||||||
|     }, 5000); |  | ||||||
|     return; |  | ||||||
|   } |  | ||||||
|   showStruk.value = true; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const closeStruk = () => { |  | ||||||
|   showStruk.value = false; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const closeStrukView = () => { |  | ||||||
|   showStrukView.value = false; |  | ||||||
|   savedTransaksi.value = null; |  | ||||||
| 
 |  | ||||||
|   // Reset pesanan setelah menutup struk view |  | ||||||
|   pesanan.value = []; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| // Handler ketika transaksi berhasil disimpan |  | ||||||
| const handleTransaksiSaved = (transaksiData) => { |  | ||||||
|   // Tutup StrukOverlay |  | ||||||
|   showStruk.value = false; |  | ||||||
| 
 |  | ||||||
|   // Simpan data transaksi |  | ||||||
|   savedTransaksi.value = transaksiData; |  | ||||||
| 
 |  | ||||||
|   // Emit ke parent (Kasir.vue) |  | ||||||
|   emit('transaksi-saved', transaksiData); |  | ||||||
| 
 |  | ||||||
|   // Buka StrukView untuk print |  | ||||||
|   setTimeout(() => { |  | ||||||
|     showStrukView.value = true; |  | ||||||
|   }, 300); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const total = computed(() => { |  | ||||||
|   let sum = 0; |  | ||||||
|   pesanan.value.forEach((item) => { |  | ||||||
|     sum += item.harga_deal; |  | ||||||
|   }); |  | ||||||
|   return sum; |  | ||||||
| }); |  | ||||||
| </script> |  | ||||||
| @ -1,440 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div> |  | ||||||
|     <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 < maxPhotos" 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: {{ maxPhotos }} 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> |  | ||||||
| 
 |  | ||||||
|     <!-- 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] p-4" |  | ||||||
|     > |  | ||||||
|       <div class="bg-white rounded-lg shadow-lg p-4 relative w-full max-w-md"> |  | ||||||
|         <h3 class="text-lg font-semibold mb-3 text-center">Ambil Foto</h3> |  | ||||||
|          |  | ||||||
|         <div class="relative bg-black rounded overflow-hidden w-full aspect-square"> |  | ||||||
|           <video  |  | ||||||
|             ref="video"  |  | ||||||
|             autoplay  |  | ||||||
|             playsinline  |  | ||||||
|             class="absolute" |  | ||||||
|             style=" |  | ||||||
|               top: 50%; |  | ||||||
|               left: 50%; |  | ||||||
|               transform: translate(-50%, -50%); |  | ||||||
|               object-fit: cover; |  | ||||||
|               width: 100%; |  | ||||||
|               height: 100%; |  | ||||||
|             " |  | ||||||
|           ></video> |  | ||||||
|            |  | ||||||
|           <!-- Square Crop Overlay --> |  | ||||||
|           <div class="absolute inset-0 pointer-events-none"> |  | ||||||
|             <!-- Crop area border --> |  | ||||||
|             <div class="absolute inset-0 border-2 border-blue-500"></div> |  | ||||||
|              |  | ||||||
|             <!-- Center crosshair --> |  | ||||||
|             <div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"> |  | ||||||
|               <div class="relative w-8 h-8"> |  | ||||||
|                 <div class="absolute top-1/2 left-0 w-full h-0.5 bg-blue-500/50 transform -translate-y-1/2"></div> |  | ||||||
|                 <div class="absolute left-1/2 top-0 h-full w-0.5 bg-blue-500/50 transform -translate-x-1/2"></div> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|          |  | ||||||
|         <canvas ref="canvas" class="hidden"></canvas> |  | ||||||
|         <div class="mt-3 flex justify-between gap-2"> |  | ||||||
|           <button @click="closeCamera" class="flex-1 px-4 py-2 bg-gray-400 text-white rounded hover:bg-gray-500 transition-colors"> |  | ||||||
|             Batal |  | ||||||
|           </button> |  | ||||||
|           <button @click="capturePhoto" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"> |  | ||||||
|             Ambil Foto |  | ||||||
|           </button> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
| 
 |  | ||||||
| <script setup> |  | ||||||
| import { ref, watch, onUnmounted, nextTick } from 'vue'; |  | ||||||
| import axios from 'axios'; |  | ||||||
| 
 |  | ||||||
| const props = defineProps({ |  | ||||||
|   modelValue: { |  | ||||||
|     type: Array, |  | ||||||
|     default: () => [], |  | ||||||
|   }, |  | ||||||
|   maxPhotos: { |  | ||||||
|     type: Number, |  | ||||||
|     default: 6, |  | ||||||
|   }, |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| const emit = defineEmits(['update:modelValue', 'error']); |  | ||||||
| 
 |  | ||||||
| const uploadedImages = ref([]); |  | ||||||
| const showUploadMenu = ref(false); |  | ||||||
| const fileInput = ref(null); |  | ||||||
| const uploadLoading = ref(false); |  | ||||||
| const isDragging = ref(false); |  | ||||||
| const uploadError = ref(''); |  | ||||||
| 
 |  | ||||||
| // Camera states |  | ||||||
| const showCamera = ref(false); |  | ||||||
| const video = ref(null); |  | ||||||
| const canvas = ref(null); |  | ||||||
| let stream = null; |  | ||||||
| 
 |  | ||||||
| // Initialize once |  | ||||||
| if (props.modelValue && props.modelValue.length > 0) { |  | ||||||
|   uploadedImages.value = [...props.modelValue]; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Only watch for external changes, no bidirectional binding |  | ||||||
| watch( |  | ||||||
|   () => props.modelValue, |  | ||||||
|   (newVal) => { |  | ||||||
|     if (JSON.stringify(newVal) !== JSON.stringify(uploadedImages.value)) { |  | ||||||
|       uploadedImages.value = newVal ? [...newVal] : []; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
| 
 |  | ||||||
| const toggleUploadMenu = () => { |  | ||||||
|   if (!uploadLoading.value && uploadedImages.value.length < props.maxPhotos) { |  | ||||||
|     showUploadMenu.value = !showUploadMenu.value; |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const triggerFileUpload = () => { |  | ||||||
|   showUploadMenu.value = false; |  | ||||||
|   fileInput.value?.click(); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const handleFileSelect = (event) => { |  | ||||||
|   const files = Array.from(event.target.files); |  | ||||||
|   processFiles(files); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const handleDrop = (event) => { |  | ||||||
|   event.preventDefault(); |  | ||||||
|   isDragging.value = false; |  | ||||||
|   if (uploadLoading.value || uploadedImages.value.length >= props.maxPhotos) return; |  | ||||||
|   const files = Array.from(event.dataTransfer.files); |  | ||||||
|   processFiles(files); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const processFiles = async (files) => { |  | ||||||
|   uploadError.value = ''; |  | ||||||
|   if (uploadedImages.value.length + files.length > props.maxPhotos) { |  | ||||||
|     uploadError.value = `Maksimal ${props.maxPhotos} foto yang dapat diupload`; |  | ||||||
|     emit('error', uploadError.value); |  | ||||||
|     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'; |  | ||||||
|       emit('error', uploadError.value); |  | ||||||
|       return false; |  | ||||||
|     } |  | ||||||
|     if (!isValidSize) { |  | ||||||
|       uploadError.value = 'Ukuran file maksimal 2MB'; |  | ||||||
|       emit('error', uploadError.value); |  | ||||||
|       return false; |  | ||||||
|     } |  | ||||||
|     return true; |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   if (validFiles.length === 0) return; |  | ||||||
| 
 |  | ||||||
|   // Process files dengan auto crop 1:1 |  | ||||||
|   uploadLoading.value = true; |  | ||||||
|   for (const file of validFiles) { |  | ||||||
|     await cropAndUploadFile(file); |  | ||||||
|   } |  | ||||||
|   uploadLoading.value = false; |  | ||||||
|    |  | ||||||
|   if (fileInput.value) fileInput.value.value = ''; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| // Auto crop 1:1 function |  | ||||||
| const cropAndUploadFile = (file) => { |  | ||||||
|   return new Promise((resolve) => { |  | ||||||
|     const reader = new FileReader(); |  | ||||||
|     reader.onload = (e) => { |  | ||||||
|       const img = new Image(); |  | ||||||
|       img.onload = () => { |  | ||||||
|         // Create canvas untuk crop 1:1 |  | ||||||
|         const size = Math.min(img.width, img.height); |  | ||||||
|         const canvas = document.createElement('canvas'); |  | ||||||
|         canvas.width = 800; |  | ||||||
|         canvas.height = 800; |  | ||||||
|          |  | ||||||
|         const ctx = canvas.getContext('2d'); |  | ||||||
|          |  | ||||||
|         // Calculate center crop |  | ||||||
|         const startX = (img.width - size) / 2; |  | ||||||
|         const startY = (img.height - size) / 2; |  | ||||||
|          |  | ||||||
|         // Draw cropped image |  | ||||||
|         ctx.drawImage( |  | ||||||
|           img, |  | ||||||
|           startX, startY, size, size, // source |  | ||||||
|           0, 0, 800, 800 // destination |  | ||||||
|         ); |  | ||||||
|          |  | ||||||
|         // Convert to blob and upload |  | ||||||
|         canvas.toBlob(async (blob) => { |  | ||||||
|           if (blob) { |  | ||||||
|             const croppedFile = new File([blob], file.name, { |  | ||||||
|               type: 'image/jpeg', |  | ||||||
|               lastModified: Date.now(), |  | ||||||
|             }); |  | ||||||
|             await uploadFile(croppedFile); |  | ||||||
|           } |  | ||||||
|           resolve(); |  | ||||||
|         }, 'image/jpeg', 0.9); |  | ||||||
|       }; |  | ||||||
|       img.src = e.target.result; |  | ||||||
|     }; |  | ||||||
|     reader.readAsDataURL(file); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const uploadFile = async (file) => { |  | ||||||
|   try { |  | ||||||
|     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); |  | ||||||
|     // Emit update after adding image |  | ||||||
|     emit('update:modelValue', [...uploadedImages.value]); |  | ||||||
|   } catch (error) { |  | ||||||
|     console.error('Upload error:', error); |  | ||||||
|     uploadError.value = error.response?.data?.message || 'Gagal mengupload foto'; |  | ||||||
|     emit('error', uploadError.value); |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| 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); |  | ||||||
|     // Emit update after removing image |  | ||||||
|     emit('update:modelValue', [...uploadedImages.value]); |  | ||||||
|   } catch (error) { |  | ||||||
|     uploadError.value = 'Gagal menghapus foto'; |  | ||||||
|     emit('error', uploadError.value); |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| // 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 videoElement = video.value; |  | ||||||
|   const canvasElement = canvas.value; |  | ||||||
|    |  | ||||||
|   // Get video dimensions |  | ||||||
|   const videoWidth = videoElement.videoWidth; |  | ||||||
|   const videoHeight = videoElement.videoHeight; |  | ||||||
|    |  | ||||||
|   // Calculate square crop (center) |  | ||||||
|   const size = Math.min(videoWidth, videoHeight); |  | ||||||
|   const startX = (videoWidth - size) / 2; |  | ||||||
|   const startY = (videoHeight - size) / 2; |  | ||||||
|    |  | ||||||
|   // Set canvas to square |  | ||||||
|   canvasElement.width = 800; |  | ||||||
|   canvasElement.height = 800; |  | ||||||
|    |  | ||||||
|   const ctx = canvasElement.getContext('2d'); |  | ||||||
|    |  | ||||||
|   // Draw cropped square image |  | ||||||
|   ctx.drawImage( |  | ||||||
|     videoElement, |  | ||||||
|     startX, startY, size, size, // source |  | ||||||
|     0, 0, 800, 800 // destination |  | ||||||
|   ); |  | ||||||
|    |  | ||||||
|   canvasElement.toBlob( |  | ||||||
|     async (blob) => { |  | ||||||
|       if (!blob) return; |  | ||||||
|       const file = new File([blob], 'camera_photo.jpg', { type: 'image/jpeg' }); |  | ||||||
|       closeCamera(); |  | ||||||
|       uploadLoading.value = true; |  | ||||||
|       await uploadFile(file); |  | ||||||
|       uploadLoading.value = false; |  | ||||||
|     }, |  | ||||||
|     'image/jpeg', |  | ||||||
|     0.9 |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| // Cleanup on unmount |  | ||||||
| onUnmounted(() => { |  | ||||||
|   if (stream) { |  | ||||||
|     stream.getTracks().forEach((track) => track.stop()); |  | ||||||
|   } |  | ||||||
| }); |  | ||||||
| </script> |  | ||||||
| @ -77,7 +77,191 @@ | |||||||
| 
 | 
 | ||||||
|                 <!-- Image Upload Section --> |                 <!-- Image Upload Section --> | ||||||
|                 <div class="flex-1"> |                 <div class="flex-1"> | ||||||
|                     <PhotoUploader v-model="uploadedImages" :maxPhotos="6" @error="handleUploadError" /> |                     <label class="block text-D mb-1">Foto</label> | ||||||
|  | 
 | ||||||
|  |                     <div class="grid grid-cols-3 gap-3 relative"> | ||||||
|  |                         <!-- Existing 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.prevent="removeImage(image.id)" | ||||||
|  |                                     type="button" | ||||||
|  |                                     :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> | ||||||
|  | 
 | ||||||
|  |                     <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> |             </div> | ||||||
| 
 | 
 | ||||||
| @ -97,6 +281,43 @@ | |||||||
|                 </button> |                 </button> | ||||||
|             </div> |             </div> | ||||||
|         </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> |     </mainLayout> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| @ -108,7 +329,6 @@ import mainLayout from "../layouts/mainLayout.vue"; | |||||||
| import InputField from "../components/InputField.vue"; | import InputField from "../components/InputField.vue"; | ||||||
| import InputSelect from "../components/InputSelect.vue"; | import InputSelect from "../components/InputSelect.vue"; | ||||||
| import CreateItemModal from "../components/CreateItemModal.vue"; | import CreateItemModal from "../components/CreateItemModal.vue"; | ||||||
| import PhotoUploader from "../components/PhotoUploader.vue"; |  | ||||||
| 
 | 
 | ||||||
| const route = useRoute(); | const route = useRoute(); | ||||||
| const router = useRouter(); | const router = useRouter(); | ||||||
| @ -127,6 +347,15 @@ const form = ref({ | |||||||
| const category = ref([]); | const category = ref([]); | ||||||
| const uploadedImages = ref([]); | const uploadedImages = ref([]); | ||||||
| const loading = ref(false); | const loading = ref(false); | ||||||
|  | const uploadLoading = ref(false); | ||||||
|  | const uploadError = ref(""); | ||||||
|  | const isDragging = ref(false); | ||||||
|  | const fileInput = ref(null); | ||||||
|  | const showUploadMenu = ref(false); | ||||||
|  | const showCamera = ref(false); | ||||||
|  | const video = ref(null); | ||||||
|  | const canvas = ref(null); | ||||||
|  | let stream = null; | ||||||
| 
 | 
 | ||||||
| const openItemModal = ref(false); | const openItemModal = ref(false); | ||||||
| const editedProduct = ref(null); | const editedProduct = ref(null); | ||||||
| @ -194,11 +423,122 @@ const loadFoto = async () => { | |||||||
|         uploadedImages.value = response.data; |         uploadedImages.value = response.data; | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|         console.error("Error loading photos:", error); |         console.error("Error loading photos:", error); | ||||||
|  |         uploadError.value = "Gagal memuat foto"; | ||||||
|     } |     } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const handleUploadError = (error) => { | const toggleUploadMenu = () => { | ||||||
|  |     if (!uploadLoading.value && uploadedImages.value.length < 6) { | ||||||
|  |         showUploadMenu.value = !showUploadMenu.value; | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const triggerFileUpload = () => { | ||||||
|  |     showUploadMenu.value = false; | ||||||
|  |     fileInput.value?.click(); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const handleFileSelect = (e) => { | ||||||
|  |     const files = Array.from(e.target.files); | ||||||
|  |     uploadFiles(files); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const handleDrop = (e) => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     isDragging.value = false; | ||||||
|  |     if (uploadLoading.value || uploadedImages.value.length >= 6) return; | ||||||
|  |     const files = Array.from(e.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); |         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"; | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | 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 () => { | const submitForm = async () => { | ||||||
| @ -212,7 +552,7 @@ const submitForm = async () => { | |||||||
|         router.push("/produk?message=Produk berhasil diperbarui"); |         router.push("/produk?message=Produk berhasil diperbarui"); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|         console.error("Submit error:", err); |         console.error("Submit error:", err); | ||||||
|         alert(err.response?.data?.message || "Gagal menyimpan produk"); |         uploadError.value = err.response?.data?.message || "Gagal menyimpan produk"; | ||||||
|     } finally { |     } finally { | ||||||
|         loading.value = false; |         loading.value = false; | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -50,7 +50,87 @@ | |||||||
| 
 | 
 | ||||||
|         <!-- Image Upload Section --> |         <!-- Image Upload Section --> | ||||||
|         <div class="flex-1"> |         <div class="flex-1"> | ||||||
|           <PhotoUploader v-model="uploadedImages" :maxPhotos="6" @error="handleUploadError" /> |           <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> |       </div> | ||||||
| 
 | 
 | ||||||
| @ -67,6 +147,9 @@ | |||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|  |     <!-- Overlay --> | ||||||
|  |     <div v-if="showUploadMenu" @click="showUploadMenu = false" class="fixed inset-0 z-10"></div> | ||||||
|  | 
 | ||||||
|     <!-- Camera Modal --> |     <!-- Camera Modal --> | ||||||
|     <div v-if="showCamera" class="fixed inset-0 bg-black/75 flex items-center justify-center z-[9999]"> |     <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"> |       <div class="bg-white w-[480px] rounded-lg shadow-lg p-4 relative"> | ||||||
| @ -89,7 +172,6 @@ import mainLayout from "../layouts/mainLayout.vue"; | |||||||
| import InputField from "../components/InputField.vue"; | import InputField from "../components/InputField.vue"; | ||||||
| import InputSelect from "../components/InputSelect.vue"; | import InputSelect from "../components/InputSelect.vue"; | ||||||
| import CreateItemModal from "../components/CreateItemModal.vue"; | import CreateItemModal from "../components/CreateItemModal.vue"; | ||||||
| import PhotoUploader from "../components/PhotoUploader.vue"; |  | ||||||
| 
 | 
 | ||||||
| const router = useRouter(); | const router = useRouter(); | ||||||
| 
 | 
 | ||||||
| @ -102,12 +184,23 @@ const form = ref({ | |||||||
|   harga_jual: null, |   harga_jual: null, | ||||||
| }); | }); | ||||||
| const category = ref([]); | const category = ref([]); | ||||||
|  | const showUploadMenu = ref(false); | ||||||
|  | const fileInput = ref(null); | ||||||
| const loading = ref(false); | const loading = ref(false); | ||||||
|  | const uploadLoading = ref(false); | ||||||
| const uploadedImages = ref([]); | const uploadedImages = ref([]); | ||||||
|  | const isDragging = ref(false); | ||||||
|  | const uploadError = ref(''); | ||||||
| const errors = ref({}); | const errors = ref({}); | ||||||
| const openItemModal = ref(false); | const openItemModal = ref(false); | ||||||
| const createdProduct = ref(null); | 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 | // Formatted values for harga_per_gram and harga_jual | ||||||
| const hargaPerGramFormatted = ref(""); | const hargaPerGramFormatted = ref(""); | ||||||
| const hargaJualFormatted = ref(""); | const hargaJualFormatted = ref(""); | ||||||
| @ -210,11 +303,119 @@ const loadFoto = async () => { | |||||||
|     uploadedImages.value = response.data; |     uploadedImages.value = response.data; | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     console.error(e); |     console.error(e); | ||||||
|  |     uploadError.value = "Gagal memuat foto"; | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const handleUploadError = (error) => { | 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); |     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) => { | const submitForm = async (addItem) => { | ||||||
| @ -245,6 +446,9 @@ const submitForm = async (addItem) => { | |||||||
|     hargaPerGramFormatted.value = ""; |     hargaPerGramFormatted.value = ""; | ||||||
|     hargaJualFormatted.value = ""; |     hargaJualFormatted.value = ""; | ||||||
|     uploadedImages.value = []; |     uploadedImages.value = []; | ||||||
|  |     uploadError.value = ''; | ||||||
|  |     showUploadMenu.value = false; | ||||||
|  |     if (fileInput.value) fileInput.value.value = ''; | ||||||
|     if (addItem) { |     if (addItem) { | ||||||
|       openCreateItemModal(createdProductData); |       openCreateItemModal(createdProductData); | ||||||
|     } else { |     } else { | ||||||
|  | |||||||
| @ -46,7 +46,7 @@ import { ref, onMounted, onUnmounted } from "vue"; | |||||||
| import axios from "axios"; | import axios from "axios"; | ||||||
| 
 | 
 | ||||||
| import mainLayout from "../layouts/mainLayout.vue"; | import mainLayout from "../layouts/mainLayout.vue"; | ||||||
| import KasirForm from "../components/KasirForm1.vue"; | import KasirForm from "../components/KasirForm.vue"; | ||||||
| import KasirTransaksiList from "../components/KasirTransaksiList.vue"; | import KasirTransaksiList from "../components/KasirTransaksiList.vue"; | ||||||
| import ModalConfirm from "../components/ModalConfirm.vue"; | import ModalConfirm from "../components/ModalConfirm.vue"; | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user