Salvando estado atual

Diversas modificações
This commit is contained in:
Eberton committed 2026-06-27 03:02:56 -03:00
1 parent 740547a8ed
commit 8dd945798a
69 files changed
+2498 -278

No files matched your search

+11 -11
View File
@@ -3,20 +3,20 @@ from entities.models import Farm
# Injects the selected farm and the list of farms into the template context for authenticated users. # Injects the selected farm and the list of farms into the template context for authenticated users.
def selected_farm_processor(request): def selected_farm_processor(request):
selected_farm = None selected_farm = None
farms = None farms = Farm.objects.none()
if request.user.is_authenticated: if request.user.is_authenticated:
farms = request.user.farms.only("id", "name") farms = request.user.farms.filter(is_active=True).only("id", "name")
farm_id = request.session.get("selected_farm_id")
farm_id = request.session.get('selected_farm_id')
if farm_id: if farm_id:
try: selected_farm = farms.filter(id=farm_id).first()
selected_farm = farms.get(id=farm_id)
except Farm.DoesNotExist: if selected_farm is None:
request.session.pop('selected_farm_id', None) request.session.pop("selected_farm_id", None)
return { return {
'SELECTED_FARM': selected_farm, "SELECTED_FARM": selected_farm,
'farms': farms "farms": farms,
} }
@@ -7,35 +7,35 @@
/* BUTTON STYLES */ /* BUTTON STYLES */
.btn-simple { .btn-simple {
color: var(--color-text-dark); color: var(--color-plus-btn);
padding: 0 var(--space-xs); padding: 0 var(--space-xs);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
} }
.btn-simple:hover { .btn-simple:hover {
color: var(--color-text-light); color: var(--color-plus-btn-hover);
background-color: var(--color-bg-dark); background-color: var(--color-bg-dark);
transition: all var(--transition-speed) ease; transition: all var(--transition-speed) ease;
} }
/* PRIMARY BUTTON (Main Action) */ /* PRIMARY BUTTON (Main Action) */
.btn-primary { .btn-primary {
background-color: var(--color-primary); background-color: var(--color-plus-btn);
color: var(--color-text-light); /* White text */
padding: var(--space-sm); padding: var(--space-sm);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
color: var(--color-text-light); /* White text */
} }
.btn-primary:hover { .btn-primary:hover {
background-color: #0056b3; /* Darker blue on hover (a specific dark value often works best) */ background-color: var(--color-plus-btn-hover); /* Darker blue on hover (a specific dark value often works best) */
} }
/* SECONDARY BUTTON (Neutral Action) */ /* SECONDARY BUTTON (Neutral Action) */
.btn-secondary { .btn-secondary {
background-color: var(--color-secondary); background-color: var(--color-secondary);
color: var(--color-text-light);
padding: var(--space-sm); padding: var(--space-sm);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
color: var(--color-text-light);
} }
.btn-secondary:hover { .btn-secondary:hover {
@@ -44,12 +44,12 @@
/* SUCCESS/DANGER BUTTONS */ /* SUCCESS/DANGER BUTTONS */
.btn-success { .btn-success {
background-color: var(--color-success); background-color: var(--color-plus-btn);
color: var(--color-text-light); color: var(--color-text-light);
} }
.btn-danger { .btn-danger {
background-color: var(--color-danger); background-color: var(--color-danger-btn);
color: var(--color-text-light); color: var(--color-text-light);
} }
@@ -9,13 +9,14 @@
/* Title */ /* Title */
.confirmation-container h1 { .confirmation-container h1 {
font-size: 1.5rem; font-size: 22px;
text-align: center; text-align: center;
} }
/* Description */ /* Description */
.confirmation-description { .confirmation-description {
text-align: end; text-align: end;
margin: 10px 0 15px 0;
} }
/* Actions */ /* Actions */
@@ -26,11 +27,13 @@
} }
/* Both buttons */ /* Both buttons */
.confirmation-container form .confirm, .negate { .confirmation-container form .confirm,
.confirmation-container form .negate {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 120px; min-width: 100px;
max-height: 30px;
padding: var(--space-sm) var(--space-sm); padding: var(--space-sm) var(--space-sm);
border: none; border: none;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
@@ -41,30 +44,22 @@
/* Confirm button */ /* Confirm button */
.confirmation-container form .confirm { .confirmation-container form .confirm {
background-color: #dc3545; background-color: var(--color-danger-btn);
color: white; color: white;
} }
.confirmation-container form .confirm:hover { .confirmation-container form .confirm:hover {
filter: brightness(0.92); background-color: var(--color-danger-btn-hover);
}
.confirmation-container form .confirm:active {
transform: scale(0.98);
} }
/* Negate button */ /* Negate button */
.confirmation-container form .negate { .confirmation-container form .negate {
background-color: var(--color-secondary); background-color: var(--color-neutral-btn);
color: var(--color-text-light); color: var(--color-text-light);
} }
.confirmation-container form .negate:hover { .confirmation-container form .negate:hover {
filter: brightness(0.92); background-color: var(--color-neutral-btn-hover);
}
.confirmation-container form .negate:active {
transform: scale(0.90);
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -0,0 +1,169 @@
/* Container do formulário */
.generic-form {
padding: 2rem;
background: var(--color-bg-form);
border-radius: 12px;
}
/* Labels */
.generic-form label {
display: block;
margin-bottom: 0.4rem;
font-weight: 600;
color: var(--color-form-label);
}
/* Campos */
.generic-form input,
.generic-form select,
.generic-form textarea {
width: 100%;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border: 1px solid var(--color-input-border);
border-radius: 8px;
font-size: 1rem;
box-sizing: border-box;
transition: all 0.2s ease;
}
/* Foco */
.generic-form input:focus,
.generic-form select:focus,
.generic-form textarea:focus {
outline: none;
border-color: var(--color-input-focus-border);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
/* Checkbox e radio */
.generic-form input[type="checkbox"],
.generic-form input[type="radio"] {
width: auto;
margin-right: 0.5rem;
}
/* Mensagens de erro */
.errorlist {
list-style: none;
padding: 0.75rem;
margin-bottom: 1rem;
background: var(--color-bg-error);
color: var(--color-text-error);
border-radius: 8px;
}
/* Texto de ajuda */
.helptext {
display: block;
margin-top: -0.5rem;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--color-text-help);
}
/* Área dos botões */
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
justify-content: flex-end;
}
/* Botão principal */
.btn-primary {
display: inline-block;
padding: 0.8rem 1.4rem;
background-color: var(--color-plus-btn);
color: var(--color-text-light); /* White text */
text-decoration: none;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: 0.2s;
}
.btn-primary:hover {
background-color: var(--color-plus-btn-hover);
}
/* Botão cancelar */
.btn-secondary {
display: inline-block;
padding: 0.8rem 1.2rem;
background-color: var(--color-neutral-btn);
color: var(--color-text-light);
text-decoration: none;
border-radius: 8px;
font-weight: 600;
transition: 0.2s;
}
.btn-secondary:hover {
background: var(--color-neutral-btn-hover);
}
/* Tom Select - campo */
.generic-form .ts-wrapper {
width: 100%;
margin-bottom: 1rem;
}
/* Caixa principal */
.generic-form .ts-control {
width: 100%;
min-height: 46px;
padding: 0.65rem 1rem;
border: 1px solid var(--color-input-border);
border-radius: 8px;
background: white;
box-sizing: border-box;
font-size: 1rem;
transition: all 0.2s ease;
}
/* Input interno */
.generic-form .ts-control input {
margin: 0;
padding: 0;
border: none;
box-shadow: none;
}
/* Foco */
.generic-form .ts-wrapper.focus .ts-control {
outline: none;
border-color: var(--color-input-focus-border);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
/* Tags selecionadas (multi select) */
.generic-form .item {
background: var(--color-plus-btn);
color: var(--color-text-light);
border-radius: 6px;
padding: 0.25rem 0.5rem;
}
/* Botão remover tag */
.generic-form .remove {
color: white;
}
/* Dropdown */
.generic-form .ts-dropdown {
border-radius: 8px;
border: 1px solid var(--color-input-border);
box-shadow: 0 4px 12px rgba(0,0,0,.1);
}
/* Opções */
.generic-form .option {
padding: .6rem 1rem;
}
.generic-form .option.active {
background: var(--color-plus-btn-hover);
color: var(--color-text-light);
}
@@ -0,0 +1,5 @@
.form-modal-title {
font-size: 22px;
margin-top: 10px;
margin-left: 10px;
}
+122 -33
View File
@@ -25,6 +25,7 @@ a {
overflow-x: hidden; overflow-x: hidden;
z-index: 150; z-index: 150;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1); box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
scrollbar-width: none;
} }
.sidebar-brand { .sidebar-brand {
@@ -110,29 +111,106 @@ body.sidebar-collapsed .main-content {
cursor: pointer; cursor: pointer;
} }
/* Inicio do css do seletor antigo */ /* Farm Selection */
.navbar-farm-selector { .ts-wrapper {
display: flex; width: clamp(200px, 25vw, 300px);
flex-direction: column; margin: 0 auto;
align-items: center;
cursor: default;
opacity: 0.75;
transition: all var(--transition-speed) ease;
} }
.navbar-farm-selector:hover { .ts-wrapper.single .ts-control {
opacity: 1; position: relative;
width: 100%;
padding: 4px 20px 4px 6px;
border: none;
border-radius: 6px;
background: transparent;
box-shadow: none;
font-size: 16px;
justify-content: center;
cursor: pointer;
transition: none;
font-weight: 600;
color: var(--color-primary);
} }
.farm-name { .ts-wrapper.single .ts-control:hover {
font-size: var(--font-md); box-shadow: none;
font-weight: 500; }
margin: 0;
.ts-wrapper.focus .ts-control {
box-shadow: none;
width: 100%;
}
.ts-wrapper.single .ts-control::after {
content: "";
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #666;
pointer-events: none;
transition: transform .2s ease;
}
.ts-wrapper.single.dropdown-active .ts-control::after {
transform: translateY(-50%) rotate(180deg);
}
.ts-dropdown {
width: 100%;
margin-top: 2px;
border: none;
border-radius: 6px;
background: #f5f5f5;
box-shadow: 2px 2px 6px rgba(0,0,0,.12);
overflow: hidden;
}
.ts-dropdown .option {
padding: 4px 6px;
font-size: 16px;
text-align: center;
cursor: pointer;
font-weight: 600;
color: var(--color-primary);
}
.ts-dropdown .option:hover {
background: #ececec;
}
.ts-dropdown .active {
background: #e0e0e0;
}
.ts-control input {
display: none;
}
.ts-control .item {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* Fim do css do seletor antigo */
.navbar-user-info { .ts-dropdown .option.see-all {
color: var(--color-primary);
background: transparent;
font-weight: 500;
border-top: 1px solid #ddd;
}
.ts-dropdown .option.see-all:hover,
.ts-dropdown .option.see-all.active {
cursor: pointer;
}
/* NAV BAR RIGHT */
.navbar-user-info,
.navbar-user-info-farm-select {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-sm); gap: var(--space-sm);
@@ -157,33 +235,44 @@ body.sidebar-collapsed .main-content {
/* BASE MODAL */ /* BASE MODAL */
.modal-base { .modal-base {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 200; z-index: 200;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.4);
} }
.modal-content { .modal-content {
background-color: var(--color-bg-light); background-color: var(--color-bg-light);
padding: var(--space-lg); padding: var(--space-md);
border-radius: var(--space-sm); border-radius: var(--space-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
} }
@media screen and (max-width: 768px) { @view-transition {
.navbar-farm-selector, navigation: auto;
.navbar-user-info { }
transition: opacity var(--transition-speed) ease;
} ::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
animation-timing-function: ease;
}
@media screen and (max-width: 768px) {
body:not(.sidebar-collapsed) .navbar-farm-selector, body:not(.sidebar-collapsed) .navbar-farm-selector,
body:not(.sidebar-collapsed) .navbar-user-info { body:not(.sidebar-collapsed) .navbar-user-info {
display: none; display: none;
} }
body:not(.sidebar-collapsed) .main-content {
transform: translateX(0);
width: 100%;
overflow: hidden;
}
.navbar { .navbar {
padding: 0 var(--space-sm); padding: 0 var(--space-sm);
} }
@@ -0,0 +1,11 @@
.modal-content {
min-width: 50vw;
max-width: 85vw;
max-height: 90vh;
overflow-y: scroll;
scrollbar-width: none;
}
.modal-content:has(.confirmation-container) {
min-width: 0;
}
@@ -1,34 +1,45 @@
:root { :root {
/* SPECIFIC COLORS */ /* SPECIFIC COLORS */
--login-background-primary: #343a40; --login-background-primary: #244238;
--login-background-secondary: #6b7480; --login-background-secondary: #396a5a;
--color-login-button: #343a40; --color-login-button: #396a5a;
--color-login-button-focus: #6b7480; --color-login-button-focus: #76948a;
--color-farm-select-hover: #343a40; --color-farm-select-hover: #244238;
--color-farm-select-info: #6d6d6d; --color-farm-select-info: #6d6d6d;
--color-new-farm-button: #343a40; --color-new-farm-button: #396a5a;
--color-new-farm-hover: #6b7480; --color-new-farm-hover: #244238;
--color-plus-btn: #396a5a;
--color-plus-btn-hover: #76948a;
--color-danger-btn: #8b3d3d;
--color-danger-btn-hover: #b37a7a;
--color-neutral-btn: #5f6b67;
--color-neutral-btn-hover: #97a39f;
--color-input-border: #5f6b67;
--color-bg-filter: #76948a;
--color-text-filter: #f5faf5;
/* GENERIC COLORS */ /* GENERIC COLORS */
--color-simple-light-gray: #ccc; --color-simple-light-gray: #ccc;
--color-border-subtle: #ccc; --color-border-subtle: #ccc;
/* PRIMARY COLORS */ /* PRIMARY COLORS */
--color-primary: #007bff; --color-primary: #244238;
--color-secondary: #6c757d; --color-secondary: #6c757d;
--color-success: #28a745; --color-success: #28a745;
--color-danger: #dc3545; --color-danger: #8b3d3d;
--color-danger-light: #f8d7da; --color-danger-light: #b37a7a;
--color-warn: #dca235; --color-warn: #dca235;
/* BACKGROUND/TEXT COLORS */ /* BACKGROUND/TEXT COLORS */
--color-bg-light: #f0f0f0; --color-bg-light: #f5f7f5;
--color-bg-white: #fafafa; --color-bg-white: #fafafa;
--color-bg-dark: #343a40; --color-bg-form: #fafafa;
--color-bg-sidebar: #343a40; --color-bg-dark: #244238;
--color-bg-sidebar: #244238;
--color-text-dark: #151422; --color-text-dark: #151422;
--color-form-label: #151422;
--color-text-light: #f8f9fa; --color-text-light: #f8f9fa;
/* SPACING DIMENSIONS */ /* SPACING DIMENSIONS */
--space-xs: 8px; --space-xs: 8px;
--space-sm: 12px; --space-sm: 12px;
@@ -0,0 +1,33 @@
// Creates TomSelect Selects when loading a page
document.addEventListener("DOMContentLoaded", () => {
new TomSelect("#farm_selection_select", {
create: false,
sortField: {
field: "text",
direction: "asc"
},
controlInput: null,
render: {
option: function(data, escape) {
return `
<div class="option ${data.value == 0 ? 'see-all' : ''}">
${escape(data.text)}
</div>
`;
}
}
});
});
// Creates TomSelect Selects when an htmx swap is detected
document.addEventListener("htmx:afterSwap", function(event) {
const animals = document.querySelector("#id_animals");
if (animals && !animals.tomselect) {
new TomSelect("#id_animals", {
plugins: ['remove_button'],
create: false,
});
}
});
@@ -1,6 +1,30 @@
<form method="POST" action="{{ action_url }}"> <form method="POST" action="{{ action_url }}" class="generic-form">
{% csrf_token %} {% csrf_token %}
{{ form }}
<button class="btn-primary" type="submit">Salvar</button> {% for field in form %}
<a href="{{ return_url }}" class="btn-primary">Cancelar</a> <div class="form-group">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}
<small class="helptext">{{ field.help_text }}</small>
{% endif %}
{{ field.errors }}
</div>
{% endfor %}
<div class="form-actions">
<button class="btn-primary" type="submit">
{% if action_button_text %}
{{ action_button_text }}
{% else %}
Salvar
{% endif %}
</button>
<a href="{{ return_url }}" class="btn-secondary">
Cancelar
</a>
</div>
</form> </form>
@@ -0,0 +1,7 @@
{% extends 'layouts/base_modal.html' %}
{% load static %}
{% block content %}
<h1 class="form-modal-title">{{ modal_title }}</h1>
{% include 'components/form.html' %}
{% endblock %}
@@ -3,18 +3,14 @@
<i class="fa-solid fa-bars" id="sidebar-toggle"></i> <i class="fa-solid fa-bars" id="sidebar-toggle"></i>
</div> </div>
{% comment %} <div class="navbar-farm-selector w33">
<span class="farm-name"><i class="fa-regular fa-circle-check"></i> {{ SELECTED_FARM.name }}</span>
<a href="{% url 'entities:user:farm_deselect' %}" class="btn-simple">Alterar</a>
</div> {% endcomment %}
<form method="post" action="{% url 'entities:user:farm_set_post' %}" class="navbar-farm-selector w33"> <form method="post" action="{% url 'entities:user:farm_set_post' %}" class="navbar-farm-selector w33">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}"> <input type="hidden" name="next" value="{{ request.path }}">
<select name="farm_id" onchange="this.form.submit()"> <select name="farm_id" id="farm_selection_select" onchange="this.form.submit()">
<option value="0" class="see-all">Ver todas</option>
{% for farm in farms %} {% for farm in farms %}
<option value="{{ farm.id }}" {% if farm.id == SELECTED_FARM.id %}selected{% endif %}> <option value="{{ farm.id }}" {% if farm.id == SELECTED_FARM.id %}selected{% endif %}>
{{ farm.name }} {{ farm.name|upper }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
@@ -7,7 +7,7 @@
<li class="sidebar-header">ACOMPANHAMENTO</li> <li class="sidebar-header">ACOMPANHAMENTO</li>
<li class="{% if request.resolver_match.url_name != 'report' %}highlight-hover{% endif %}"> <li class="{% if request.resolver_match.url_name != 'report' %}highlight-hover{% endif %}">
<a href="{% url 'entities:farm:farm_home' %}" class="sidebar-link {% if request.resolver_match.url_name == 'report' %}active{% endif %}"><i class="fa-solid fa-chart-pie"></i> Relatórios</a> <a href="{% url 'report:dashboard' %}" class="sidebar-link {% if request.resolver_match.url_name == 'report' %}active{% endif %}"><i class="fa-solid fa-chart-pie"></i> Painel</a>
</li> </li>
<li class="sidebar-header">REGISTROS</li> <li class="sidebar-header">REGISTROS</li>
@@ -27,12 +27,14 @@
</li> </li>
<li class="{% if request.resolver_match.url_name not in 'paddock_list, paddock_create, paddock_update, paddock_delete' %}highlight-hover{% endif %}"> <li class="{% if request.resolver_match.url_name not in 'paddock_list, paddock_create, paddock_update, paddock_delete' %}highlight-hover{% endif %}">
<a href="{% url 'entities:farm:paddock_list' %}" class="sidebar-link {% if request.resolver_match.url_name in 'paddock_list, paddock_create, paddock_update, paddock_delete' %}active{% endif %}"><i class="fa-solid fa-leaf"></i> Piquetes</a> <a href="{% url 'entities:farm:paddock_list' %}" class="sidebar-link {% if request.resolver_match.url_name in 'paddock_list, paddock_create, paddock_update, paddock_delete' %}active{% endif %}"><i class="fa-solid fa-seedling"></i> Piquetes</a>
</li> </li>
<!--
<li class="{% if request.resolver_match.url_name not in 'paddock_group_list, paddock_group_create, paddock_group_update, paddock_group_delete' %}highlight-hover{% endif %}"> <li class="{% if request.resolver_match.url_name not in 'paddock_group_list, paddock_group_create, paddock_group_update, paddock_group_delete' %}highlight-hover{% endif %}">
<a href="{% url 'entities:farm:paddock_group_list' %}" class="sidebar-link {% if request.resolver_match.url_name in 'paddock_group_list, paddock_group_create, paddock_group_update, paddock_group_delete' %}active{% endif %}"><i class="fa-solid fa-layer-group"></i> Blocos</a> <a href="{% url 'entities:farm:paddock_group_list' %}" class="sidebar-link {% if request.resolver_match.url_name in 'paddock_group_list, paddock_group_create, paddock_group_update, paddock_group_delete' %}active{% endif %}"><i class="fa-solid fa-layer-group"></i> Blocos</a>
</li> </li>
-->
<li class="sidebar-header">CONFIGURAÇÔES</li> <li class="sidebar-header">CONFIGURAÇÔES</li>
@@ -41,7 +43,7 @@
</li> </li>
{% if user.is_staff or user.is_superuser %} {% if user.is_staff or user.is_superuser %}
<li class="{% if request.resolver_match.url_name not in 'admin' %}highlight-hover{% endif %}"> <li class="{% if request.resolver_match.url_name not in 'admin' %}highlight-hover{% endif %}">
<a href="{% url 'admin:index' %}" class="sidebar-link"><i class="fa-solid fa-user-tie"></i> Painel Administrativo</a> <a href="{% url 'admin:index' %}" target="_blank" class="sidebar-link"><i class="fa-solid fa-user-tie"></i> Painel Administrativo</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
@@ -15,13 +15,20 @@
<link rel="stylesheet" href="{% static 'core/css/global.css' %}"> <link rel="stylesheet" href="{% static 'core/css/global.css' %}">
<!-- Templates based on base_model can't pass their stylesheet on block extra_css --> <!-- Templates based on base_model can't pass their stylesheet on block extra_css -->
<link rel="stylesheet" href="{% static 'core/css/layouts/base_modal.css' %}">
<link rel="stylesheet" href="{% static 'core/css/components/confirmation_popup.css' %}"> <link rel="stylesheet" href="{% static 'core/css/components/confirmation_popup.css' %}">
<link rel="stylesheet" href="{% static 'farm/css/user_authorize.css' %}"> <link rel="stylesheet" href="{% static 'farm/css/user_authorize.css' %}">
<link rel="stylesheet" href="{% static 'core/css/components/form.css' %}">
<link rel="stylesheet" href="{% static 'core/css/components/form_modal.css' %}">
<link rel="stylesheet" href="{% static 'report/css/request_report.css' %}">
<link rel="stylesheet" href="{% static 'rotation/css/rotation_view_more.css' %}">
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
<!-- Importing Font Awesome --> <!-- Importing Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Importing Tom Select -->
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.6.1/dist/css/tom-select.css" rel="stylesheet">
</head> </head>
<body> <body>
@@ -34,7 +41,11 @@
</body> </body>
<!-- Importing custom JS --> <!-- Importing custom JS -->
<script src="{% static 'core/js/main.js' %}"></script> <script src="{% static 'core/js/main.js' %}"></script>
<script src="{% static 'core/js/tomselect.js' %}"></script>
<!-- Importing HTMX --> <!-- Importing HTMX -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
<!-- Importing Tom Select -->
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.6.1/dist/js/tom-select.complete.min.js"></script>
{% block extra_js %}{% endblock extra_js %} {% block extra_js %}{% endblock extra_js %}
</html> </html>
@@ -15,16 +15,24 @@
<link rel="stylesheet" href="{% static 'core/css/components/buttons.css' %}"> <link rel="stylesheet" href="{% static 'core/css/components/buttons.css' %}">
<link rel="stylesheet" href="{% static 'user/css/farm_select.css' %}"> <link rel="stylesheet" href="{% static 'user/css/farm_select.css' %}">
<!-- Templates based on base_model can't pass their stylesheet on block extra_css -->
<link rel="stylesheet" href="{% static 'core/css/layouts/base_modal.css' %}">
<link rel="stylesheet" href="{% static 'core/css/components/confirmation_popup.css' %}">
<link rel="stylesheet" href="{% static 'farm/css/user_authorize.css' %}">
<link rel="stylesheet" href="{% static 'core/css/components/form.css' %}">
<link rel="stylesheet" href="{% static 'core/css/components/form_modal.css' %}">
<!-- Importing Font Awesome --> <!-- Importing Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
{% block extra_css %}{% endblock extra_css %}
</head> </head>
<body> <body>
<nav class="farm-select-navbar"> <nav class="farm-select-navbar">
<div class="navbar-brand"> <div class="navbar-brand">
MultiFarm MultiFarm
</div> </div>
<div class="navbar-user-info"> <div class="navbar-user-info-farm-select">
<span class="username">Olá, {{ request.user.short_name }}</span> <span class="username">Olá, {{ request.user.short_name }}</span>
<a href="{% url 'entities:user:user_logout' %}"><i class="fa-solid fa-right-from-bracket"></i></a> <a href="{% url 'entities:user:user_logout' %}"><i class="fa-solid fa-right-from-bracket"></i></a>
</div> </div>
@@ -41,7 +49,6 @@
<div class="farm-item"> <div class="farm-item">
<h3 class="farm-name">{{ farm.name }}</h3> <h3 class="farm-name">{{ farm.name }}</h3>
<p class="farm-description"> <p class="farm-description">
<span class="total-area">Área total: {{ farm.total_area }}m²</span>
<span class="count-piquetes">Piquetes: {{ farm.total_paddocks }}</span> <span class="count-piquetes">Piquetes: {{ farm.total_paddocks }}</span>
<span class="count-animais">Animais: {{ farm.total_animals }}</span> <span class="count-animais">Animais: {{ farm.total_animals }}</span>
</p> </p>
@@ -49,13 +56,16 @@
</a> </a>
</li> </li>
{% endfor %} {% endfor %}
<p><a hx-get="{% url "entities:farm:farm_create" %}" hx-swap="beforeend" hx-target="body" class="new-farm-btn"><i class="fa-solid fa-plus"></i> Criar nova fazenda</a></p>
</ul> </ul>
{% else %} {% else %}
<div class="no-farms"> <div class="no-farms">
<p>Nenhuma fazenda encontrada</p> <p>Nenhuma fazenda encontrada</p>
<p><a href="#"><i class="fa-solid fa-plus"></i> Criar nova fazenda</a></p> <p><a hx-get="{% url "entities:farm:farm_create" %}" hx-swap="beforeend" hx-target="body" class="new-farm-btn"><i class="fa-solid fa-plus"></i> Criar nova fazenda</a></p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</body> </body>
<!-- Importing HTMX -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
</html> </html>
@@ -16,7 +16,7 @@ def get_attr(obj, attr):
value = value() value = value()
# If none, return empty string to avoid displaying "None" in the template # If none, return empty string to avoid displaying "None" in the template
return value if value is not None else "" return value if value is not None else "-"
except Exception: except Exception:
return "" return "- !"
+18 -3
View File
@@ -1,11 +1,12 @@
from django.contrib import admin from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from .models.user import User from .models.user import User
from .models.farm import Farm, PaddockGroup, Paddock from .models.farm import Farm, PaddockGroup, Paddock
from .models.animal import Animal, BatchType, Batch, BatchAnimal, FeedingRecord from .models.animal import Animal, BatchType, Batch, BatchAnimal, FeedingRecord
from .models.rotation import Rotation from .models.rotation import Rotation
admin.site.register([ from .models import (
User, User,
Farm, Farm,
PaddockGroup, PaddockGroup,
@@ -15,5 +16,19 @@ admin.site.register([
BatchAnimal, BatchAnimal,
FeedingRecord, FeedingRecord,
Rotation, Rotation,
BatchType BatchType,
]) )
class DefaultImportExportAdmin(ImportExportModelAdmin):
pass
admin.site.register(User, DefaultImportExportAdmin)
admin.site.register(Farm, DefaultImportExportAdmin)
admin.site.register(PaddockGroup, DefaultImportExportAdmin)
admin.site.register(Paddock, DefaultImportExportAdmin)
admin.site.register(Animal, DefaultImportExportAdmin)
admin.site.register(Batch, DefaultImportExportAdmin)
admin.site.register(BatchAnimal, DefaultImportExportAdmin)
admin.site.register(FeedingRecord, DefaultImportExportAdmin)
admin.site.register(Rotation, DefaultImportExportAdmin)
admin.site.register(BatchType, DefaultImportExportAdmin)
+5 -6
View File
@@ -4,11 +4,10 @@ from ..models import Farm, PaddockGroup, Paddock
class FarmForm(forms.ModelForm): class FarmForm(forms.ModelForm):
class Meta: class Meta:
model = Farm model = Farm
fields = ['name', 'total_area'] fields = ['name']
labels = { labels = {
'name': 'Nome', 'name': 'Nome',
'total_area': 'Área total',
} }
FarmCreateForm = FarmEditForm = FarmForm # Alias since its implementations are the same FarmCreateForm = FarmEditForm = FarmForm # Alias since its implementations are the same
@@ -26,12 +25,12 @@ PaddockGroupCreateForm = PaddockGroupEditForm = PaddockGroupForm
class PaddockForm(forms.ModelForm): class PaddockForm(forms.ModelForm):
class Meta: class Meta:
model = Paddock model = Paddock
fields = ['paddock_number', 'area', 'load_capacity', 'paddock_group'] fields = ['paddock_number', 'area'] #, 'load_capacity', 'paddock_group']
labels = { labels = {
'paddock_number': 'Número do piquete', 'paddock_number': 'Número do piquete',
'area': 'Área', 'area': 'Área (m²)',
'load_capacity': 'Lotação máxima', # 'load_capacity': 'Lotação máxima',
'paddock_group': 'Bloco', # 'paddock_group': 'Bloco',
} }
PaddockCreateForm = PaddockEditForm = PaddockForm PaddockCreateForm = PaddockEditForm = PaddockForm
+10 -10
View File
@@ -4,20 +4,20 @@ from ..models import Rotation
class RotationEntryForm(forms.ModelForm): class RotationEntryForm(forms.ModelForm):
class Meta: class Meta:
model = Rotation model = Rotation
fields = ['batch', 'entry_height', 'paddock'] fields = ['batch', 'paddock', 'entry_date']
labels = { labels = {
'batch': 'Lote de animais', 'batch': 'Lote de animais',
'entry_height': 'Altura do pasto',
'paddock': 'Piquete', 'paddock': 'Piquete',
'entry_date': 'Horário da entrada'
} }
class RotationExitForm(forms.ModelForm): widgets = {
class Meta: 'entry_date': forms.DateTimeInput(
model = Rotation attrs={
fields = ['batch', 'exit_height'] 'type': 'datetime-local',
'class': 'form-control'
labels = { },
'batch': 'Lote de animais', format='%Y-%m-%dT%H:%M'
'exit_height': 'Altura do pasto', ),
} }
@@ -0,0 +1,23 @@
# Generated by Django 6.0.2 on 2026-06-08 16:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('entities', '0003_farm_default_consumption_farm_summer_consumption_and_more'),
]
operations = [
migrations.AddField(
model_name='farm',
name='ideal_paddock_occupation',
field=models.IntegerField(default=2),
),
migrations.AddField(
model_name='farm',
name='ideal_paddock_rest',
field=models.IntegerField(default=20),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.2 on 2026-06-10 15:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('entities', '0004_farm_ideal_paddock_occupation_and_more'),
]
operations = [
migrations.AlterField(
model_name='farm',
name='default_consumption',
field=models.DecimalField(decimal_places=2, default=2.5, max_digits=4),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 6.0.2 on 2026-06-21 20:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('entities', '0005_alter_farm_default_consumption'),
]
operations = [
migrations.RemoveField(
model_name='farm',
name='total_area',
),
]
@@ -0,0 +1,19 @@
# Generated by Django 6.0.2 on 2026-06-23 00:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('entities', '0006_remove_farm_total_area'),
]
operations = [
migrations.AlterField(
model_name='rotation',
name='batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='rotations', to='entities.batch'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.2 on 2026-06-23 00:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('entities', '0007_alter_rotation_batch'),
]
operations = [
migrations.AlterField(
model_name='farm',
name='default_consumption',
field=models.DecimalField(decimal_places=2, default=2.75, max_digits=4),
),
]
+29 -7
View File
@@ -1,6 +1,7 @@
from datetime import date from datetime import date
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q, Sum
from decimal import Decimal
class Animal(models.Model): class Animal(models.Model):
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
@@ -10,7 +11,7 @@ class Animal(models.Model):
farm = models.ForeignKey('entities.Farm', on_delete=models.CASCADE, related_name='animals') farm = models.ForeignKey('entities.Farm', on_delete=models.CASCADE, related_name='animals')
def __str__(self): def __str__(self):
return f"Animal {self.identifier} - birth {self.birth} - weight {self.weight} - farm {self.farm} - is_active {self.is_active}" return f"{self.identifier}"
@property @property
def age(self): def age(self):
@@ -56,12 +57,14 @@ class BatchType(models.Model):
consumption_factor = models.DecimalField(max_digits=4, decimal_places=2, default=1.00) consumption_factor = models.DecimalField(max_digits=4, decimal_places=2, default=1.00)
def __str__(self): def __str__(self):
state = 'Ativo' if self.is_active else 'Inativo' return f"{self.name} ({self.consumption_percentage}%)"
return f"{state} - {self.name}, {self.consumption_factor}"
@property @property
def consumption_percentage(self): def consumption_percentage(self):
return int((self.consumption_factor - 1) * 100) value = int((self.consumption_factor - 1) * 100)
if value >= 0:
return f'+{value}'
return f'{value}'
class Batch(models.Model): class Batch(models.Model):
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
@@ -87,6 +90,15 @@ class Batch(models.Model):
self.batchanimal_set.all().update(is_active=True) self.batchanimal_set.all().update(is_active=True)
self.__original_is_active = self.is_active self.__original_is_active = self.is_active
@property
def total_ugm(self):
if self.animals:
total_weight = self.animals.aggregate(
total=Sum("weight")
)["total"] or 0
return Decimal(f"{total_weight / 500:.2f}")
def __str__(self): def __str__(self):
return self.identifier return self.identifier
@@ -112,20 +124,30 @@ class BatchAnimal(models.Model):
def __str__(self): def __str__(self):
return f"{self.animal.identifier} in {self.batch.identifier}" return f"{self.animal.identifier} in {self.batch.identifier}"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__original_is_active = self.is_active
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_active_changed = self.__original_is_active != self.is_active is_active_changed = (
self.__original_is_active != self.is_active
)
if is_active_changed and self.is_active: if is_active_changed and self.is_active:
raise ValueError( raise ValueError(
"Reactivating a BatchAnimal is not allowed automatically because it may violate " "Reactivating a BatchAnimal is not allowed automatically because it may violate "
"the unique active batch constraint for an animal. Review the logic in " "the unique active batch constraint for an animal. Review the logic in "
"models/animal.py (BatchAnimal.save) before proceeding." "models/animal.py (BatchAnimal.save) before proceeding."
) )
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.__original_is_active = self.is_active
class FeedingRecord(models.Model): class FeedingRecord(models.Model):
animal = models.ForeignKey(Animal, on_delete=models.CASCADE, related_name='feeding_record') animal = models.ForeignKey(Animal, on_delete=models.CASCADE, related_name='feeding_record')
rotation = models.ForeignKey('entities.Rotation', on_delete=models.CASCADE, related_name='feeding_record') rotation = models.ForeignKey('entities.Rotation', on_delete=models.CASCADE, related_name='feeding_record')
feed_quantity = models.DecimalField(max_digits=10, decimal_places=2) feed_quantity = models.DecimalField(max_digits=10, decimal_places=2)
def __str__(self): def __str__(self):
return f"Consumo de {self.animal.identifier} - {self.feed_quantity} kg" return f"{self.animal.identifier} - {self.feed_quantity}kg"
+44 -9
View File
@@ -1,12 +1,16 @@
from django.db import models from django.db import models
from django.db.models import Sum
from django.apps import apps
from django.utils import timezone
class Farm(models.Model): class Farm(models.Model):
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
name = models.CharField(max_length=45) name = models.CharField(max_length=45)
total_area = models.IntegerField(null=True, blank=True) default_consumption = models.DecimalField(max_digits=4, decimal_places=2, default=2.75)
default_consumption = models.DecimalField(max_digits=4, decimal_places=2, default=1.00)
summer_consumption = models.DecimalField(max_digits=4, decimal_places=2, default=1.00) summer_consumption = models.DecimalField(max_digits=4, decimal_places=2, default=1.00)
winter_consumption = models.DecimalField(max_digits=4, decimal_places=2, default=1.00) winter_consumption = models.DecimalField(max_digits=4, decimal_places=2, default=1.00)
ideal_paddock_occupation = models.IntegerField(default=2)
ideal_paddock_rest = models.IntegerField(default=20)
def __str__(self): def __str__(self):
@@ -14,14 +18,40 @@ class Farm(models.Model):
@property @property
def animal_count(self): def animal_count(self):
return self.animals.filter( return self.animals.filter(is_active=True).count()
is_active=True
).count()
@property @property
def paddock_count(self): def paddock_count(self):
return self.paddocks.filter( return self.paddocks.filter(is_active=True).count()
@property
def total_ugm(self):
total_weight = (
self.animals
.filter(is_active=True)
.aggregate(total=Sum('weight'))['total']
or 0
)
return round(total_weight / 500, 2)
@property
def batch_count(self):
Batch = apps.get_model('entities', 'Batch')
return Batch.objects.filter(
animals__farm=self,
is_active=True is_active=True
).distinct().count()
@property
def paddocks_area(self):
return sum(paddock.area for paddock in self.paddocks.filter(is_active=True))
@property
def total_rotations(self):
Rotation = apps.get_model('entities', 'Rotation')
return Rotation.objects.filter(
is_active=True,
paddock__farm=self
).count() ).count()
@@ -31,7 +61,7 @@ class PaddockGroup(models.Model):
description = models.TextField(null=True, blank=True) description = models.TextField(null=True, blank=True)
def __str__(self): def __str__(self):
return f"Bloco {self.id}" return f"{self.id}"
class Paddock(models.Model): class Paddock(models.Model):
@@ -44,10 +74,15 @@ class Paddock(models.Model):
paddock_group = models.ForeignKey(PaddockGroup, on_delete=models.SET_NULL, related_name='paddocks', null=True, blank=True) paddock_group = models.ForeignKey(PaddockGroup, on_delete=models.SET_NULL, related_name='paddocks', null=True, blank=True)
def __str__(self): def __str__(self):
return f"Piquete {self.paddock_number} - Bloco {self.paddock_group.id}" return f"{self.paddock_number}"
@property @property
def occupied_as_text(self): def occupied_as_text(self):
if self.occupied: if self.occupied:
return "Sim" return "Sim"
return "Não" return "Não"
# @property
# def is_occupied(self):
# from . import Rotation
# return Rotation.objects.filter(paddock=self, is_active=True).exists()
+37 -14
View File
@@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from decimal import Decimal
class Rotation(models.Model): class Rotation(models.Model):
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
@@ -9,28 +10,50 @@ class Rotation(models.Model):
exit_height = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) exit_height = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
animal_count = models.IntegerField() animal_count = models.IntegerField()
livestock_unit = models.DecimalField(max_digits=10, decimal_places=2) livestock_unit = models.DecimalField(max_digits=10, decimal_places=2)
batch = models.ForeignKey('entities.Batch', on_delete=models.SET_NULL, related_name='rotations', null=True, blank=True) batch = models.ForeignKey('entities.Batch', on_delete=models.PROTECT, related_name='rotations', null=True, blank=True)
paddock = models.ForeignKey('entities.Paddock', on_delete=models.CASCADE, related_name='rotations') paddock = models.ForeignKey('entities.Paddock', on_delete=models.CASCADE, related_name='rotations')
def __str__(self): def __str__(self):
return f"Movimentação {self.id} - Lote {self.batch.identifier}" return f"Movimentação {self.id}"
@property @property
def duration(self): def duration(self):
end = self.exit_date or timezone.now() end = self.exit_date or timezone.now()
return end - self.entry_date delta = end - self.entry_date
return Decimal(str(round(delta.total_seconds() / 86400, 2)))
@property @property
def duration_display_day(self): def duration_display_day(self):
end = self.exit_date or timezone.now() days = self.duration
delta = end - self.entry_date
if days < 1:
return "menos de um"
days = delta.days string_day = f"{days}".replace('.', ',')
return string_day
if days == 0:
return "Hoje" @property
def livestock_unit_day(self):
if days == 1: if self.duration == 0:
return "1" return 0
return Decimal(self.livestock_unit * self.duration)
return f"{days}"
@property
def animal_count_day(self):
if self.duration == 0:
return 0
return Decimal(self.animal_count * self.duration)
@property
def season(self):
month = self.exit_date.month
day = self.exit_date.day
if (month == 12 and day >= 21) or (month in [1, 2]) or (month == 3 and day < 20):
return "Summer"
elif (month == 3 and day >= 20) or (month in [4, 5]) or (month == 6 and day < 21):
return "Fall"
elif (month == 6 and day >= 21) or (month in [7, 8]) or (month == 9 and day < 22):
return "Winter"
else:
return "Spring"
+6 -2
View File
@@ -54,6 +54,7 @@ def get_animal_create_context():
context['form'] = form context['form'] = form
context['action_url'] = reverse('entities:animal:animal_create') context['action_url'] = reverse('entities:animal:animal_create')
context['return_url'] = reverse('entities:animal:animal_list') context['return_url'] = reverse('entities:animal:animal_list')
context['modal_title'] = 'Cadastrar animal'
return context return context
@@ -82,6 +83,7 @@ def get_animal_update_context(request, animal_id):
) )
context['return_url'] = reverse('entities:animal:animal_list') context['return_url'] = reverse('entities:animal:animal_list')
context['form'] = form context['form'] = form
context['modal_title'] = 'Editar animal'
return context return context
@@ -120,7 +122,8 @@ def get_batch_create_context(request):
'title': 'Novo Lote', 'title': 'Novo Lote',
'form': form, 'form': form,
'action_url': reverse('entities:animal:batch_create'), 'action_url': reverse('entities:animal:batch_create'),
'return_url': reverse('entities:animal:batch_list') 'return_url': reverse('entities:animal:batch_list'),
'modal_title': 'Cadastrar lote'
} }
return context return context
@@ -153,7 +156,8 @@ def get_batch_update_context(request, batch_id):
'entities:animal:batch_update', 'entities:animal:batch_update',
kwargs={'batch_id': batch_old.pk} kwargs={'batch_id': batch_old.pk}
), ),
'form': form 'form': form,
'modal_title': 'Editar lote'
} }
return context return context
+7 -2
View File
@@ -70,7 +70,7 @@ def get_farm_home_context(request):
batch_types = _get_farms_batch_types(request) batch_types = _get_farms_batch_types(request)
context = {} context = {}
context['title'] = selected_farm.name context['title'] = 'Configurações da Fazenda'
context['selected_farm'] = selected_farm context['selected_farm'] = selected_farm
context['farms'] = farms_with_counts context['farms'] = farms_with_counts
context['allowed_users'] = allowed_users context['allowed_users'] = allowed_users
@@ -86,6 +86,7 @@ def get_farm_create_context():
context['form'] = form context['form'] = form
context['action_url'] = reverse('entities:farm:farm_create') context['action_url'] = reverse('entities:farm:farm_create')
context['return_url'] = reverse('entities:farm:farm_home') context['return_url'] = reverse('entities:farm:farm_home')
context['modal_title'] = 'Criar fazenda'
return context return context
@@ -143,15 +144,19 @@ def delete_farm(request, farm_id):
farm.is_active = False farm.is_active = False
farm.save() farm.save()
def update_consumption(request): def update_metrics(request):
default_consumption = Decimal(request.POST.get('default-consume')) default_consumption = Decimal(request.POST.get('default-consume'))
summer_consumption = Decimal(request.POST.get('summer-consume')) summer_consumption = Decimal(request.POST.get('summer-consume'))
winter_consumption = Decimal(request.POST.get('winter-consume')) winter_consumption = Decimal(request.POST.get('winter-consume'))
ideal_paddock_occupation = int(request.POST.get('ideal_paddock_occupation'))
ideal_paddock_rest = int(request.POST.get('ideal_paddock_rest'))
selected_farm = _get_selected_farm(request) selected_farm = _get_selected_farm(request)
selected_farm.default_consumption = default_consumption selected_farm.default_consumption = default_consumption
selected_farm.summer_consumption = summer_consumption selected_farm.summer_consumption = summer_consumption
selected_farm.winter_consumption = winter_consumption selected_farm.winter_consumption = winter_consumption
selected_farm.ideal_paddock_occupation = ideal_paddock_occupation
selected_farm.ideal_paddock_rest = ideal_paddock_rest
selected_farm.save() selected_farm.save()
@@ -0,0 +1,73 @@
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.db.models import Max, Q, F, Exists, OuterRef
from django.utils.safestring import mark_safe
from django.utils import timezone
from entities.models import Farm, Rotation, Paddock, Batch
from entities.forms import RotationEntryForm
from report.services import _get_rotation_consumption
def _get_selected_farm_id(request):
return request.session['selected_farm_id']
def _get_selected_farm(request):
return Farm.objects.get(id=_get_selected_farm_id(request))
def _get_rotation_by_id(request, rotation_id):
farm = _get_selected_farm(request)
return get_object_or_404(Rotation, pk=rotation_id, paddock__farm=farm)
def _get_batches(farm):
return (Batch.objects
.filter(animals__farm=farm, is_active=True)
.annotate(
in_rotation=Exists(
Rotation.objects.filter(
batch=OuterRef("pk"),
exit_date__isnull=True
)
)
)
.distinct()
)
def _get_free_paddocks(farm):
now = timezone.now()
paddocks = (
Paddock.objects
.filter(farm=farm, occupied=False)
.annotate(last_exit=Max("rotations__exit_date"))
.order_by(F("last_exit").asc(nulls_first=True))
)
for paddock in paddocks:
paddock.current_rest = (now - paddock.last_exit).days if paddock.last_exit else None
return paddocks
def get_rotation_create_context(request):
farm = _get_selected_farm(request)
form = RotationEntryForm()
form.fields['batch'].queryset = _get_batches(farm)
form.fields['batch'].empty_label = "Selecione o lote"
form.fields['paddock'].queryset = _get_free_paddocks(farm)
form.fields['paddock'].empty_label = "Selecione o piquete"
return {
'form': form,
'modal_title': 'Iniciar movimentação',
'action_button_text': mark_safe("""<i class="fa-solid fa-arrow-rotate-right"></i> Movimentar""")
}
def get_view_more_context(request, rotation_id):
rotation = _get_rotation_by_id(request, rotation_id)
consume = _get_rotation_consumption(rotation, _get_selected_farm(request))
free_paddocks = _get_free_paddocks(request)
return {
'rotation': rotation,
'consume': consume,
'free_paddocks': free_paddocks
}
+6 -1
View File
@@ -72,6 +72,11 @@ def farm_set(request, farm_id=None):
farm_id = farm_id or request.POST.get("farm_id") farm_id = farm_id or request.POST.get("farm_id")
next_url = request.POST.get("next") next_url = request.POST.get("next")
print(farm_id)
if farm_id == '0':
return farm_deselect(request)
if not farm_id: if not farm_id:
return redirect('entities:user:farm_select') return redirect('entities:user:farm_select')
@@ -79,7 +84,7 @@ def farm_set(request, farm_id=None):
farm = request.user.farms.get(id=farm_id) farm = request.user.farms.get(id=farm_id)
request.session['selected_farm_id'] = farm.id request.session['selected_farm_id'] = farm.id
return redirect(next_url or 'entities:farm:farm_home') return redirect(next_url or 'report:dashboard')
except request.user.farms.model.DoesNotExist: except request.user.farms.model.DoesNotExist:
return redirect('entities:user:farm_select') return redirect('entities:user:farm_select')
@@ -10,31 +10,6 @@
color: #6c757d; color: #6c757d;
} }
.header-right {
display: flex;
gap: 12px;
}
.farm-info {
min-width: 100px;
padding: 14px;
text-align: center;
background: white;
border: 1px solid #e9ecef;
border-radius: 10px;
box-shadow: 0 2px 6px rgba(0,0,0,.04);
}
.farm-info p:first-child {
font-size: .8rem;
color: #6c757d;
}
.farm-info p:last-child {
font-size: 1.2rem;
font-weight: 600;
}
/* Containers */ /* Containers */
.farm-config-container, .farm-config-container,
.batch-type-config-container, .batch-type-config-container,
@@ -51,7 +26,8 @@
/* Headers */ /* Headers */
.allowed-users-header, .allowed-users-header,
.batch-type-config-header, .batch-type-config-header,
.section-header { .section-header,
.available-farms-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -66,7 +42,8 @@
} }
.allowed-users-header a, .allowed-users-header a,
.batch-type-config-header a { .batch-type-config-header a,
.available-farms-header a {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -77,6 +54,8 @@
background-color: #198754; background-color: #198754;
border-radius: 5px; border-radius: 5px;
padding: 3px 6px; padding: 3px 6px;
width: 120px;
justify-content: center;
} }
/* Consumptions */ /* Consumptions */
@@ -93,11 +72,19 @@
} }
.config-card-title { .config-card-title {
margin-bottom: 8px; display: flex;
font-size: .85rem; justify-content: space-between;
align-items: center;
padding: 0 3px;
margin-bottom: 5px;
font-size: 14px;
color: #6c757d; color: #6c757d;
} }
.metric-question {
cursor: pointer;
}
.config-card input { .config-card input {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
@@ -112,12 +99,12 @@
} }
.config-submit { .config-submit {
flex: 1; flex: .5;
display: flex; display: flex;
} }
.config-submit button { .config-submit button {
width: 50%; width: 100%;
height: 50%; height: 50%;
margin: auto; margin: auto;
border: none; border: none;
@@ -196,7 +183,7 @@
gap: 14px; gap: 14px;
} }
.available-farms-title { .available-farms-header {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -40,14 +40,14 @@
padding: 0 var(--space-sm); padding: 0 var(--space-sm);
border: none; border: none;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
background: var(--color-primary); background: var(--color-neutral-btn);
color: var(--color-text-light); color: var(--color-text-light);
cursor: pointer; cursor: pointer;
transition: background var(--transition-speed); transition: background var(--transition-speed);
} }
.entity_search form button:hover { .entity_search form button:hover {
background: var(--color-secondary); background: var(--color-neutral-btn-hover);
} }
/* CLEAR FILTER */ /* CLEAR FILTER */
@@ -57,16 +57,15 @@
gap: 4px; gap: 4px;
height: 32px; height: 32px;
padding: 0 var(--space-sm); padding: 0 var(--space-sm);
background: var(--color-danger-light); background: var(--color-bg-filter);
color: var(--color-danger); color: var(--color-text-filter);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
text-decoration: none; text-decoration: none;
font-size: var(--font-sm); font-size: var(--font-sm);
} }
.btn-clear-filter:hover { .btn-clear-filter:hover {
background: var(--color-danger); background: var(--color-danger-btn-hover);
color: var(--color-text-light);
} }
/* NEW BUTTON */ /* NEW BUTTON */
@@ -75,7 +74,7 @@
user-select: none; user-select: none;
height: 32px; height: 32px;
padding: 0 var(--space-md); padding: 0 var(--space-md);
background: var(--color-success); background: var(--color-plus-btn);
color: var(--color-text-light); color: var(--color-text-light);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
text-decoration: none; text-decoration: none;
@@ -87,7 +86,7 @@
} }
.new-entity-button:hover { .new-entity-button:hover {
background: var(--color-success); background: var(--color-plus-btn-hover);
opacity: 0.85; opacity: 0.85;
} }
@@ -9,8 +9,7 @@
/* Active rotation cards */ /* Active rotation cards */
.active-rotation-card { .active-rotation-card {
min-width: 300px; width: 300px;
max-width: 300px;
background: #ffffff; background: #ffffff;
color: #212529; color: #212529;
border: 1px solid #e9ecef; border: 1px solid #e9ecef;
@@ -23,9 +22,15 @@
transition: 0.2s ease; transition: 0.2s ease;
} }
.active-rotation-card {
user-select: none;
}
.active-rotation-card:hover { .active-rotation-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
border-color: var(--color-primary);
cursor: pointer;
} }
.active-rotation-card p { .active-rotation-card p {
@@ -68,4 +73,8 @@
align-items: center; align-items: center;
} }
.active-rotation-card {
width: 100%;
}
} }
@@ -0,0 +1,176 @@
.rotation-view-more {
display: flex;
flex-direction: column;
gap: 28px;
}
.rotation-view-more-rotation-status {
text-align: center;
}
.rotation-view-more-rotation-status h2 {
margin-top: 8px;
color: var(--color-primary);
}
.rotation-view-more-status-badge {
display: inline-block;
padding: 6px 13px;
border-radius: 999px;
background: rgba(46, 204, 113, .15);
color: #27ae60;
font-size: 13px;
font-weight: 600;
}
.rotation-view-more-rotation-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.rotation-view-more-card {
padding: 4px;
border-radius: 12px;
border: 1px solid var(--color-secondary);
text-align: center;
background: white;
}
.rotation-view-more-label {
display: block;
color: #777;
font-size: 13px;
margin-bottom: 6px;
}
.rotation-view-more-card strong {
font-size: 22px;
color: var(--color-primary);
}
.rotation-view-more-rotation-section h3,
.rotation-view-more-rotation-next h3 {
margin-bottom: 16px;
color: var(--color-primary);
}
.rotation-view-more-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.rotation-view-more-info {
display: flex;
flex-direction: column;
padding: 14px;
border: 1px solid #e5e5e5;
border-radius: 10px;
background: #fafafa;
}
.rotation-view-more-info span {
font-size: 13px;
color: #888;
margin-bottom: 5px;
}
.rotation-view-more-info strong {
color: var(--color-primary);
}
.rotation-view-more-rotation-next {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 8px;
}
.rotation-view-more-rotation-next h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.rotation-view-more-rotation-next .rotation-next-actions {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
align-items: center;
}
.rotation-view-more-rotation-next select {
height: 44px;
width: 100%;
padding: 0 16px;
border-radius: 8px;
border: 1px solid #ddd;
background: white;
font-size: 15px;
color: #444;
outline: none;
cursor: pointer;
}
.rotation-view-more-rotation-next select:focus {
border-color: var(--color-primary);
}
.rotation-view-more-rotate,
.rotation-view-finish-rotate {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: .2s;
white-space: nowrap;
}
.rotation-view-more-rotate {
border: none;
background: var(--color-primary);
color: white;
}
.rotation-view-more-rotate:hover {
filter: brightness(.95);
}
.rotation-view-finish-rotate {
border: 1px solid var(--color-secondary);
background: white;
color: var(--color-secondary);
}
.rotation-view-finish-rotate:hover {
background: var(--color-secondary);
color: white;
}
@media (max-width: 700px) {
.rotation-view-more-rotation-cards {
grid-template-columns: 1fr;
}
.rotation-view-more-info-grid {
grid-template-columns: 1fr;
}
.rotation-view-more-rotation-next .rotation-next-actions {
grid-template-columns: 1fr;
}
.rotation-view-more-rotation-next select,
.rotation-view-more-rotate,
.rotation-view-finish-rotate {
width: 100%;
}
}
@@ -39,4 +39,14 @@ document.addEventListener("DOMContentLoaded", () => {
isAnimating = false; isAnimating = false;
} }
} }
}); });
document.addEventListener("DOMContentLoaded", function () {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) {
document.querySelector("#table").scrollIntoView({
behavior: "auto"
});
}
});
@@ -19,7 +19,7 @@ body {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 var(--space-lg); padding: 0 var(--space-lg);
z-index: 950; z-index: 20;
} }
/* SITE'S TITLE */ /* SITE'S TITLE */
@@ -43,7 +43,7 @@ body {
.farm-select-container h1 { .farm-select-container h1 {
font-size: var(--font-xl); font-size: var(--font-xl);
color: #1f2937; color: var(--login-background-primary);
margin-bottom: var(--space-xl); margin-bottom: var(--space-xl);
} }
@@ -127,7 +127,7 @@ body {
} }
/* NEW FARM BUTTON */ /* NEW FARM BUTTON */
.no-farms a { .new-farm-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-xs); gap: var(--space-xs);
@@ -141,7 +141,7 @@ body {
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.no-farms a:hover { .new-farm-btn:hover {
background-color: var(--color-new-farm-hover); background-color: var(--color-new-farm-hover);
} }
@@ -2,9 +2,11 @@
{% load static %} {% load static %}
{% block extra_css %} {% block extra_css %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'farm/css/home.css' %}"> <link rel="stylesheet" href="{% static 'farm/css/home.css' %}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
New - Farm Nova Fazenda <br>
{% include 'components/form.html' %}
{% endblock %} {% endblock %}
@@ -10,40 +10,34 @@
{% block content %} {% block content %}
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<h1 class="title">{{ title }}</h1> <h1 class="title">{{ selected_farm.name }}</h1>
<p>Configurações da fazenda</p> <p>Configurações da fazenda</p>
</div> </div>
<div class="header-right">
<div class="farm-info">
<p>Animais</p>
<p>{{ selected_farm.animal_count }}</p>
</div>
<div class="farm-info">
<p>Piquetes</p>
<p>{{ selected_farm.paddock_count }}</p>
</div>
<div class="farm-info">
<p>Área</p>
<p>{{ selected_farm.total_area }}</p>
</div>
</div>
</div> </div>
<div class="farm-config-container"> <div class="farm-config-container">
<h2 class="farm-config-title">Fatores de consumo</h2> <h2 class="farm-config-title">Métricas da fazenda</h2>
<form action="{% url "entities:farm:consumption_update" %}" method="POST"> <form action="{% url "entities:farm:metrics_update" %}" method="POST">
{% csrf_token %} {% csrf_token %}
<div class="config-card"> <div class="config-card" title="Percentual do peso do animal correspondente ao seu consumo diário">
<p class="config-card-title">Consumo padrão</p> <p class="config-card-title">Consumo padrão <i class="fa-solid fa-circle-question metric-question"></i></p>
<input type="number" name="default-consume" min="0.01" step="0.01" value="{{ selected_farm.default_consumption|unlocalize }}"> <input type="number" name="default-consume" min="0.01" step="0.01" value="{{ selected_farm.default_consumption|unlocalize }}" required>
</div> </div>
<div class="config-card"> <div class="config-card" title="Variação do consumo no verão (1,00 = sem variação)">
<p class="config-card-title">Consumo no verão</p> <p class="config-card-title">Consumo no verão <i class="fa-solid fa-circle-question metric-question"></i></p>
<input type="number" name="summer-consume" min="0.01" step="0.01" value="{{ selected_farm.summer_consumption|unlocalize }}"> <input type="number" name="summer-consume" min="0.01" step="0.01" value="{{ selected_farm.summer_consumption|unlocalize }}" required>
</div> </div>
<div class="config-card"> <div class="config-card" title="Variação do consumo no inverno (1,00 = sem variação)">
<p class="config-card-title">Consumo no inverno</p> <p class="config-card-title">Consumo no inverno <i class="fa-solid fa-circle-question metric-question"></i></p>
<input type="number" name="winter-consume" min="0.01" step="0.01" value="{{ selected_farm.winter_consumption|unlocalize }}"> <input type="number" name="winter-consume" min="0.01" step="0.01" value="{{ selected_farm.winter_consumption|unlocalize }}" required>
</div>
<div class="config-card" title="Tempo ideal de ocupação em um piquete em dias">
<p class="config-card-title">Tempo de ocupação <i class="fa-solid fa-circle-question metric-question"></i></p>
<input type="number" name="ideal_paddock_occupation" min="1" step="1" value="{{ selected_farm.ideal_paddock_occupation|unlocalize }}" required>
</div>
<div class="config-card" title="Tempo ideal de descanço para uma piquete em dias">
<p class="config-card-title">Tempo de repouso <i class="fa-solid fa-circle-question metric-question"></i></p>
<input type="number" name="ideal_paddock_rest" min="1" step="1" value="{{ selected_farm.ideal_paddock_rest|unlocalize }}" required>
</div> </div>
<div class="config-submit"> <div class="config-submit">
<button type="submit">Salvar</button> <button type="submit">Salvar</button>
@@ -54,8 +48,11 @@
<div class="batch-type-config-container"> <div class="batch-type-config-container">
<div class="batch-type-config-header"> <div class="batch-type-config-header">
<h2 class="batch-type-title">Tipos de lote</h2> <h2 class="batch-type-title">Tipos de lote</h2>
<a hx-get="{% url "entities:animal:batchtype_create" %}" hx-swap="beforeend" hx-target="body"><i class="fa-solid fa-plus"></i> Tipo</a> <a hx-get="{% url "entities:animal:batchtype_create" %}" hx-swap="beforeend" hx-target="body"><i class="fa-solid fa-plus"></i> Tipo de Lote</a>
</div> </div>
{% if batch_types|length < 1 %}
<p class="type-consumption-factor">Nenhum tipo de lote foi cadastrado ainda</p>
{% endif %}
{% for type in batch_types %} {% for type in batch_types %}
<div class="batch-type-card"> <div class="batch-type-card">
<div class="batch-type-left"> <div class="batch-type-left">
@@ -87,7 +84,10 @@
</div> </div>
<div class="available-farms-container"> <div class="available-farms-container">
<h2 class="available-farms-title">Suas fazendas</h2> <div class="available-farms-header">
<h2 class="available-farms-title">Suas fazendas</h2>
<a hx-get="{% url "entities:farm:farm_create" %}" hx-swap="beforeend" hx-target="body"><i class="fa-solid fa-plus"></i> Fazenda</a>
</div>
{% for farm in farms %} {% for farm in farms %}
<div class="available-farm-card"> <div class="available-farm-card">
<p class="available-farm-name">{{ farm.name }}</p> <p class="available-farm-name">{{ farm.name }}</p>
@@ -0,0 +1,11 @@
{% extends 'layouts/base_modal.html' %}
{% load static %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'layouts/entity_create.css' %}">
{% endblock %}
{% block content %}
<h1 class="entity-modal-title">{{ modal_title }}</h1>
{% include 'components/form.html' %}
{% endblock %}
@@ -27,6 +27,7 @@
<button type="submit"> <button type="submit">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
</button> </button>
<!-- New -->
<a class="new-entity-button" hx-get="{% url create_url %}" hx-swap="beforeend" hx-target="body">Novo <i class="fa-solid fa-plus"></i></a> <a class="new-entity-button" hx-get="{% url create_url %}" hx-swap="beforeend" hx-target="body">Novo <i class="fa-solid fa-plus"></i></a>
</form> </form>
</div> </div>
@@ -0,0 +1,11 @@
{% extends 'layouts/base_modal.html' %}
{% load static %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'layouts/entity_update.css' %}">
{% endblock %}
{% block content %}
<h1 class="entity-modal-title">{{ modal_title }}</h1>
{% include 'components/form.html' %}
{% endblock %}
@@ -0,0 +1 @@
MOVE TEMPLATE
@@ -12,13 +12,13 @@
<p>Movimentações em aberto</p> <p>Movimentações em aberto</p>
<div class="open-rotations-container"> <div class="open-rotations-container">
{% for obj in open_rotations %} {% for obj in open_rotations %}
<div class="active-rotation-card"> <div class="active-rotation-card" hx-get="{% url 'entities:rotation:view_more' obj.id %}" hx-swap="beforeend" hx-target="body">
<div class="identification"> <div class="identification">
<p>Lote {{ obj.batch.identifier }} em piquete {{ obj.paddock.paddock_number }}</p> <p><i class="fa-solid fa-cow"></i> Lote {{ obj.batch.identifier }} em piquete {{ obj.paddock.paddock_number }}</p>
</div> </div>
<div class="duration"> <div class="duration">
<p>Há {{ obj.duration_display_day }} dias, desde {{ obj.entry_date|date:"d/m/Y" }}</p> <p><i class="fa-solid fa-arrow-rotate-right"></i> Há {{ obj.duration_display_day }} dias, desde {{ obj.entry_date|date:"d/m/Y" }}</p>
</div> </div>
<div class="animals"> <div class="animals">
@@ -39,12 +39,12 @@
{% endblock %} {% endblock %}
{% block table %} {% block table %}
<table> <table id="table">
<tr> <tr>
{% for col in columns %} {% for col in columns %}
<th>{{ col.label }}</th> <th>{{ col.label }}</th>
{% endfor %} {% endfor %}
<th>Excluir</th> {% comment %} <th>Excluir</th> {% endcomment %}
</tr> </tr>
{% for obj in object_list %} {% for obj in object_list %}
@@ -56,14 +56,14 @@
{% endfor %} {% endfor %}
<!-- Delete --> <!-- Delete -->
<td> {% comment %} <td>
<form method="POST" action="{% url delete_url obj.id %}"> <form method="POST" action="{% url delete_url obj.id %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn-icon-trash"> <button type="submit" class="btn-icon-trash">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
</button> </button>
</form> </form>
</td> </td> {% endcomment %}
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
@@ -0,0 +1,101 @@
{% extends 'layouts/base_modal.html' %}
{% load static %}
{% block content %}
<div class="rotation-view-more">
<div class="rotation-view-more-rotation-status">
<span class="rotation-view-more-status-badge">● Em andamento</span>
<h2>Movimentação #{{ rotation.id }}</h2>
</div>
<div class="rotation-view-more-rotation-cards">
<div class="rotation-view-more-card">
<span class="rotation-view-more-label">Tempo no piquete</span>
<strong>{{ rotation.duration_display_day }} dias</strong>
</div>
<div class="rotation-view-more-card">
<span class="rotation-view-more-label">UGM-dia</span>
<strong>{{ rotation.livestock_unit_day }}</strong>
</div>
<div class="rotation-view-more-card">
<span class="rotation-view-more-label">Animais-dia</span>
<strong>{{ rotation.animal_count_day }}</strong>
</div>
</div>
<div class="rotation-view-more-rotation-section">
<h3>Informações</h3>
<div class="rotation-view-more-info-grid">
<div class="rotation-view-more-info">
<span>Lote</span>
<strong>{{ rotation.batch|default:"—" }}</strong>
</div>
<div class="rotation-view-more-info">
<span>Piquete</span>
<strong>{{ rotation.paddock }}</strong>
</div>
<div class="rotation-view-more-info">
<span>Entrada</span>
<strong>{{ rotation.entry_date|date:"d/m/Y H:i" }}</strong>
</div>
<div class="rotation-view-more-info">
<span>Animais</span>
<strong>{{ rotation.animal_count }}</strong>
</div>
<div class="rotation-view-more-info">
<span>UGM</span>
<strong>{{ rotation.livestock_unit }}</strong>
</div>
<div class="rotation-view-more-info">
<span>Consumo</span>
<strong>{{ consume }} Kg</strong>
</div>
</div>
</div>
<div class="rotation-view-more-rotation-next">
<h3>
<i class="fa-solid fa-rotate"></i>
Rotacionar lote
</h3>
<div class="rotation-next-actions">
{% if free_paddocks|length > 0 %}
<select name="next_paddock">
<option selected disabled>Selecione o próximo piquete</option>
{% for paddock in free_paddocks %}
<option value="{{ paddock.id }}">{{ paddock.paddock_number }} | {% if paddock.current_rest %}Descanço: {{ paddock.current_rest }} dias {% else %}nunca utilizado{% endif %}</option>
{% endfor %}
</select>
<button class="rotation-view-more-rotate">
<i class="fa-solid fa-rotate"></i>
Dar andamento
</button>
{% endif %}
<button class="rotation-view-finish-rotate">
<i class="fa-solid fa-clipboard-check"></i>
Encerrar movimentação
</button>
</div>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -17,7 +17,7 @@ urlpatterns = [
path('create/', views.farm_create, name='farm_create'), path('create/', views.farm_create, name='farm_create'),
path('update/<int:farm_id>', views.farm_update, name='farm_update'), path('update/<int:farm_id>', views.farm_update, name='farm_update'),
path('delete/<int:farm_id>', views.farm_delete, name='farm_delete'), path('delete/<int:farm_id>', views.farm_delete, name='farm_delete'),
path('consumption/update', views.consumption_update, name='consumption_update'), path('metrics/update', views.metrics_update, name='metrics_update'),
path('paddock/create/', views.paddock_create, name='paddock_create'), path('paddock/create/', views.paddock_create, name='paddock_create'),
path('paddock/update/<int:paddock_id>', views.paddock_update, name='paddock_update'), path('paddock/update/<int:paddock_id>', views.paddock_update, name='paddock_update'),
+2 -2
View File
@@ -7,7 +7,7 @@ app_name = 'rotation'
urlpatterns = [ urlpatterns = [
path('', RotationListView.as_view(), name='rotation_home'), path('', RotationListView.as_view(), name='rotation_home'),
path('create/', views.rotation_create, name='rotation_create'), path('create/', views.rotation_create, name='rotation_create'),
path('delete/<int:rotation_id>', views.rotation_delete, name='rotation_delete'), # path('delete/<int:rotation_id>', views.rotation_delete, name='rotation_delete'),
path('more/<int:rotation_id>', views.rotation_view_more, name='view_more'),
] ]
+12 -12
View File
@@ -9,26 +9,26 @@ from ..services import animal as services
def animal_create(request): def animal_create(request):
context = services.get_animal_create_context() context = services.get_animal_create_context()
if request.method == 'GET': if request.method == 'GET':
return render(request, 'animal/animal_create.html', context) return render(request, 'components/form_modal.html', context)
if request.method == 'POST': if request.method == 'POST':
if services.create_animal(request): if services.create_animal(request):
return redirect('entities:animal:animal_list') return redirect('entities:animal:animal_list')
context['error'] = "Não foi possível registrar o animal" context['error'] = "Não foi possível registrar o animal"
return render(request, 'animal/animal_create.html', context) return render(request, 'components/form_modal.html', context)
@farm_required @farm_required
def animal_update(request, animal_id): def animal_update(request, animal_id):
context = services.get_animal_update_context(request, animal_id) context = services.get_animal_update_context(request, animal_id)
if request.method == 'GET': if request.method == 'GET':
return render(request, 'animal/animal_update.html', context) return render(request, 'components/form_modal.html', context)
if request.method == 'POST': if request.method == 'POST':
if services.update_animal(request, animal_id): if services.update_animal(request, animal_id):
return redirect('entities:animal:animal_list') return redirect('entities:animal:animal_list')
context['error'] = "Não foi possível editar o animal" context['error'] = "Não foi possível editar o animal"
return render(request, 'animal/animal_update.html', context) return render(request, 'components/form_modal.html', context)
@farm_required @farm_required
@@ -48,26 +48,26 @@ def animal_delete(request, animal_id):
def batch_create(request): def batch_create(request):
context = services.get_batch_create_context(request) context = services.get_batch_create_context(request)
if request.method == 'GET': if request.method == 'GET':
return render(request, 'batch/batch_create.html', context) return render(request, 'components/form_modal.html', context)
if request.method == 'POST': if request.method == 'POST':
if services.create_batch(request): if services.create_batch(request):
return redirect('entities:animal:batch_list') return redirect('entities:animal:batch_list')
context['error'] = "Não foi possível criar o lote" context['error'] = "Não foi possível criar o lote"
return render(request, 'batch/batch_create.html', context) return render(request, 'components/form_modal.html', context)
@farm_required @farm_required
def batch_update(request, batch_id): def batch_update(request, batch_id):
context = services.get_batch_update_context(request, batch_id) context = services.get_batch_update_context(request, batch_id)
if request.method == 'GET': if request.method == 'GET':
return render(request, 'batch/batch_update.html', context) return render(request, 'components/form_modal.html', context)
if request.method == 'POST': if request.method == 'POST':
if services.update_batch(request, batch_id): if services.update_batch(request, batch_id):
return redirect('entities:animal:batch_list') return redirect('entities:animal:batch_list')
context['error'] = "Não foi possível editar o lote" context['error'] = "Não foi possível editar o lote"
return render(request, 'batch/batch_update.html', context) return render(request, 'components/form_modal.html', context)
@farm_required @farm_required
@@ -86,25 +86,25 @@ def batch_delete(request, batch_id):
def batchtype_create(request): def batchtype_create(request):
context = services.get_batchtype_create_context(request) context = services.get_batchtype_create_context(request)
if request.method == 'GET': if request.method == 'GET':
return render(request, 'batch/batchtype_create.html', context) return render(request, 'components/form_modal.html', context)
if request.method == 'POST': if request.method == 'POST':
if services.create_batchtype(request): if services.create_batchtype(request):
return redirect('entities:farm:farm_home') return redirect('entities:farm:farm_home')
context['error'] = "Não foi possível criar o tipo de lote" context['error'] = "Não foi possível criar o tipo de lote"
return render(request, 'batch/batchtype_create.html', context) return render(request, 'components/form_modal.html', context)
@farm_required @farm_required
def batchtype_update(request, batchtype_id): def batchtype_update(request, batchtype_id):
context = services.get_batchtype_update_context(request, batchtype_id) context = services.get_batchtype_update_context(request, batchtype_id)
if request.method == 'GET': if request.method == 'GET':
return render(request, 'batch/batchtype_update.html', context) return render(request, 'components/form_modal.html', context)
if request.method == 'POST': if request.method == 'POST':
if services.update_batchtype(request, batchtype_id): if services.update_batchtype(request, batchtype_id):
return redirect('entities:farm:farm_home') return redirect('entities:farm:farm_home')
context['error'] = "Não foi possível editar o tipo de lote" context['error'] = "Não foi possível editar o tipo de lote"
return render(request, 'batch/batchtype_update.html', context) return render(request, 'components/form_modal.html', context)
@farm_required @farm_required
def batchtype_delete(request, batchtype_id): def batchtype_delete(request, batchtype_id):
+6 -6
View File
@@ -9,17 +9,16 @@ def farm_home(request):
return render(request, 'farm/farm_home.html', context) return render(request, 'farm/farm_home.html', context)
@farm_required
def farm_create(request): def farm_create(request):
context = services.get_farm_create_context() context = services.get_farm_create_context()
if request.method == 'GET': if request.method == 'GET':
return render(request, 'farm/farm_create.html', context) return render(request, 'components/form_modal.html', context)
if request.method == 'POST': if request.method == 'POST':
if services.create_farm(request): if services.create_farm(request):
return redirect('entities:farm:farm_home') return redirect('entities:farm:farm_home')
context['error'] = "Não foi possível criar a fazenda" context['error'] = "Não foi possível criar a fazenda"
return render(request, 'farm/farm_create.html', context) return render(request, 'components/form_modal.html', context)
@farm_required @farm_required
def farm_update(request, farm_id): def farm_update(request, farm_id):
@@ -42,11 +41,12 @@ def farm_delete(request, farm_id):
if request.method == 'POST': if request.method == 'POST':
services.delete_farm(request, farm_id) services.delete_farm(request, farm_id)
return redirect('entities:farm:farm_create') return redirect('entities:farm:farm_home')
def consumption_update(request): @farm_required
def metrics_update(request):
if request.method=='POST': if request.method=='POST':
services.update_consumption(request) services.update_metrics(request)
return redirect('entities:farm:farm_home') return redirect('entities:farm:farm_home')
@@ -9,8 +9,9 @@ class BatchListView(BaseListView):
columns = [ columns = [
{"field": "identifier", "label": "Identificador"}, {"field": "identifier", "label": "Identificador"},
{"field": "animal_cathgory", "label": "Categoria"}, {"field": "animal_category", "label": "Categoria"},
{"field": "batch_type", "label": "Tipo"}, {"field": "batch_type", "label": "Tipo"},
{"field": "total_ugm", "label": "UGM"},
] ]
create_url = "entities:animal:batch_create" create_url = "entities:animal:batch_create"
@@ -9,8 +9,8 @@ class PaddockListView(BaseListView):
columns = [ columns = [
{"field": "paddock_number", "label": "Número"}, {"field": "paddock_number", "label": "Número"},
{"field": "occupied_as_text", "label": "Ocupado"}, {"field": "occupied_as_text", "label": "Ocupado"},
{"field": "area", "label": "Área"}, {"field": "area", "label": "Área (m²)"},
{"field": "load_capacity", "label": "Capacidade"}, # {"field": "load_capacity", "label": "Capacidade"},
] ]
create_url = "entities:farm:paddock_create" create_url = "entities:farm:paddock_create"
@@ -24,11 +24,11 @@ class RotationListView(BaseListView):
details_url = "entities:rotation:rotation_home" # Trocar pela url de detalhe details_url = "entities:rotation:rotation_home" # Trocar pela url de detalhe
filter_fields = ["id", "batch", "paddock"] filter_fields = ["id", "batch", "paddock"]
order_by = ["id"] # Orders from oldest to newest order_by = ["-id"]
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
self.open_rotations = queryset.filter(exit_date__isnull=True, paddock__farm=self.selected_farm) self.open_rotations = queryset.filter(exit_date__isnull=True, is_active=True, paddock__farm=self.selected_farm)
return super().get_queryset().filter(is_active=True, paddock__farm=self.selected_farm) return super().get_queryset().filter(is_active=True, paddock__farm=self.selected_farm)
+10 -6
View File
@@ -1,14 +1,18 @@
from django.shortcuts import render from django.shortcuts import render
from core.decorators import farm_required from core.decorators import farm_required
@farm_required from ..services import rotation as services
def rotation_home(request):
return render(request, 'rotation/rotation_home.html')
@farm_required @farm_required
def rotation_create(request): def rotation_create(request):
return render(request, 'rotation/rotation_create.html') context = services.get_rotation_create_context(request)
return render(request, 'components/form_modal.html', context)
# @farm_required
# def rotation_delete(request):
# return render(request, 'rotation/rotation_delete.html')
@farm_required @farm_required
def rotation_delete(request): def rotation_view_more(request, rotation_id):
return render(request, 'rotation/rotation_delete.html') context = services.get_view_more_context(request, rotation_id)
return render(request, 'rotation/rotation_view_more.html', context)
+12
View File
@@ -0,0 +1,12 @@
from django import forms
class ReportForm(forms.Form):
start_date = forms.DateField(
label='Data Inicial',
widget=forms.DateInput(attrs={'type': 'date'})
)
end_date = forms.DateField(
label='Data Final',
widget=forms.DateInput(attrs={'type': 'date'})
)
+40
View File
@@ -0,0 +1,40 @@
from django.contrib.staticfiles import finders
from io import BytesIO
from django.http import HttpResponse
from django.template.loader import get_template
from xhtml2pdf import pisa
def render_report_pdf(template_name, context, filename='relatorio.pdf'):
css_path = finders.find('report/css/report.css')
with open(css_path, encoding='utf-8') as f:
context['pdf_css'] = f.read()
template = get_template(template_name)
html = template.render(context)
pdf_buffer = BytesIO()
result = pisa.CreatePDF(
html,
dest=pdf_buffer,
encoding='utf-8'
)
if result.err:
return HttpResponse(
'Erro ao gerar PDF',
status=500
)
response = HttpResponse(
pdf_buffer.getvalue(),
content_type='application/pdf'
)
response['Content-Disposition'] = (
f'attachment; filename="{filename}"'
)
return response
+493
View File
@@ -0,0 +1,493 @@
from django.shortcuts import get_object_or_404
from django.urls import reverse
from entities.models import Farm, Rotation
from .forms import ReportForm
from datetime import timedelta
from django.utils import timezone
from collections import defaultdict
from decimal import Decimal
from datetime import datetime
from .utils import dumps_clean
from collections import defaultdict
def _get_selected_farm_id(request):
return request.session['selected_farm_id']
def _get_selected_farm(request):
return get_object_or_404(
Farm,
pk=_get_selected_farm_id(request),
is_active=True
)
def _get_farm_area(farm):
if farm.paddocks_area > 10000:
return f'{farm.paddocks_area / 10000} ha'
return f'{farm.paddocks_area} m²'
def _get_past_rotations(farm, days):
today = timezone.now()
one_year_ago = today - timedelta(days=days)
return Rotation.objects.filter(
is_active=True,
paddock__farm=farm,
entry_date__gte=one_year_ago,
exit_date__isnull=False,
)
def _group_paddock_rotations(rotations):
paddock_rotations = defaultdict(list)
for rotation in rotations:
if rotation.exit_date:
paddock_rotations[rotation.paddock_id].append(rotation)
for paddock_id, rotations_list in paddock_rotations.items():
rotations_list.sort(
key=lambda r: r.entry_date
)
return paddock_rotations
def _calculate_rest_periods(rotations):
paddock_rotations = _group_paddock_rotations(rotations)
rests = []
for paddock_id, rotations_list in paddock_rotations.items():
for i in range(len(rotations_list) - 1):
current_rotation = rotations_list[i]
next_rotation = rotations_list[i + 1]
rest_days = (
next_rotation.entry_date -
current_rotation.exit_date
).days
rests.append({
'paddock': current_rotation.paddock,
'days': rest_days,
'previous_rotation': current_rotation,
'next_rotation': next_rotation,
})
return rests
def _get_warnings(rotations, selected_farm):
ideal_occupation = selected_farm.ideal_paddock_occupation
ideal_rest = selected_farm.ideal_paddock_rest
rests = _calculate_rest_periods(rotations)
warnings = set()
for rotation in rotations:
occupation_days = (rotation.exit_date - rotation.entry_date).days
if occupation_days > ideal_occupation:
warnings.add(f'Ocupação acima do ideal (Piquete {rotation.paddock.paddock_number}, duração {occupation_days} dias)')
for rest in rests:
if rest['days'] < ideal_rest:
warnings.add(f'Descanso abaixo do ideal (Piquete {rest['paddock']}, duração {rest['days']} dias)')
return warnings
def _get_average_occupations(rotations):
total_count = rotations.count()
if total_count == 0:
return 0
total_sum = 0
for rotation in rotations:
total_sum += rotation.duration
return round(total_sum / total_count, 1)
def _get_average_rest(rotations):
rests = _calculate_rest_periods(rotations)
rests_count = len(rests)
if rests_count == 0:
return 0
rest_sum = 0
for rest in rests:
rest_sum += rest['days']
return round(rest_sum / rests_count, 2)
def _get_total_consume(rotations, selected_farm):
livestock_consume = 500 * (selected_farm.default_consumption / 100)
duration_factor = Decimal(-0.4946)
total_consume = 0
for rotation in rotations:
batchtype_factor = 1
if rotation.batch and rotation.batch.batch_type:
batchtype_factor = (
rotation.batch.batch_type.consumption_factor or 1
)
total_consume += livestock_consume * rotation.livestock_unit_day * batchtype_factor * rotation.duration ** duration_factor
return Decimal(round(total_consume / 1000, 2))
def _get_average_paddock_consumption(rotations, selected_farm):
total_consume = _get_total_consume(rotations, selected_farm)
paddock_count = (rotations.values('paddock').distinct().count())
if paddock_count == 0:
return 0
return Decimal(round(total_consume / paddock_count, 4))
def _get_total_produced(rotations, selected_farm):
total_consume = _get_total_consume(rotations, selected_farm)
production_factor = Decimal(1.35)
return Decimal(round(total_consume * production_factor, 2))
def _get_average_paddock_production(rotations, selected_farm):
total_produced = _get_total_produced(rotations, selected_farm)
paddock_count = (rotations.values('paddock').distinct().count())
if paddock_count == 0:
return 0
return Decimal(round(total_produced / paddock_count, 4))
def _get_average_hectare_production(rotations, selected_farm):
total_production = _get_total_produced(rotations, selected_farm)
total_hectares = Decimal(sum(rotation.paddock.area or 0 for rotation in rotations)) / Decimal('10000')
if total_hectares == 0:
return 0
return Decimal(round(total_production / total_hectares, 4))
def get_last_twelve_months():
MONTHS = [
"Jan", "Fev", "Mar", "Abr",
"Mai", "Jun", "Jul", "Ago",
"Set", "Out", "Nov", "Dez"
]
today = datetime.now()
months = []
for i in range(11, -1, -1):
month = today.month - i
year = today.year
while month <= 0:
month += 12
year -= 1
months.append(
f"{MONTHS[month - 1]}/{str(year)[2:]}"
)
return months
def _get_monthly_metric(farm, metric_function):
data = []
today = timezone.now()
for i in range(11, -1, -1):
month = today.month - i
year = today.year
while month <= 0:
month += 12
year -= 1
month_rotations = Rotation.objects.filter(
is_active=True,
paddock__farm=farm,
exit_date__isnull=False,
entry_date__year=year,
entry_date__month=month
)
data.append(
metric_function(month_rotations)
)
return data
def _get_monthly_average_rest(farm):
today = timezone.now()
rotations = _get_past_rotations(farm, 365)
rests = _calculate_rest_periods(rotations)
monthly_rests = defaultdict(list)
for rest in rests:
rest_month = rest['next_rotation'].entry_date.month
rest_year = rest['next_rotation'].entry_date.year
monthly_rests[(rest_year, rest_month)].append(
rest['days']
)
data = []
for i in range(11, -1, -1):
month = today.month - i
year = today.year
while month <= 0:
month += 12
year -= 1
values = monthly_rests.get(
(year, month),
[]
)
if values:
data.append(
round(sum(values) / len(values), 2)
)
else:
data.append(0)
return data
def get_dashboard_context(request):
farm = _get_selected_farm(request)
context_rotations = _get_past_rotations(farm, 365)
context = {}
context['title'] = 'Dashboard'
context['farm'] = _get_selected_farm(request)
context['farmcards'] = [
{'title': 'Animais', 'info': farm.animal_count},
{'title': 'UGM Total', 'info': farm.total_ugm},
{'title': 'Lotes', 'info': farm.batch_count},
{'title': 'Piquetes', 'info': farm.paddock_count},
{'title': 'Área dos Piquetes', 'info': _get_farm_area(farm)},
{'title': 'Movimentações Totais', 'info': farm.total_rotations},
]
context['warnings'] = _get_warnings(context_rotations, farm)
context['average_occupation'] = _get_average_occupations(context_rotations)
context['average_rest'] = _get_average_rest(context_rotations)
context['rotation_count'] = context_rotations.count()
context['total_consumed'] = _get_total_consume(context_rotations, farm)
context['average_paddock_consumption'] = _get_average_paddock_consumption(context_rotations, farm)
context['total_produced'] = _get_total_produced(context_rotations, farm)
context['average_paddock_production'] = _get_average_paddock_production(context_rotations, farm)
context['average_hectare_production'] = _get_average_hectare_production(context_rotations, farm)
# charts context
context['months'] = dumps_clean(get_last_twelve_months())
context['occupation'] = dumps_clean(_get_monthly_metric(farm, _get_average_occupations))
context['rest'] = dumps_clean(_get_monthly_average_rest(farm))
context['consume'] = dumps_clean(_get_monthly_metric(farm, lambda r: _get_total_consume(r, farm)))
context['production'] = dumps_clean(_get_monthly_metric(farm, lambda r: _get_total_produced(r, farm)))
return context
def get_request_report_context(request):
form = ReportForm()
return {
'form': form,
'action_url': reverse('report:create_report'),
'return_url': reverse('report:dashboard'),
'action_button_text' : 'Gerar',
}
def _get_rotations_between_dates(
farm,
start_date,
end_date
):
return Rotation.objects.filter(
is_active=True,
paddock__farm=farm,
entry_date__date__gte=start_date,
entry_date__date__lte=end_date,
exit_date__isnull=False
).select_related(
'paddock',
'batch',
'batch__batch_type'
)
def _get_rotation_consumption(
rotation,
farm
):
livestock_consume = (
500 * (farm.default_consumption / 100)
)
duration_factor = Decimal('-0.4946')
batchtype_factor = 1
if (
rotation.batch and
rotation.batch.batch_type
):
batchtype_factor = (
rotation.batch.batch_type.consumption_factor or 1
)
total = (
livestock_consume *
rotation.livestock_unit_day *
batchtype_factor *
rotation.duration ** duration_factor
)
return Decimal(
round(total, 4)
)
def _get_rotation_production(
rotation,
farm
):
return Decimal(
round(
_get_rotation_consumption(
rotation,
farm
) * Decimal('1.35'),
4
)
)
def _get_report_summary(
rotations,
farm
):
return {
'rotation_count': rotations.count(),
'average_occupation': _get_average_occupations(rotations),
'average_rest': _get_average_rest(rotations),
'total_consumed': _get_total_consume(rotations, farm),
'total_produced': _get_total_produced(rotations, farm),
'average_paddock_production':
_get_average_paddock_production(
rotations,
farm
),
'average_hectare_production':
_get_average_hectare_production(
rotations,
farm
)
}
def _get_paddock_report(
rotations,
farm
):
report = []
paddocks = (
rotations
.values_list(
'paddock_id',
flat=True
)
.distinct()
)
for paddock_id in paddocks:
paddock_rotations = rotations.filter(
paddock_id=paddock_id
)
paddock = paddock_rotations.first().paddock
report.append({
'paddock': paddock,
'area': paddock.area,
'rotation_count': paddock_rotations.count(),
'average_occupation': _get_average_occupations(paddock_rotations),
'average_rest': _get_average_rest(paddock_rotations),
'consumption': _get_total_consume(paddock_rotations,farm),
'production': _get_total_produced(paddock_rotations,farm),
})
return sorted(
report,
key=lambda x: x['paddock'].paddock_number
)
def _get_batch_report(rotations, farm):
report = []
batch_ids = (rotations.values_list('batch_id', flat=True).distinct())
for batch_id in batch_ids:
if batch_id is None:
continue
batch_rotations = rotations.filter(batch_id=batch_id)
batch = batch_rotations.first().batch
report.append({
'batch': batch,
'identifier': batch.identifier,
'animal_category': batch.animal_category,
'batch_type': (batch.batch_type.name if batch.batch_type else '-'),
'rotation_count': batch_rotations.count(),
'consumption':_get_total_consume( batch_rotations, farm),
'production':_get_total_produced( batch_rotations, farm),
})
return sorted(
report,
key=lambda x: x['identifier']
)
def _get_rotation_report(rotations, farm):
report = []
for rotation in rotations.order_by('entry_date'):
report.append({
'entry_date': rotation.entry_date,
'exit_date': rotation.exit_date,
'paddock': rotation.paddock,
'batch': rotation.batch,
'animal_count': rotation.animal_count,
'occupation': rotation.duration,
'livestock_unit_day': round(rotation.livestock_unit_day, 2),
'consumption': _get_rotation_consumption(rotation, farm),
'production': _get_rotation_production(rotation, farm)
})
return report
def get_report_context(request):
farm = _get_selected_farm(request)
form = ReportForm(request.POST)
if form.is_valid():
start_date = form.cleaned_data['start_date']
end_date = form.cleaned_data['end_date']
rotations = _get_rotations_between_dates(farm, start_date, end_date)
return {
'title': 'Relatório Completo',
'farm': farm,
'start_date': start_date,
'end_date': end_date,
'generated_at': timezone.now(),
'summary': _get_report_summary(rotations, farm),
'warnings': _get_warnings(rotations, farm),
'paddocks': _get_paddock_report(rotations, farm),
'batches': _get_batch_report(rotations, farm),
'rotations': _get_rotation_report(rotations, farm)
}
@@ -0,0 +1,261 @@
/* Header */
.dashboard-header{
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:30px;
}
.generate-report-button{
display:flex;
align-items:center;
gap:8px;
padding:12px 18px;
background:#2f855a;
color:white;
text-decoration:none;
border-radius:8px;
font-weight:600;
transition:0.2s;
}
.generate-report-button:hover{
background:#276749;
}
/* Sections */
.dashboard-farm,
.dashboard-warnings,
.dashboard-rotation,
.dashboard-consumption-production{
background:white;
border-radius:14px;
padding:25px;
margin-bottom:25px;
box-shadow:0 2px 10px rgba(0,0,0,.05);
}
.dashboard-subtitle{
font-size:24px;
font-weight:600;
}
.subtitle-footer{
margin-top:4px;
margin-bottom:20px;
color:#718096;
font-size:14px;
}
/* Farm */
.dashboard-farm-cards-container{
display:flex;
flex-wrap:nowrap;
gap:10px;
margin-top:20px;
}
.dashboard-farm-infocard{
flex:1 1 180px;
border:1px solid #e5e7eb;
border-radius:12px;
padding:15px;
transition:0.2s;
}
.dashboard-farm-infocard .title{
font-size:13px;
color:#6b7280;
text-wrap: nowrap;
}
.dashboard-farm-infocard .info{
font-size:28px;
font-weight:700;
margin-top:8px;
text-wrap: nowrap;
}
/* Warnings */
.dashboard-warnings .warning{
background:#fff7ed;
border-left:4px solid #f59e0b;
border-radius:8px;
padding:14px;
margin-top:12px;
}
.warning-description{
color:#92400e;
font-size:14px;
}
.dashboard-warnings .warnings-container {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease;
}
.dashboard-warnings.show .warnings-toggler {
transform: rotate(-180deg);
}
.warnings-toggler {
margin-left: 20px;
cursor: pointer;
}
.dashboard-warnings .warnings-toggler {
transform: rotate(0deg);
transition: transform 0.25s ease;
}
/* Rotations */
.dashboard-rotation-cards-container{
display:grid;
grid-template-columns:repeat(auto-fit,minmax(220px,1fr));
gap:15px;
margin-bottom:20px;
}
.rotation-info-card{
border:1px solid #e5e7eb;
border-radius:12px;
padding:18px;
}
.rotation-info-card .title{
color:#6b7280;
font-size:13px;
}
.rotation-info-card .info{
margin-top:8px;
font-size:28px;
font-weight:700;
}
.dashboard-rotation-graphs{
display:grid;
grid-template-columns:1fr 1fr;
gap:20px;
}
.average-rotation-month,
.average-rest-month{
border:1px solid #e5e7eb;
border-radius:12px;
padding:20px;
min-height:320px;
}
.graph-title{
font-size:16px;
font-weight:600;
margin-bottom:15px;
}
/* Consumption & Production */
.dashboard-consumption-production{
background:white;
border-radius:14px;
padding:25px;
margin-bottom:25px;
box-shadow:0 2px 10px rgba(0,0,0,.05);
}
.consumption-production-grid{
display:grid;
grid-template-columns:1fr 1fr;
gap:20px;
}
.dashboard-consumption-container,
.dashboard-production-container{
display:flex;
flex-direction:column;
gap:20px;
}
.consumption-card-container,
.production-card-container{
display:flex;
text-wrap: nowrap;
justify-content: space-between;
gap:15px;
}
.consumption-card,
.production-card{
border:1px solid #e5e7eb;
border-radius:12px;
padding:18px;
width: 100%;
}
.consumption-card .title,
.production-card .title{
color:#6b7280;
font-size:13px;
}
.consumption-card .info,
.production-card .info{
margin-top:8px;
font-size:28px;
font-weight:700;
}
.consumption-graph-container,
.production-graph-container{
width:100%;
flex:1;
}
.consumption-graph,
.production-graph{
border:1px solid #e5e7eb;
border-radius:12px;
padding:20px;
min-height:350px;
height:100%;
}
/* Responsive */
@media(max-width:1100px){
.dashboard-farm-cards-container{
flex-wrap:wrap;
}
.dashboard-farm-infocard {
flex: 1 0 calc(50% - 10px);
}
.dashboard-rotation-graphs{
grid-template-columns:1fr;
}
.consumption-production-grid{
grid-template-columns:1fr;
}
.consumption-card-container,
.production-card-container{
flex-wrap: wrap;
}
}
@media(max-width:768px){
.dashboard-header{
flex-direction:column;
align-items:flex-start;
gap:15px;
}
.generate-report-button{
width:100%;
justify-content:center;
}
}
@@ -0,0 +1,64 @@
@page {
size: A4;
margin: 2cm;
}
body {
font-family: Arial, sans-serif;
font-size: 11px;
color: #222;
}
h1, h2, h3 {
margin-bottom: 8px;
}
h1 {
text-align: center;
}
h2 {
border-bottom: 1px solid #ccc;
padding-bottom: 4px;
margin-top: 24px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
margin-bottom: 16px;
}
th, td {
border: 1px solid #ccc;
padding: 3px;
}
th {
background: #f2f2f2;
text-align: left;
}
.text-center {
text-align: center;
}
.cover {
margin-top: 180px;
text-align: center;
}
.cover-title {
font-size: 28px;
font-weight: bold;
}
.cover-subtitle {
font-size: 16px;
margin-top: 20px;
}
ul {
margin-top: 4px;
}
@@ -0,0 +1,5 @@
.request-report-title {
font-size: 22px;
margin-top: 10px;
margin-left: 10px;
}
@@ -0,0 +1,49 @@
document.addEventListener("DOMContentLoaded", function () {
if (!window.months) return;
new Chart(document.getElementById("occupation-chart"), {
type: "line",
data: {
labels: months,
datasets: [{ data: occupation, label: "Ocupação média (dias)", borderWidth: 2, tension: 0.3 }]
},
options: {
scales: {
y: {
beginAtZero: true
}
}
}
});
new Chart(document.getElementById("rest-chart"), {
type: "line",
data: {
labels: months,
datasets: [{ data: rest, label: "Repouso médio (dias)", borderWidth: 2, tension: 0.3 }]
},
options: {
scales: {
y: {
beginAtZero: true
}
}
}
});
new Chart(document.getElementById("consume-chart"), {
type: "bar",
data: {
labels: months,
datasets: [{ data: consume, label: "Consumo (Mg)", borderWidth: 1 }]
}
});
new Chart(document.getElementById("production-chart"), {
type: "bar",
data: {
labels: months,
datasets: [{ data: production, label: "Produção (Mg)", borderWidth: 1 }]
}
});
});
@@ -0,0 +1,18 @@
// Warnings Toggle
document.addEventListener("DOMContentLoaded", () => {
const toggler = document.getElementById("warnings-toggler");
const container = document.getElementById("dashboard-warnings");
const warningsBox = container.querySelector(".warnings-container");
if (!toggler) return;
toggler.addEventListener("click", () => {
container.classList.toggle("show");
if (container.classList.contains("show")) {
warningsBox.style.maxHeight = warningsBox.scrollHeight + "px";
} else {
warningsBox.style.maxHeight = "0px";
}
});
});
@@ -0,0 +1,130 @@
{% extends 'layouts/base.html' %}
{% load static %}
{% block extra_css %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'report/css/dashboard.css' %}">
{% endblock extra_css %}
{% block content %}
<div class="dashboard-header">
<h1 class="page-title">Painel</h1>
<a hx-get="{% url "report:request_report" %}" hx-swap="beforeend" hx-target="body" class="generate-report-button"><i class="fa-solid fa-file-arrow-down"></i> Exportar relatório completo</a>
</div>
<div class="dashboard-farm">
<h2 class="dashboard-subtitle">{{ farm.name }}</h2>
<div class="dashboard-farm-cards-container">
{% for card in farmcards %}
<div class="dashboard-farm-infocard">
<p class="title">{{ card.title }}</p>
<p class="info">{{ card.info }}</p>
</div>
{% endfor %}
</div>
</div>
{% if warnings %}
<div class="dashboard-warnings" id="dashboard-warnings">
<h2 class="dashboard-subtitle"><i class="fa-solid fa-triangle-exclamation"></i> Avisos <i class="fa-solid fa-angle-down warnings-toggler" id="warnings-toggler"></i></h2>
<p class="subtitle-footer">Últimos 12 meses</p>
<div class="warnings-container">
{% for warning in warnings %}
<div class="warning">
<p class="warning-description">{{ warning }}</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="dashboard-rotation">
<h2 class="dashboard-subtitle"><i class="fa-solid fa-arrows-spin"></i> Movimentações</h2>
<p class="subtitle-footer">Últimos 12 meses</p>
<div class="dashboard-rotation-cards-container">
<div class="rotation-info-card">
<p class="title">Tempo de Ocupação Médio</p>
<p class="info">{{ average_occupation }} dias</p>
</div>
<div class="rotation-info-card">
<p class="title">Tempo de Repouso Médio</p>
<p class="info">{{ average_rest }} dias</p>
</div>
<div class="rotation-info-card">
<p class="title">Movimentações realizadas</p>
<p class="info">{{ rotation_count }}</p>
</div>
</div>
<div class="dashboard-rotation-graphs">
<div class="average-rotation-month">
<p class="graph-title">Média de ocupação por mês</p>
<canvas id="occupation-chart"></canvas>
</div>
<div class="average-rest-month">
<p class="graph-title">Média de repouso por mês</p>
<canvas id="rest-chart"></canvas>
</div>
</div>
</div>
<div class="dashboard-consumption-production">
<h2 class="dashboard-subtitle"><i class="fa-solid fa-seedling"></i> Consumo e Produção</h2>
<p class="subtitle-footer">Últimos 12 meses</p>
<div class="consumption-production-grid">
<div class="dashboard-consumption-container">
<div class="consumption-card-container">
<div class="consumption-card">
<p class="title">Consumo Total</p>
<p class="info">{{ total_consumed }} Mg</p>
</div>
<div class="consumption-card">
<p class="title">Consumo por piquete</p>
<p class="info">{{ average_paddock_consumption }} Mg</p>
</div>
</div>
<div class="consumption-graph-container">
<div class="consumption-graph">
<p class="graph-title">Média mensal de consumo por piquete</p>
<canvas id="consume-chart"></canvas>
</div>
</div>
</div>
<div class="dashboard-production-container">
<div class="production-card-container">
<div class="production-card">
<p class="title">Produção Total</p>
<p class="info">{{ total_produced }} Mg</p>
</div>
<div class="production-card">
<p class="title">Produção por piquete</p>
<p class="info">{{ average_paddock_production }} Mg</p>
</div>
<div class="production-card">
<p class="title">Produção por hectare</p>
<p class="info">{{ average_hectare_production }} Mg</p>
</div>
</div>
<div class="production-graph-container">
<div class="production-graph">
<p class="graph-title">Média mensal de produção por piquete</p>
<canvas id="production-chart"></canvas>
</div>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block extra_js %}
{{ block.super }}
<script>
window.months = {{ months|safe }};
window.occupation = {{ occupation|safe }};
window.rest = {{ rest|safe }};
window.consume = {{ consume|safe }};
window.production = {{ production|safe }};
</script>
<script src="{% static "report/js/dashboard.js" %}"></script>
<script src="{% static "report/js/chart_creation.js" %}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endblock extra_js %}
@@ -0,0 +1,157 @@
<!DOCTYPE html>
</html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<title>Relatório Completo</title>
<style>
{{ pdf_css|safe }}
</style>
</head>
<body>
<div class="cover">
<div class="cover-title">Relatório Completo de Pastejo</div>
<div class="cover-subtitle">{{ farm.name }}</div>
<p>Período analisado: {{ start_date }} até {{ end_date }}</p>
<p>Emitido em: {{ generated_at }}</p>
</div>
<h2>Resumo Geral</h2>
<table>
<tr>
<th>Indicador</th>
<th>Valor</th>
</tr>
<tr>
<td>Movimentações</td>
<td>{{ summary.rotation_count }}</td>
</tr>
<tr>
<td>Tempo médio de ocupação</td>
<td>{{ summary.average_occupation }} dias</td>
</tr>
<tr>
<td>Tempo médio de repouso</td>
<td>{{ summary.average_rest }} dias</td>
</tr>
<tr>
<td>Consumo total</td>
<td>{{ summary.total_consumed }} Mg</td>
</tr>
<tr>
<td>Produção total</td>
<td>{{ summary.total_produced }} Mg</td>
</tr>
<tr>
<td>Produção média por piquete</td>
<td>{{ summary.average_paddock_production }} Mg</td>
</tr>
<tr>
<td>Produção média por hectare</td>
<td>{{ summary.average_hectare_production }} Mg/ha</td>
</tr>
</table>
<h2>Avisos</h2>
{% if warnings %}
<ul>
{% for warning in warnings %}
<li>{{ warning }}</li>
{% endfor %}
</ul>
{% else %}
<p>Nenhum aviso encontrado para o período analisado.</p>
{% endif %}
<h2>Indicadores por Piquete</h2>
<table>
<tr>
<th>Piquete</th>
<th>Área (m²)</th>
<th>Mov.</th>
<th>TO Médio</th>
<th>TR Médio</th>
<th>Consumo (Mg)</th>
<th>Produção (Mg)</th>
</tr>
{% for paddock in paddocks %}
<tr>
<td>{{ paddock.paddock.paddock_number }}</td>
<td>{{ paddock.area }}</td>
<td>{{ paddock.rotation_count }}</td>
<td>{{ paddock.average_occupation }}</td>
<td>{{ paddock.average_rest }}</td>
<td>{{ paddock.consumption }}</td>
<td>{{ paddock.production }}</td>
</tr>
{% endfor %}
</table>
<h2>Indicadores por Lote</h2>
<table>
<tr>
<th>Lote</th>
<th>Categoria</th>
<th>Tipo</th>
<th>Movimentações</th>
<th>Consumo (Mg)</th>
<th>Produção (Mg)</th>
</tr>
{% for batch in batches %}
<tr>
<td>{{ batch.identifier }}</td>
<td>{{ batch.animal_category }}</td>
<td>{{ batch.batch_type }}</td>
<td>{{ batch.rotation_count }}</td>
<td>{{ batch.consumption }}</td>
<td>{{ batch.production }}</td>
</tr>
{% endfor %}
</table>
<h2>Movimentações</h2>
<table>
<tr>
<th>Entrada</th>
<th>Saída</th>
<th>Piquete</th>
<th>Lote</th>
<th>Animais</th>
<th>UGM-dia</th>
<th>Ocupação (dias)</th>
<th>Consumo (Kg)</th>
<th>Produção (Kg)</th>
</tr>
{% for rotation in rotations %}
<tr>
<td>{{ rotation.entry_date|date:"d/m/y H:i" }}</td>
<td>{{ rotation.exit_date|date:"d/m/y H:i" }}</td>
<td>{{ rotation.paddock.paddock_number }}</td>
<td>{{ rotation.batch }}</td>
<td>{{ rotation.animal_count }}</td>
<td>{{ rotation.livestock_unit_day }}</td>
<td>{{ rotation.occupation }}</td>
<td>{{ rotation.consumption }}</td>
<td>{{ rotation.production }}</td>
</tr>
{% endfor %}
</table>
<h2>Metodologia</h2>
<p>Os indicadores apresentados neste relatório foram calculados a partir dos registros de movimentação dos lotes entre os piquetes.</p>
<p>O consumo de pastagem foi estimado considerando uma Unidade de Gado Maior equivalente a 500 kg de peso vivo e um consumo diário de matéria seca correspondente a {{ farm.default_consumption }}% do peso vivo.</p>
<p>Os valores de consumo foram corrigidos conforme o tipo de lote e ajustados pelo tempo de permanência utilizando fator exponencial baseado na quarta lei do Pastoreio Racional Voisin.</p>
<p>A produção de forragem foi estimada pela aplicação de um fator de correção de 1,35 sobre o consumo estimado, representando perdas por pisoteio, material não consumido e demais fatores inerentes ao processo de pastejo.</p>
</body>
</html>
@@ -0,0 +1,7 @@
{% extends 'layouts/base_modal.html' %}
{% load static %}
{% block content %}
<h1 class="request-report-title">Gerar relatório</h1>
{% include 'components/form.html' %}
{% endblock %}
+5 -7
View File
@@ -1,13 +1,11 @@
from django.urls import path from django.urls import path
from django.http import HttpResponse
from django.contrib.auth.decorators import login_required from . import views
app_name = 'report' app_name = 'report'
@login_required(login_url='/user/login/')
def home(request):
return HttpResponse("Home app page")
urlpatterns = [ urlpatterns = [
path('', home), path('', views.dashboard, name='dashboard'),
path("request/", views.request_report, name="request_report"),
path('report/', views.create_report, name='create_report'),
] ]
+16
View File
@@ -0,0 +1,16 @@
import json
from decimal import Decimal
from datetime import date, datetime
def json_safe(value):
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
return value
def dumps_clean(data):
return json.dumps(
data,
default=json_safe
)
+19 -2
View File
@@ -1,3 +1,20 @@
from django.shortcuts import render from django.shortcuts import render, redirect
from . import services
from .pdf import render_report_pdf
# Create your views here. def dashboard(request):
context = services.get_dashboard_context(request)
return render(request, 'report/dashboard.html', context)
def request_report(request):
context = services.get_request_report_context(request)
return render(request, 'report/request_report.html', context)
def create_report(request):
if request.method != 'POST':
return redirect('report:dashboard')
context = services.get_report_context(request)
return render_report_pdf('report/report_pdf.html', context, filename='relatorio.pdf')
# return render(request, 'report/report_pdf.html', context)
+1
View File
@@ -26,6 +26,7 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'import_export',
# My apps # My apps
'core', 'core',
'entities', 'entities',
BIN
View File
Binary file not shown.