fix bugs
This commit is contained in:
parent
80a514f1de
commit
01f8d2114e
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
Binary file not shown.
17
inventory/migrations/0003_remove_supplier_rating.py
Normal file
17
inventory/migrations/0003_remove_supplier_rating.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-19 08:54
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0002_customer_supplier'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='supplier',
|
||||
name='rating',
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import ManufacturingOrder, ManufacturingLine, ManufacturingOrderLine
|
||||
from .models import ManufacturingOrder, ManufacturingLine, ManufacturingOrderLine, BillOfMaterials, BillOfMaterialsTotal
|
||||
|
||||
@admin.register(ManufacturingOrder)
|
||||
class ManufacturingOrderAdmin(admin.ModelAdmin):
|
||||
@ -20,6 +20,7 @@ class ManufacturingOrderAdmin(admin.ModelAdmin):
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('product', 'created_by')
|
||||
|
||||
|
||||
@admin.register(ManufacturingLine)
|
||||
class ManufacturingLineAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'capacity_per_hour', 'is_active', 'created_at')
|
||||
@ -32,6 +33,7 @@ class ManufacturingLineAdmin(admin.ModelAdmin):
|
||||
('Status', {'fields': ('is_active',)}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ManufacturingOrderLine)
|
||||
class ManufacturingOrderLineAdmin(admin.ModelAdmin):
|
||||
list_display = ('manufacturing_order', 'manufacturing_line', 'actual_quantity',
|
||||
@ -48,3 +50,35 @@ class ManufacturingOrderLineAdmin(admin.ModelAdmin):
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('manufacturing_order', 'manufacturing_line')
|
||||
|
||||
|
||||
@admin.register(BillOfMaterials)
|
||||
class BillOfMaterialsAdmin(admin.ModelAdmin):
|
||||
list_display = ('manufactured_product', 'component', 'quantity', 'unit', 'created_at')
|
||||
list_filter = ('manufactured_product__category', 'created_at')
|
||||
search_fields = ('manufactured_product__name', 'manufactured_product__code',
|
||||
'component__name', 'component__code')
|
||||
ordering = ('manufactured_product__name', 'component__name')
|
||||
|
||||
fieldsets = (
|
||||
('BOM Information', {'fields': ('manufactured_product', 'component', 'quantity', 'unit')}),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('manufactured_product', 'component')
|
||||
|
||||
|
||||
@admin.register(BillOfMaterialsTotal)
|
||||
class BillOfMaterialsTotalAdmin(admin.ModelAdmin):
|
||||
list_display = ('bom', 'total_cost', 'total_weight', 'last_calculated')
|
||||
list_filter = ('last_calculated',)
|
||||
search_fields = ('bom__manufactured_product__name', 'bom__component__name')
|
||||
ordering = ('-last_calculated',)
|
||||
|
||||
fieldsets = (
|
||||
('BOM Total Information', {'fields': ('bom', 'total_cost', 'total_weight')}),
|
||||
)
|
||||
readonly_fields = ('last_calculated',)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('bom__manufactured_product', 'bom__component')
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
from django import forms
|
||||
from .models import ManufacturingOrder
|
||||
from django.forms import formset_factory
|
||||
from django.forms.widgets import NumberInput
|
||||
from .models import ManufacturingOrder, BillOfMaterials
|
||||
from inventory.models import Product
|
||||
|
||||
class ManufacturingOrderForm(forms.ModelForm):
|
||||
@ -10,6 +12,7 @@ class ManufacturingOrderForm(forms.ModelForm):
|
||||
fields = ['product', 'quantity', 'date', 'notes']
|
||||
widgets = {
|
||||
'date': forms.DateInput(attrs={'type': 'date'}),
|
||||
'quantity': forms.NumberInput(attrs={'step': '0.01'}),
|
||||
'notes': forms.Textarea(attrs={'rows': 3}),
|
||||
}
|
||||
|
||||
@ -31,4 +34,145 @@ class ManufacturingOrderForm(forms.ModelForm):
|
||||
quantity = self.cleaned_data.get('quantity')
|
||||
if quantity <= 0:
|
||||
raise forms.ValidationError("Quantity must be greater than zero.")
|
||||
return quantity
|
||||
return quantity
|
||||
|
||||
|
||||
class BillOfMaterialsForm(forms.ModelForm):
|
||||
"""Form for creating and editing Bill of Materials entries"""
|
||||
|
||||
class Meta:
|
||||
model = BillOfMaterials
|
||||
fields = ['manufactured_product', 'component', 'quantity', 'unit']
|
||||
widgets = {
|
||||
'quantity': forms.NumberInput(attrs={'step': '0.0001'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add Bootstrap classes
|
||||
for field in self.fields.values():
|
||||
if isinstance(field.widget, (forms.CheckboxInput, forms.RadioSelect)):
|
||||
field.widget.attrs.update({'class': 'form-check-input'})
|
||||
elif isinstance(field.widget, forms.Textarea):
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
elif isinstance(field.widget, forms.DateInput):
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
else:
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
# Filter manufactured products to only show those marked as manufactured
|
||||
self.fields['manufactured_product'].queryset = Product.objects.filter(is_manufactured=True)
|
||||
|
||||
def clean_quantity(self):
|
||||
"""Ensure quantity is positive"""
|
||||
quantity = self.cleaned_data.get('quantity')
|
||||
if quantity <= 0:
|
||||
raise forms.ValidationError("Quantity must be greater than zero.")
|
||||
return quantity
|
||||
|
||||
def clean(self):
|
||||
"""Validate that manufactured product and component are not the same"""
|
||||
cleaned_data = super().clean()
|
||||
manufactured_product = cleaned_data.get('manufactured_product')
|
||||
component = cleaned_data.get('component')
|
||||
|
||||
if manufactured_product and component and manufactured_product == component:
|
||||
raise forms.ValidationError("A product cannot be a component of itself.")
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class MultipleBillOfMaterialsForm(forms.Form):
|
||||
"""Form for creating multiple BOM entries at once"""
|
||||
|
||||
manufactured_product = forms.ModelChoiceField(
|
||||
queryset=Product.objects.filter(is_manufactured=True),
|
||||
label="Manufactured Product",
|
||||
help_text="The product that is manufactured using these components"
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add Bootstrap classes
|
||||
for field in self.fields.values():
|
||||
if isinstance(field.widget, (forms.CheckboxInput, forms.RadioSelect)):
|
||||
field.widget.attrs.update({'class': 'form-check-input'})
|
||||
elif isinstance(field.widget, forms.Textarea):
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
elif isinstance(field.widget, forms.DateInput):
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
else:
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
def clean_manufactured_product(self):
|
||||
"""Ensure the selected product is marked as manufactured"""
|
||||
manufactured_product = self.cleaned_data.get('manufactured_product')
|
||||
if manufactured_product and not manufactured_product.is_manufactured:
|
||||
raise forms.ValidationError("Selected product is not marked as manufactured.")
|
||||
return manufactured_product
|
||||
|
||||
|
||||
class BOMComponentForm(forms.Form):
|
||||
"""Form for individual BOM component entry"""
|
||||
|
||||
component = forms.ModelChoiceField(
|
||||
queryset=Product.objects.all(),
|
||||
label="Component",
|
||||
help_text="A component used in manufacturing"
|
||||
)
|
||||
quantity = forms.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
min_value=0.0001,
|
||||
label="Quantity",
|
||||
help_text="Quantity of component needed per unit of manufactured product"
|
||||
)
|
||||
unit = forms.CharField(
|
||||
max_length=20,
|
||||
required=False,
|
||||
label="Unit",
|
||||
help_text="Unit of measurement for the component (e.g., kg, pieces, meters)",
|
||||
widget=forms.TextInput(attrs={'readonly': 'readonly'})
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add Bootstrap classes
|
||||
for field in self.fields.values():
|
||||
if isinstance(field.widget, (forms.CheckboxInput, forms.RadioSelect)):
|
||||
field.widget.attrs.update({'class': 'form-check-input'})
|
||||
elif isinstance(field.widget, forms.Textarea):
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
elif isinstance(field.widget, forms.DateInput):
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
else:
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
def clean_quantity(self):
|
||||
"""Ensure quantity is positive"""
|
||||
quantity = self.cleaned_data.get('quantity')
|
||||
if quantity <= 0:
|
||||
raise forms.ValidationError("Quantity must be greater than zero.")
|
||||
return quantity
|
||||
|
||||
def clean(self):
|
||||
"""Validate that component is provided"""
|
||||
cleaned_data = super().clean()
|
||||
component = cleaned_data.get('component')
|
||||
|
||||
# If no component is selected, this might be an empty form
|
||||
if not component:
|
||||
# Check if any other fields have data
|
||||
quantity = cleaned_data.get('quantity')
|
||||
unit = cleaned_data.get('unit')
|
||||
|
||||
# If other fields have data but no component, raise error
|
||||
if quantity or unit:
|
||||
raise forms.ValidationError("Component is required.")
|
||||
|
||||
# If all fields are empty, mark form as empty
|
||||
raise forms.ValidationError("This form is empty.")
|
||||
|
||||
return cleaned_data
|
||||
# Create a formset for BOM components
|
||||
BOMComponentFormSet = formset_factory(BOMComponentForm, extra=1, min_num=1, validate_min=True)
|
||||
@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-19 08:54
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0003_remove_supplier_rating'),
|
||||
('manufacture', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BillOfMaterials',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.DecimalField(decimal_places=4, help_text='Quantity of component needed per unit of manufactured product', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
|
||||
('unit', models.CharField(blank=True, help_text='Unit of measurement for the component (e.g., kg, pieces, meters)', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('component', models.ForeignKey(help_text='A component used in manufacturing', on_delete=django.db.models.deletion.CASCADE, related_name='bom_components', to='inventory.product')),
|
||||
('manufactured_product', models.ForeignKey(help_text='The product that is manufactured using this BOM', on_delete=django.db.models.deletion.CASCADE, related_name='bom_manufactured', to='inventory.product')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Bill of Materials',
|
||||
'verbose_name_plural': 'Bills of Materials',
|
||||
'ordering': ['manufactured_product__name', 'component__name'],
|
||||
'unique_together': {('manufactured_product', 'component')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BillOfMaterialsTotal',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('total_cost', models.DecimalField(decimal_places=4, default=0, max_digits=12)),
|
||||
('total_weight', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||
('last_calculated', models.DateTimeField(auto_now=True)),
|
||||
('bom', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='totals', to='manufacture.billofmaterials')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'BOM Total',
|
||||
'verbose_name_plural': 'BOM Totals',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -60,11 +60,51 @@ class ManufacturingOrder(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to calculate total cost and update product stock"""
|
||||
# Calculate total cost
|
||||
self.total_cost = self.labor_cost + self.overhead_cost
|
||||
# Check if product has BOM entries
|
||||
bom_entries = BillOfMaterials.objects.filter(manufactured_product=self.product)
|
||||
|
||||
# Calculate total cost based on BOM if available, otherwise use manual costs
|
||||
if bom_entries.exists():
|
||||
# Calculate total cost based on BOM
|
||||
bom_total_cost = 0
|
||||
for bom_entry in bom_entries:
|
||||
component_cost = bom_entry.get_total_component_cost()
|
||||
bom_total_cost += component_cost * self.quantity
|
||||
|
||||
# Add labor and overhead costs
|
||||
self.total_cost = bom_total_cost + self.labor_cost + self.overhead_cost
|
||||
else:
|
||||
# Calculate total cost using manual costs
|
||||
self.total_cost = self.labor_cost + self.overhead_cost
|
||||
|
||||
# If this is a new order and status is completed, add to inventory
|
||||
if not self.pk and self.status == 'completed':
|
||||
# Check if product has BOM entries and deduct components from stock
|
||||
if bom_entries.exists():
|
||||
# Check if there's enough stock for all components
|
||||
insufficient_stock = []
|
||||
for bom_entry in bom_entries:
|
||||
required_quantity = bom_entry.quantity * self.quantity
|
||||
if bom_entry.component.current_stock < required_quantity:
|
||||
insufficient_stock.append({
|
||||
'component': bom_entry.component.name,
|
||||
'required': required_quantity,
|
||||
'available': bom_entry.component.current_stock
|
||||
})
|
||||
|
||||
# If there's insufficient stock, raise an error
|
||||
if insufficient_stock:
|
||||
error_msg = "Insufficient stock for components: "
|
||||
for item in insufficient_stock:
|
||||
error_msg += f"{item['component']} (required: {item['required']}, available: {item['available']}) "
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Deduct components from stock
|
||||
for bom_entry in bom_entries:
|
||||
required_quantity = bom_entry.quantity * self.quantity
|
||||
bom_entry.component.current_stock -= required_quantity
|
||||
bom_entry.component.save()
|
||||
|
||||
# Update product stock
|
||||
self.product.current_stock += self.quantity
|
||||
|
||||
@ -101,6 +141,7 @@ class ManufacturingOrder(models.Model):
|
||||
return ((self.product.selling_price - unit_cost) / unit_cost) * 100
|
||||
return Decimal('0')
|
||||
|
||||
|
||||
class ManufacturingLine(models.Model):
|
||||
"""Manufacturing line/workstation information"""
|
||||
|
||||
@ -125,6 +166,7 @@ class ManufacturingLine(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ManufacturingOrderLine(models.Model):
|
||||
"""Individual line items for manufacturing orders (optional for future expansion)"""
|
||||
|
||||
@ -151,3 +193,77 @@ class ManufacturingOrderLine(models.Model):
|
||||
if self.start_time and self.end_time:
|
||||
return self.end_time - self.start_time
|
||||
return None
|
||||
|
||||
|
||||
class BillOfMaterials(models.Model):
|
||||
"""Bill of Materials - defines components needed to manufacture a product"""
|
||||
|
||||
manufactured_product = models.ForeignKey(
|
||||
'inventory.Product',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bom_manufactured',
|
||||
help_text='The product that is manufactured using this BOM'
|
||||
)
|
||||
component = models.ForeignKey(
|
||||
'inventory.Product',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bom_components',
|
||||
help_text='A component used in manufacturing'
|
||||
)
|
||||
quantity = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
||||
help_text='Quantity of component needed per unit of manufactured product'
|
||||
)
|
||||
unit = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
help_text='Unit of measurement for the component (e.g., kg, pieces, meters)'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('manufactured_product', 'component')
|
||||
ordering = ['manufactured_product__name', 'component__name']
|
||||
verbose_name = _('Bill of Materials')
|
||||
verbose_name_plural = _('Bills of Materials')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.manufactured_product.name} - {self.component.name} ({self.quantity} {self.unit})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to ensure the manufactured product is marked as manufactured"""
|
||||
# Ensure the manufactured product is marked as manufactured
|
||||
if not self.manufactured_product.is_manufactured:
|
||||
self.manufactured_product.is_manufactured = True
|
||||
self.manufactured_product.save()
|
||||
|
||||
# If unit is not provided, use the component's unit
|
||||
if not self.unit and self.component.unit:
|
||||
self.unit = self.component.unit
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_total_component_cost(self):
|
||||
"""Calculate total cost of this component for one unit of manufactured product"""
|
||||
return self.quantity * self.component.cost_price
|
||||
|
||||
|
||||
class BillOfMaterialsTotal(models.Model):
|
||||
"""Pre-calculated totals for a BOM to improve performance"""
|
||||
|
||||
bom = models.OneToOneField(BillOfMaterials, on_delete=models.CASCADE, related_name='totals')
|
||||
total_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
||||
total_weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||
last_calculated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('BOM Total')
|
||||
verbose_name_plural = _('BOM Totals')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bom} - Total: {self.total_cost}"
|
||||
|
||||
0
manufacture/templatetags/__init__.py
Normal file
0
manufacture/templatetags/__init__.py
Normal file
79
manufacture/templatetags/manufacture_extras.py
Normal file
79
manufacture/templatetags/manufacture_extras.py
Normal file
@ -0,0 +1,79 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter
|
||||
def format_number(value, decimal_places=2):
|
||||
"""
|
||||
Format a decimal value to display with Indonesian number formatting.
|
||||
Uses '.' as thousand separator and ',' as decimal separator.
|
||||
Removes trailing zeros after decimal.
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
|
||||
try:
|
||||
# Convert to float
|
||||
num = float(value)
|
||||
|
||||
# Format with specified decimal places
|
||||
formatted = f"{num:.{decimal_places}f}"
|
||||
|
||||
# Split into integer and decimal parts
|
||||
if '.' in formatted:
|
||||
integer_part, decimal_part = formatted.split('.')
|
||||
else:
|
||||
integer_part, decimal_part = formatted, ''
|
||||
|
||||
# Add thousand separators to integer part
|
||||
# Reverse the string, add separators every 3 digits, then reverse back
|
||||
integer_part = '{:,}'.format(int(integer_part)).replace(',', '.')
|
||||
|
||||
# Handle decimal part - remove trailing zeros
|
||||
if decimal_part and int(decimal_part) != 0:
|
||||
# Remove trailing zeros
|
||||
decimal_part = decimal_part.rstrip('0')
|
||||
return f"{integer_part},{decimal_part}"
|
||||
else:
|
||||
return integer_part
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
|
||||
@register.filter
|
||||
def format_quantity(value):
|
||||
"""
|
||||
Format a decimal value to display with Indonesian number formatting for quantities.
|
||||
Uses '.' as thousand separator and ',' as decimal separator.
|
||||
Removes trailing zeros after decimal.
|
||||
"""
|
||||
return format_number(value, 4)
|
||||
|
||||
@register.filter
|
||||
def format_currency(value):
|
||||
"""
|
||||
Format a decimal value to display with Indonesian number formatting for currency.
|
||||
Uses '.' as thousand separator and ',' as decimal separator.
|
||||
Always shows 2 decimal places for currency.
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
|
||||
try:
|
||||
# Convert to float
|
||||
num = float(value)
|
||||
|
||||
# Format with 2 decimal places for currency
|
||||
formatted = f"{num:,.2f}"
|
||||
|
||||
# Split into integer and decimal parts
|
||||
integer_part, decimal_part = formatted.split('.')
|
||||
|
||||
# Add thousand separators to integer part
|
||||
integer_part = '{:,}'.format(int(integer_part)).replace(',', '.')
|
||||
|
||||
# Combine with decimal part
|
||||
return f"{integer_part},{decimal_part}"
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
@ -10,4 +10,12 @@ urlpatterns = [
|
||||
path('<int:pk>/', views.ManufactureDetailView.as_view(), name='manufacture_detail'),
|
||||
path('<int:pk>/edit/', views.manufacture_edit, name='manufacture_edit'),
|
||||
path('<int:pk>/delete/', views.manufacture_delete, name='manufacture_delete'),
|
||||
|
||||
# BOM URLs
|
||||
path('bom/', views.BillOfMaterialsListView.as_view(), name='bom_list'),
|
||||
path('bom/create/', views.bom_create, name='bom_create'),
|
||||
path('bom/<int:pk>/', views.BillOfMaterialsDetailView.as_view(), name='bom_detail'),
|
||||
path('bom/<int:pk>/edit/', views.bom_edit, name='bom_edit'),
|
||||
path('bom/<int:pk>/delete/', views.bom_delete, name='bom_delete'),
|
||||
path('bom/product/<int:product_id>/info/', views.get_product_info, name='get_product_info'),
|
||||
]
|
||||
|
||||
@ -3,8 +3,11 @@ from django.views.generic import ListView, DetailView
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.contrib import messages
|
||||
from .models import ManufacturingOrder
|
||||
from .forms import ManufacturingOrderForm
|
||||
from django.http import JsonResponse
|
||||
from .models import ManufacturingOrder, BillOfMaterials
|
||||
from inventory.models import Product
|
||||
from .forms import ManufacturingOrderForm, BillOfMaterialsForm, MultipleBillOfMaterialsForm, BOMComponentFormSet
|
||||
from users.views import has_manufacturing_access
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class ManufactureListView(ListView):
|
||||
@ -13,6 +16,23 @@ class ManufactureListView(ListView):
|
||||
context_object_name = 'manufacturing_orders'
|
||||
paginate_by = 20
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class BillOfMaterialsListView(ListView):
|
||||
model = BillOfMaterials
|
||||
template_name = 'manufacture/bom_list.html'
|
||||
context_object_name = 'boms'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
# Optionally filter by manufactured product
|
||||
product_id = self.request.GET.get('product_id')
|
||||
if product_id:
|
||||
queryset = queryset.filter(manufactured_product_id=product_id)
|
||||
return queryset
|
||||
|
||||
|
||||
@login_required
|
||||
def manufacture_create(request):
|
||||
"""Create a new manufacturing order with simple input"""
|
||||
@ -46,11 +66,11 @@ def manufacture_create(request):
|
||||
|
||||
return render(request, 'manufacture/manufacture_form.html', {'form': form, 'title': 'Create Manufacturing Order'})
|
||||
|
||||
|
||||
@login_required
|
||||
def manufacture_edit(request, pk):
|
||||
"""Edit an existing manufacturing order"""
|
||||
from users.views import is_administrator
|
||||
if not is_administrator(request.user):
|
||||
if not has_manufacturing_access(request.user):
|
||||
messages.error(request, 'You do not have permission to edit manufacturing orders.')
|
||||
return redirect('manufacture:manufacture_detail', pk=pk)
|
||||
|
||||
@ -71,11 +91,11 @@ def manufacture_edit(request, pk):
|
||||
'manufacturing_order': manufacturing_order
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def manufacture_delete(request, pk):
|
||||
"""Delete a manufacturing order"""
|
||||
from users.views import is_administrator
|
||||
if not is_administrator(request.user):
|
||||
if not has_manufacturing_access(request.user):
|
||||
messages.error(request, 'You do not have permission to delete manufacturing orders.')
|
||||
return redirect('manufacture:manufacture_list')
|
||||
|
||||
@ -90,8 +110,171 @@ def manufacture_delete(request, pk):
|
||||
return render(request, 'manufacture/manufacture_confirm_delete.html', {'manufacturing_order': manufacturing_order})
|
||||
|
||||
|
||||
@login_required
|
||||
def bom_create(request):
|
||||
"""Create a new bill of materials entry"""
|
||||
if not has_manufacturing_access(request.user):
|
||||
messages.error(request, 'You do not have permission to create bill of materials entries.')
|
||||
return redirect('manufacture:bom_list')
|
||||
|
||||
if request.method == 'POST':
|
||||
main_form = MultipleBillOfMaterialsForm(request.POST)
|
||||
component_formset = BOMComponentFormSet(request.POST)
|
||||
|
||||
if main_form.is_valid() and component_formset.is_valid():
|
||||
manufactured_product = main_form.cleaned_data['manufactured_product']
|
||||
|
||||
# Save all component entries
|
||||
saved_components = []
|
||||
for component_form in component_formset:
|
||||
# Skip empty forms
|
||||
if not component_form.cleaned_data:
|
||||
continue
|
||||
|
||||
component = component_form.cleaned_data['component']
|
||||
quantity = component_form.cleaned_data['quantity']
|
||||
unit = component_form.cleaned_data['unit']
|
||||
|
||||
# Create BOM entry
|
||||
bom = BillOfMaterials(
|
||||
manufactured_product=manufactured_product,
|
||||
component=component,
|
||||
quantity=quantity,
|
||||
unit=unit
|
||||
)
|
||||
bom.save()
|
||||
saved_components.append(bom)
|
||||
|
||||
messages.success(request, f'Bill of Materials for "{manufactured_product.name}" with {len(saved_components)} components created successfully!')
|
||||
return redirect('manufacture:bom_list')
|
||||
else:
|
||||
main_form = MultipleBillOfMaterialsForm()
|
||||
component_formset = BOMComponentFormSet()
|
||||
|
||||
return render(request, 'manufacture/bom_form.html', {
|
||||
'main_form': main_form,
|
||||
'component_formset': component_formset,
|
||||
'title': 'Create Bill of Materials'
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def bom_edit(request, pk):
|
||||
"""Edit an existing bill of materials entry"""
|
||||
if not has_manufacturing_access(request.user):
|
||||
messages.error(request, 'You do not have permission to edit bill of materials.')
|
||||
return redirect('manufacture:bom_list')
|
||||
|
||||
# Get the BOM entry to edit
|
||||
bom = get_object_or_404(BillOfMaterials, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
main_form = MultipleBillOfMaterialsForm(request.POST)
|
||||
component_formset = BOMComponentFormSet(request.POST)
|
||||
|
||||
if main_form.is_valid() and component_formset.is_valid():
|
||||
manufactured_product = main_form.cleaned_data['manufactured_product']
|
||||
|
||||
# Delete existing BOM entries for this manufactured product
|
||||
BillOfMaterials.objects.filter(manufactured_product=manufactured_product).delete()
|
||||
|
||||
# Save all component entries
|
||||
saved_components = []
|
||||
for component_form in component_formset:
|
||||
# Skip empty forms
|
||||
if not component_form.cleaned_data:
|
||||
continue
|
||||
|
||||
component = component_form.cleaned_data['component']
|
||||
quantity = component_form.cleaned_data['quantity']
|
||||
unit = component_form.cleaned_data['unit']
|
||||
|
||||
# Create BOM entry
|
||||
bom_entry = BillOfMaterials(
|
||||
manufactured_product=manufactured_product,
|
||||
component=component,
|
||||
quantity=quantity,
|
||||
unit=unit
|
||||
)
|
||||
bom_entry.save()
|
||||
saved_components.append(bom_entry)
|
||||
|
||||
messages.success(request, f'Bill of Materials for "{manufactured_product.name}" with {len(saved_components)} components updated successfully!')
|
||||
return redirect('manufacture:bom_list')
|
||||
else:
|
||||
# Pre-populate the form with existing components
|
||||
main_form = MultipleBillOfMaterialsForm(initial={'manufactured_product': bom.manufactured_product})
|
||||
|
||||
# Get all existing components for this manufactured product
|
||||
existing_components = BillOfMaterials.objects.filter(manufactured_product=bom.manufactured_product)
|
||||
|
||||
# Create initial data for the formset
|
||||
initial_data = []
|
||||
for existing_bom in existing_components:
|
||||
initial_data.append({
|
||||
'component': existing_bom.component,
|
||||
'quantity': existing_bom.quantity,
|
||||
'unit': existing_bom.unit
|
||||
})
|
||||
|
||||
# Add extra empty forms if needed
|
||||
while len(initial_data) < 1:
|
||||
initial_data.append({})
|
||||
|
||||
component_formset = BOMComponentFormSet(initial=initial_data)
|
||||
|
||||
return render(request, 'manufacture/bom_form.html', {
|
||||
'main_form': main_form,
|
||||
'component_formset': component_formset,
|
||||
'title': 'Edit Bill of Materials',
|
||||
'bom': bom
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def bom_delete(request, pk):
|
||||
"""Delete a bill of materials entry"""
|
||||
if not has_manufacturing_access(request.user):
|
||||
messages.error(request, 'You do not have permission to delete bill of materials entries.')
|
||||
return redirect('manufacture:bom_list')
|
||||
|
||||
bom = get_object_or_404(BillOfMaterials, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
manufactured_product_name = bom.manufactured_product.name
|
||||
bom.delete()
|
||||
messages.success(request, f'Bill of Materials entry for "{manufactured_product_name}" deleted successfully!')
|
||||
return redirect('manufacture:bom_list')
|
||||
|
||||
return render(request, 'manufacture/bom_confirm_delete.html', {'bom': bom})
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class ManufactureDetailView(DetailView):
|
||||
model = ManufacturingOrder
|
||||
template_name = 'manufacture/manufacture_detail.html'
|
||||
context_object_name = 'manufacturing_order'
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class BillOfMaterialsDetailView(DetailView):
|
||||
model = BillOfMaterials
|
||||
template_name = 'manufacture/bom_detail.html'
|
||||
context_object_name = 'bom'
|
||||
|
||||
@login_required
|
||||
def get_product_info(request, product_id):
|
||||
"""Return product information including unit as JSON"""
|
||||
if not has_manufacturing_access(request.user):
|
||||
return JsonResponse({'error': 'Permission denied'}, status=403)
|
||||
|
||||
try:
|
||||
product = Product.objects.get(id=product_id)
|
||||
return JsonResponse({
|
||||
'id': product.id,
|
||||
'name': product.name,
|
||||
'unit': product.unit,
|
||||
'unit_display': product.get_unit_display()
|
||||
})
|
||||
except Product.DoesNotExist:
|
||||
return JsonResponse({'error': 'Product not found'}, status=404)
|
||||
|
||||
Binary file not shown.
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.7 on 2025-08-19 09:11
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0003_remove_supplier_rating'),
|
||||
('purchase', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
name='supplier',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchase_orders', to='inventory.supplier'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Supplier',
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.7 on 2025-08-19 09:11
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0003_remove_supplier_rating'),
|
||||
('sales', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='saleorder',
|
||||
name='customer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sale_orders', to='inventory.customer'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Customer',
|
||||
),
|
||||
]
|
||||
@ -129,6 +129,12 @@
|
||||
Manufacturing
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'manufacture:bom_list' %}">
|
||||
<i class="bi bi-list-check"></i>
|
||||
Bill of Materials
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if user.has_inventory_permission %}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load manufacture_extras %}
|
||||
|
||||
{% block title %}Product - {{ product.name }}{% endblock %}
|
||||
|
||||
@ -110,11 +111,11 @@
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-3">
|
||||
<h6 class="text-muted">Cost Price</h6>
|
||||
<h4 class="text-primary">Rp {{ product.cost_price|floatformat:0 }}</h4>
|
||||
<h4 class="text-primary">Rp {{ product.cost_price|format_currency }}</h4>
|
||||
</div>
|
||||
<div class="text-center mb-3">
|
||||
<h6 class="text-muted">Selling Price</h6>
|
||||
<h4 class="text-success">Rp {{ product.selling_price|floatformat:0 }}</h4>
|
||||
<h4 class="text-success">Rp {{ product.selling_price|format_currency }}</h4>
|
||||
</div>
|
||||
{% if product.selling_price > 0 and product.cost_price > 0 %}
|
||||
<div class="text-center">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load manufacture_extras %}
|
||||
|
||||
{% block title %}Inventory - Products{% endblock %}
|
||||
|
||||
@ -46,8 +47,8 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ product.min_stock_level }} {{ product.unit }}</td>
|
||||
<td>Rp {{ product.cost_price|floatformat:0 }}</td>
|
||||
<td>Rp {{ product.selling_price|floatformat:0 }}</td>
|
||||
<td>Rp {{ product.cost_price|format_currency }}</td>
|
||||
<td>Rp {{ product.selling_price|format_currency }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if product.is_active %}success{% else %}secondary{% endif %}">
|
||||
{{ product.is_active|yesno:"Active,Inactive" }}
|
||||
|
||||
47
templates/manufacture/bom_confirm_delete.html
Normal file
47
templates/manufacture/bom_confirm_delete.html
Normal file
@ -0,0 +1,47 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Delete BOM Entry{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-trash me-2"></i>
|
||||
Delete BOM Entry
|
||||
</h1>
|
||||
<a href="{% url 'manufacture:bom_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>
|
||||
Back to BOM List
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
Confirm Deletion
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="lead">Are you sure you want to delete the BOM entry for <strong>"{{ bom.manufactured_product.name }}"</strong> with component <strong>"{{ bom.component.name }}"</strong>?</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{% url 'manufacture:bom_list' %}" class="btn btn-secondary me-md-2">
|
||||
<i class="bi bi-x-circle me-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash me-2"></i>
|
||||
Delete BOM Entry
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
130
templates/manufacture/bom_detail.html
Normal file
130
templates/manufacture/bom_detail.html
Normal file
@ -0,0 +1,130 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load manufacture_extras %}
|
||||
|
||||
{% block title %}BOM - {{ bom.manufactured_product.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-list-check me-2"></i>
|
||||
Bill of Materials
|
||||
</h1>
|
||||
<div>
|
||||
<a href="{% url 'manufacture:bom_list' %}" class="btn btn-outline-secondary me-2">
|
||||
<i class="bi bi-arrow-left me-2"></i>
|
||||
Back to List
|
||||
</a>
|
||||
{% if user.is_superuser or user.is_staff %}
|
||||
<a href="{% url 'manufacture:bom_edit' bom.pk %}" class="btn btn-primary">
|
||||
<i class="bi bi-pencil me-2"></i>
|
||||
Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">BOM Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Manufactured Product:</strong> {{ bom.manufactured_product.name }}</p>
|
||||
<p><strong>Product Code:</strong> {{ bom.manufactured_product.code }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Component:</strong> {{ bom.component.name }}</p>
|
||||
<p><strong>Quantity:</strong> {{ bom.quantity|format_quantity }}</p>
|
||||
<p><strong>Unit:</strong> {{ bom.unit|default:bom.component.unit }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show all components for this manufactured product -->
|
||||
{% with bom.manufactured_product.bom_manufactured.all as all_components %}
|
||||
{% if all_components.count > 1 %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">All Components for {{ bom.manufactured_product.name }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Quantity</th>
|
||||
<th>Unit</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for component_bom in all_components %}
|
||||
<tr>
|
||||
<td>{{ component_bom.component.name }}</td>
|
||||
<td>{{ component_bom.quantity|format_quantity }}</td>
|
||||
<td>{{ component_bom.unit|default:component_bom.component.unit }}</td>
|
||||
<td>
|
||||
{% if component_bom.pk != bom.pk %}
|
||||
<a href="{% url 'manufacture:bom_detail' component_bom.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'manufacture:bom_edit' component_bom.pk %}" class="btn btn-sm btn-outline-warning ms-1">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Cost Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Component Cost Price:</strong> Rp {{ bom.component.cost_price|format_currency }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Total Component Cost:</strong> Rp {{ bom.get_total_component_cost|format_currency }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Product Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Component Code:</strong> {{ bom.component.code }}</p>
|
||||
<p><strong>Component Category:</strong> {{ bom.component.category.name|default:"N/A" }}</p>
|
||||
<p><strong>Component Current Stock:</strong> {{ bom.component.current_stock }}</p>
|
||||
<p><strong>Component Unit:</strong> {{ bom.component.unit }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Timestamps</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Created:</strong> {{ bom.created_at|date:"d/m/Y H:i" }}</p>
|
||||
<p><strong>Updated:</strong> {{ bom.updated_at|date:"d/m/Y H:i" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
338
templates/manufacture/bom_form.html
Normal file
338
templates/manufacture/bom_form.html
Normal file
@ -0,0 +1,338 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-list-check me-2"></i>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<a href="{% url 'manufacture:bom_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>
|
||||
Back to BOM List
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Bill of Materials Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" id="bom-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{# Use dynamic form for both creation and editing #}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% if main_form %}
|
||||
{{ main_form.manufactured_product|as_crispy_field }}
|
||||
{% else %}
|
||||
{{ form.manufactured_product|as_crispy_field }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4 mb-3">Components</h5>
|
||||
{% if component_formset %}
|
||||
{{ component_formset.management_form }}
|
||||
|
||||
<div id="component-forms-container">
|
||||
{% for component_form in component_formset %}
|
||||
<div class="card mb-3 component-form">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="card-title mb-0">Component #{{ forloop.counter }}</h6>
|
||||
{% if forloop.counter > 1 %}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-component">
|
||||
<i class="bi bi-trash"></i> Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ component_form.component|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ component_form.quantity|as_crispy_field }}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ component_form.unit|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<button type="button" id="add-component" class="btn btn-outline-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>
|
||||
Add Another Component
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Single BOM form for backward compatibility #}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form.component|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form.quantity|as_crispy_field }}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form.unit|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
Save BOM Entry
|
||||
</button>
|
||||
<a href="{% url 'manufacture:bom_list' %}" class="btn btn-outline-secondary ms-2">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">BOM Tips</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-info-circle text-primary me-2"></i>
|
||||
Select the manufactured product and component
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-calculator text-success me-2"></i>
|
||||
Enter the exact quantity needed
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-rulers text-info me-2"></i>
|
||||
Unit of measurement is automatically populated
|
||||
</li>
|
||||
{% if component_formset %}
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-plus-circle text-warning me-2"></i>
|
||||
Add multiple components dynamically
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-dash-circle text-danger me-2"></i>
|
||||
Remove components you don't need
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Get CSRF token
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
// Function to update unit field based on selected component
|
||||
function updateUnitField(componentSelect, unitInput) {
|
||||
const productId = componentSelect.value;
|
||||
|
||||
if (!productId) {
|
||||
unitInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Make AJAX call to get product info
|
||||
fetch(`/manufacture/bom/product/${productId}/info/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken'),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.unit) {
|
||||
unitInput.value = data.unit;
|
||||
} else {
|
||||
unitInput.value = '';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching product info:', error);
|
||||
unitInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we have the dynamic form
|
||||
if (document.getElementById('component-forms-container')) {
|
||||
// Add component button
|
||||
document.getElementById('add-component').addEventListener('click', function() {
|
||||
var container = document.getElementById('component-forms-container');
|
||||
var formCount = container.querySelectorAll('.component-form').length;
|
||||
var totalForms = document.getElementById('id_component-TOTAL_FORMS');
|
||||
|
||||
// Clone the first form as a template
|
||||
var firstForm = container.querySelector('.component-form');
|
||||
var newForm = firstForm.cloneNode(true);
|
||||
|
||||
// Update form indices
|
||||
var newFormIndex = formCount;
|
||||
var formRegex = /component-\d+/g;
|
||||
|
||||
// Update all input names and IDs
|
||||
newForm.innerHTML = newForm.innerHTML.replace(formRegex, 'component-' + newFormIndex);
|
||||
|
||||
// Clear input values
|
||||
var inputs = newForm.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(function(input) {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else if (input.type !== 'hidden') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Update header
|
||||
var header = newForm.querySelector('.card-header h6');
|
||||
header.textContent = 'Component #' + (newFormIndex + 1);
|
||||
|
||||
// Add remove button if it doesn't exist
|
||||
var removeButtonContainer = newForm.querySelector('.card-header');
|
||||
if (!removeButtonContainer.querySelector('.remove-component')) {
|
||||
var removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'btn btn-sm btn-outline-danger remove-component';
|
||||
removeButton.innerHTML = '<i class="bi bi-trash"></i> Remove';
|
||||
removeButtonContainer.appendChild(removeButton);
|
||||
}
|
||||
|
||||
// Add event listener to the new remove button
|
||||
var newRemoveButton = newForm.querySelector('.remove-component');
|
||||
newRemoveButton.addEventListener('click', function() {
|
||||
newForm.remove();
|
||||
updateFormIndices();
|
||||
});
|
||||
|
||||
// Add event listener to the new component select
|
||||
var newComponentSelect = newForm.querySelector('select[id^="id_component-"][id$="-component"]');
|
||||
var newUnitInput = newForm.querySelector('input[id^="id_component-"][id$="-unit"]');
|
||||
if (newComponentSelect && newUnitInput) {
|
||||
newComponentSelect.addEventListener('change', function() {
|
||||
updateUnitField(this, newUnitInput);
|
||||
});
|
||||
}
|
||||
|
||||
// Append new form
|
||||
container.appendChild(newForm);
|
||||
|
||||
// Update total forms count
|
||||
totalForms.value = formCount + 1;
|
||||
});
|
||||
|
||||
// Remove component buttons
|
||||
document.querySelectorAll('.remove-component').forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
var form = this.closest('.component-form');
|
||||
form.remove();
|
||||
updateFormIndices();
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listeners to existing component selects
|
||||
document.querySelectorAll('.component-form select[id$="-component"]').forEach(function(select) {
|
||||
var unitInput = select.closest('.component-form').querySelector('input[id$="-unit"]');
|
||||
if (unitInput) {
|
||||
select.addEventListener('change', function() {
|
||||
updateUnitField(this, unitInput);
|
||||
});
|
||||
|
||||
// Initialize unit field if component is already selected
|
||||
if (select.value) {
|
||||
updateUnitField(select, unitInput);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update form indices function
|
||||
function updateFormIndices() {
|
||||
var forms = document.querySelectorAll('.component-form');
|
||||
var totalForms = document.getElementById('id_component-TOTAL_FORMS');
|
||||
|
||||
forms.forEach(function(form, index) {
|
||||
// Update header
|
||||
var header = form.querySelector('.card-header h6');
|
||||
header.textContent = 'Component #' + (index + 1);
|
||||
|
||||
// Update form indices in names and IDs
|
||||
var formRegex = /component-\d+/g;
|
||||
form.innerHTML = form.innerHTML.replace(formRegex, 'component-' + index);
|
||||
|
||||
// Update event listeners for component selects
|
||||
var componentSelect = form.querySelector('select[id$="-component"]');
|
||||
var unitInput = form.querySelector('input[id$="-unit"]');
|
||||
if (componentSelect && unitInput) {
|
||||
// Remove existing event listeners by cloning
|
||||
var newSelect = componentSelect.cloneNode(true);
|
||||
componentSelect.parentNode.replaceChild(newSelect, componentSelect);
|
||||
|
||||
// Add new event listener
|
||||
newSelect.addEventListener('change', function() {
|
||||
updateUnitField(this, unitInput);
|
||||
});
|
||||
}
|
||||
|
||||
// Update remove button event listener
|
||||
var removeButton = form.querySelector('.remove-component');
|
||||
if (removeButton) {
|
||||
// Remove existing event listener by cloning
|
||||
var newRemoveButton = removeButton.cloneNode(true);
|
||||
removeButton.parentNode.replaceChild(newRemoveButton, removeButton);
|
||||
|
||||
// Add new event listener
|
||||
newRemoveButton.addEventListener('click', function() {
|
||||
form.remove();
|
||||
updateFormIndices();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update total forms count
|
||||
totalForms.value = forms.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
95
templates/manufacture/bom_list.html
Normal file
95
templates/manufacture/bom_list.html
Normal file
@ -0,0 +1,95 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load manufacture_extras %}
|
||||
|
||||
{% block title %}Bill of Materials{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-list-check me-2"></i>
|
||||
Bill of Materials
|
||||
</h1>
|
||||
<a href="{% url 'manufacture:bom_create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>
|
||||
New BOM Entry (Multiple Components)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if boms %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Manufactured Product</th>
|
||||
<th>Component</th>
|
||||
<th>Quantity</th>
|
||||
<th>Unit</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for bom in boms %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ bom.manufactured_product.name }}</strong>
|
||||
<div class="small text-muted">{{ bom.manufactured_product.code }}</div>
|
||||
</td>
|
||||
<td>{{ bom.component.name }}</td>
|
||||
<td>{{ bom.quantity|format_quantity }}</td>
|
||||
<td>{{ bom.unit|default:bom.component.unit }}</td>
|
||||
<td>{{ bom.created_at|date:"d/m/Y" }}</td>
|
||||
<td>
|
||||
<a href="{% url 'manufacture:bom_detail' bom.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'manufacture:bom_edit' bom.pk %}" class="btn btn-sm btn-outline-warning ms-1">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="{% url 'manufacture:bom_delete' bom.pk %}" class="btn btn-sm btn-outline-danger ms-1">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-list-check display-1 text-muted"></i>
|
||||
<h4 class="mt-3 text-muted">No Bill of Materials Entries</h4>
|
||||
<p class="text-muted">Start by creating your first BOM entry.</p>
|
||||
<a href="{% url 'manufacture:bom_create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>
|
||||
Create BOM Entry
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load manufacture_extras %}
|
||||
|
||||
{% block title %}Manufacturing Order - {{ manufacturing_order.order_number }}{% endblock %}
|
||||
|
||||
@ -64,26 +65,26 @@
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Labor Cost</h6>
|
||||
<h4 class="text-primary">Rp {{ manufacturing_order.labor_cost|floatformat:0 }}</h4>
|
||||
<h4 class="text-primary">Rp {{ manufacturing_order.labor_cost|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Overhead Cost</h6>
|
||||
<h4 class="text-warning">Rp {{ manufacturing_order.overhead_cost|floatformat:0 }}</h4>
|
||||
<h4 class="text-warning">Rp {{ manufacturing_order.overhead_cost|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Total Cost</h6>
|
||||
<h4 class="text-success">Rp {{ manufacturing_order.total_cost|floatformat:0 }}</h4>
|
||||
<h4 class="text-success">Rp {{ manufacturing_order.total_cost|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<h6 class="text-muted">Unit Cost</h6>
|
||||
<h5 class="text-info">Rp {{ manufacturing_order.get_unit_cost|floatformat:0 }}</h5>
|
||||
<h5 class="text-info">Rp {{ manufacturing_order.get_unit_cost|format_currency }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -98,8 +99,8 @@
|
||||
<p><strong>Product Code:</strong> {{ manufacturing_order.product.code }}</p>
|
||||
<p><strong>Category:</strong> {{ manufacturing_order.product.category.name|default:"N/A" }}</p>
|
||||
<p><strong>Current Stock:</strong> {{ manufacturing_order.product.current_stock }}</p>
|
||||
<p><strong>Cost Price:</strong> Rp {{ manufacturing_order.product.cost_price|floatformat:0 }}</p>
|
||||
<p><strong>Selling Price:</strong> Rp {{ manufacturing_order.product.selling_price|floatformat:0 }}</p>
|
||||
<p><strong>Cost Price:</strong> Rp {{ manufacturing_order.product.cost_price|format_currency }}</p>
|
||||
<p><strong>Selling Price:</strong> Rp {{ manufacturing_order.product.selling_price|format_currency }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load manufacture_extras %}
|
||||
|
||||
{% block title %}Manufacturing Orders{% endblock %}
|
||||
|
||||
@ -44,7 +45,7 @@
|
||||
{{ order.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>Rp {{ order.total_cost|floatformat:0 }}</td>
|
||||
<td>Rp {{ order.total_cost|format_currency }}</td>
|
||||
<td>
|
||||
<a href="{% url 'manufacture:manufacture_detail' order.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load manufacture_extras %}
|
||||
|
||||
{% block title %}Purchase Order - {{ purchase_order.order_number }}{% endblock %}
|
||||
|
||||
@ -62,25 +63,25 @@
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Subtotal</h6>
|
||||
<h4 class="text-primary">Rp {{ purchase_order.subtotal|floatformat:0 }}</h4>
|
||||
<h4 class="text-primary">Rp {{ purchase_order.subtotal|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Tax</h6>
|
||||
<h4 class="text-warning">Rp {{ purchase_order.tax_amount|floatformat:0 }}</h4>
|
||||
<h4 class="text-warning">Rp {{ purchase_order.tax_amount|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Shipping</h6>
|
||||
<h4 class="text-info">Rp {{ purchase_order.shipping_cost|floatformat:0 }}</h4>
|
||||
<h4 class="text-info">Rp {{ purchase_order.shipping_cost|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Total</h6>
|
||||
<h4 class="text-success">Rp {{ purchase_order.total_amount|floatformat:0 }}</h4>
|
||||
<h4 class="text-success">Rp {{ purchase_order.total_amount|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -103,7 +104,7 @@
|
||||
{{ purchase_order.supplier.rating }}/5
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>Credit Limit:</strong> Rp {{ purchase_order.supplier.credit_limit|floatformat:0 }}</p>
|
||||
<p><strong>Credit Limit:</strong> Rp {{ purchase_order.supplier.credit_limit|format_currency }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load manufacture_extras %}
|
||||
|
||||
{% block title %}Purchase Orders{% endblock %}
|
||||
|
||||
@ -43,8 +44,8 @@
|
||||
{{ order.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>Rp {{ order.subtotal|floatformat:0 }}</td>
|
||||
<td>Rp {{ order.total_amount|floatformat:0 }}</td>
|
||||
<td>Rp {{ order.subtotal|format_currency }}</td>
|
||||
<td>Rp {{ order.total_amount|format_currency }}</td>
|
||||
<td>
|
||||
<a href="{% url 'purchase:purchase_detail' order.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load manufacture_extras %}
|
||||
|
||||
{% block title %}Sales Order - {{ sale_order.order_number }}{% endblock %}
|
||||
|
||||
@ -62,25 +63,25 @@
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Subtotal</h6>
|
||||
<h4 class="text-primary">Rp {{ sale_order.subtotal|floatformat:0 }}</h4>
|
||||
<h4 class="text-primary">Rp {{ sale_order.subtotal|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Tax</h6>
|
||||
<h4 class="text-warning">Rp {{ sale_order.tax_amount|floatformat:0 }}</h4>
|
||||
<h4 class="text-warning">Rp {{ sale_order.tax_amount|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Discount</h6>
|
||||
<h4 class="text-info">Rp {{ sale_order.discount_amount|floatformat:0 }}</h4>
|
||||
<h4 class="text-info">Rp {{ sale_order.discount_amount|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h6 class="text-muted">Total</h6>
|
||||
<h4 class="text-success">Rp {{ sale_order.total_amount|floatformat:0 }}</h4>
|
||||
<h4 class="text-success">Rp {{ sale_order.total_amount|format_currency }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -99,7 +100,7 @@
|
||||
<p><strong>Contact:</strong> {{ sale_order.customer.contact_person|default:"N/A" }}</p>
|
||||
<p><strong>Email:</strong> {{ sale_order.customer.email|default:"N/A" }}</p>
|
||||
<p><strong>Phone:</strong> {{ sale_order.customer.phone|default:"N/A" }}</p>
|
||||
<p><strong>Credit Limit:</strong> Rp {{ sale_order.customer.credit_limit|floatformat:0 }}</p>
|
||||
<p><strong>Credit Limit:</strong> Rp {{ sale_order.customer.credit_limit|format_currency }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load manufacture_extras %}
|
||||
|
||||
{% block title %}Sales Orders{% endblock %}
|
||||
|
||||
@ -43,8 +44,8 @@
|
||||
{{ order.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>Rp {{ order.subtotal|floatformat:0 }}</td>
|
||||
<td>Rp {{ order.total_amount|floatformat:0 }}</td>
|
||||
<td>Rp {{ order.subtotal|format_currency }}</td>
|
||||
<td>Rp {{ order.total_amount|format_currency }}</td>
|
||||
<td>
|
||||
<a href="{% url 'sales:sales_detail' order.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
|
||||
Binary file not shown.
@ -176,3 +176,14 @@ def group_delete(request, pk):
|
||||
return redirect('users:group_list')
|
||||
|
||||
return render(request, 'users/group_confirm_delete.html', {'group': group})
|
||||
|
||||
|
||||
def has_manufacturing_access(user):
|
||||
"""Check if user has manufacturing access (either admin or manufacturing permission)"""
|
||||
if user.is_superuser:
|
||||
return True
|
||||
if user.group and user.group.name == 'Administrators':
|
||||
return True
|
||||
if hasattr(user, 'has_manufacturing_permission') and user.has_manufacturing_permission():
|
||||
return True
|
||||
return False
|
||||
|
||||
Loading…
Reference in New Issue
Block a user