[Refactor] Fix edit produk
This commit is contained in:
parent
ecc9385c28
commit
271a1e3660
29
.env.example
29
.env.example
@ -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
|
||||
|
||||
|
||||
91
Dockerfile
91
Dockerfile
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
75
nginx.conf
75
nginx.conf
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,12 +108,15 @@
|
||||
<!-- 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="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"
|
||||
@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,
|
||||
@ -173,6 +175,72 @@
|
||||
></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
|
||||
@ -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,25 +375,25 @@ 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 () => {
|
||||
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 () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/produk/edit/${productId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
@ -354,9 +408,9 @@ const loadProduk = async () => {
|
||||
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() || "");
|
||||
} 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);
|
||||
@ -394,53 +453,40 @@ 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;
|
||||
}
|
||||
|
||||
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';
|
||||
@ -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,
|
||||
{
|
||||
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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user