diff --git a/shiftflow/popup_views.py b/shiftflow/popup_views.py new file mode 100644 index 0000000..24d7f6a --- /dev/null +++ b/shiftflow/popup_views.py @@ -0,0 +1,171 @@ +from django import forms +from django.contrib.auth.mixins import LoginRequiredMixin +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.views.generic import CreateView, UpdateView + +from warehouse.models import Material, MaterialCategory, SteelGrade + +from .models import Company, Deal + + +class _PopupRoleMixin(LoginRequiredMixin): + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, "profile", None) + role = profile.role if profile else ("admin" if request.user.is_superuser else "operator") + if role not in ["admin", "technologist"]: + return redirect("registry") + return super().dispatch(request, *args, **kwargs) + + def get_target(self): + return (self.request.GET.get("target") or self.request.POST.get("target") or "").strip() + + def form_valid(self, form): + self.object = form.save() + return TemplateResponse( + self.request, + "shiftflow/popup_done.html", + {"target": self.get_target(), "value": self.object.pk, "label": self.get_popup_label()}, + ) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["target"] = self.get_target() + return ctx + + def get_popup_label(self): + return str(self.object) + + +class _BootstrapModelForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for name, field in self.fields.items(): + widget = field.widget + if isinstance(widget, (forms.Select, forms.SelectMultiple)): + cls = "form-select border-secondary" + elif isinstance(widget, forms.CheckboxInput): + cls = "form-check-input" + else: + cls = "form-control border-secondary" + widget.attrs["class"] = cls + + +class DealForm(_BootstrapModelForm): + class Meta: + model = Deal + fields = ["number", "status", "company", "description"] + + +class CompanyForm(_BootstrapModelForm): + class Meta: + model = Company + fields = ["name", "description"] + + +class MaterialForm(_BootstrapModelForm): + class Meta: + model = Material + fields = ["category", "steel_grade", "name"] + + +class MaterialCategoryForm(_BootstrapModelForm): + class Meta: + model = MaterialCategory + fields = ["name", "gost_standard"] + + +class SteelGradeForm(_BootstrapModelForm): + class Meta: + model = SteelGrade + fields = ["name", "gost_standard"] + + +class DealPopupCreateView(_PopupRoleMixin, CreateView): + template_name = "shiftflow/popup_form.html" + model = Deal + form_class = DealForm + + def get_popup_label(self): + return self.object.number + + +class DealPopupUpdateView(_PopupRoleMixin, UpdateView): + template_name = "shiftflow/popup_form.html" + model = Deal + form_class = DealForm + + def get_popup_label(self): + return self.object.number + + +class CompanyPopupCreateView(_PopupRoleMixin, CreateView): + template_name = "shiftflow/popup_form.html" + model = Company + form_class = CompanyForm + + def get_popup_label(self): + return self.object.name + + +class CompanyPopupUpdateView(_PopupRoleMixin, UpdateView): + template_name = "shiftflow/popup_form.html" + model = Company + form_class = CompanyForm + + def get_popup_label(self): + return self.object.name + + +class MaterialPopupCreateView(_PopupRoleMixin, CreateView): + template_name = "shiftflow/popup_form.html" + model = Material + form_class = MaterialForm + + def get_popup_label(self): + return self.object.full_name + + +class MaterialPopupUpdateView(_PopupRoleMixin, UpdateView): + template_name = "shiftflow/popup_form.html" + model = Material + form_class = MaterialForm + + def get_popup_label(self): + return self.object.full_name + + +class MaterialCategoryPopupCreateView(_PopupRoleMixin, CreateView): + template_name = "shiftflow/popup_form.html" + model = MaterialCategory + form_class = MaterialCategoryForm + + def get_popup_label(self): + return self.object.name + + +class MaterialCategoryPopupUpdateView(_PopupRoleMixin, UpdateView): + template_name = "shiftflow/popup_form.html" + model = MaterialCategory + form_class = MaterialCategoryForm + + def get_popup_label(self): + return self.object.name + + +class SteelGradePopupCreateView(_PopupRoleMixin, CreateView): + template_name = "shiftflow/popup_form.html" + model = SteelGrade + form_class = SteelGradeForm + + def get_popup_label(self): + return self.object.name + + +class SteelGradePopupUpdateView(_PopupRoleMixin, UpdateView): + template_name = "shiftflow/popup_form.html" + model = SteelGrade + form_class = SteelGradeForm + + def get_popup_label(self): + return self.object.name \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/customer_deals.html b/shiftflow/templates/shiftflow/customer_deals.html new file mode 100644 index 0000000..2740cd3 --- /dev/null +++ b/shiftflow/templates/shiftflow/customer_deals.html @@ -0,0 +1,158 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+

Сделки

+
{{ company.name }}
+
+ +
+ Статус: +
+ + + + + + + + +
+
+
+ +
+ + + К заказчикам + +
+
+ +
+
+ + + + + + + + + + {% for d in deals %} + + + + + + {% empty %} + + {% endfor %} + +
СделкаОписаниеСтатус
{{ d.number }}{{ d.description|default:"" }} + + {{ d.get_status_display }} + +
Сделок не найдено
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/customers.html b/shiftflow/templates/shiftflow/customers.html new file mode 100644 index 0000000..3b0ee0f --- /dev/null +++ b/shiftflow/templates/shiftflow/customers.html @@ -0,0 +1,115 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Заказчики

+ +
+ +
+
+ + + + + + + + + {% for c in companies %} + + + + + {% empty %} + + {% endfor %} + +
ЗаказчикПримечание
{{ c.name }}{{ c.description|default:"" }}
Заказчиков не найдено
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/item_detail.html b/shiftflow/templates/shiftflow/item_detail.html index 397f7c8..523ad3a 100644 --- a/shiftflow/templates/shiftflow/item_detail.html +++ b/shiftflow/templates/shiftflow/item_detail.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load l10n %} {% block content %}
@@ -72,13 +73,29 @@
- +
{% else %}
Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.
+ {% if user_role == 'master' %} +
+
+ + +
+
+ + +
+
+ + +
+
+ {% endif %} {% endif %} {% endif %} @@ -124,7 +141,7 @@
- +
diff --git a/shiftflow/templates/shiftflow/partials/_filter.html b/shiftflow/templates/shiftflow/partials/_filter.html index 471a8b0..6fa6dbb 100644 --- a/shiftflow/templates/shiftflow/partials/_filter.html +++ b/shiftflow/templates/shiftflow/partials/_filter.html @@ -73,14 +73,63 @@ diff --git a/shiftflow/templates/shiftflow/planning.html b/shiftflow/templates/shiftflow/planning.html index 086c4fd..d6bdeec 100644 --- a/shiftflow/templates/shiftflow/planning.html +++ b/shiftflow/templates/shiftflow/planning.html @@ -4,7 +4,7 @@
-

Планирование

+

Сделки

Сделки:
@@ -134,6 +134,21 @@ document.addEventListener('DOMContentLoaded', function () { }); }); + const statusRadios = document.querySelectorAll('input[name="status"]'); + const qs = new URLSearchParams(window.location.search); + if (!qs.get('status')){ + try{ + const saved = localStorage.getItem('planning_status'); + if (saved){ + const r = Array.from(statusRadios).find(x=>x.value===saved); + if (r && !r.checked){ r.checked = true; r.form.submit(); } + } + }catch(_){ } + } + statusRadios.forEach(r=> r.addEventListener('change', ()=>{ + try{ localStorage.setItem('planning_status', r.value); }catch(_){ } + })); + const dealModal = document.getElementById('dealModal'); const dealId = document.getElementById('dealId'); const dealNumber = document.getElementById('dealNumber'); diff --git a/shiftflow/templates/shiftflow/planning_deal.html b/shiftflow/templates/shiftflow/planning_deal.html index 9d7e504..a6f910c 100644 --- a/shiftflow/templates/shiftflow/planning_deal.html +++ b/shiftflow/templates/shiftflow/planning_deal.html @@ -20,7 +20,7 @@ Назад - + Добавить деталь
@@ -43,7 +43,7 @@ {% for t in tasks %} - + {{ t.drawing_name|default:"Б/ч" }} {{ t.material.full_name|default:t.material.name }} {{ t.size_value }} @@ -88,12 +88,13 @@
- - + {% endfor %} - +
@@ -113,10 +114,18 @@ diff --git a/shiftflow/templates/shiftflow/popup_done.html b/shiftflow/templates/shiftflow/popup_done.html new file mode 100644 index 0000000..5e04ed7 --- /dev/null +++ b/shiftflow/templates/shiftflow/popup_done.html @@ -0,0 +1,20 @@ + + + + + + Сохранено + + + + + \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/popup_form.html b/shiftflow/templates/shiftflow/popup_form.html new file mode 100644 index 0000000..b66781d --- /dev/null +++ b/shiftflow/templates/shiftflow/popup_form.html @@ -0,0 +1,46 @@ + + + + + + {{ view.model._meta.verbose_name }} + + + +
+
{{ view.model._meta.verbose_name }}
+ +
+ + + {% csrf_token %} + + + {% if form.non_field_errors %} +
+ {% for e in form.non_field_errors %}
{{ e }}
{% endfor %} +
+ {% endif %} + + {% for field in form %} +
+ + {% if field.field.widget.input_type == "checkbox" %} +
+ {{ field }} +
+ {% else %} + {{ field }} + {% endif %} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for e in field.errors %}
{{ e }}
{% endfor %} +
+ {% endfor %} + +
+ + +
+ + + \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/task_create.html b/shiftflow/templates/shiftflow/task_create.html index 496cd84..6a381cd 100644 --- a/shiftflow/templates/shiftflow/task_create.html +++ b/shiftflow/templates/shiftflow/task_create.html @@ -6,13 +6,20 @@

Новое задание

- - Назад - + {% if request.GET.next %} + + Назад + + {% else %} + + Назад + + {% endif %}
{% csrf_token %} + {% if form.non_field_errors %}
diff --git a/shiftflow/templates/shiftflow/task_items.html b/shiftflow/templates/shiftflow/task_items.html new file mode 100644 index 0000000..c8fea4b --- /dev/null +++ b/shiftflow/templates/shiftflow/task_items.html @@ -0,0 +1,92 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+

+ {{ task.drawing_name|default:"Б/ч" }} +

+
+ Сделка {{ task.deal.number }}{% if task.deal.company %} · {{ task.deal.company.name }}{% endif %} +
+
+ + Назад к сделке + +
+ +
+
+ + + + + + + + + + + + + + + + + {% for it in items %} + + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаСделкаСтанокНаименованиеГабаритыПлан / ФактМатериалФайлыСтатус
{{ it.date|date:"d.m.y" }}{{ it.task.deal.number|default:"-" }}{{ it.machine.name }}{{ it.task.drawing_name|default:"Б/ч" }}{{ it.task.size_value|default:"-" }} + {{ it.quantity_plan }} / + {{ it.quantity_fact }} + {{ it.task.material.full_name|default:it.task.material.name|default:"-" }} + {% if it.task.drawing_file %} + + + + {% endif %} + {% if it.task.extra_drawing %} + + + + {% endif %} + + {% if it.is_synced_1c %} + + {% else %} + + {% endif %} + + {{ it.get_status_display }} +
Пунктов сменки не найдено
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/urls.py b/shiftflow/urls.py index 8263a24..5807423 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -1,6 +1,8 @@ from django.urls import path from .views import ( CompanyUpsertView, + CustomerDealsView, + CustomersView, DealDetailView, DealPlanningView, DealUpsertView, @@ -15,6 +17,7 @@ from .views import ( RegistryPrintView, RegistryView, SteelGradeUpsertView, + TaskItemsView, ) urlpatterns = [ @@ -23,9 +26,12 @@ urlpatterns = [ # Реестр path('registry/', RegistryView.as_view(), name='registry'), - # Планирование + # Сделки path('planning/', PlanningView.as_view(), name='planning'), path('planning/deal//', DealPlanningView.as_view(), name='planning_deal'), + path('planning/task//items/', TaskItemsView.as_view(), name='task_items'), + path('customers/', CustomersView.as_view(), name='customers'), + path('customers//', CustomerDealsView.as_view(), name='customer_deals'), path('planning/add/', PlanningAddView.as_view(), name='planning_add'), path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'), path('planning/deal//json/', DealDetailView.as_view(), name='deal_json'), diff --git a/shiftflow/views.py b/shiftflow/views.py index 7f1e3f2..f6ceb93 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -286,6 +286,83 @@ class DealPlanningView(LoginRequiredMixin, TemplateView): return context +class TaskItemsView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/task_items.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + context['user_role'] = role + + task = get_object_or_404( + ProductionTask.objects.select_related('deal', 'deal__company', 'material'), + pk=self.kwargs['pk'], + ) + context['task'] = task + + items = Item.objects.filter(task=task).select_related('machine').order_by('-date', 'machine__name', '-id') + context['items'] = items + return context + + +class CustomersView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/customers.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + context['user_role'] = role + + companies = Company.objects.all().order_by('name') + context['companies'] = companies + return context + + +class CustomerDealsView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/customer_deals.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + if role not in ['admin', 'technologist']: + return redirect('registry') + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + profile = getattr(self.request.user, 'profile', None) + role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') + context['user_role'] = role + + company = get_object_or_404(Company, pk=self.kwargs['pk']) + context['company'] = company + + status = (self.request.GET.get('status') or 'work').strip() + allowed = {k for k, _ in Deal.STATUS_CHOICES} + if status not in allowed: + status = 'work' + + context['selected_status'] = status + context['deals'] = Deal.objects.select_related('company').filter(company=company, status=status).order_by('-id') + return context + + class PlanningAddView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) @@ -366,7 +443,12 @@ class ProductionTaskCreateView(LoginRequiredMixin, FormView): task.extra_drawing = form.cleaned_data['extra_drawing'] task.save() - return super().form_valid(form) + + next_url = (self.request.POST.get('next') or '').strip() + if next_url.startswith('/'): + return redirect(next_url) + + return redirect('planning_deal', pk=task.deal_id) class DealDetailView(LoginRequiredMixin, View): @@ -608,20 +690,24 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): return redirect('registry') if role in ['operator', 'master']: - if self.object.status != 'work': - return redirect('registry') - material_taken = (request.POST.get('material_taken') or '').strip() usable_waste = (request.POST.get('usable_waste') or '').strip() scrap_weight_raw = (request.POST.get('scrap_weight') or '').strip() + status = request.POST.get('status', self.object.status) + + # Разрешаем мастеру редактировать операторские поля всегда, + # оператору — только в процессе закрытия + if role == 'operator' and self.object.status != 'work': + return redirect('registry') errors = [] - if not material_taken: - errors.append('Заполни поле "Взятый материал"') - if not usable_waste: - errors.append('Заполни поле "Остаток ДО"') - if scrap_weight_raw == '': - errors.append('Заполни поле "Лом (кг)" (можно 0)') + if role == 'operator' and self.object.status == 'work': + if not material_taken: + errors.append('Заполни поле "Взятый материал"') + if not usable_waste: + errors.append('Заполни поле "Остаток ДО"') + if scrap_weight_raw == '': + errors.append('Заполни поле "Лом (кг)" (можно 0)') scrap_weight = None if scrap_weight_raw != '': @@ -630,25 +716,26 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): except ValueError: errors.append('Поле "Лом (кг)" должно быть числом') - status = request.POST.get('status', self.object.status) - if errors: context = self.get_context_data() context['errors'] = errors return self.render_to_response(context) - self.object.material_taken = material_taken - self.object.usable_waste = usable_waste + if material_taken: + self.object.material_taken = material_taken + if usable_waste: + self.object.usable_waste = usable_waste if scrap_weight is not None: self.object.scrap_weight = scrap_weight - if status == 'done': + # Логика закрытия доступна и мастеру, и оператору, но только из 'work' + if self.object.status == 'work' and status == 'done': self.object.quantity_fact = self.object.quantity_plan self.object.status = 'done' self.object.save() return redirect('registry') - if status == 'partial': + if self.object.status == 'work' and status == 'partial': try: fact = int(request.POST.get('quantity_fact', '0')) except ValueError: @@ -677,6 +764,8 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): return redirect('registry') + # Если статус не менялся (или не 'work'), просто сохраняем поля + self.object.save(update_fields=['material_taken', 'usable_waste', 'scrap_weight']) return redirect('registry') if role == 'clerk': diff --git a/templates/components/_navbar.html b/templates/components/_navbar.html index f1c9467..58e71fb 100644 --- a/templates/components/_navbar.html +++ b/templates/components/_navbar.html @@ -17,7 +17,10 @@ {% if user_role in 'admin,technologist' %} + {% endif %}