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 .models import Category, Medicine, Batch, StockTransaction, Supplier, Faktur
from .models import Category, Medicine, Batch, StockTransaction, Supplier, Faktur, AppSetting
# DN-WRS Branding
admin.site.site_header = "DN-WRS Admin"
admin.site.site_title = "DN-WRS Portal"
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)
class SupplierAdmin(admin.ModelAdmin):
list_display = ('name', 'contact_person', 'phone')
@ -36,4 +46,4 @@ class BatchAdmin(admin.ModelAdmin):
@admin.register(StockTransaction)
class StockTransactionAdmin(admin.ModelAdmin):
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 time
from .models import AppSetting
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 {
"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", ""),
# Used for cache-busting static assets
"deployment_timestamp": int(time.time()),
}
}

View File

@ -1,5 +1,17 @@
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 Meta:
@ -104,4 +116,4 @@ class StockOutForm(forms.Form):
except (ValueError, TypeError):
pass
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.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):
name = models.CharField(max_length=100, verbose_name="Nama Kategori")
description = models.TextField(blank=True, verbose_name="Deskripsi")

View File

@ -18,110 +18,350 @@
<!-- Custom CSS -->
<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 %}
</head>
<body>
{% if user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-custom sticky-top py-3 mb-4">
<div class="container">
<a class="navbar-brand fw-bold text-primary d-flex align-items-center" href="{% url 'home' %}">
<i data-lucide="package" class="me-2"></i>
<span>{{ project_name|default:"DN-WRS" }}</span>
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<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>
<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>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link px-3 d-flex align-items-center {% if request.resolver_match.url_name == 'home' %}active{% endif %}" href="{% url 'home' %}">
<i data-lucide="layout-dashboard" class="me-2 icon-sm"></i> Beranda
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link px-3 dropdown-toggle d-flex align-items-center" href="#" role="button" data-bs-toggle="dropdown">
<i data-lucide="database" class="me-2 icon-sm"></i> Master Data
</a>
<ul class="dropdown-menu border-0 shadow-lg rounded-4 p-2">
<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>
<li><a class="dropdown-item rounded-3" href="{% url 'category_list' %}"><i data-lucide="tag" class="me-2 icon-sm"></i> Kategori</a></li>
<li><a class="dropdown-item rounded-3" href="{% url 'supplier_list' %}"><i data-lucide="truck" class="me-2 icon-sm"></i> Supplier</a></li>
</ul>
</li>
<li class="nav-item">
<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' %}">
<i data-lucide="file-plus" class="me-2 icon-sm"></i> Faktur Masuk
</a>
</li>
<li class="nav-item">
<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' %}">
<i data-lucide="file-minus" class="me-2 icon-sm"></i> Barang Keluar
</a>
</li>
<li class="nav-item">
<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
</a>
</li>
</ul>
<div class="d-flex align-items-center">
<div class="dropdown">
<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">
<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;">
{{ user.username|slice:":1"|upper }}
</div>
<span class="fw-semibold">{{ user.username }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end border-0 shadow-lg rounded-4 p-2">
<li><a class="dropdown-item rounded-3 py-2" href="/admin/password_change/">Ubah Password</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="dropdown-item rounded-3 py-2 text-danger">Keluar</button>
</form>
</li>
</ul>
<form action="{% url 'medicine_list' %}" method="get" class="search-container d-none d-md-block">
<i data-lucide="search"></i>
<input type="text" name="q" class="form-control border-0 shadow-sm" placeholder="Cari barang atau faktur..." value="{{ query|default:'' }}">
</form>
<div class="ms-auto d-flex align-items-center">
<div class="dropdown">
<button class="user-profile-btn shadow-none border-0 d-flex align-items-center" type="button" data-bs-toggle="dropdown">
<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;">
{{ user.username|slice:":1"|upper }}
</div>
<span class="small fw-bold d-none d-sm-block">{{ user.username }}</span>
<i data-lucide="chevron-down" class="ms-1 text-muted" style="width: 14px; height: 14px;"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end border-0 shadow-lg rounded-4 p-2 mt-2">
<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><hr class="dropdown-divider opacity-50"></li>
<li>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<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>
</form>
</li>
</ul>
</div>
</div>
</header>
<div class="container-fluid px-4 py-4">
{% 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">
{% if message.tags == 'success' %}
<i data-lucide="check-circle" class="me-2 text-success"></i>
{% elif message.tags == 'error' %}
<i data-lucide="x-circle" class="me-2 text-danger"></i>
{% else %}
<i data-lucide="alert-circle" class="me-2 text-info"></i>
{% endif %}
<span class="fw-medium">{{ message }}</span>
</div>
<button type="button" class="btn-close shadow-none" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<main class="fade-in">
{% block content %}{% endblock %}
</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>
</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 %}
</footer>
</div>
<main class="container py-2 fade-in">
{% block content %}{% endblock %}
{% else %}
<main>
{% block login_content %}{% block content_no_auth %}{% endblock %}{% endblock %}
</main>
<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>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Initialize Lucide icons
lucide.createIcons();
// Custom icon sizes
document.querySelectorAll('.icon-sm').forEach(el => {
el.setAttribute('width', '18');
el.setAttribute('height', '18');
});
// Sidebar Toggle for Mobile
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebarOverlay');
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>
{% block extra_js %}{% endblock %}
</body>

View File

@ -1,8 +1,56 @@
{% extends 'base.html' %}
{% 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="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">
<h4 class="fw-bold mb-4 d-flex align-items-center">
<div class="bg-danger-subtle text-danger p-2 rounded-3 me-3">
@ -35,6 +83,29 @@
</form>
</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 class="col-md-8">
<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>
<h2 class="fw-bold mb-0 text-primary">#{{ faktur.faktur_number }}</h2>
</div>
<div class="text-end">
<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 class="d-flex gap-2">
<a href="{% url 'download_template' 'masuk' %}" class="btn btn-outline-success rounded-pill px-3">
<i data-lucide="download" class="me-1 icon-sm"></i> Template Excel
</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>
{% 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="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">
<h5 class="fw-bold mb-4 d-flex align-items-center">
<div class="bg-success-subtle text-success p-2 rounded-3 me-3">
<i data-lucide="package-plus"></i>
</div>
Input Barang
Input Manual
</h5>
<form method="post">
{% csrf_token %}
<input type="hidden" name="add_item" value="1">
{% for field in form %}
<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>
{{ field }}
{% if field.errors %}
<div class="text-danger small">{{ field.errors }}</div>
{% endif %}
</div>
{% endfor %}
<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>
</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 class="col-md-8">
<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">
<i data-lucide="list"></i>
</div>
Rincian Barang
Rincian Barang Terinput
</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 class="table-responsive">
<table class="table align-middle">
@ -106,6 +200,11 @@
<!-- Select2 CSS -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<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 {
border: 1px solid #e2e8f0;
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 = [
path('', views.home, name='home'),
path('settings/', views.app_settings, name='app_settings'),
path('medicines/', views.medicine_list, name='medicine_list'),
path('medicines/export-low-stock/', views.export_low_stock_pdf, name='export_low_stock_pdf'),
path('suppliers/', views.supplier_list, name='supplier_list'),
@ -14,4 +15,14 @@ urlpatterns = [
path('laporan/', views.laporan_transaksi, name='laporan_transaksi'),
path('transaksi/<int:pk>/delete/', views.delete_transaksi, name='delete_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 platform
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.shortcuts import render, redirect, get_object_or_404
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 import messages
from django.http import JsonResponse, HttpResponse
from .models import Medicine, Batch, Category, StockTransaction, Supplier, Faktur
from .forms import SupplierForm, FakturForm, StockInForm, StockOutForm, CategoryForm, MedicineForm
from .models import Medicine, Batch, Category, StockTransaction, Supplier, Faktur, AppSetting
from .forms import SupplierForm, FakturForm, StockInForm, StockOutForm, CategoryForm, MedicineForm, AppSettingForm
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter, A4
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from ai.local_ai_api import LocalAIApi
@login_required
def home(request):
"""Render the medicine warehouse dashboard."""
now = timezone.now()
today = now.date()
settings = AppSetting.objects.first()
project_name = settings.app_name if settings else "DN-WRS"
# Stats
total_medicines = Medicine.objects.count()
@ -71,7 +79,7 @@ def home(request):
chart_data_out.append(out_sum)
context = {
"project_name": "DN-WRS",
"project_name": project_name,
"total_medicines": total_medicines,
"total_stock": total_stock,
"expired_count": expired_batches_count,
@ -87,6 +95,23 @@ def home(request):
}
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 ---
@login_required
@ -103,8 +128,7 @@ def category_list(request):
categories = Category.objects.all().order_by('name')
return render(request, 'core/categories.html', {
'categories': categories,
'form': form,
'project_name': 'DN-WRS'
'form': form
})
@login_required
@ -121,8 +145,7 @@ def supplier_list(request):
suppliers = Supplier.objects.all().order_by('name')
return render(request, 'core/suppliers.html', {
'suppliers': suppliers,
'form': form,
'project_name': 'DN-WRS'
'form': form
})
@login_required
@ -164,7 +187,6 @@ def medicine_list(request):
return render(request, 'core/medicines.html', {
'medicines': medicines,
'form': form,
'project_name': 'DN-WRS',
'query': query,
'show_low_stock': show_low_stock,
'low_stock_count': low_stock_count,
@ -261,46 +283,52 @@ def input_faktur(request):
fakturs = Faktur.objects.all().order_by('-created_at')
return render(request, 'core/input_faktur.html', {
'faktur_form': faktur_form,
'fakturs': fakturs,
'project_name': 'DN-WRS'
'fakturs': fakturs
})
@login_required
def faktur_detail(request, pk):
faktur = get_object_or_404(Faktur, pk=pk)
if request.method == 'POST':
form = StockInForm(request.POST)
if form.is_valid():
# Create Batch
batch = Batch.objects.create(
medicine=form.cleaned_data['medicine'],
faktur=faktur,
batch_number=form.cleaned_data['batch_number'],
expiry_date=form.cleaned_data['expiry_date'],
quantity=form.cleaned_data['quantity'],
buying_price=form.cleaned_data['buying_price'],
selling_price=form.cleaned_data['selling_price']
)
# Create Transaction
StockTransaction.objects.create(
medicine=batch.medicine,
batch=batch,
faktur=faktur,
transaction_type='IN',
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)
if 'add_item' in request.POST:
form = StockInForm(request.POST)
if form.is_valid():
# Create Batch
batch = Batch.objects.create(
medicine=form.cleaned_data['medicine'],
faktur=faktur,
batch_number=form.cleaned_data['batch_number'],
expiry_date=form.cleaned_data['expiry_date'],
quantity=form.cleaned_data['quantity'],
buying_price=form.cleaned_data['buying_price'],
selling_price=form.cleaned_data['selling_price']
)
# Create Transaction
StockTransaction.objects.create(
medicine=batch.medicine,
batch=batch,
faktur=faktur,
transaction_type='IN',
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)
else:
form = StockInForm()
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', {
'faktur': faktur,
'form': form,
'items': items,
'project_name': 'DN-WRS'
'cart': cart if show_cart else []
})
@login_required
@ -333,10 +361,12 @@ def barang_keluar(request):
form = StockOutForm()
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', {
'form': form,
'transactions': transactions,
'project_name': 'DN-WRS'
'cart': cart
})
@login_required
@ -349,8 +379,7 @@ def get_batches(request):
def laporan_transaksi(request):
transactions = StockTransaction.objects.all().order_by('-created_at')
return render(request, 'core/laporan_transaksi.html', {
'transactions': transactions,
'project_name': 'DN-WRS'
'transactions': transactions
})
@login_required
@ -398,6 +427,195 @@ def edit_transaksi(request, pk):
return redirect('laporan_transaksi')
return render(request, 'core/edit_transaksi.html', {
'transaction': transaction,
'project_name': 'DN-WRS'
})
'transaction': transaction
})
# --- 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
mysqlclient==2.2.7
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