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_NAME=Abbauf-Kasir
APP_ENV=local APP_ENV=production
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
APP_LOCALE=id APP_LOCALE=en
APP_FALLBACK_LOCALE=id APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=id_ID APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database # APP_MAINTENANCE_STORE=database
@ -20,15 +20,12 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=mysql DB_CONNECTION=sqlite
DB_HOST=mysql # DB_HOST=127.0.0.1
DB_PORT=3306 # DB_PORT=3306
DB_DATABASE=kasir_db # DB_DATABASE=laravel
DB_USERNAME=kasir_user # DB_USERNAME=root
DB_PASSWORD=kasir_password # DB_PASSWORD=
# MySQL Root Password (untuk Docker)
MYSQL_ROOT_PASSWORD=root_password
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@ -46,7 +43,7 @@ CACHE_STORE=database
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_HOST=redis REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 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 WORKDIR /app
# Copy package files untuk caching layer
COPY package*.json ./ COPY package*.json ./
RUN npm install
# Install dependencies
RUN npm ci --legacy-peer-deps
# Copy seluruh source code
COPY . . COPY . .
# Build production assets
RUN npm run build 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 \ # Install system dependencies dan PHP extensions
git unzip libzip-dev libpng-dev libonig-dev libxml2-dev curl \ RUN apk update && apk add --no-cache \
&& docker-php-ext-install pdo_mysql zip gd mbstring exif pcntl bcmath \ git \
&& apt-get clean && rm -rf /var/lib/apt/lists/* # Cleanup untuk ukuran kecil 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 COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html 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 . .
# Copy hasil build Vue
# Copy hasil build Vue dari stage 1
COPY --from=node_builder /app/public/build /var/www/html/public/build COPY --from=node_builder /app/public/build /var/www/html/public/build
RUN composer install --no-dev --optimize-autoloader # Generate autoload files dengan optimasi
RUN php artisan storage:link || true RUN composer dump-autoload --optimize --classmap-authoritative
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache # Optimasi cache untuk performa laporan/transaksi
# Set permission dan user non-root # Create required directories dan set permissions
RUN chown -R www-data:www-data /var/www/html \ RUN mkdir -p \
&& chmod -R 755 /var/www/html/storage \ storage/framework/cache \
&& chmod -R 755 /var/www/html/bootstrap/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 EXPOSE 9000
CMD ["php-fpm"] CMD ["php-fpm"]

View File

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

View File

@ -1,58 +1,176 @@
version: '3.8'
services: services:
# ========================================
# Laravel PHP-FPM Application
# ========================================
laravel: laravel:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: laravel_app_prod container_name: abbauf_kasir_app
restart: unless-stopped
working_dir: /var/www/html
volumes: volumes:
- storage_data:/var/www/html/storage # Mount storage untuk uploads dan logs (persistent)
ports: - ./storage:/var/www/html/storage
- "9000" # Mount public build assets (read-only)
depends_on: - ./public/build:/var/www/html/public/build:ro
- mysql
environment: environment:
APP_ENV: production # Application
APP_DEBUG: false 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} APP_KEY: ${APP_KEY}
DB_CONNECTION: mysql DB_CONNECTION: mysql
DB_HOST: mysql DB_HOST: mysql
DB_PORT: 3306 DB_PORT: 3306
DB_DATABASE: ${DB_DATABASE} DB_DATABASE: ${DB_DATABASE:-kasir_db}
DB_USERNAME: ${DB_USERNAME} DB_USERNAME: ${DB_USERNAME:-kasir_user}
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: redis
nginx: QUEUE_CONNECTION: redis
image: nginx:alpine
container_name: nginx_prod
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
volumes_from:
- laravel:ro
depends_on: depends_on:
- laravel - laravel
- redis
- mysql
networks:
- kasir_network
command: php artisan queue:work --tries=3 --timeout=90
mysql: # ========================================
image: mysql:8 # Networks
container_name: mysql_db_prod # ========================================
restart: unless-stopped networks:
environment: kasir_network:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} driver: bridge
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"
# ========================================
# Persistent Volumes
# ========================================
volumes: volumes:
mysql_data: mysql_data:
storage_data: driver: local
redis_data:
driver: local

View File

@ -1,14 +1,43 @@
# ========================================
# Abbauf Kasir - Nginx Configuration
# ========================================
server { server {
listen 80; listen 80;
index index.php index.html; listen [::]:80;
error_log /var/log/nginx/error.log; server_name localhost;
access_log /var/log/nginx/access.log;
root /var/www/html/public;
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 / { location / {
try_files $uri $uri/ /index.php?$query_string; try_files $uri $uri/ /index.php?$query_string;
} }
# PHP-FPM configuration
location ~ \.php$ { location ~ \.php$ {
try_files $uri =404; try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_split_path_info ^(.+\.php)(/.+)$;
@ -17,5 +46,43 @@ server {
include fastcgi_params; include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info; 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"> <div class="flex-1">
<label class="block text-D mb-1">Harga per Gram</label> <label class="block text-D mb-1">Harga per Gram</label>
<InputField <InputField
v-model="hargaPerGramFormatted" v-model="form.harga_per_gram"
type="text" type="number"
step="0.01"
placeholder="Masukkan harga per gram" placeholder="Masukkan harga per gram"
@input="formatHargaPerGramInput" @input="calculateHargaJual"
@keypress="onlyNumbers"
/> />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<label class="block text-D mb-1">Harga Jual</label> <label class="block text-D mb-1">Harga Jual</label>
<InputField <InputField
v-model="hargaJualFormatted" v-model="form.harga_jual"
type="text" type="number"
step="0.01"
placeholder="Masukkan harga jual" placeholder="Masukkan harga jual"
@input="formatHargaJualInput"
@keypress="onlyNumbers"
/> />
</div> </div>
</div> </div>
@ -80,7 +79,7 @@
<div class="flex-1"> <div class="flex-1">
<label class="block text-D mb-1">Foto</label> <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 --> <!-- Existing Images -->
<div <div
v-for="(image, index) in uploadedImages" v-for="(image, index) in uploadedImages"
@ -109,24 +108,85 @@
<!-- Upload Button --> <!-- Upload Button -->
<div <div
v-if="uploadedImages.length < 6" v-if="uploadedImages.length < 6"
@drop="handleDrop" class="relative aspect-square"
@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,
}"
> >
<div class="text-center"> <div
<div @drop="handleDrop"
v-if="!uploadLoading" @dragover.prevent
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2 group-hover:bg-D transition-colors" @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 <svg
class="w-6 h-6 text-white" class="w-5 h-5 text-gray-600"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -135,42 +195,50 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" 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> ></path>
</svg> </svg>
</div> <div>
<div <div class="font-medium text-gray-900">
v-else Upload dari File
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2" </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 <svg
class="animate-spin w-6 h-6 text-white" class="w-5 h-5 text-gray-600"
fill="none" fill="none"
stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path <path
class="opacity-75" stroke-linecap="round"
fill="currentColor" stroke-linejoin="round"
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" stroke-width="2"
></path> 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> </svg>
</div> <div>
<p <div class="font-medium text-gray-900">
class="text-xs text-gray-600 font-medium" Ambil dari Kamera
v-html=" </div>
uploadLoading <div class="text-sm text-gray-500">
? 'Uploading...' Foto langsung dengan kamera
: 'Unggah<br/>Foto' </div>
" </div>
></p> </button>
</div> </div>
</div> </div>
</div> </div>
@ -213,6 +281,43 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Overlay -->
<div
v-if="showUploadMenu"
@click="showUploadMenu = false"
class="fixed inset-0 z-10"
></div>
<!-- Camera Modal -->
<div
v-if="showCamera"
class="fixed inset-0 bg-black/75 flex items-center justify-center z-[9999]"
>
<div class="bg-white w-[480px] rounded-lg shadow-lg p-4 relative">
<video
ref="video"
autoplay
playsinline
class="w-full h-64 bg-black rounded"
></video>
<canvas ref="canvas" class="hidden"></canvas>
<div class="mt-3 flex justify-between">
<button
@click="closeCamera"
class="px-4 py-2 bg-gray-400 text-white rounded"
>
Batal
</button>
<button
@click="capturePhoto"
class="px-4 py-2 bg-blue-500 text-white rounded"
>
Ambil Foto
</button>
</div>
</div>
</div>
</mainLayout> </mainLayout>
</template> </template>
@ -235,8 +340,8 @@ const form = ref({
id_kategori: null, id_kategori: null,
berat: 0, berat: 0,
kadar: 0, kadar: 0,
harga_per_gram: null, harga_per_gram: 0,
harga_jual: null, harga_jual: 0,
}); });
const category = ref([]); const category = ref([]);
@ -246,66 +351,15 @@ const uploadLoading = ref(false);
const uploadError = ref(""); const uploadError = ref("");
const isDragging = ref(false); const isDragging = ref(false);
const fileInput = ref(null); const fileInput = ref(null);
const showUploadMenu = ref(false);
const showCamera = ref(false);
const video = ref(null);
const canvas = ref(null);
let stream = null;
const openItemModal = ref(false); const openItemModal = ref(false);
const editedProduct = ref(null); const editedProduct = ref(null);
// 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(() => { const isFormValid = computed(() => {
return ( return (
form.value.nama && form.value.nama &&
@ -321,42 +375,42 @@ const calculateHargaJual = () => {
const berat = parseFloat(form.value.berat) || 0; const berat = parseFloat(form.value.berat) || 0;
const hargaPerGram = parseFloat(form.value.harga_per_gram) || 0; const hargaPerGram = parseFloat(form.value.harga_per_gram) || 0;
if (berat > 0 && hargaPerGram > 0) { if (berat > 0 && hargaPerGram > 0) {
const hargaJual = berat * hargaPerGram; form.value.harga_jual = berat * hargaPerGram;
form.value.harga_jual = hargaJual;
hargaJualFormatted.value = formatNumber(hargaJual.toFixed(0));
} else {
form.value.harga_jual = null;
hargaJualFormatted.value = "";
} }
}; };
const loadKategori = async () => { const loadKategori = async () => {
const response = await axios.get("/api/kategori", { try {
headers: { const response = await axios.get("/api/kategori", {
Authorization: `Bearer ${localStorage.getItem("token")}`, headers: {
}, Authorization: `Bearer ${localStorage.getItem("token")}`,
}); },
category.value = response.data.map((c) => ({ value: c.id, label: c.nama })); });
category.value = response.data.map((c) => ({ value: c.id, label: c.nama }));
} catch (error) {
console.error("Error loading categories:", error);
}
}; };
const loadProduk = async () => { const loadProduk = async () => {
const response = await axios.get(`/api/produk/edit/${productId}`, { try {
headers: { const response = await axios.get(`/api/produk/edit/${productId}`, {
Authorization: `Bearer ${localStorage.getItem("token")}`, headers: {
}, Authorization: `Bearer ${localStorage.getItem("token")}`,
}); },
const produk = response.data; });
form.value = { const produk = response.data;
nama: produk.nama, form.value = {
id_kategori: produk.id_kategori, nama: produk.nama,
berat: produk.berat, id_kategori: produk.id_kategori,
kadar: produk.kadar, berat: produk.berat,
harga_per_gram: produk.harga_per_gram, kadar: produk.kadar,
harga_jual: produk.harga_jual, 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() || ""); } catch (error) {
hargaJualFormatted.value = formatNumber(produk.harga_jual?.toString() || ""); console.error("Error loading product:", error);
}
}; };
const loadFoto = async () => { const loadFoto = async () => {
@ -367,18 +421,23 @@ const loadFoto = async () => {
}, },
}); });
uploadedImages.value = response.data; uploadedImages.value = response.data;
} catch (e) { } catch (error) {
console.error(e); console.error("Error loading photos:", error);
uploadError.value = "Gagal memuat foto"; uploadError.value = "Gagal memuat foto";
} }
}; };
const triggerFileInput = () => { const toggleUploadMenu = () => {
if (!uploadLoading.value && uploadedImages.value.length < 6) { 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 handleFileSelect = (e) => {
const files = Array.from(e.target.files); const files = Array.from(e.target.files);
uploadFiles(files); uploadFiles(files);
@ -393,60 +452,47 @@ const handleDrop = (e) => {
}; };
const uploadFiles = async (files) => { const uploadFiles = async (files) => {
uploadError.value = ''; uploadError.value = '';
if (uploadedImages.value.length + files.length > 6) {
if (uploadedImages.value.length + files.length > 6) { uploadError.value = 'Maksimal 6 foto yang dapat diupload';
uploadError.value = 'Maksimal 6 foto yang dapat diupload'; return;
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;
} }
const validFiles = files.filter(file => {
if (!isValidSize) { const isValidType = ['image/jpeg', 'image/jpg', 'image/png'].includes(file.type);
uploadError.value = 'Ukuran file maksimal 2MB'; const isValidSize = file.size <= 2 * 1024 * 1024;
return false; 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) => { 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 () => { const submitForm = async () => {
loading.value = true; loading.value = true;
try { try {
await axios.put( await axios.put(`/api/produk/${productId}`, form.value, {
`/api/produk/${productId}`,form.value, headers: {
{ Authorization: `Bearer ${localStorage.getItem("token")}`,
headers: { },
Authorization: `Bearer ${localStorage.getItem("token")}`, });
},
}
);
router.push("/produk?message=Produk berhasil diperbarui"); router.push("/produk?message=Produk berhasil diperbarui");
} catch (err) { } catch (err) {
console.error("Submit error:", err);
uploadError.value = err.response?.data?.message || "Gagal menyimpan produk"; uploadError.value = err.response?.data?.message || "Gagal menyimpan produk";
console.error(err);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -484,6 +560,7 @@ const submitForm = async () => {
const closeItemModal = () => { const closeItemModal = () => {
openItemModal.value = false; openItemModal.value = false;
editedProduct.value = null;
}; };
const back = () => { const back = () => {
@ -493,7 +570,6 @@ const back = () => {
onMounted(async () => { onMounted(async () => {
await loadKategori(); await loadKategori();
await loadProduk(); await loadProduk();
loadFoto(); await loadFoto();
}); });
</script> </script>