diff --git a/.env.example b/.env.example index 3435e25..6999aad 100644 --- a/.env.example +++ b/.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 diff --git a/Dockerfile b/Dockerfile index cb391c9..74e5344 100644 --- a/Dockerfile +++ b/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"] diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6f8fdfe..2aa598c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -31,7 +31,7 @@ class DatabaseSeeder extends Seeder ]); Sales::factory()->create([ - 'nama' => 'Umum', + 'nama' => 'Kasir', 'no_hp' => '-', 'alamat' => '-', ]); diff --git a/docker-compose.yml b/docker-compose.yml index b7cac6c..3419561 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/nginx.conf b/nginx.conf index 443a33e..0ffd63c 100644 --- a/nginx.conf +++ b/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; + 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; } -} \ No newline at end of file + + # 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; + } +} diff --git a/resources/js/pages/EditProduk.vue b/resources/js/pages/EditProduk.vue index 3af7d7f..89188d1 100644 --- a/resources/js/pages/EditProduk.vue +++ b/resources/js/pages/EditProduk.vue @@ -56,21 +56,20 @@
@@ -80,7 +79,7 @@
-
+
-
-
+
+
+ + + +
+
+ + + + +
+

+
+
+ + +
+
-
+
+ Upload dari File +
+
+ Pilih foto dari galeri +
+
+ +
-

+
+
+ Ambil dari Kamera +
+
+ Foto langsung dengan kamera +
+
+
@@ -213,6 +281,43 @@
+ + +
+ + +
+
+ + +
+ + +
+
+
@@ -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(); }); -