Compare commits
	
		
			4 Commits
		
	
	
		
			edeb3ba932
			...
			e1103f1819
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | e1103f1819 | ||
|  | ce8218dc47 | ||
|  | 3ef626d97a | ||
|  | 884975181c | 
							
								
								
									
										2
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,5 +1,5 @@ | ||||
| { | ||||
|     "name": "html", | ||||
|     "name": "Kasir", | ||||
|     "lockfileVersion": 3, | ||||
|     "requires": true, | ||||
|     "packages": { | ||||
|  | ||||
							
								
								
									
										344
									
								
								resources/js/components/KasirForm1.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										344
									
								
								resources/js/components/KasirForm1.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,344 @@ | ||||
| <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> | ||||
							
								
								
									
										440
									
								
								resources/js/components/PhotoUploader.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										440
									
								
								resources/js/components/PhotoUploader.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,440 @@ | ||||
| <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,191 +77,7 @@ | ||||
| 
 | ||||
|                 <!-- 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"> | ||||
|                         <!-- 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> | ||||
|                     <PhotoUploader v-model="uploadedImages" :maxPhotos="6" @error="handleUploadError" /> | ||||
|                 </div> | ||||
|             </div> | ||||
| 
 | ||||
| @ -281,43 +97,6 @@ | ||||
|                 </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> | ||||
| 
 | ||||
| @ -329,6 +108,7 @@ import mainLayout from "../layouts/mainLayout.vue"; | ||||
| import InputField from "../components/InputField.vue"; | ||||
| import InputSelect from "../components/InputSelect.vue"; | ||||
| import CreateItemModal from "../components/CreateItemModal.vue"; | ||||
| import PhotoUploader from "../components/PhotoUploader.vue"; | ||||
| 
 | ||||
| const route = useRoute(); | ||||
| const router = useRouter(); | ||||
| @ -347,15 +127,6 @@ const form = ref({ | ||||
| const category = ref([]); | ||||
| const uploadedImages = ref([]); | ||||
| 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 editedProduct = ref(null); | ||||
| @ -423,122 +194,11 @@ const loadFoto = async () => { | ||||
|         uploadedImages.value = response.data; | ||||
|     } catch (error) { | ||||
|         console.error("Error loading photos:", error); | ||||
|         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 = (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); | ||||
|         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 handleUploadError = (error) => { | ||||
|     console.error('Upload error:', error); | ||||
| }; | ||||
| 
 | ||||
| const submitForm = async () => { | ||||
| @ -552,7 +212,7 @@ const submitForm = async () => { | ||||
|         router.push("/produk?message=Produk berhasil diperbarui"); | ||||
|     } catch (err) { | ||||
|         console.error("Submit error:", err); | ||||
|         uploadError.value = err.response?.data?.message || "Gagal menyimpan produk"; | ||||
|         alert(err.response?.data?.message || "Gagal menyimpan produk"); | ||||
|     } finally { | ||||
|         loading.value = false; | ||||
|     } | ||||
|  | ||||
| @ -50,87 +50,7 @@ | ||||
| 
 | ||||
|         <!-- 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> | ||||
|           <PhotoUploader v-model="uploadedImages" :maxPhotos="6" @error="handleUploadError" /> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
| @ -147,9 +67,6 @@ | ||||
|       </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"> | ||||
| @ -172,6 +89,7 @@ import mainLayout from "../layouts/mainLayout.vue"; | ||||
| import InputField from "../components/InputField.vue"; | ||||
| import InputSelect from "../components/InputSelect.vue"; | ||||
| import CreateItemModal from "../components/CreateItemModal.vue"; | ||||
| import PhotoUploader from "../components/PhotoUploader.vue"; | ||||
| 
 | ||||
| const router = useRouter(); | ||||
| 
 | ||||
| @ -184,23 +102,12 @@ const form = ref({ | ||||
|   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(""); | ||||
| @ -303,119 +210,11 @@ const loadFoto = async () => { | ||||
|     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 handleUploadError = (error) => { | ||||
|   console.error('Upload error:', error); | ||||
| }; | ||||
| 
 | ||||
| const submitForm = async (addItem) => { | ||||
| @ -446,9 +245,6 @@ const submitForm = async (addItem) => { | ||||
|     hargaPerGramFormatted.value = ""; | ||||
|     hargaJualFormatted.value = ""; | ||||
|     uploadedImages.value = []; | ||||
|     uploadError.value = ''; | ||||
|     showUploadMenu.value = false; | ||||
|     if (fileInput.value) fileInput.value.value = ''; | ||||
|     if (addItem) { | ||||
|       openCreateItemModal(createdProductData); | ||||
|     } else { | ||||
|  | ||||
| @ -46,7 +46,7 @@ import { ref, onMounted, onUnmounted } from "vue"; | ||||
| import axios from "axios"; | ||||
| 
 | ||||
| import mainLayout from "../layouts/mainLayout.vue"; | ||||
| import KasirForm from "../components/KasirForm.vue"; | ||||
| import KasirForm from "../components/KasirForm1.vue"; | ||||
| import KasirTransaksiList from "../components/KasirTransaksiList.vue"; | ||||
| import ModalConfirm from "../components/ModalConfirm.vue"; | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user