This commit is contained in:
Flatlogic Bot 2026-02-06 12:02:27 +00:00
parent 28bc475346
commit 04c4db511d
21 changed files with 949 additions and 147 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,21 @@
from django.contrib import admin from django.contrib import admin
from .models import Category, Medicine, Batch, StockTransaction, Supplier, Faktur from .models import Category, Medicine, Batch, StockTransaction, Supplier, Faktur, AppSetting
# DN-WRS Branding # DN-WRS Branding
admin.site.site_header = "DN-WRS Admin" admin.site.site_header = "DN-WRS Admin"
admin.site.site_title = "DN-WRS Portal" admin.site.site_title = "DN-WRS Portal"
admin.site.index_title = "Selamat Datang di Manajemen DN-WRS" admin.site.index_title = "Selamat Datang di Manajemen DN-WRS"
@admin.register(AppSetting)
class AppSettingAdmin(admin.ModelAdmin):
list_display = ('app_name', 'app_email', 'app_phone')
def has_add_permission(self, request):
# Allow only one instance of AppSetting
if self.model.objects.exists():
return False
return True
@admin.register(Supplier) @admin.register(Supplier)
class SupplierAdmin(admin.ModelAdmin): class SupplierAdmin(admin.ModelAdmin):
list_display = ('name', 'contact_person', 'phone') list_display = ('name', 'contact_person', 'phone')
@ -36,4 +46,4 @@ class BatchAdmin(admin.ModelAdmin):
@admin.register(StockTransaction) @admin.register(StockTransaction)
class StockTransactionAdmin(admin.ModelAdmin): class StockTransactionAdmin(admin.ModelAdmin):
list_display = ('medicine', 'transaction_type', 'quantity', 'created_at') list_display = ('medicine', 'transaction_type', 'quantity', 'created_at')
list_filter = ('transaction_type', 'created_at') list_filter = ('transaction_type', 'created_at')

View File

@ -1,13 +1,23 @@
import os import os
import time import time
from .models import AppSetting
def project_context(request): def project_context(request):
""" """
Adds project-specific environment variables to the template context globally. Adds project-specific environment variables and settings to the template context globally.
""" """
settings = AppSetting.objects.first()
if not settings:
# Create default settings if none exist
settings = AppSetting.objects.create(app_name="DN-WRS")
return { return {
"project_description": os.getenv("PROJECT_DESCRIPTION", ""), "project_name": settings.app_name,
"project_description": settings.app_description or os.getenv("PROJECT_DESCRIPTION", ""),
"project_address": settings.app_address,
"project_phone": settings.app_phone,
"project_email": settings.app_email,
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
# Used for cache-busting static assets # Used for cache-busting static assets
"deployment_timestamp": int(time.time()), "deployment_timestamp": int(time.time()),
} }

View File

@ -1,5 +1,17 @@
from django import forms from django import forms
from .models import Supplier, Faktur, Medicine, Batch, StockTransaction, Category from .models import Supplier, Faktur, Medicine, Batch, StockTransaction, Category, AppSetting
class AppSettingForm(forms.ModelForm):
class Meta:
model = AppSetting
fields = ['app_name', 'app_description', 'app_address', 'app_phone', 'app_email']
widgets = {
'app_name': forms.TextInput(attrs={'class': 'form-control'}),
'app_description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'app_address': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'app_phone': forms.TextInput(attrs={'class': 'form-control'}),
'app_email': forms.EmailInput(attrs={'class': 'form-control'}),
}
class CategoryForm(forms.ModelForm): class CategoryForm(forms.ModelForm):
class Meta: class Meta:
@ -104,4 +116,4 @@ class StockOutForm(forms.Form):
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
elif self.initial.get('medicine'): elif self.initial.get('medicine'):
self.fields['batch'].queryset = Batch.objects.filter(medicine_id=self.initial.get('medicine'), quantity__gt=0) self.fields['batch'].queryset = Batch.objects.filter(medicine_id=self.initial.get('medicine'), quantity__gt=0)

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-02-06 11:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_medicine_alternative_supplier_medicine_main_supplier'),
]
operations = [
migrations.CreateModel(
name='AppSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('app_name', models.CharField(default='DN-WRS', max_length=100, verbose_name='Nama Aplikasi')),
('app_description', models.TextField(blank=True, verbose_name='Deskripsi Aplikasi')),
('app_address', models.TextField(blank=True, verbose_name='Alamat')),
('app_phone', models.CharField(blank=True, max_length=20, verbose_name='Nomor Telepon')),
('app_email', models.EmailField(blank=True, max_length=254, verbose_name='Email')),
],
options={
'verbose_name': 'Pengaturan Aplikasi',
'verbose_name_plural': 'Pengaturan Aplikasi',
},
),
]

View File

@ -1,6 +1,20 @@
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
class AppSetting(models.Model):
app_name = models.CharField(max_length=100, default="DN-WRS", verbose_name="Nama Aplikasi")
app_description = models.TextField(blank=True, verbose_name="Deskripsi Aplikasi")
app_address = models.TextField(blank=True, verbose_name="Alamat")
app_phone = models.CharField(max_length=20, blank=True, verbose_name="Nomor Telepon")
app_email = models.EmailField(blank=True, verbose_name="Email")
def __str__(self):
return self.app_name
class Meta:
verbose_name = "Pengaturan Aplikasi"
verbose_name_plural = "Pengaturan Aplikasi"
class Category(models.Model): class Category(models.Model):
name = models.CharField(max_length=100, verbose_name="Nama Kategori") name = models.CharField(max_length=100, verbose_name="Nama Kategori")
description = models.TextField(blank=True, verbose_name="Deskripsi") description = models.TextField(blank=True, verbose_name="Deskripsi")

View File

@ -18,110 +18,350 @@
<!-- Custom CSS --> <!-- Custom CSS -->
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={% now 'U' %}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={% now 'U' %}">
<style>
:root {
--primary-gradient: linear-gradient(135deg, #0d6efd 0%, #00d2ff 100%);
--sidebar-width: 280px;
--glass-bg: rgba(255, 255, 255, 0.8);
--glass-border: rgba(255, 255, 255, 0.3);
}
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background-color: #f8f9fa;
color: #212529;
min-height: 100vh;
}
/* Sidebar Styles */
.sidebar {
width: var(--sidebar-width);
height: 100vh;
position: fixed;
left: 0;
top: 0;
background: #fff;
border-right: 1px solid #e9ecef;
display: flex;
flex-direction: column;
z-index: 1040;
transition: transform 0.3s ease;
}
.main-wrapper {
margin-left: var(--sidebar-width);
min-height: 100vh;
display: flex;
flex-direction: column;
transition: margin-left 0.3s ease;
}
.sidebar-brand {
padding: 2rem 1.5rem;
text-decoration: none;
display: flex;
align-items: center;
}
.sidebar-brand span {
background: var(--primary-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 800;
font-size: 1.25rem;
}
.sidebar-nav {
flex-grow: 1;
padding: 0 1rem;
overflow-y: auto;
}
.sidebar-section-title {
padding: 1.5rem 1rem 0.5rem;
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 700;
color: #adb5bd;
letter-spacing: 0.05rem;
}
.nav-item-link {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
color: #495057;
text-decoration: none;
font-weight: 500;
border-radius: 12px;
margin-bottom: 0.25rem;
transition: all 0.2s ease;
}
.nav-item-link:hover {
background: #f8f9fa;
color: #0d6efd;
}
.nav-item-link.active {
background: #e7f1ff;
color: #0d6efd;
}
.nav-item-link i {
margin-right: 12px;
width: 20px;
height: 20px;
}
/* Top Header in Main Wrapper */
.top-header {
height: 70px;
background: rgba(248, 249, 250, 0.8);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
padding: 0 2rem;
position: sticky;
top: 0;
z-index: 1030;
}
.search-container {
position: relative;
max-width: 400px;
width: 100%;
}
.search-container input {
padding-left: 40px;
border-radius: 50px;
background: #fff;
border: 1px solid #e9ecef;
height: 42px;
}
.search-container i {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: #adb5bd;
}
.user-profile-btn {
background: #fff;
border: 1px solid #e9ecef;
padding: 5px 15px 5px 5px;
border-radius: 50px;
display: flex;
align-items: center;
transition: all 0.2s;
}
.user-profile-btn:hover {
border-color: #0d6efd;
background: #f8f9fa;
}
/* Mobile Adjustments */
@media (max-width: 991.98px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.show {
transform: translateX(0);
}
.main-wrapper {
margin-left: 0;
}
.sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1035;
}
.sidebar-overlay.show {
display: block;
}
}
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body> <body>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-custom sticky-top py-3 mb-4"> <div class="sidebar-overlay" id="sidebarOverlay"></div>
<div class="container">
<a class="navbar-brand fw-bold text-primary d-flex align-items-center" href="{% url 'home' %}"> <!-- Sidebar -->
<i data-lucide="package" class="me-2"></i> <aside class="sidebar" id="sidebar">
<span>{{ project_name|default:"DN-WRS" }}</span> <a class="sidebar-brand" href="{% url 'home' %}">
<i data-lucide="package" class="me-2 text-primary" style="width: 32px; height: 32px;"></i>
<span>{{ project_name|default:"DN-WRS" }}</span>
</a>
<div class="sidebar-nav">
<div class="sidebar-section-title">Menu Utama</div>
<a href="{% url 'home' %}" class="nav-item-link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
<i data-lucide="layout-dashboard"></i> Dashboard
</a> </a>
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<div class="sidebar-section-title">Manajemen Data</div>
<a href="{% url 'medicine_list' %}" class="nav-item-link {% if request.resolver_match.url_name == 'medicine_list' %}active{% endif %}">
<i data-lucide="pill"></i> Daftar Barang
</a>
<a href="{% url 'category_list' %}" class="nav-item-link {% if request.resolver_match.url_name == 'category_list' %}active{% endif %}">
<i data-lucide="tag"></i> Kategori
</a>
<a href="{% url 'supplier_list' %}" class="nav-item-link {% if request.resolver_match.url_name == 'supplier_list' %}active{% endif %}">
<i data-lucide="truck"></i> Supplier
</a>
<div class="sidebar-section-title">Transaksi</div>
<a href="{% url 'input_faktur' %}" class="nav-item-link {% if request.resolver_match.url_name == 'input_faktur' or request.resolver_match.url_name == 'faktur_detail' %}active{% endif %}">
<i data-lucide="file-plus"></i> Inbound / Faktur
</a>
<a href="{% url 'barang_keluar' %}" class="nav-item-link {% if request.resolver_match.url_name == 'barang_keluar' %}active{% endif %}">
<i data-lucide="file-minus"></i> Outbound / Keluar
</a>
<a href="{% url 'laporan_transaksi' %}" class="nav-item-link {% if request.resolver_match.url_name == 'laporan_transaksi' %}active{% endif %}">
<i data-lucide="bar-chart-2"></i> Laporan Stok
</a>
<div class="sidebar-section-title">Sistem</div>
<a href="{% url 'app_settings' %}" class="nav-item-link {% if request.resolver_match.url_name == 'app_settings' %}active{% endif %}">
<i data-lucide="settings"></i> Pengaturan Aplikasi
</a>
<a href="/admin/password_change/" class="nav-item-link">
<i data-lucide="key"></i> Keamanan
</a>
</div>
<div class="p-3 border-top">
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="nav-item-link text-danger border-0 bg-transparent w-100 text-start">
<i data-lucide="log-out"></i> Keluar
</button>
</form>
</div>
</aside>
<div class="main-wrapper">
<!-- Top Header -->
<header class="top-header">
<button class="btn btn-link p-0 me-3 d-lg-none text-dark shadow-none" id="sidebarToggle">
<i data-lucide="menu"></i> <i data-lucide="menu"></i>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto"> <form action="{% url 'medicine_list' %}" method="get" class="search-container d-none d-md-block">
<li class="nav-item"> <i data-lucide="search"></i>
<a class="nav-link px-3 d-flex align-items-center {% if request.resolver_match.url_name == 'home' %}active{% endif %}" href="{% url 'home' %}"> <input type="text" name="q" class="form-control border-0 shadow-sm" placeholder="Cari barang atau faktur..." value="{{ query|default:'' }}">
<i data-lucide="layout-dashboard" class="me-2 icon-sm"></i> Beranda </form>
</a>
</li> <div class="ms-auto d-flex align-items-center">
<li class="nav-item dropdown"> <div class="dropdown">
<a class="nav-link px-3 dropdown-toggle d-flex align-items-center" href="#" role="button" data-bs-toggle="dropdown"> <button class="user-profile-btn shadow-none border-0 d-flex align-items-center" type="button" data-bs-toggle="dropdown">
<i data-lucide="database" class="me-2 icon-sm"></i> Master Data <div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px; font-weight: 700; font-size: 0.8rem;">
</a> {{ user.username|slice:":1"|upper }}
<ul class="dropdown-menu border-0 shadow-lg rounded-4 p-2"> </div>
<li><a class="dropdown-item rounded-3" href="{% url 'medicine_list' %}"><i data-lucide="pill" class="me-2 icon-sm"></i> Data Barang</a></li> <span class="small fw-bold d-none d-sm-block">{{ user.username }}</span>
<li><a class="dropdown-item rounded-3" href="{% url 'category_list' %}"><i data-lucide="tag" class="me-2 icon-sm"></i> Kategori</a></li> <i data-lucide="chevron-down" class="ms-1 text-muted" style="width: 14px; height: 14px;"></i>
<li><a class="dropdown-item rounded-3" href="{% url 'supplier_list' %}"><i data-lucide="truck" class="me-2 icon-sm"></i> Supplier</a></li> </button>
</ul> <ul class="dropdown-menu dropdown-menu-end border-0 shadow-lg rounded-4 p-2 mt-2">
</li> <li><a class="dropdown-item rounded-3 py-2" href="{% url 'app_settings' %}"><i data-lucide="settings" class="me-2 icon-sm"></i> Pengaturan</a></li>
<li class="nav-item"> <li><hr class="dropdown-divider opacity-50"></li>
<a class="nav-link px-3 d-flex align-items-center {% if request.resolver_match.url_name == 'input_faktur' %}active{% endif %}" href="{% url 'input_faktur' %}"> <li>
<i data-lucide="file-plus" class="me-2 icon-sm"></i> Faktur Masuk <form method="post" action="{% url 'logout' %}">
</a> {% csrf_token %}
</li> <button type="submit" class="dropdown-item rounded-3 py-2 text-danger"><i data-lucide="log-out" class="me-2 icon-sm"></i> Keluar</button>
<li class="nav-item"> </form>
<a class="nav-link px-3 d-flex align-items-center {% if request.resolver_match.url_name == 'barang_keluar' %}active{% endif %}" href="{% url 'barang_keluar' %}"> </li>
<i data-lucide="file-minus" class="me-2 icon-sm"></i> Barang Keluar </ul>
</a> </div>
</li> </div>
<li class="nav-item"> </header>
<a class="nav-link px-3 d-flex align-items-center {% if request.resolver_match.url_name == 'laporan_transaksi' %}active{% endif %}" href="{% url 'laporan_transaksi' %}">
<i data-lucide="bar-chart-2" class="me-2 icon-sm"></i> Laporan <div class="container-fluid px-4 py-4">
</a> {% if messages %}
</li> {% for message in messages %}
</ul> <div class="alert alert-{{ message.tags }} alert-dismissible fade show rounded-4 border-0 shadow-sm mb-4" role="alert">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="dropdown"> {% if message.tags == 'success' %}
<button class="btn btn-link text-dark text-decoration-none dropdown-toggle d-flex align-items-center" type="button" id="userDropdown" data-bs-toggle="dropdown"> <i data-lucide="check-circle" class="me-2 text-success"></i>
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px; font-size: 0.8rem;"> {% elif message.tags == 'error' %}
{{ user.username|slice:":1"|upper }} <i data-lucide="x-circle" class="me-2 text-danger"></i>
</div> {% else %}
<span class="fw-semibold">{{ user.username }}</span> <i data-lucide="alert-circle" class="me-2 text-info"></i>
</button> {% endif %}
<ul class="dropdown-menu dropdown-menu-end border-0 shadow-lg rounded-4 p-2"> <span class="fw-medium">{{ message }}</span>
<li><a class="dropdown-item rounded-3 py-2" href="/admin/password_change/">Ubah Password</a></li> </div>
<li><hr class="dropdown-divider"></li> <button type="button" class="btn-close shadow-none" data-bs-dismiss="alert" aria-label="Close"></button>
<li> </div>
<form method="post" action="{% url 'logout' %}"> {% endfor %}
{% csrf_token %} {% endif %}
<button type="submit" class="dropdown-item rounded-3 py-2 text-danger">Keluar</button>
</form> <main class="fade-in">
</li> {% block content %}{% endblock %}
</ul> </main>
</div>
<footer class="mt-auto py-4 bg-white border-top">
<div class="container-fluid px-4">
<div class="row align-items-center">
<div class="col-md-6 text-center text-md-start">
<p class="mb-0 text-muted small">&copy; {% now "Y" %} {{ project_name|default:"DN-WRS" }}</p>
</div>
<div class="col-md-6 text-center text-md-end">
<div class="d-flex justify-content-center justify-content-md-end gap-3 text-muted" style="font-size: 0.75rem;">
<span>v2.0.0</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </footer>
</nav>
{% endif %}
<div class="container">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show rounded-4 border-0 shadow-sm mb-4" role="alert">
<div class="d-flex align-items-center">
<i data-lucide="{% if message.tags == 'success' %}check-circle{% else %}alert-circle{% endif %}" class="me-2"></i>
{{ message }}
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
</div> </div>
{% else %}
<main class="container py-2 fade-in"> <main>
{% block content %}{% endblock %} {% block login_content %}{% block content_no_auth %}{% endblock %}{% endblock %}
</main> </main>
{% endif %}
<footer class="container py-5 text-center text-muted small mt-auto">
<p class="mb-0">&copy; {% now "Y" %} {{ project_name|default:"DN-WRS" }}</p>
<p class="opacity-50">Sistem Manajemen Gudang Premium</p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
// Initialize Lucide icons // Initialize Lucide icons
lucide.createIcons(); lucide.createIcons();
// Custom icon sizes // Sidebar Toggle for Mobile
document.querySelectorAll('.icon-sm').forEach(el => { const sidebar = document.getElementById('sidebar');
el.setAttribute('width', '18'); const overlay = document.getElementById('sidebarOverlay');
el.setAttribute('height', '18'); const toggle = document.getElementById('sidebarToggle');
});
if (toggle) {
toggle.addEventListener('click', () => {
sidebar.classList.toggle('show');
overlay.classList.toggle('show');
});
}
if (overlay) {
overlay.addEventListener('click', () => {
sidebar.classList.remove('show');
overlay.classList.remove('show');
});
}
</script> </script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>

View File

@ -1,8 +1,56 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
{% if cart %}
<div class="card glass-card border-0 shadow-sm mb-4 border-start border-4 border-warning">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0 text-warning d-flex align-items-center">
<i data-lucide="shopping-cart" class="me-2"></i> Konfirmasi Import Barang Keluar
</h5>
<div class="d-flex gap-2">
<a href="{% url 'clear_cart' 'keluar' %}" class="btn btn-light btn-sm rounded-pill px-3 text-danger">Batalkan Semua</a>
<a href="{% url 'process_cart' 'keluar' %}" class="btn btn-warning btn-sm rounded-pill px-4 fw-bold">Proses Pengeluaran Barang</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Barang (SKU)</th>
<th>Batch Number</th>
<th class="text-end">Qty Keluar</th>
<th>Catatan</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
{% for item in cart %}
<tr class="{% if not item.valid %}table-danger-subtle{% endif %}">
<td>
<div class="fw-bold">{{ item.sku }}</div>
</td>
<td><code>{{ item.batch }}</code></td>
<td class="text-end">{{ item.qty }}</td>
<td>{{ item.note }}</td>
<td class="text-center">
{% if item.valid %}
<span class="badge bg-success rounded-pill"><i data-lucide="check" class="icon-xs"></i> Ready</span>
{% else %}
<span class="badge bg-danger rounded-pill">Batch/Stok Tidak Valid</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="row g-4"> <div class="row g-4">
<div class="col-md-4"> <div class="col-md-4">
<div class="card glass-card border-0 shadow-sm"> <div class="card glass-card border-0 shadow-sm mb-4">
<div class="card-body p-4"> <div class="card-body p-4">
<h4 class="fw-bold mb-4 d-flex align-items-center"> <h4 class="fw-bold mb-4 d-flex align-items-center">
<div class="bg-danger-subtle text-danger p-2 rounded-3 me-3"> <div class="bg-danger-subtle text-danger p-2 rounded-3 me-3">
@ -35,6 +83,29 @@
</form> </form>
</div> </div>
</div> </div>
<div class="card glass-card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0 d-flex align-items-center">
<div class="bg-info-subtle text-info p-2 rounded-3 me-3">
<i data-lucide="file-spreadsheet"></i>
</div>
Import Excel
</h5>
<a href="{% url 'download_template' 'keluar' %}" class="btn btn-sm btn-outline-success rounded-pill">Template</a>
</div>
<form action="{% url 'import_excel' 'keluar' %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
<input type="file" name="file" class="form-control" accept=".xlsx, .xls" required>
</div>
<button type="submit" class="btn btn-info text-white w-100 shadow-sm">
<i data-lucide="upload" class="me-2 icon-sm"></i> Upload Excel Keluar
</button>
</form>
</div>
</div>
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<div class="card glass-card border-0 shadow-sm h-100"> <div class="card glass-card border-0 shadow-sm h-100">

View File

@ -13,35 +13,89 @@
<h6 class="text-muted small fw-bold text-uppercase mb-1" style="letter-spacing: 1px;">NOMOR FAKTUR</h6> <h6 class="text-muted small fw-bold text-uppercase mb-1" style="letter-spacing: 1px;">NOMOR FAKTUR</h6>
<h2 class="fw-bold mb-0 text-primary">#{{ faktur.faktur_number }}</h2> <h2 class="fw-bold mb-0 text-primary">#{{ faktur.faktur_number }}</h2>
</div> </div>
<div class="text-end"> <div class="d-flex gap-2">
<h6 class="text-muted small fw-bold text-uppercase mb-1" style="letter-spacing: 1px;">SUPPLIER</h6> <a href="{% url 'download_template' 'masuk' %}" class="btn btn-outline-success rounded-pill px-3">
<div class="badge bg-primary-subtle text-primary rounded-pill px-4 py-2 fs-6 fw-bold"> <i data-lucide="download" class="me-1 icon-sm"></i> Template Excel
<i data-lucide="truck" class="me-2 icon-sm"></i> {{ faktur.supplier.name|default:"Internal" }} </a>
<div class="text-end ms-4">
<h6 class="text-muted small fw-bold text-uppercase mb-1" style="letter-spacing: 1px;">SUPPLIER</h6>
<div class="badge bg-primary-subtle text-primary rounded-pill px-4 py-2 fs-6 fw-bold">
<i data-lucide="truck" class="me-2 icon-sm"></i> {{ faktur.supplier.name|default:"Internal" }}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% if cart %}
<div class="card glass-card border-0 shadow-sm mb-4 border-start border-4 border-warning">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0 text-warning d-flex align-items-center">
<i data-lucide="shopping-cart" class="me-2"></i> Konfirmasi Import (Keranjang)
</h5>
<div class="d-flex gap-2">
<a href="{% url 'clear_cart_faktur' 'masuk' faktur.id %}" class="btn btn-light btn-sm rounded-pill px-3 text-danger">Batalkan Semua</a>
<a href="{% url 'process_cart_faktur' 'masuk' faktur.id %}" class="btn btn-warning btn-sm rounded-pill px-4 fw-bold">Proses & Simpan Ke Database</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Barang (SKU)</th>
<th>Batch</th>
<th>Expired</th>
<th class="text-end">Qty</th>
<th class="text-end">Harga Beli</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
{% for item in cart %}
<tr class="{% if not item.valid %}table-danger-subtle{% endif %}">
<td>
<div class="fw-bold">{{ item.name }}</div>
<div class="small text-muted">{{ item.sku }}</div>
</td>
<td><code>{{ item.batch }}</code></td>
<td>{{ item.expiry }}</td>
<td class="text-end">{{ item.qty }}</td>
<td class="text-end">Rp {{ item.buy|floatformat:2 }}</td>
<td class="text-center">
{% if item.valid %}
<span class="badge bg-success rounded-pill"><i data-lucide="check" class="icon-xs"></i> Ready</span>
{% else %}
<span class="badge bg-danger rounded-pill" title="SKU tidak ditemukan di database Master Barang">SKU Tidak Dikenal</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="row g-4"> <div class="row g-4">
<div class="col-md-4"> <div class="col-md-4">
<div class="card glass-card border-0 shadow-sm"> <div class="card glass-card border-0 shadow-sm mb-4">
<div class="card-body p-4"> <div class="card-body p-4">
<h5 class="fw-bold mb-4 d-flex align-items-center"> <h5 class="fw-bold mb-4 d-flex align-items-center">
<div class="bg-success-subtle text-success p-2 rounded-3 me-3"> <div class="bg-success-subtle text-success p-2 rounded-3 me-3">
<i data-lucide="package-plus"></i> <i data-lucide="package-plus"></i>
</div> </div>
Input Barang Input Manual
</h5> </h5>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="add_item" value="1">
{% for field in form %} {% for field in form %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label small fw-bold text-muted text-uppercase mb-1" style="font-size: 0.65rem; letter-spacing: 0.5px;">{{ field.label }}</label> <label class="form-label small fw-bold text-muted text-uppercase mb-1" style="font-size: 0.65rem; letter-spacing: 0.5px;">{{ field.label }}</label>
{{ field }} {{ field }}
{% if field.errors %}
<div class="text-danger small">{{ field.errors }}</div>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
<button type="submit" class="btn btn-primary w-100 py-3 mt-3 shadow-sm d-flex align-items-center justify-content-center"> <button type="submit" class="btn btn-primary w-100 py-3 mt-3 shadow-sm d-flex align-items-center justify-content-center">
@ -50,6 +104,46 @@
</form> </form>
</div> </div>
</div> </div>
<div class="card glass-card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3 d-flex align-items-center">
<div class="bg-info-subtle text-info p-2 rounded-3 me-3">
<i data-lucide="file-spreadsheet"></i>
</div>
Import Excel
</h5>
<form action="{% url 'import_excel_faktur' 'masuk' faktur.id %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
<input type="file" name="file" class="form-control" accept=".xlsx, .xls" required>
</div>
<button type="submit" class="btn btn-info text-white w-100 shadow-sm">
<i data-lucide="upload" class="me-2 icon-sm"></i> Upload Excel
</button>
</form>
</div>
</div>
<div class="card glass-card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="fw-bold mb-3 d-flex align-items-center">
<div class="bg-purple-subtle text-purple p-2 rounded-3 me-3">
<i data-lucide="scan"></i>
</div>
Scan Foto Faktur
</h5>
<form action="{% url 'ocr_faktur' faktur.id %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
<input type="file" name="image" class="form-control" accept="image/*" required>
</div>
<button type="submit" class="btn btn-purple text-white w-100 shadow-sm">
<i data-lucide="camera" class="me-2 icon-sm"></i> Scan & Ekstrak Data
</button>
</form>
</div>
</div>
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<div class="card glass-card border-0 shadow-sm h-100"> <div class="card glass-card border-0 shadow-sm h-100">
@ -59,9 +153,9 @@
<div class="bg-primary-subtle text-primary p-2 rounded-3 me-3"> <div class="bg-primary-subtle text-primary p-2 rounded-3 me-3">
<i data-lucide="list"></i> <i data-lucide="list"></i>
</div> </div>
Rincian Barang Rincian Barang Terinput
</h5> </h5>
<span class="badge bg-light text-dark border px-3 py-2 rounded-pill">{{ items|length }} Item</span> <span class="badge bg-light text-dark border px-3 py-2 rounded-pill">{{ items|length }} Item Terdaftar</span>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table align-middle"> <table class="table align-middle">
@ -106,6 +200,11 @@
<!-- Select2 CSS --> <!-- Select2 CSS -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<style> <style>
.bg-purple-subtle { background-color: #f3e8ff; }
.text-purple { color: #7e22ce; }
.btn-purple { background-color: #9333ea; border-color: #9333ea; }
.btn-purple:hover { background-color: #7e22ce; border-color: #7e22ce; }
.select2-container--default .select2-selection--single { .select2-container--default .select2-selection--single {
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
height: 48px; height: 48px;

View File

@ -0,0 +1,85 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-0 shadow-lg rounded-4 overflow-hidden mb-4">
<div class="card-header bg-white border-0 py-4 px-4">
<div class="d-flex align-items-center">
<div class="bg-primary bg-opacity-10 p-3 rounded-4 me-3">
<i data-lucide="settings" class="text-primary"></i>
</div>
<div>
<h4 class="fw-bold mb-0">Pengaturan Aplikasi</h4>
<p class="text-muted small mb-0">Konfigurasi identitas sistem pergudangan</p>
</div>
</div>
</div>
<div class="card-body p-4">
<form method="post">
{% csrf_token %}
<div class="row">
<div class="col-md-12 mb-4">
<label class="form-label fw-semibold">Nama Aplikasi</label>
{{ form.app_name }}
<div class="form-text">Nama ini akan muncul di navigasi dan judul halaman.</div>
</div>
<div class="col-md-12 mb-4">
<label class="form-label fw-semibold">Deskripsi</label>
{{ form.app_description }}
</div>
<div class="col-md-6 mb-4">
<label class="form-label fw-semibold">Nomor Telepon</label>
{{ form.app_phone }}
</div>
<div class="col-md-6 mb-4">
<label class="form-label fw-semibold">Email</label>
{{ form.app_email }}
</div>
<div class="col-md-12 mb-4">
<label class="form-label fw-semibold">Alamat</label>
{{ form.app_address }}
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-2">
<a href="{% url 'home' %}" class="btn btn-light px-4 rounded-3">Batal</a>
<button type="submit" class="btn btn-primary px-4 rounded-3 d-flex align-items-center">
<i data-lucide="save" class="me-2 icon-sm"></i> Simpan Perubahan
</button>
</div>
</form>
</div>
</div>
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center mb-3">
<i data-lucide="info" class="text-info me-2"></i>
<h5 class="fw-bold mb-0">Informasi Sistem</h5>
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="p-3 bg-light rounded-3">
<div class="text-muted small">Django Version</div>
<div class="fw-bold">{{ django_version }}</div>
</div>
</div>
<div class="col-md-6">
<div class="p-3 bg-light rounded-3">
<div class="text-muted small">Python Version</div>
<div class="fw-bold">{{ python_version }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -3,6 +3,7 @@ from . import views
urlpatterns = [ urlpatterns = [
path('', views.home, name='home'), path('', views.home, name='home'),
path('settings/', views.app_settings, name='app_settings'),
path('medicines/', views.medicine_list, name='medicine_list'), path('medicines/', views.medicine_list, name='medicine_list'),
path('medicines/export-low-stock/', views.export_low_stock_pdf, name='export_low_stock_pdf'), path('medicines/export-low-stock/', views.export_low_stock_pdf, name='export_low_stock_pdf'),
path('suppliers/', views.supplier_list, name='supplier_list'), path('suppliers/', views.supplier_list, name='supplier_list'),
@ -14,4 +15,14 @@ urlpatterns = [
path('laporan/', views.laporan_transaksi, name='laporan_transaksi'), path('laporan/', views.laporan_transaksi, name='laporan_transaksi'),
path('transaksi/<int:pk>/delete/', views.delete_transaksi, name='delete_transaksi'), path('transaksi/<int:pk>/delete/', views.delete_transaksi, name='delete_transaksi'),
path('transaksi/<int:pk>/edit/', views.edit_transaksi, name='edit_transaksi'), path('transaksi/<int:pk>/edit/', views.edit_transaksi, name='edit_transaksi'),
# Import / Template / OCR
path('template/download/<str:type>/', views.download_template, name='download_template'),
path('import/excel/<str:type>/', views.import_excel, name='import_excel'),
path('import/excel/<str:type>/<int:faktur_id>/', views.import_excel, name='import_excel_faktur'),
path('ocr/faktur/<int:faktur_id>/', views.ocr_faktur, name='ocr_faktur'),
path('cart/process/<str:type>/', views.process_cart, name='process_cart'),
path('cart/process/<str:type>/<int:faktur_id>/', views.process_cart, name='process_cart_faktur'),
path('cart/clear/<str:type>/', views.clear_cart, name='clear_cart'),
path('cart/clear/<str:type>/<int:faktur_id>/', views.clear_cart, name='clear_cart_faktur'),
] ]

View File

@ -1,7 +1,10 @@
import os import os
import platform import platform
import io import io
from datetime import timedelta import base64
import json
import pandas as pd
from datetime import timedelta, datetime
from django import get_version as django_version from django import get_version as django_version
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.utils import timezone from django.utils import timezone
@ -9,20 +12,25 @@ from django.db.models import Sum, Q
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib import messages from django.contrib import messages
from django.http import JsonResponse, HttpResponse from django.http import JsonResponse, HttpResponse
from .models import Medicine, Batch, Category, StockTransaction, Supplier, Faktur from .models import Medicine, Batch, Category, StockTransaction, Supplier, Faktur, AppSetting
from .forms import SupplierForm, FakturForm, StockInForm, StockOutForm, CategoryForm, MedicineForm from .forms import SupplierForm, FakturForm, StockInForm, StockOutForm, CategoryForm, MedicineForm, AppSettingForm
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.pagesizes import letter, A4 from reportlab.lib.pagesizes import letter, A4
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from ai.local_ai_api import LocalAIApi
@login_required @login_required
def home(request): def home(request):
"""Render the medicine warehouse dashboard.""" """Render the medicine warehouse dashboard."""
now = timezone.now() now = timezone.now()
today = now.date() today = now.date()
settings = AppSetting.objects.first()
project_name = settings.app_name if settings else "DN-WRS"
# Stats # Stats
total_medicines = Medicine.objects.count() total_medicines = Medicine.objects.count()
@ -71,7 +79,7 @@ def home(request):
chart_data_out.append(out_sum) chart_data_out.append(out_sum)
context = { context = {
"project_name": "DN-WRS", "project_name": project_name,
"total_medicines": total_medicines, "total_medicines": total_medicines,
"total_stock": total_stock, "total_stock": total_stock,
"expired_count": expired_batches_count, "expired_count": expired_batches_count,
@ -87,6 +95,23 @@ def home(request):
} }
return render(request, "core/index.html", context) return render(request, "core/index.html", context)
@login_required
def app_settings(request):
settings = AppSetting.objects.first()
if not settings:
settings = AppSetting.objects.create(app_name="DN-WRS")
if request.method == 'POST':
form = AppSettingForm(request.POST, instance=settings)
if form.is_valid():
form.save()
messages.success(request, "Pengaturan aplikasi berhasil diperbarui.")
return redirect('app_settings')
else:
form = AppSettingForm(instance=settings)
return render(request, 'core/settings.html', {'form': form, 'settings': settings})
# --- MASTER DATA MANAGEMENT --- # --- MASTER DATA MANAGEMENT ---
@login_required @login_required
@ -103,8 +128,7 @@ def category_list(request):
categories = Category.objects.all().order_by('name') categories = Category.objects.all().order_by('name')
return render(request, 'core/categories.html', { return render(request, 'core/categories.html', {
'categories': categories, 'categories': categories,
'form': form, 'form': form
'project_name': 'DN-WRS'
}) })
@login_required @login_required
@ -121,8 +145,7 @@ def supplier_list(request):
suppliers = Supplier.objects.all().order_by('name') suppliers = Supplier.objects.all().order_by('name')
return render(request, 'core/suppliers.html', { return render(request, 'core/suppliers.html', {
'suppliers': suppliers, 'suppliers': suppliers,
'form': form, 'form': form
'project_name': 'DN-WRS'
}) })
@login_required @login_required
@ -164,7 +187,6 @@ def medicine_list(request):
return render(request, 'core/medicines.html', { return render(request, 'core/medicines.html', {
'medicines': medicines, 'medicines': medicines,
'form': form, 'form': form,
'project_name': 'DN-WRS',
'query': query, 'query': query,
'show_low_stock': show_low_stock, 'show_low_stock': show_low_stock,
'low_stock_count': low_stock_count, 'low_stock_count': low_stock_count,
@ -261,46 +283,52 @@ def input_faktur(request):
fakturs = Faktur.objects.all().order_by('-created_at') fakturs = Faktur.objects.all().order_by('-created_at')
return render(request, 'core/input_faktur.html', { return render(request, 'core/input_faktur.html', {
'faktur_form': faktur_form, 'faktur_form': faktur_form,
'fakturs': fakturs, 'fakturs': fakturs
'project_name': 'DN-WRS'
}) })
@login_required @login_required
def faktur_detail(request, pk): def faktur_detail(request, pk):
faktur = get_object_or_404(Faktur, pk=pk) faktur = get_object_or_404(Faktur, pk=pk)
if request.method == 'POST': if request.method == 'POST':
form = StockInForm(request.POST) if 'add_item' in request.POST:
if form.is_valid(): form = StockInForm(request.POST)
# Create Batch if form.is_valid():
batch = Batch.objects.create( # Create Batch
medicine=form.cleaned_data['medicine'], batch = Batch.objects.create(
faktur=faktur, medicine=form.cleaned_data['medicine'],
batch_number=form.cleaned_data['batch_number'], faktur=faktur,
expiry_date=form.cleaned_data['expiry_date'], batch_number=form.cleaned_data['batch_number'],
quantity=form.cleaned_data['quantity'], expiry_date=form.cleaned_data['expiry_date'],
buying_price=form.cleaned_data['buying_price'], quantity=form.cleaned_data['quantity'],
selling_price=form.cleaned_data['selling_price'] buying_price=form.cleaned_data['buying_price'],
) selling_price=form.cleaned_data['selling_price']
# Create Transaction )
StockTransaction.objects.create( # Create Transaction
medicine=batch.medicine, StockTransaction.objects.create(
batch=batch, medicine=batch.medicine,
faktur=faktur, batch=batch,
transaction_type='IN', faktur=faktur,
quantity=batch.quantity, transaction_type='IN',
note=f"Input dari Faktur {faktur.faktur_number}" quantity=batch.quantity,
) note=f"Input dari Faktur {faktur.faktur_number}"
messages.success(request, f"Barang {batch.medicine.name} berhasil ditambahkan.") )
return redirect('faktur_detail', pk=pk) messages.success(request, f"Barang {batch.medicine.name} berhasil ditambahkan.")
return redirect('faktur_detail', pk=pk)
else: else:
form = StockInForm() form = StockInForm()
items = Batch.objects.filter(faktur=faktur) items = Batch.objects.filter(faktur=faktur)
cart = request.session.get('import_cart', [])
cart_faktur_id = request.session.get('cart_faktur_id')
# Only show cart if it belongs to this faktur
show_cart = cart and cart_faktur_id == pk
return render(request, 'core/faktur_detail.html', { return render(request, 'core/faktur_detail.html', {
'faktur': faktur, 'faktur': faktur,
'form': form, 'form': form,
'items': items, 'items': items,
'project_name': 'DN-WRS' 'cart': cart if show_cart else []
}) })
@login_required @login_required
@ -333,10 +361,12 @@ def barang_keluar(request):
form = StockOutForm() form = StockOutForm()
transactions = StockTransaction.objects.filter(transaction_type='OUT').order_by('-created_at')[:10] transactions = StockTransaction.objects.filter(transaction_type='OUT').order_by('-created_at')[:10]
cart = request.session.get('export_cart', [])
return render(request, 'core/barang_keluar.html', { return render(request, 'core/barang_keluar.html', {
'form': form, 'form': form,
'transactions': transactions, 'transactions': transactions,
'project_name': 'DN-WRS' 'cart': cart
}) })
@login_required @login_required
@ -349,8 +379,7 @@ def get_batches(request):
def laporan_transaksi(request): def laporan_transaksi(request):
transactions = StockTransaction.objects.all().order_by('-created_at') transactions = StockTransaction.objects.all().order_by('-created_at')
return render(request, 'core/laporan_transaksi.html', { return render(request, 'core/laporan_transaksi.html', {
'transactions': transactions, 'transactions': transactions
'project_name': 'DN-WRS'
}) })
@login_required @login_required
@ -398,6 +427,195 @@ def edit_transaksi(request, pk):
return redirect('laporan_transaksi') return redirect('laporan_transaksi')
return render(request, 'core/edit_transaksi.html', { return render(request, 'core/edit_transaksi.html', {
'transaction': transaction, 'transaction': transaction
'project_name': 'DN-WRS' })
})
# --- IMPORT / EXCEL / OCR ---
@login_required
def download_template(request, type):
if type == 'masuk':
cols = ['SKU', 'Nama Barang', 'Batch', 'Expiry Date (YYYY-MM-DD)', 'Qty', 'Harga Beli', 'Harga Jual']
filename = 'template_barang_masuk.xlsx'
else:
cols = ['SKU', 'Batch Number', 'Qty', 'Catatan']
filename = 'template_barang_keluar.xlsx'
df = pd.DataFrame(columns=cols)
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine='openpyxl') as writer:
df.to_excel(writer, index=False)
buffer.seek(0)
response = HttpResponse(buffer.read(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
response['Content-Disposition'] = f'attachment; filename={filename}'
return response
@login_required
def import_excel(request, type, faktur_id=None):
if request.method == 'POST' and request.FILES.get('file'):
file = request.FILES['file']
try:
df = pd.read_excel(file)
items = []
for _, row in df.iterrows():
if type == 'masuk':
# Validate SKU
sku = str(row.get('SKU', '')).strip()
medicine = Medicine.objects.filter(sku=sku).first()
items.append({
'sku': sku,
'name': row.get('Nama Barang', medicine.name if medicine else 'Unknown'),
'batch': str(row.get('Batch', '')),
'expiry': str(row.get('Expiry Date (YYYY-MM-DD)', '')),
'qty': int(row.get('Qty', 0)),
'buy': float(row.get('Harga Beli', 0)),
'sell': float(row.get('Harga Jual', 0)),
'valid': medicine is not None
})
else:
sku = str(row.get('SKU', '')).strip()
batch_num = str(row.get('Batch Number', '')).strip()
medicine = Medicine.objects.filter(sku=sku).first()
batch = Batch.objects.filter(medicine=medicine, batch_number=batch_num).first() if medicine else None
items.append({
'sku': sku,
'batch': batch_num,
'qty': int(row.get('Qty', 0)),
'note': str(row.get('Catatan', '')),
'valid': batch is not None and batch.quantity >= int(row.get('Qty', 0))
})
if type == 'masuk':
request.session['import_cart'] = items
request.session['cart_faktur_id'] = faktur_id
return redirect('faktur_detail', pk=faktur_id)
else:
request.session['export_cart'] = items
return redirect('barang_keluar')
except Exception as e:
messages.error(request, f"Gagal mengimpor file: {str(e)}")
return redirect('home')
@login_required
def ocr_faktur(request, faktur_id):
if request.method == 'POST' and request.FILES.get('image'):
image_file = request.FILES['image']
try:
# Read image and encode to base64
img_data = base64.b64encode(image_file.read()).decode('utf-8')
prompt = """
Extract items from this invoice image. Return a JSON object with a list 'items'.
Each item should have: 'sku', 'name', 'batch', 'expiry' (YYYY-MM-DD), 'qty', 'buy', 'sell'.
If SKU is not visible, leave it empty.
Format the output strictly as JSON.
"""
response = LocalAIApi.create_response({
"model": "gpt-4o",
"input": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_data}"}}
]
}
],
"text": {"format": {"type": "json_object"}}
})
if response.get("success"):
data = LocalAIApi.decode_json_from_response(response)
items = data.get('items', [])
# Enrich with validation
for item in items:
sku = item.get('sku', '')
medicine = Medicine.objects.filter(sku=sku).first() if sku else None
item['valid'] = medicine is not None
request.session['import_cart'] = items
request.session['cart_faktur_id'] = faktur_id
messages.success(request, "OCR Berhasil. Silakan periksa data di keranjang.")
else:
messages.error(request, f"Gagal OCR: {response.get('error')}")
except Exception as e:
messages.error(request, f"Error OCR: {str(e)}")
return redirect('faktur_detail', pk=faktur_id)
@login_required
def process_cart(request, type, faktur_id=None):
if type == 'masuk':
cart = request.session.get('import_cart', [])
faktur = get_object_or_404(Faktur, pk=faktur_id)
count = 0
for item in cart:
if item.get('valid'):
medicine = Medicine.objects.get(sku=item['sku'])
batch = Batch.objects.create(
medicine=medicine,
faktur=faktur,
batch_number=item['batch'],
expiry_date=item['expiry'],
quantity=item['qty'],
buying_price=item['buy'],
selling_price=item['sell']
)
StockTransaction.objects.create(
medicine=medicine,
batch=batch,
faktur=faktur,
transaction_type='IN',
quantity=item['qty'],
note=f"Import Excel/OCR Faktur {faktur.faktur_number}"
)
count += 1
del request.session['import_cart']
del request.session['cart_faktur_id']
messages.success(request, f"{count} barang berhasil diimpor.")
return redirect('faktur_detail', pk=faktur_id)
else:
cart = request.session.get('export_cart', [])
count = 0
for item in cart:
if item.get('valid'):
medicine = Medicine.objects.get(sku=item['sku'])
batch = Batch.objects.get(medicine=medicine, batch_number=item['batch'])
qty = item['qty']
batch.quantity -= qty
batch.save()
StockTransaction.objects.create(
medicine=medicine,
batch=batch,
transaction_type='OUT',
quantity=qty,
note=item.get('note', 'Import Excel')
)
count += 1
del request.session['export_cart']
messages.success(request, f"{count} barang keluar berhasil diproses.")
return redirect('barang_keluar')
@login_required
def clear_cart(request, type, faktur_id=None):
if type == 'masuk':
if 'import_cart' in request.session:
del request.session['import_cart']
return redirect('faktur_detail', pk=faktur_id)
else:
if 'export_cart' in request.session:
del request.session['export_cart']
return redirect('barang_keluar')

View File

@ -1,4 +1,8 @@
Django==5.2.7 Django==5.2.7
mysqlclient==2.2.7 mysqlclient==2.2.7
python-dotenv==1.1.1 python-dotenv==1.1.1
reportlab==4.1.0 reportlab==4.1.0
pandas==2.2.3
openpyxl==3.1.5
Pillow==11.1.0
httpx==0.28.1