diff --git a/shiftflow/templates/shiftflow/customer_deals.html b/shiftflow/templates/shiftflow/customer_deals.html index 2740cd3..2de318e 100644 --- a/shiftflow/templates/shiftflow/customer_deals.html +++ b/shiftflow/templates/shiftflow/customer_deals.html @@ -25,9 +25,11 @@
+ {% if user_role in 'admin,technologist' %} + {% endif %} К заказчикам diff --git a/shiftflow/templates/shiftflow/customers.html b/shiftflow/templates/shiftflow/customers.html index 3b0ee0f..70a8149 100644 --- a/shiftflow/templates/shiftflow/customers.html +++ b/shiftflow/templates/shiftflow/customers.html @@ -4,9 +4,11 @@

Заказчики

+ {% if user_role in 'admin,technologist' %} + {% endif %}
diff --git a/shiftflow/templates/shiftflow/item_detail.html b/shiftflow/templates/shiftflow/item_detail.html index 1be7a51..bb1d5d6 100644 --- a/shiftflow/templates/shiftflow/item_detail.html +++ b/shiftflow/templates/shiftflow/item_detail.html @@ -7,12 +7,27 @@
-

{{ item.task.drawing_name|default:"Без названия" }}

- Сделка № {{ item.task.deal.number }} +

+ {% if user_role == 'operator' %} + {{ item.task.drawing_name|default:"Без названия" }} + {% else %} + + {{ item.task.drawing_name|default:"Без названия" }} + + {% endif %} +

+ {% if user_role == 'operator' %} + Сделка № {{ item.task.deal.number }} + {% else %} + + Сделка № {{ item.task.deal.number }} + + {% endif %}
{% csrf_token %} + {% if errors %}
@@ -55,22 +70,12 @@ {% if user_role in 'operator,master' %} {% if item.status == 'work' %}
-
Закрыть задание:
-
- - -
+
+
+ + +
-
- - -
- -
@@ -187,38 +192,25 @@ {% endif %}
- Назад - + Назад +
+ + {% if item.status == 'work' %} + + + {% endif %} + +
- {% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/partials/_filter.html b/shiftflow/templates/shiftflow/partials/_filter.html index 6fa6dbb..27f7a33 100644 --- a/shiftflow/templates/shiftflow/partials/_filter.html +++ b/shiftflow/templates/shiftflow/partials/_filter.html @@ -66,7 +66,7 @@
@@ -104,6 +104,10 @@ function restoreFilters(){ if (!form) return false; const qs = new URLSearchParams(window.location.search); + if (qs.get('reset') === '1') { + try { localStorage.removeItem('registry_filters'); } catch(_) {} + return false; + } if (qs.get('filtered') === '1') return false; let raw = null; try { raw = localStorage.getItem('registry_filters'); } catch(_){} if (!raw) return false; @@ -128,6 +132,14 @@ if (form){ form.addEventListener('change', saveFilters, true); + + const resetBtn = document.getElementById('registryResetBtn'); + if (resetBtn) { + resetBtn.addEventListener('click', function () { + try { localStorage.removeItem('registry_filters'); } catch(_) {} + }); + } + restoreFilters(); } }); diff --git a/shiftflow/templates/shiftflow/partials/_items_table.html b/shiftflow/templates/shiftflow/partials/_items_table.html new file mode 100644 index 0000000..738899b --- /dev/null +++ b/shiftflow/templates/shiftflow/partials/_items_table.html @@ -0,0 +1,94 @@ +
+
+ + + + + + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаСделкаСтанокНаименованиеГабаритыПрогрессПлан / ФактМатериалФайлыСтатус
{{ item.date|date:"d.m.y" }}{{ item.task.deal.number|default:"-" }}{{ item.machine.name }}{{ item.task.drawing_name|default:"Б/ч" }}{{ item.task.size_value|default:"-" }} +
+
+
+
+ {{ item.quantity_plan }} / + {{ item.quantity_fact }} + {{ item.task.material.full_name|default:item.task.material.name|default:"-" }} + {% if item.task.drawing_file %} + + + + {% endif %} + {% if item.task.extra_drawing %} + + + + {% endif %} + + {% if item.is_synced_1c %} + + {% else %} + + {% endif %} + + + {{ item.get_status_display }} + +
Заданий не найдено
+
+
+ + \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/planning.html b/shiftflow/templates/shiftflow/planning.html index 7553ccb..3af8a79 100644 --- a/shiftflow/templates/shiftflow/planning.html +++ b/shiftflow/templates/shiftflow/planning.html @@ -19,9 +19,11 @@
+ {% if user_role in 'admin,technologist' %} + {% endif %}
diff --git a/shiftflow/templates/shiftflow/planning_deal.html b/shiftflow/templates/shiftflow/planning_deal.html index 8dab548..0c1f85c 100644 --- a/shiftflow/templates/shiftflow/planning_deal.html +++ b/shiftflow/templates/shiftflow/planning_deal.html @@ -20,9 +20,11 @@ Назад + {% if user_role in 'admin,technologist' %} Добавить деталь + {% endif %}
@@ -72,6 +74,7 @@ {% endif %} + {% if user_role in 'admin,technologist' %} + {% endif %} {% empty %} diff --git a/shiftflow/templates/shiftflow/registry.html b/shiftflow/templates/shiftflow/registry.html index 8e13a45..eff68e2 100644 --- a/shiftflow/templates/shiftflow/registry.html +++ b/shiftflow/templates/shiftflow/registry.html @@ -13,81 +13,6 @@ {% endif %}
-
-
- - - - - - - - - - - - - - - - - {% for item in items %} - - - - - - - - - - - - - {% empty %} - - {% endfor %} - -
ДатаСделкаСтанокНаименованиеГабаритыПлан / ФактМатериалФайлыСтатус
{{ item.date|date:"d.m.y" }}{{ item.task.deal.number|default:"-" }}{{ item.machine.name }}{{ item.task.drawing_name|default:"Б/ч" }}{{ item.task.size_value|default:"-" }} - {{ item.quantity_plan }} / - {{ item.quantity_fact }} - {{ item.task.material.full_name|default:item.task.material.name|default:"-" }} - {% if item.task.drawing_file %} - - - - {% endif %} - {% if item.task.extra_drawing %} - - - - {% endif %} - - {% if item.is_synced_1c %} - - {% else %} - - {% endif %} - - - {{ item.get_status_display }} - -
Заданий не найдено
-
-
+ {% include 'shiftflow/partials/_items_table.html' with items=items %} - - {% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/task_items.html b/shiftflow/templates/shiftflow/task_items.html index c8fea4b..c54e112 100644 --- a/shiftflow/templates/shiftflow/task_items.html +++ b/shiftflow/templates/shiftflow/task_items.html @@ -16,77 +16,6 @@ -
-
- - - - - - - - - - - - - - - - - {% 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 }} -
Пунктов сменки не найдено
-
-
+ {% include 'shiftflow/partials/_items_table.html' with items=items %} - - {% endblock %} \ No newline at end of file diff --git a/shiftflow/views.py b/shiftflow/views.py index 84a3512..4a6efed 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -1,4 +1,5 @@ from datetime import datetime +from urllib.parse import urlsplit from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When from django.db.models.functions import Coalesce @@ -37,7 +38,10 @@ class RegistryView(LoginRequiredMixin, ListView): user = self.request.user profile = getattr(user, 'profile', None) role = profile.role if profile else 'operator' + # Флаг, что фильтрация была применена через форму. Если нет — используем дефолты filtered = self.request.GET.get('filtered') + # Принудительный сброс фильтров (?reset=1) — ведёт себя как первый заход на страницу + reset = self.request.GET.get('reset') # Станки m_ids = self.request.GET.getlist('m_ids') @@ -59,18 +63,18 @@ class RegistryView(LoginRequiredMixin, ListView): expanded.append(s) queryset = queryset.filter(status__in=expanded) - # Даты + # Диапазон дат, задаваемый пользователем. Если фильтры не активны или явно указан reset=1 — используем дефолты start_date = self.request.GET.get('start_date') end_date = self.request.GET.get('end_date') - if not filtered: + # Дефолтный режим: последние 7 дней и только статус "В работе" + is_default = (not filtered) or bool(reset) + + if is_default: today = timezone.localdate() - if role == 'clerk': - queryset = queryset.filter(date=today, status__in=['done', 'partial']) - elif role in ['operator', 'master']: - queryset = queryset.filter(date=today, status__in=['work']) - else: - queryset = queryset.filter(date=today, status__in=['work', 'leftover']) + week_ago = today - timezone.timedelta(days=7) + queryset = queryset.filter(date__gte=week_ago, date__lte=today, status__in=['work']) else: + # Пользователь указал фильтры вручную — применяем их как есть if start_date: queryset = queryset.filter(date__gte=start_date) if end_date: @@ -103,16 +107,15 @@ class RegistryView(LoginRequiredMixin, ListView): context['machines'] = machines filtered = self.request.GET.get('filtered') - if not filtered: - today_str = timezone.localdate().strftime('%Y-%m-%d') - context['start_date'] = today_str - context['end_date'] = today_str - if role == 'clerk': - context['selected_statuses'] = ['closed'] - elif role in ['operator', 'master']: - context['selected_statuses'] = ['work'] - else: - context['selected_statuses'] = ['work', 'leftover'] + reset = self.request.GET.get('reset') + # Дефолтное состояние формы фильтра: все станки включены, статус "В работе", + # период от сегодня−7 до сегодня. Совпадает с серверной выборкой выше + if (not filtered) or reset: + today = timezone.localdate() + week_ago = today - timezone.timedelta(days=7) + context['start_date'] = week_ago.strftime('%Y-%m-%d') + context['end_date'] = today.strftime('%Y-%m-%d') + context['selected_statuses'] = ['work'] context['selected_machines'] = [m.id for m in machines] context['all_selected_machines'] = True else: @@ -123,6 +126,19 @@ class RegistryView(LoginRequiredMixin, ListView): context['is_synced'] = self.request.GET.get('is_synced', '') context['all_selected_machines'] = False + items = list(context.get('items') or []) + for it in items: + plan = int(it.quantity_plan or 0) + fact = int(it.quantity_fact or 0) + if plan > 0: + fact_pct = int(round(fact * 100 / plan)) + else: + fact_pct = 0 + it.fact_pct = fact_pct + it.fact_width = max(0, min(100, fact_pct)) + it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning' + context['items'] = items + return context @@ -222,7 +238,7 @@ class PlanningView(LoginRequiredMixin, TemplateView): 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']: + if role not in ['admin', 'technologist', 'master', 'clerk']: return redirect('registry') return super().dispatch(request, *args, **kwargs) @@ -249,7 +265,7 @@ class DealPlanningView(LoginRequiredMixin, TemplateView): 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']: + if role not in ['admin', 'technologist', 'master', 'clerk']: return redirect('registry') return super().dispatch(request, *args, **kwargs) @@ -282,6 +298,8 @@ class DealPlanningView(LoginRequiredMixin, TemplateView): ).order_by('-id') tasks = list(tasks_qs) + # Рассчитываем показатели прогресса для визуализации: + # done_pct/plan_pct — проценты от "Надо"; done_width/plan_width — ширины сегментов бары, ограниченные 0..100 for t in tasks: need = int(t.quantity_ordered or 0) done_qty = int(t.done_qty or 0) @@ -313,7 +331,7 @@ class TaskItemsView(LoginRequiredMixin, TemplateView): 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']: + if role not in ['admin', 'technologist', 'master', 'clerk']: return redirect('registry') return super().dispatch(request, *args, **kwargs) @@ -329,7 +347,17 @@ class TaskItemsView(LoginRequiredMixin, TemplateView): ) context['task'] = task - items = Item.objects.filter(task=task).select_related('machine').order_by('-date', 'machine__name', '-id') + items = list(Item.objects.filter(task=task).select_related('machine').order_by('-date', 'machine__name', '-id')) + for it in items: + plan = int(it.quantity_plan or 0) + fact = int(it.quantity_fact or 0) + if plan > 0: + fact_pct = int(round(fact * 100 / plan)) + else: + fact_pct = 0 + it.fact_pct = fact_pct + it.fact_width = max(0, min(100, fact_pct)) + it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning' context['items'] = items return context @@ -340,7 +368,7 @@ class CustomersView(LoginRequiredMixin, TemplateView): 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']: + if role not in ['admin', 'technologist', 'master', 'clerk']: return redirect('registry') return super().dispatch(request, *args, **kwargs) @@ -361,7 +389,7 @@ class CustomerDealsView(LoginRequiredMixin, TemplateView): 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']: + if role not in ['admin', 'technologist', 'master', 'clerk']: return redirect('registry') return super().dispatch(request, *args, **kwargs) @@ -667,6 +695,22 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator') context['user_role'] = role context['machines'] = Machine.objects.all() + + # Вычисляем URL "Назад": приоритетно берём ?next=..., иначе пробуем Referer + # Используем только ссылки на текущий хост, чтобы избежать внешних редиректов + next_url = (self.request.GET.get('next') or '').strip() + back_url = '' + if next_url.startswith('/'): + back_url = next_url + else: + ref = (self.request.META.get('HTTP_REFERER') or '').strip() + if ref: + parts = urlsplit(ref) + if parts.netloc == self.request.get_host(): + back_url = parts.path + (('?' + parts.query) if parts.query else '') + if not back_url: + back_url = str(reverse_lazy('registry')) + context['back_url'] = back_url return context def post(self, request, *args, **kwargs): @@ -674,7 +718,24 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') + # Поддерживаем "умный" возврат после действия: ?next=... или Referer + next_url = (request.POST.get('next') or '').strip() + if not next_url: + ref = (request.META.get('HTTP_REFERER') or '').strip() + if ref: + parts = urlsplit(ref) + if parts.netloc == request.get_host(): + next_url = parts.path + (('?' + parts.query) if parts.query else '') + + def redirect_back(): + # Возвращаемся туда, откуда пришли, иначе в реестр + if next_url.startswith('/'): + return redirect(next_url) + return redirect('registry') + if role in ['admin', 'technologist']: + action = request.POST.get('action', 'save') + machine_id = request.POST.get('machine') if machine_id and machine_id.isdigit(): self.object.machine_id = int(machine_id) @@ -691,11 +752,6 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): if quantity_fact and quantity_fact.isdigit(): self.object.quantity_fact = int(quantity_fact) - status = request.POST.get('status') - allowed_statuses = {k for k, _ in self.object.STATUS_CHOICES} - if status in allowed_statuses: - self.object.status = status - self.object.is_synced_1c = bool(request.POST.get('is_synced_1c')) self.object.material_taken = request.POST.get('material_taken', self.object.material_taken) self.object.usable_waste = request.POST.get('usable_waste', self.object.usable_waste) @@ -707,35 +763,70 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): except ValueError: pass + # Действия закрытия для админа/технолога + if action == 'close_done' and self.object.status == 'work': + self.object.quantity_fact = self.object.quantity_plan + self.object.status = 'done' + self.object.save() + return redirect_back() + + if action == 'close_partial' and self.object.status == 'work': + try: + fact = int(request.POST.get('quantity_fact', '0')) + except ValueError: + fact = 0 + fact = max(0, min(fact, self.object.quantity_plan)) + residual = self.object.quantity_plan - fact + self.object.quantity_fact = fact + self.object.status = 'partial' + self.object.save() + if residual > 0: + Item.objects.create( + task=self.object.task, + date=self.object.date, + machine=self.object.machine, + quantity_plan=residual, + quantity_fact=0, + status='leftover', + is_synced_1c=False, + ) + return redirect_back() + self.object.save() - return redirect('registry') + return redirect_back() if role in ['operator', 'master']: + action = request.POST.get('action', 'save') 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) - machine_changed = False - if role == 'master': - machine_id = request.POST.get('machine') - if machine_id and machine_id.isdigit(): - self.object.machine_id = int(machine_id) - machine_changed = True + if action == 'save': + qf = request.POST.get('quantity_fact') + if qf and qf.isdigit(): + self.object.quantity_fact = int(qf) + machine_changed = False + if role == 'master': + machine_id = request.POST.get('machine') + if machine_id and machine_id.isdigit(): + self.object.machine_id = int(machine_id) + machine_changed = True + fields = ['quantity_fact'] + if machine_changed: + fields.append('machine') + self.object.save(update_fields=fields) + return redirect_back() - # Разрешаем мастеру редактировать операторские поля всегда, - # оператору — только в процессе закрытия - if role == 'operator' and self.object.status != 'work': - return redirect('registry') + if self.object.status != 'work': + return redirect_back() errors = [] - 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)') + 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 != '': @@ -749,21 +840,18 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): context['errors'] = errors return self.render_to_response(context) - if material_taken: - self.object.material_taken = material_taken - if usable_waste: - self.object.usable_waste = usable_waste + self.object.material_taken = material_taken + self.object.usable_waste = usable_waste if scrap_weight is not None: self.object.scrap_weight = scrap_weight - # Логика закрытия доступна и мастеру, и оператору, но только из 'work' - if self.object.status == 'work' and status == 'done': + if action == 'close_done': self.object.quantity_fact = self.object.quantity_plan self.object.status = 'done' self.object.save() - return redirect('registry') + return redirect_back() - if self.object.status == 'work' and status == 'partial': + if action == 'close_partial': try: fact = int(request.POST.get('quantity_fact', '0')) except ValueError: @@ -789,24 +877,18 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView): status='leftover', is_synced_1c=False, ) + return redirect_back() - return redirect('registry') - - # Если статус не менялся (или не 'work'), просто сохраняем поля - update_fields = ['material_taken', 'usable_waste', 'scrap_weight'] - if machine_changed: - update_fields.append('machine') - self.object.save(update_fields=update_fields) - return redirect('registry') + return redirect_back() if role == 'clerk': if self.object.status not in ['done', 'partial']: - return redirect('registry') + return redirect_back() self.object.is_synced_1c = bool(request.POST.get('is_synced_1c')) self.object.save(update_fields=['is_synced_1c']) - return redirect('registry') + return redirect_back() - return redirect('registry') + return redirect_back() def get_success_url(self): return reverse_lazy('registry') \ No newline at end of file diff --git a/templates/components/_navbar.html b/templates/components/_navbar.html index 58e71fb..a38a1cb 100644 --- a/templates/components/_navbar.html +++ b/templates/components/_navbar.html @@ -15,7 +15,7 @@ Реестр - {% if user_role in 'admin,technologist' %} + {% if user_role in 'admin,technologist,master,clerk' %}