Merge branch 'development' of https://git.abbauf.com/Magang-2025/Kasir into development

This commit is contained in:
adityaalfarison 2025-10-16 17:06:29 +07:00
commit 878e652630
6 changed files with 597 additions and 284 deletions

View File

@ -1,12 +1,12 @@
APP_NAME=KasirTMJC
APP_ENV=local
APP_NAME=Abbauf-Kasir
APP_ENV=production
APP_KEY=
APP_DEBUG=true
APP_DEBUG=false
APP_URL=http://localhost
APP_LOCALE=id
APP_FALLBACK_LOCALE=id
APP_FAKER_LOCALE=id_ID
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
@ -20,15 +20,12 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=kasir_db
DB_USERNAME=kasir_user
DB_PASSWORD=kasir_password
# MySQL Root Password (untuk Docker)
MYSQL_ROOT_PASSWORD=root_password
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
@ -46,7 +43,7 @@ CACHE_STORE=database
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

View File

@ -1,36 +1,91 @@
# Stage 1: Build Vue (tetap sama)
FROM node:20 as node_builder
# ========================================
# Stage 1: Build Frontend Assets (Vue.js)
# ========================================
FROM node:20-alpine as node_builder
WORKDIR /app
# Copy package files untuk caching layer
COPY package*.json ./
RUN npm install
# Install dependencies
RUN npm ci --legacy-peer-deps
# Copy seluruh source code
COPY . .
# Build production assets
RUN npm run build
# Stage 2: Laravel
FROM php:8.3-fpm
# ========================================
# Stage 2: Laravel Application
# ========================================
FROM php:8.3-fpm-alpine
RUN apt-get update && apt-get install -y \
git unzip libzip-dev libpng-dev libonig-dev libxml2-dev curl \
&& docker-php-ext-install pdo_mysql zip gd mbstring exif pcntl bcmath \
&& apt-get clean && rm -rf /var/lib/apt/lists/* # Cleanup untuk ukuran kecil
# Install system dependencies dan PHP extensions
RUN apk update && apk add --no-cache \
git \
unzip \
libzip-dev \
libpng-dev \
oniguruma-dev \
libxml2-dev \
curl \
mysql-client \
autoconf \
g++ \
make \
&& docker-php-ext-install \
pdo_mysql \
zip \
gd \
mbstring \
exif \
pcntl \
bcmath \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del autoconf g++ make \
&& rm -rf /var/cache/apk/* /tmp/*
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Copy source code
# Copy composer files untuk caching layer
COPY composer.json composer.lock ./
# Install PHP dependencies
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Copy application source code
COPY . .
# Copy hasil build Vue
# Copy hasil build Vue dari stage 1
COPY --from=node_builder /app/public/build /var/www/html/public/build
RUN composer install --no-dev --optimize-autoloader
RUN php artisan storage:link || true
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache # Optimasi cache untuk performa laporan/transaksi
# Generate autoload files dengan optimasi
RUN composer dump-autoload --optimize --classmap-authoritative
# Set permission dan user non-root
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/storage \
&& chmod -R 755 /var/www/html/bootstrap/cache
# Create required directories dan set permissions
RUN mkdir -p \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache \
&& chown -R www-data:www-data \
/var/www/html/storage \
/var/www/html/bootstrap/cache \
&& chmod -R 775 \
/var/www/html/storage \
/var/www/html/bootstrap/cache
# Switch ke user non-root untuk keamanan
USER www-data
EXPOSE 9000
CMD ["php-fpm"]

View File

@ -31,7 +31,7 @@ class DatabaseSeeder extends Seeder
]);
Sales::factory()->create([
'nama' => 'Umum',
'nama' => 'Kasir',
'no_hp' => '-',
'alamat' => '-',
]);

View File

@ -1,58 +1,176 @@
version: '3.8'
services:
# ========================================
# Laravel PHP-FPM Application
# ========================================
laravel:
build:
context: .
dockerfile: Dockerfile
container_name: laravel_app_prod
container_name: abbauf_kasir_app
restart: unless-stopped
working_dir: /var/www/html
volumes:
- storage_data:/var/www/html/storage
ports:
- "9000"
depends_on:
- mysql
# Mount storage untuk uploads dan logs (persistent)
- ./storage:/var/www/html/storage
# Mount public build assets (read-only)
- ./public/build:/var/www/html/public/build:ro
environment:
APP_ENV: production
APP_DEBUG: false
# Application
APP_NAME: ${APP_NAME:-Abbauf-Kasir}
APP_ENV: ${APP_ENV:-production}
APP_KEY: ${APP_KEY}
APP_DEBUG: ${APP_DEBUG:-false}
APP_URL: ${APP_URL:-http://localhost}
# Database
DB_CONNECTION: mysql
DB_HOST: mysql
DB_PORT: 3306
DB_DATABASE: ${DB_DATABASE:-kasir_db}
DB_USERNAME: ${DB_USERNAME:-kasir_user}
DB_PASSWORD: ${DB_PASSWORD}
# Cache & Session
CACHE_STORE: redis
SESSION_DRIVER: redis
REDIS_HOST: redis
REDIS_PORT: 6379
# Queue
QUEUE_CONNECTION: redis
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
networks:
- kasir_network
healthcheck:
test: ["CMD", "php-fpm", "-t"]
interval: 30s
timeout: 10s
retries: 3
# ========================================
# Nginx Web Server
# ========================================
nginx:
image: nginx:alpine
container_name: abbauf_kasir_nginx
restart: unless-stopped
ports:
- "${APP_PORT:-80}:80"
volumes:
# Nginx configuration
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
# Laravel public directory (untuk static assets)
- ./public:/var/www/html/public:ro
# Storage symlink untuk file uploads
- ./storage/app/public:/var/www/html/public/storage:ro
depends_on:
- laravel
networks:
- kasir_network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
# ========================================
# MySQL Database
# ========================================
mysql:
image: mysql:8.4
container_name: abbauf_kasir_db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_secret_password}
MYSQL_DATABASE: ${DB_DATABASE:-kasir_db}
MYSQL_USER: ${DB_USERNAME:-kasir_user}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_CHARACTER_SET_SERVER: utf8mb4
MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci
ports:
- "${DB_PORT:-3306}:3306"
volumes:
- mysql_data:/var/lib/mysql
# Optional: backup folder
- ./docker/mysql/backups:/backups
networks:
- kasir_network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-root_secret_password}"]
interval: 10s
timeout: 5s
retries: 5
command: --default-authentication-plugin=mysql_native_password
# ========================================
# Redis Cache & Session Store
# ========================================
redis:
image: redis:7-alpine
container_name: abbauf_kasir_redis
restart: unless-stopped
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis_data:/data
networks:
- kasir_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
command: redis-server --appendonly yes
# ========================================
# Queue Worker (Optional - untuk background jobs)
# ========================================
queue:
build:
context: .
dockerfile: Dockerfile
container_name: abbauf_kasir_queue
restart: unless-stopped
working_dir: /var/www/html
volumes:
- ./storage:/var/www/html/storage
environment:
APP_ENV: ${APP_ENV:-production}
APP_KEY: ${APP_KEY}
DB_CONNECTION: mysql
DB_HOST: mysql
DB_PORT: 3306
DB_DATABASE: ${DB_DATABASE}
DB_USERNAME: ${DB_USERNAME}
DB_DATABASE: ${DB_DATABASE:-kasir_db}
DB_USERNAME: ${DB_USERNAME:-kasir_user}
DB_PASSWORD: ${DB_PASSWORD}
nginx:
image: nginx:alpine
container_name: nginx_prod
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
volumes_from:
- laravel:ro
REDIS_HOST: redis
QUEUE_CONNECTION: redis
depends_on:
- laravel
- redis
- mysql
networks:
- kasir_network
command: php artisan queue:work --tries=3 --timeout=90
mysql:
image: mysql:8
container_name: mysql_db_prod
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:alpine
container_name: redis_prod
ports:
- "6379:6379"
# ========================================
# Networks
# ========================================
networks:
kasir_network:
driver: bridge
# ========================================
# Persistent Volumes
# ========================================
volumes:
mysql_data:
storage_data:
driver: local
redis_data:
driver: local

View File

@ -1,14 +1,43 @@
# ========================================
# Abbauf Kasir - Nginx Configuration
# ========================================
server {
listen 80;
index index.php index.html;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
root /var/www/html/public;
listen [::]:80;
server_name localhost;
root /var/www/html/public;
index index.php index.html index.htm;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Client body size limit (untuk upload file)
client_max_body_size 20M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml font/truetype font/opentype
application/vnd.ms-fontobject image/svg+xml;
# Main location block
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP-FPM configuration
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
@ -17,5 +46,43 @@ server {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
# Buffer settings untuk performa
fastcgi_buffer_size 128k;
fastcgi_buffers 256 16k;
fastcgi_busy_buffers_size 256k;
fastcgi_temp_file_write_size 256k;
# Timeout settings
fastcgi_read_timeout 300;
fastcgi_connect_timeout 300;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Deny access to sensitive files
location ~ /(?:\.env|\.git|composer\.json|composer\.lock|package\.json|package-lock\.json|README\.md|\.gitignore) {
deny all;
access_log off;
log_not_found off;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

View File

@ -56,21 +56,20 @@
<div class="flex-1">
<label class="block text-D mb-1">Harga per Gram</label>
<InputField
v-model="hargaPerGramFormatted"
type="text"
v-model="form.harga_per_gram"
type="number"
step="0.01"
placeholder="Masukkan harga per gram"
@input="formatHargaPerGramInput"
@keypress="onlyNumbers"
@input="calculateHargaJual"
/>
</div>
<div class="flex-1">
<label class="block text-D mb-1">Harga Jual</label>
<InputField
v-model="hargaJualFormatted"
type="text"
v-model="form.harga_jual"
type="number"
step="0.01"
placeholder="Masukkan harga jual"
@input="formatHargaJualInput"
@keypress="onlyNumbers"
/>
</div>
</div>
@ -80,7 +79,7 @@
<div class="flex-1">
<label class="block text-D mb-1">Foto</label>
<div class="grid grid-cols-3 gap-3">
<div class="grid grid-cols-3 gap-3 relative">
<!-- Existing Images -->
<div
v-for="(image, index) in uploadedImages"
@ -109,24 +108,85 @@
<!-- Upload Button -->
<div
v-if="uploadedImages.length < 6"
@drop="handleDrop"
@dragover.prevent
@dragenter.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@click="triggerFileInput"
class="aspect-square 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,
}"
class="relative aspect-square"
>
<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"
<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-6 h-6 text-white"
class="w-5 h-5 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -135,42 +195,50 @@
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
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
v-else
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2"
<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="animate-spin w-6 h-6 text-white"
class="w-5 h-5 text-gray-600"
fill="none"
stroke="currentColor"
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>
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>
<p
class="text-xs text-gray-600 font-medium"
v-html="
uploadLoading
? 'Uploading...'
: 'Unggah<br/>Foto'
"
></p>
<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>
@ -213,6 +281,43 @@
</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>
@ -235,8 +340,8 @@ const form = ref({
id_kategori: null,
berat: 0,
kadar: 0,
harga_per_gram: null,
harga_jual: null,
harga_per_gram: 0,
harga_jual: 0,
});
const category = ref([]);
@ -246,66 +351,15 @@ 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);
// Formatted values for harga_per_gram and harga_jual
const hargaPerGramFormatted = ref("");
const hargaJualFormatted = ref("");
// Format angka dengan pemisah ribuan
const formatNumber = (num) => {
if (!num) return "";
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
};
// Menghapus format dan mengambil angka asli
const unformatNumber = (str) => {
if (!str) return null;
const cleaned = str.replace(/\./g, "");
const number = parseFloat(cleaned);
return isNaN(number) ? null : number;
};
// Handler untuk format input harga per gram
const formatHargaPerGramInput = (event) => {
const value = event.target.value;
const cleanValue = value.replace(/\D/g, "");
if (cleanValue) {
const formatted = formatNumber(cleanValue);
hargaPerGramFormatted.value = formatted;
form.value.harga_per_gram = parseFloat(cleanValue);
calculateHargaJual();
} else {
hargaPerGramFormatted.value = "";
form.value.harga_per_gram = null;
calculateHargaJual();
}
};
// Handler untuk format input harga jual
const formatHargaJualInput = (event) => {
const value = event.target.value;
const cleanValue = value.replace(/\D/g, "");
if (cleanValue) {
const formatted = formatNumber(cleanValue);
hargaJualFormatted.value = formatted;
form.value.harga_jual = parseFloat(cleanValue);
} else {
hargaJualFormatted.value = "";
form.value.harga_jual = null;
}
};
// Hanya izinkan angka saat mengetik
const onlyNumbers = (event) => {
const char = String.fromCharCode(event.which);
if (!/[0-9]/.test(char)) {
event.preventDefault();
}
};
const isFormValid = computed(() => {
return (
form.value.nama &&
@ -321,42 +375,42 @@ const calculateHargaJual = () => {
const berat = parseFloat(form.value.berat) || 0;
const hargaPerGram = parseFloat(form.value.harga_per_gram) || 0;
if (berat > 0 && hargaPerGram > 0) {
const hargaJual = berat * hargaPerGram;
form.value.harga_jual = hargaJual;
hargaJualFormatted.value = formatNumber(hargaJual.toFixed(0));
} else {
form.value.harga_jual = null;
hargaJualFormatted.value = "";
form.value.harga_jual = berat * hargaPerGram;
}
};
const loadKategori = async () => {
const response = await axios.get("/api/kategori", {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
category.value = response.data.map((c) => ({ value: c.id, label: c.nama }));
try {
const response = await axios.get("/api/kategori", {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
category.value = response.data.map((c) => ({ value: c.id, label: c.nama }));
} catch (error) {
console.error("Error loading categories:", error);
}
};
const loadProduk = async () => {
const response = await axios.get(`/api/produk/edit/${productId}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
const produk = response.data;
form.value = {
nama: produk.nama,
id_kategori: produk.id_kategori,
berat: produk.berat,
kadar: produk.kadar,
harga_per_gram: produk.harga_per_gram,
harga_jual: produk.harga_jual,
};
// Set formatted values after loading
hargaPerGramFormatted.value = formatNumber(produk.harga_per_gram?.toString() || "");
hargaJualFormatted.value = formatNumber(produk.harga_jual?.toString() || "");
try {
const response = await axios.get(`/api/produk/edit/${productId}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
const produk = response.data;
form.value = {
nama: produk.nama,
id_kategori: produk.id_kategori,
berat: produk.berat,
kadar: produk.kadar,
harga_per_gram: produk.harga_per_gram,
harga_jual: produk.harga_jual,
};
} catch (error) {
console.error("Error loading product:", error);
}
};
const loadFoto = async () => {
@ -367,18 +421,23 @@ const loadFoto = async () => {
},
});
uploadedImages.value = response.data;
} catch (e) {
console.error(e);
} catch (error) {
console.error("Error loading photos:", error);
uploadError.value = "Gagal memuat foto";
}
};
const triggerFileInput = () => {
const toggleUploadMenu = () => {
if (!uploadLoading.value && uploadedImages.value.length < 6) {
fileInput.value?.click();
showUploadMenu.value = !showUploadMenu.value;
}
};
const triggerFileUpload = () => {
showUploadMenu.value = false;
fileInput.value?.click();
};
const handleFileSelect = (e) => {
const files = Array.from(e.target.files);
uploadFiles(files);
@ -393,60 +452,47 @@ const handleDrop = (e) => {
};
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;
uploadError.value = '';
if (uploadedImages.value.length + files.length > 6) {
uploadError.value = 'Maksimal 6 foto yang dapat diupload';
return;
}
if (!isValidSize) {
uploadError.value = 'Ukuran file maksimal 2MB';
return false;
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;
}
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) => {
@ -462,21 +508,51 @@ const removeImage = async (id) => {
}
};
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 () => {
loading.value = true;
try {
await axios.put(
`/api/produk/${productId}`,form.value,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
}
);
await axios.put(`/api/produk/${productId}`, form.value, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
router.push("/produk?message=Produk berhasil diperbarui");
} catch (err) {
console.error("Submit error:", err);
uploadError.value = err.response?.data?.message || "Gagal menyimpan produk";
console.error(err);
} finally {
loading.value = false;
}
@ -484,6 +560,7 @@ const submitForm = async () => {
const closeItemModal = () => {
openItemModal.value = false;
editedProduct.value = null;
};
const back = () => {
@ -493,7 +570,6 @@ const back = () => {
onMounted(async () => {
await loadKategori();
await loadProduk();
loadFoto();
await loadFoto();
});
</script>