Add an intake screen to the ops portal

Harvest intakes could only be recorded in the administration area or through the
API, so the first record the business actually creates was entered outside the
portal it works in. Add list, create and correct screens under Harvest Intakes.

The form is deliberately stricter than the model. HarvestIntake allows almost
everything to be blank because records also arrive by API, and the platform
copes by flagging the gaps later — the EUDR completeness check has an intake
data test whose whole job is to catch a missing date or weight. The form
therefore requires every field those checks read: reference, collector group,
origin zone, date, weight and grade.

Three things it does beyond capturing the fields: it suggests the next intake
reference for the year (editable, since it may have to match a weighbridge
ticket), fills the origin zone in from the collector group while leaving it
editable, and refuses a duplicate reference — including one differing only in
case. It queries, rather than blocks, when the chosen zone is not the one the
group is registered to, and it marks unverified zones in the dropdown, because
geolocation passes only for a verified zone.

Correcting amends the record in place: the reference a buyer or auditor quotes
does not change.
This commit is contained in:
ixgadmin 2026-09-17 13:45:39 +00:00
parent d5bc06802e
commit 7d7248a23b
9 changed files with 713 additions and 3 deletions

View file

@ -23,6 +23,8 @@ User = get_user_model()
OPS_ROUTES = [
"/ops/",
"/ops/intakes/",
"/ops/intakes/new/",
"/ops/batches/",
"/ops/qc/",
"/ops/qc/verify/",

156
apps/ops/forms.py Normal file
View file

@ -0,0 +1,156 @@
"""Forms for the ops portal.
The intake form is deliberately **stricter than the model**. ``HarvestIntake``
allows almost everything to be blank, because records also arrive through the
API, and the platform copes by flagging the gaps later: the EUDR completeness
check has an "intake data" test whose whole job is to catch a missing date or
weight. A portal form that let the same blanks through would simply move that
problem to a screen where a person could have prevented it.
So every field the checks read is required here — reference, collector group,
origin zone, date, weight and grade — while the fields that are genuinely
optional at intake (receiving officer, the supporting file's name) stay optional.
"""
from django import forms
from apps.compliance.models import CollectorGroup, HarvestIntake, OriginZone
# The visual grades the product specification defines.
GRADE_CHOICES = [("A", "Grade A"), ("B", "Grade B"), ("C", "Grade C")]
class ZoneChoiceField(forms.ModelChoiceField):
"""Zone dropdown that says which zones are not verified yet.
Geolocation in the EUDR case passes only for a verified zone, so an operator
choosing one should be able to see that before it fails a check. It is a
label, not a block: an intake can legitimately be recorded against a zone
that is still awaiting verification.
"""
def label_from_instance(self, zone):
where = zone.region or zone.country or ""
mark = ""
if zone.verification_status != OriginZone.VerificationStatus.VERIFIED:
mark = f" — {zone.get_verification_status_display().lower()}"
return f"{zone.zone_code}{f' ({where})' if where else ''}{mark}"
class CollectorGroupChoiceField(forms.ModelChoiceField):
"""Group dropdown that names the zone each group draws from."""
def label_from_instance(self, group):
if not group.origin_zone:
return f"{group.name} — no origin zone set"
return f"{group.name} — {group.origin_zone.zone_code}"
class HarvestIntakeForm(forms.ModelForm):
collector_group = CollectorGroupChoiceField(
queryset=CollectorGroup.objects.select_related("origin_zone").order_by("name"),
label="Collector group",
help_text="The registered group that delivered the material.",
empty_label="Select the collector group",
)
origin_zone = ZoneChoiceField(
queryset=OriginZone.objects.order_by("zone_code"),
label="Origin zone",
help_text="Where it was collected. Filled in from the group, and can be changed.",
empty_label="Select the origin zone",
)
grade = forms.ChoiceField(
choices=GRADE_CHOICES,
label="Grade",
help_text="Visual grade recorded at intake, as defined by the product specification.",
widget=forms.Select(attrs={"class": "form-select"}),
)
class Meta:
model = HarvestIntake
fields = [
"intake_reference",
"collector_group",
"origin_zone",
"intake_date",
"weight_kg",
"grade",
"receiving_officer",
"status",
"evidence_file",
]
labels = {
"intake_reference": "Intake reference",
"intake_date": "Intake date",
"weight_kg": "Weight (kg)",
"receiving_officer": "Receiving officer",
"evidence_file": "Supporting file name",
}
help_texts = {
"intake_reference": "Must be unique. Suggested from the last intake recorded, and can be overwritten to match the weighbridge ticket.",
"intake_date": "The day the material was received.",
"weight_kg": "Weight received, in kilograms.",
"receiving_officer": "Who received and weighed it. Defaults to you.",
"status": "Received is the normal state for a new intake; Verified is set once the record has been checked.",
"evidence_file": "The name of the supporting record, typically the weighbridge ticket. Files are not uploaded by the platform yet, so this is the name it is filed under.",
}
widgets = {
"intake_reference": forms.TextInput(
attrs={"class": "form-control", "autocomplete": "off"}
),
"collector_group": forms.Select(attrs={"class": "form-select"}),
"origin_zone": forms.Select(attrs={"class": "form-select"}),
"intake_date": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
),
"weight_kg": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01", "min": "0.01"}
),
"receiving_officer": forms.TextInput(attrs={"class": "form-control"}),
"status": forms.Select(attrs={"class": "form-select"}),
"evidence_file": forms.TextInput(attrs={"class": "form-control"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# The model tolerates blanks; the portal form does not. See the module
# docstring: these are the fields the completeness checks read.
for name in ("collector_group", "origin_zone", "intake_date", "weight_kg"):
self.fields[name].required = True
self.fields["status"].required = True
def clean_intake_reference(self):
reference = (self.cleaned_data["intake_reference"] or "").strip()
if not reference:
raise forms.ValidationError("An intake reference is required.")
clashes = HarvestIntake.objects.filter(intake_reference__iexact=reference)
if self.instance.pk:
clashes = clashes.exclude(pk=self.instance.pk)
if clashes.exists():
raise forms.ValidationError(
f"Intake {reference} already exists. References must be unique."
)
return reference
def clean_weight_kg(self):
weight = self.cleaned_data["weight_kg"]
if weight is None:
raise forms.ValidationError("A weight is required.")
if weight <= 0:
raise forms.ValidationError("Weight must be greater than zero.")
return weight
def clean(self):
cleaned = super().clean()
group = cleaned.get("collector_group")
zone = cleaned.get("origin_zone")
if group and zone and group.origin_zone_id and group.origin_zone_id != zone.pk:
# Not an error — a group can deliver from more than one area — but it
# is worth surfacing, because the two records will disagree.
self.add_error(
"origin_zone",
forms.ValidationError(
f"{group.name} is registered to {group.origin_zone.zone_code}, "
f"not {zone.zone_code}. Save it this way only if that is intended."
),
)
return cleaned

View file

@ -0,0 +1,50 @@
/* Fill an intake's origin zone in from the collector group that delivered it.
*
* A group is registered to a zone, so choosing the group is enough to know where
* the material came from; asking for both invites the two records to disagree.
* The zone stays editable, and a saved value is never overwritten — a group can
* legitimately deliver from more than one area, and a correction here must stick.
*/
(function () {
"use strict";
function init() {
var form = document.getElementById("intake-form");
if (!form) {
return;
}
var groupSelect = document.getElementById("id_collector_group");
var zoneSelect = document.getElementById("id_origin_zone");
if (!groupSelect || !zoneSelect) {
return;
}
var groupZones = {};
try {
groupZones = JSON.parse(form.dataset.groupZones || "{}");
} catch (err) {
groupZones = {};
}
function fillZone() {
var zoneId = groupZones[groupSelect.value];
if (zoneId) {
zoneSelect.value = zoneId;
}
}
// Only fill a blank zone on load, so correcting a record does not silently
// rewrite the zone that was recorded at the time.
if (!zoneSelect.value) {
fillZone();
}
groupSelect.addEventListener("change", fillZone);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

215
apps/ops/tests.py Normal file
View file

@ -0,0 +1,215 @@
"""The ops intake screens.
Two things matter here and neither is cosmetic: that only operations can create
intake records, and that the form refuses the incomplete records the platform's
own checks exist to catch. The model tolerates a missing date, weight or zone
because records also arrive by API; the portal form does not.
"""
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from apps.compliance.models import CollectorGroup, Facility, HarvestIntake, OriginZone
from .views import suggested_intake_reference
User = get_user_model()
class IntakeTestCase(TestCase):
def setUp(self):
self.ops = User.objects.create_user(
email="intake-ops@ixg.local", password="pass1234", role="ops",
full_name="Intake Officer", is_staff=True,
)
self.buyer = User.objects.create_user(
email="intake-buyer@ixg.local", password="pass1234", role="buyer",
full_name="Intake Buyer", is_staff=True,
)
self.facility = Facility.objects.create(name="IXG Test Facility")
self.zone = OriginZone.objects.create(
facility=self.facility, zone_code="NG-KT-ZTEST", country="Nigeria",
region="Katsina State", verification_status="verified",
)
self.other_zone = OriginZone.objects.create(
facility=self.facility, zone_code="NG-KT-ZTEST2", country="Nigeria",
region="Katsina State", verification_status="unverified",
)
self.group = CollectorGroup.objects.create(
name="Test Collective", contact_reference="CG-TEST-001",
origin_zone=self.zone,
)
self.client_ops = Client(SERVER_NAME="ops.ixgltd.com")
self.client_ops.force_login(self.ops)
self.client_buyer = Client(SERVER_NAME="ops.ixgltd.com")
self.client_buyer.force_login(self.buyer)
self.intake = HarvestIntake.objects.create(
intake_reference="IXG-INTAKE-2026-9001", collector_group=self.group,
origin_zone=self.zone, intake_date="2026-09-01",
weight_kg=Decimal("500.00"), grade="A", status="received",
)
def payload(self, **overrides):
data = {
"intake_reference": "IXG-INTAKE-2026-9002",
"collector_group": str(self.group.pk),
"origin_zone": str(self.zone.pk),
"intake_date": "2026-09-10",
"weight_kg": "640.00",
"grade": "A",
"receiving_officer": "Intake Officer",
"status": "received",
"evidence_file": "weighbridge-ticket-9002.pdf",
}
data.update(overrides)
return data
def errors_for(self, response, field=None):
form = response.context["form"]
if field:
return " ".join(form.errors.get(field, []))
return " ".join(str(v) for v in form.errors.values())
class IntakeAccessTests(IntakeTestCase):
def test_ops_can_open_the_list(self):
r = self.client_ops.get("/ops/intakes/")
self.assertEqual(r.status_code, 200)
self.assertContains(r, "IXG-INTAKE-2026-9001")
def test_ops_can_open_the_create_form(self):
self.assertEqual(self.client_ops.get("/ops/intakes/new/").status_code, 200)
def test_ops_can_open_the_edit_form(self):
r = self.client_ops.get(f"/ops/intakes/{self.intake.pk}/edit/")
self.assertEqual(r.status_code, 200)
self.assertContains(r, "IXG-INTAKE-2026-9001")
def test_a_buyer_cannot_open_the_intake_screens(self):
for path in ("/ops/intakes/", "/ops/intakes/new/", f"/ops/intakes/{self.intake.pk}/edit/"):
with self.subTest(path=path):
self.assertEqual(self.client_buyer.get(path).status_code, 403)
def test_the_intake_list_appears_in_the_sidebar(self):
self.assertContains(self.client_ops.get("/ops/intakes/"), "Harvest Intakes")
class IntakeCreateTests(IntakeTestCase):
def test_a_complete_intake_is_recorded(self):
r = self.client_ops.post("/ops/intakes/new/", self.payload(), follow=True)
self.assertEqual(r.status_code, 200)
intake = HarvestIntake.objects.get(intake_reference="IXG-INTAKE-2026-9002")
self.assertEqual(intake.weight_kg, Decimal("640.00"))
self.assertEqual(intake.origin_zone, self.zone)
self.assertEqual(intake.collector_group, self.group)
self.assertContains(r, "recorded")
def test_the_form_suggests_the_next_reference_and_today(self):
r = self.client_ops.get("/ops/intakes/new/")
self.assertContains(r, 'value="IXG-INTAKE-2026-9002"')
self.assertContains(r, "Intake Officer")
def test_a_duplicate_reference_is_refused(self):
r = self.client_ops.post(
"/ops/intakes/new/", self.payload(intake_reference="IXG-INTAKE-2026-9001")
)
self.assertEqual(r.status_code, 200)
self.assertIn("already exists", self.errors_for(r, "intake_reference"))
self.assertEqual(HarvestIntake.objects.count(), 1)
def test_a_duplicate_differing_only_in_case_is_refused(self):
r = self.client_ops.post(
"/ops/intakes/new/", self.payload(intake_reference="ixg-intake-2026-9001")
)
self.assertIn("already exists", self.errors_for(r, "intake_reference"))
def test_a_zero_or_negative_weight_is_refused(self):
for weight in ("0", "-5"):
with self.subTest(weight=weight):
r = self.client_ops.post("/ops/intakes/new/", self.payload(weight_kg=weight))
self.assertIn("greater than zero", self.errors_for(r, "weight_kg"))
def test_the_fields_the_checks_read_are_required(self):
cases = {
"intake_reference": "",
"collector_group": "",
"origin_zone": "",
"intake_date": "",
"weight_kg": "",
"grade": "",
}
for field, blank in cases.items():
with self.subTest(field=field):
r = self.client_ops.post("/ops/intakes/new/", self.payload(**{field: blank}))
self.assertEqual(r.status_code, 200)
self.assertTrue(
self.errors_for(r, field), f"{field} was accepted blank"
)
def test_an_unknown_grade_is_refused(self):
r = self.client_ops.post("/ops/intakes/new/", self.payload(grade="Z"))
self.assertTrue(self.errors_for(r, "grade"))
def test_a_zone_that_does_not_match_the_group_is_queried(self):
r = self.client_ops.post(
"/ops/intakes/new/", self.payload(origin_zone=str(self.other_zone.pk))
)
self.assertIn("registered to", self.errors_for(r, "origin_zone"))
def test_an_unverified_zone_is_allowed_but_flagged(self):
"""Recording against a zone still awaiting verification must stay possible."""
group_without_zone = CollectorGroup.objects.create(name="Unzoned Collective")
r = self.client_ops.post(
"/ops/intakes/new/",
self.payload(collector_group=str(group_without_zone.pk),
origin_zone=str(self.other_zone.pk)),
)
self.assertEqual(r.status_code, 302)
self.assertTrue(
HarvestIntake.objects.filter(intake_reference="IXG-INTAKE-2026-9002").exists()
)
def test_the_form_labels_an_unverified_zone(self):
r = self.client_ops.get("/ops/intakes/new/")
self.assertContains(r, "unverified")
class IntakeEditTests(IntakeTestCase):
def test_an_intake_can_be_corrected(self):
data = self.payload(intake_reference="IXG-INTAKE-2026-9001", weight_kg="510.00")
r = self.client_ops.post(f"/ops/intakes/{self.intake.pk}/edit/", data, follow=True)
self.assertEqual(r.status_code, 200)
self.intake.refresh_from_db()
self.assertEqual(self.intake.weight_kg, Decimal("510.00"))
self.assertContains(r, "updated")
def test_saving_an_intake_under_its_own_reference_is_not_a_duplicate(self):
data = self.payload(intake_reference="IXG-INTAKE-2026-9001")
r = self.client_ops.post(f"/ops/intakes/{self.intake.pk}/edit/", data)
self.assertEqual(r.status_code, 302)
def test_correcting_does_not_create_a_second_record(self):
data = self.payload(intake_reference="IXG-INTAKE-2026-9001", grade="B")
self.client_ops.post(f"/ops/intakes/{self.intake.pk}/edit/", data)
self.assertEqual(HarvestIntake.objects.count(), 1)
class SuggestedReferenceTests(IntakeTestCase):
def test_the_suggestion_continues_the_year_sequence(self):
HarvestIntake.objects.create(
intake_reference="IXG-INTAKE-2026-9042", intake_date="2026-09-02",
weight_kg=Decimal("10.00"), grade="A",
)
self.assertEqual(suggested_intake_reference(), "IXG-INTAKE-2026-9043")
def test_the_suggestion_ignores_other_years_and_odd_references(self):
HarvestIntake.objects.create(
intake_reference="IXG-INTAKE-2025-9999", intake_date="2025-09-02",
weight_kg=Decimal("10.00"), grade="A",
)
HarvestIntake.objects.create(
intake_reference="IXG-INTAKE-2026-ABC", intake_date="2026-09-02",
weight_kg=Decimal("10.00"), grade="A",
)
self.assertEqual(suggested_intake_reference(), "IXG-INTAKE-2026-9002")

View file

@ -7,6 +7,9 @@ urlpatterns = [
path('', views.DashboardView.as_view(), name='dashboard'),
path('batches/', views.BatchListView.as_view(), name='batch_list'),
path('batches/<uuid:pk>/', views.BatchDetailView.as_view(), name='batch_detail'),
path('intakes/', views.IntakeListView.as_view(), name='intake_list'),
path('intakes/new/', views.IntakeCreateView.as_view(), name='intake_create'),
path('intakes/<uuid:pk>/edit/', views.IntakeUpdateView.as_view(), name='intake_update'),
path('qc/', views.QCListView.as_view(), name='qc_list'),
path('qc/<uuid:pk>/', views.QCDetailView.as_view(), name='qc_detail'),
path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'),

View file

@ -1,8 +1,12 @@
import json
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.db.models import Count, Q
from django.shortcuts import redirect
from django.urls import reverse
from django.utils import timezone
from django.views.generic import TemplateView, ListView, DetailView
from django.views.generic import TemplateView, ListView, DetailView, CreateView, UpdateView
from django.views import View
from django.shortcuts import get_object_or_404
@ -14,9 +18,28 @@ from apps.documents.downloads import file_response
from apps.documents.models import Document, CertificateOfAnalysis
from apps.esg.models import ESGFieldReport
from apps.notifications.models import Notification
from apps.compliance.models import DueDiligenceCase
from apps.compliance.models import CollectorGroup, DueDiligenceCase, HarvestIntake
from apps.guides.views import GuideDetailView as BaseGuideDetailView
from apps.guides.views import GuideIndexView as BaseGuideIndexView
from .forms import HarvestIntakeForm
def suggested_intake_reference(today=None):
"""Next intake reference, continuing this year's sequence.
Suggested rather than imposed: the reference may have to match a weighbridge
ticket, so the field stays editable.
"""
year = (today or timezone.localdate()).year
prefix = f"IXG-INTAKE-{year}-"
highest = 0
for ref in HarvestIntake.objects.filter(
intake_reference__startswith=prefix
).values_list("intake_reference", flat=True):
tail = ref[len(prefix):]
if tail.isdigit():
highest = max(highest, int(tail))
return f"{prefix}{highest + 1:04d}"
class OpsRequiredMixin(UserPassesTestMixin):
@ -372,7 +395,6 @@ class EUDRCaseDetailView(OpsRequiredMixin, DetailView):
return ctx
def post(self, request, *args, **kwargs):
from django.contrib import messages
from apps.compliance import services
case = self.get_object()
@ -402,6 +424,103 @@ class EUDRCaseDetailView(OpsRequiredMixin, DetailView):
return redirect(self.request.path)
class IntakeListView(OpsRequiredMixin, ListView):
"""Harvest intakes, the first record the business actually creates itself."""
model = HarvestIntake
template_name = 'ops/intake_list.html'
context_object_name = 'intakes'
paginate_by = 25
def get_queryset(self):
qs = HarvestIntake.objects.select_related(
'collector_group', 'origin_zone'
).order_by('-intake_date', '-intake_reference')
status = self.request.GET.get('status')
q = self.request.GET.get('q')
if status:
qs = qs.filter(status=status)
if q:
qs = qs.filter(intake_reference__icontains=q)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['status_choices'] = HarvestIntake.Status.choices
ctx['current_status'] = self.request.GET.get('status', '')
ctx['current_q'] = self.request.GET.get('q', '')
return ctx
class IntakeFormContextMixin:
"""Shared context for the create and edit forms."""
template_name = 'ops/intake_form.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
# Which zone each collector group draws from, so the form can fill the
# zone in when a group is chosen. Kept in a data attribute rather than
# injected into a script block.
ctx['group_zones'] = json.dumps({
str(group.pk): str(group.origin_zone_id)
for group in CollectorGroup.objects.exclude(origin_zone__isnull=True)
})
return ctx
class IntakeCreateView(IntakeFormContextMixin, OpsRequiredMixin, CreateView):
model = HarvestIntake
form_class = HarvestIntakeForm
def get_initial(self):
initial = super().get_initial()
initial['intake_reference'] = suggested_intake_reference()
initial['intake_date'] = timezone.localdate()
initial['status'] = HarvestIntake.Status.RECEIVED
officer = self.request.user.full_name or self.request.user.email
if officer:
initial['receiving_officer'] = officer
return initial
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['page_title'] = 'New harvest intake'
ctx['submit_label'] = 'Record intake'
return ctx
def form_valid(self, form):
response = super().form_valid(form)
messages.success(
self.request, f"Intake {self.object.intake_reference} recorded."
)
return response
def get_success_url(self):
return reverse('ops:intake_list')
class IntakeUpdateView(IntakeFormContextMixin, OpsRequiredMixin, UpdateView):
model = HarvestIntake
form_class = HarvestIntakeForm
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['page_title'] = f"Correct intake {self.object.intake_reference}"
ctx['submit_label'] = 'Save changes'
return ctx
def form_valid(self, form):
response = super().form_valid(form)
messages.success(
self.request, f"Intake {self.object.intake_reference} updated."
)
return response
def get_success_url(self):
return reverse('ops:intake_list')
class GuideIndexView(OpsRequiredMixin, BaseGuideIndexView):
template_name = 'guides/index_ops.html'

View file

@ -36,6 +36,7 @@ body { font-size: .875rem; background: #f5f6fa; }
<div class=brand>IXG Platform</div>
<ul class=nav flex-column>
<li class=nav-item><a class=nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %} href={% url 'ops:dashboard' %}><i class=bi bi-speedometer2></i> Dashboard</a></li>
<li class=nav-item><a class=nav-link {% if 'intake' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:intake_list' %}><i class=bi bi-basket></i> Harvest Intakes</a></li>
<li class=nav-item><a class=nav-link {% if 'batch' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:batch_list' %}><i class=bi bi-box-seam></i> Batches</a></li>
<li class=nav-item><a class=nav-link {% if 'qc' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:qc_list' %}><i class=bi bi-clipboard-check></i> QC Results</a></li>
<li class=nav-item><a class=nav-link {% if 'verify' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:qc_verify_list' %}><i class=bi bi-person-check></i> QC Approvals</a></li>

View file

@ -0,0 +1,71 @@
{% extends 'ops/base_ops.html' %}
{% load static %}
{% block title %}{{ page_title }} - IXG Operations{% endblock %}
{% block content %}
<div class=page-header d-flex justify-content-between align-items-center>
<h2>{{ page_title }}</h2>
<a href={% url 'ops:intake_list' %} class=btn btn-sm btn-outline-secondary><i class=bi bi-arrow-left></i> Back</a>
</div>
<div class=row g-3>
<div class=col-md-7>
<div class=stat-card>
<form method=post novalidate id=intake-form data-group-zones="{{ group_zones }}">
{% csrf_token %}
{% if form.non_field_errors %}
{% for e in form.non_field_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
{% endif %}
{% for field in form %}
<div class=mb-3>
<label class=form-label for="{{ field.id_for_label }}">
{{ field.label }}{% if field.field.required %} <span class=text-danger>*</span>{% endif %}
</label>
{{ field }}
{% if field.help_text %}<div class=form-text>{{ field.help_text }}</div>{% endif %}
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
{% endfor %}
<button type=submit class="btn btn-primary">{{ submit_label }}</button>
<a href={% url 'ops:intake_list' %} class="btn btn-outline-secondary">Cancel</a>
</form>
</div>
</div>
<div class=col-md-5>
<div class="stat-card mb-3">
<h6 class=mb-3><i class=bi bi-info-circle></i> Recording an intake</h6>
<p class="small mb-2">
An intake is the moment a collector group hands over shea at the processing
site. It is the record a finished batch is traced back to, so the fields
marked <span class=text-danger>*</span> are required: they are the ones the
platform's due-diligence checks read.
</p>
<p class="small mb-0">
If something is wrong on a saved intake, use <strong>Correct</strong> on the
list. Nothing is deleted — the record is amended, so the reference a buyer
or an auditor sees stays the same.
</p>
</div>
<div class=stat-card>
<h6 class=mb-3><i class=bi bi-shield-check></i> What these records feed</h6>
<ul class="small mb-0 ps-3">
<li><strong>Lineage</strong> — a batch needs at least one intake linked to it</li>
<li><strong>Intake data</strong> — a linked intake must have both a date and a weight</li>
<li><strong>Geolocation</strong> — every linked intake must point at a <em>verified</em> origin zone</li>
</ul>
<p class="small text-secondary mt-2 mb-0">
A missing date, weight or zone holds the batch's EUDR case back until it is
corrected here.
</p>
</div>
</div>
</div>
<script src="{% static 'js/ixg_intake_form.js' %}"></script>
{% endblock %}

View file

@ -0,0 +1,93 @@
{% extends 'ops/base_ops.html' %}
{% block title %}Harvest Intakes - IXG Operations{% endblock %}
{% block content %}
{% if messages %}
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
{% endif %}
<div class=page-header d-flex justify-content-between align-items-center>
<h2>Harvest Intakes</h2>
<div>
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
<a href={% url 'ops:intake_create' %} class=btn btn-sm btn-primary><i class=bi bi-plus-lg></i> New intake</a>
</div>
</div>
<div class=filter-bar>
<form method=get class=row g-2 align-items-end>
<div class=col-auto>
<input type=text name=q class=form-control form-control-sm placeholder=Search reference... value={{ current_q }}>
</div>
<div class=col-auto>
<select name=status class=form-select form-select-sm>
<option value=>All Statuses</option>
{% for val, label in status_choices %}
<option value={{ val }} {% if current_status == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class=col-auto>
<button type=submit class=btn btn-sm btn-primary>Filter</button>
<a href={% url 'ops:intake_list' %} class=btn btn-sm btn-outline-secondary>Clear</a>
</div>
</form>
</div>
<div class=stat-card p-0>
<table class=table table-hover mb-0>
<thead>
<tr>
<th>Reference</th>
<th>Intake Date</th>
<th>Weight (kg)</th>
<th>Grade</th>
<th>Collector Group</th>
<th>Origin Zone</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for i in intakes %}
<tr>
<td><strong>{{ i.intake_reference }}</strong></td>
<td>{{ i.intake_date|date:'d/m/Y'|default:'-' }}</td>
<td>{{ i.weight_kg|default:'-' }}</td>
<td>{{ i.grade|default:'-' }}</td>
<td>{{ i.collector_group.name|default:'-' }}</td>
<td>
{% if i.origin_zone %}
{{ i.origin_zone.zone_code }}
{% if i.origin_zone.verification_status != 'verified' %}
<span class="badge bg-warning text-dark">{{ i.origin_zone.get_verification_status_display }}</span>
{% endif %}
{% else %}-{% endif %}
</td>
<td><span class=badge bg-secondary badge-status>{{ i.get_status_display }}</span></td>
<td><a href={% url 'ops:intake_update' i.pk %} class=btn btn-sm btn-outline-primary>Correct</a></td>
</tr>
{% empty %}
<tr><td colspan=8 class=text-muted text-center py-3>No intakes recorded yet</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<nav class=mt-3>
<ul class=pagination pagination-sm justify-content-center>
{% if page_obj.has_previous %}
<li class=page-item><a class=page-link href=?page=1>&laquo;</a></li>
<li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>
{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}
<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li>
<li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}