Фильтры сохраняются, мастер получил расширенные возможности
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s

This commit is contained in:
2026-04-01 01:04:46 +03:00
parent c2778d9ec8
commit d0289f6aec
14 changed files with 865 additions and 34 deletions

171
shiftflow/popup_views.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,158 @@
{% extends 'base.html' %}
{% block content %}
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<div>
<h3 class="text-accent mb-0"><i class="bi bi-briefcase me-2"></i>Сделки</h3>
<div class="small text-muted">{{ company.name }}</div>
</div>
<form method="get" class="d-flex align-items-center gap-2">
<span class="small text-muted">Статус:</span>
<div class="d-flex flex-wrap gap-1">
<input type="radio" class="btn-check" name="status" id="cust_s_work" value="work" {% if selected_status == 'work' %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-primary btn-sm" for="cust_s_work">В работе</label>
<input type="radio" class="btn-check" name="status" id="cust_s_lead" value="lead" {% if selected_status == 'lead' %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-secondary btn-sm" for="cust_s_lead">Зашла</label>
<input type="radio" class="btn-check" name="status" id="cust_s_done" value="done" {% if selected_status == 'done' %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-success btn-sm" for="cust_s_done">Завершена</label>
</div>
</form>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#dealModal">
<i class="bi bi-plus-lg me-1"></i>Создать сделку
</button>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'customers' %}">
<i class="bi bi-arrow-left me-1"></i>К заказчикам
</a>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Сделка</th>
<th>Описание</th>
<th style="width: 140px;">Статус</th>
</tr>
</thead>
<tbody>
{% for d in deals %}
<tr class="planning-row" style="cursor:pointer" data-href="{% url 'planning_deal' d.id %}">
<td><span class="text-accent fw-bold">{{ d.number }}</span></td>
<td class="small text-muted">{{ d.description|default:"" }}</td>
<td>
<span class="badge {% if d.status == 'work' %}bg-primary{% elif d.status == 'done' %}bg-success{% else %}bg-secondary{% endif %}">
{{ d.get_status_display }}
</span>
</td>
</tr>
{% empty %}
<tr><td colspan="3" class="text-center p-5 text-muted">Сделок не найдено</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" id="dealModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title">Сделка</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small text-muted">№ Сделки</label>
<input type="text" class="form-control border-secondary" id="dealNumber">
</div>
<div class="mb-3">
<label class="form-label small text-muted">Статус</label>
<select class="form-select border-secondary" id="dealStatus">
<option value="lead">Зашла</option>
<option value="work" selected>В работе</option>
<option value="done">Завершена</option>
</select>
</div>
<div class="mb-0">
<label class="form-label small text-muted">Описание</label>
<textarea class="form-control border-secondary" rows="3" id="dealDescription"></textarea>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-outline-accent" id="dealSaveBtn">Сохранить</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('tr.planning-row[data-href]').forEach(function (row) {
row.addEventListener('click', function () {
const href = row.getAttribute('data-href');
if (href) window.location.href = href;
});
});
const dealModal = document.getElementById('dealModal');
const dealNumber = document.getElementById('dealNumber');
const dealStatus = document.getElementById('dealStatus');
const dealDescription = document.getElementById('dealDescription');
const dealSaveBtn = document.getElementById('dealSaveBtn');
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
async function postForm(url, data) {
const csrftoken = getCookie('csrftoken');
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-CSRFToken': csrftoken,
},
body: new URLSearchParams(data).toString(),
});
if (!res.ok) throw new Error('request_failed');
return await res.json();
}
if (dealModal) {
dealModal.addEventListener('show.bs.modal', function () {
if (dealNumber) dealNumber.value = '';
if (dealDescription) dealDescription.value = '';
if (dealStatus) dealStatus.value = 'work';
});
}
if (dealSaveBtn) {
dealSaveBtn.addEventListener('click', async function () {
const payload = {
number: (dealNumber ? dealNumber.value : ''),
status: dealStatus ? dealStatus.value : 'work',
company_id: '{{ company.id }}',
description: (dealDescription ? dealDescription.value : ''),
};
await postForm('{% url "deal_upsert" %}', payload);
window.location.reload();
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,115 @@
{% extends 'base.html' %}
{% block content %}
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-building me-2"></i>Заказчики</h3>
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#companyModal">
<i class="bi bi-plus-lg me-1"></i>Добавить заказчика
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Заказчик</th>
<th>Примечание</th>
</tr>
</thead>
<tbody>
{% for c in companies %}
<tr class="customer-row" style="cursor:pointer" data-href="{% url 'customer_deals' c.id %}">
<td class="fw-bold">{{ c.name }}</td>
<td class="small text-muted">{{ c.description|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="2" class="text-center p-5 text-muted">Заказчиков не найдено</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" id="companyModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title">Заказчик</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small text-muted">Название</label>
<input type="text" class="form-control border-secondary" id="companyName">
</div>
<div class="mb-0">
<label class="form-label small text-muted">Примечание</label>
<textarea class="form-control border-secondary" rows="3" id="companyDescription"></textarea>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-outline-accent" id="companySaveBtn">Сохранить</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('tr.customer-row[data-href]').forEach(function (row) {
row.addEventListener('click', function () {
const href = row.getAttribute('data-href');
if (href) window.location.href = href;
});
});
const companyModal = document.getElementById('companyModal');
const companyName = document.getElementById('companyName');
const companyDescription = document.getElementById('companyDescription');
const companySaveBtn = document.getElementById('companySaveBtn');
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
async function postForm(url, data) {
const csrftoken = getCookie('csrftoken');
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-CSRFToken': csrftoken,
},
body: new URLSearchParams(data).toString(),
});
if (!res.ok) throw new Error('request_failed');
return await res.json();
}
if (companyModal) {
companyModal.addEventListener('show.bs.modal', function () {
if (companyName) companyName.value = '';
if (companyDescription) companyDescription.value = '';
});
}
if (companySaveBtn) {
companySaveBtn.addEventListener('click', async function () {
const payload = {
name: (companyName ? companyName.value : ''),
description: (companyDescription ? companyDescription.value : ''),
};
await postForm('{% url "company_upsert" %}', payload);
window.location.reload();
});
}
});
</script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load l10n %}
{% block content %}
<div class="row justify-content-center">
@@ -72,13 +73,29 @@
</div>
<div class="col-md-4">
<label class="small text-muted">Лом (кг)</label>
<input type="number" step="0.01" min="0" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight }}" required>
<input type="number" step="0.01" min="0" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight|default_if_none:'0'|unlocalize }}" required>
</div>
</div>
</div>
{% else %}
<div class="alert alert-success">Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.</div>
<input type="hidden" name="quantity_fact" value="{{ item.quantity_fact }}">
{% if user_role == 'master' %}
<div class="row g-3 mt-3 text-start">
<div class="col-md-4">
<label class="small text-muted">Взятый материал</label>
<input type="text" name="material_taken" class="form-control border-secondary" value="{{ item.material_taken }}" placeholder="Напр: 3 трубы по 12м">
</div>
<div class="col-md-4">
<label class="small text-muted">Остаток ДО</label>
<input type="text" name="usable_waste" class="form-control border-secondary" value="{{ item.usable_waste }}" placeholder="Напр: 0.8м / 12кг">
</div>
<div class="col-md-4">
<label class="small text-muted">Лом (кг)</label>
<input type="number" step="0.01" min="0" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight|default_if_none:'0'|unlocalize }}">
</div>
</div>
{% endif %}
{% endif %}
{% endif %}
@@ -124,7 +141,7 @@
</div>
<div class="col-md-4">
<label class="small text-muted">Лом (кг)</label>
<input type="number" step="0.01" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight }}">
<input type="number" step="0.01" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight|default_if_none:'0'|unlocalize }}">
</div>
</div>

View File

@@ -73,14 +73,63 @@
</form>
<script>
document.addEventListener('DOMContentLoaded', function(){
const form = document.getElementById('filter-form');
const s = document.querySelector('input[name="start_date"]');
const e = document.querySelector('input[name="end_date"]');
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const today = `${now.getFullYear()}-${mm}-${dd}`;
if (s && !s.value) s.value = today;
const weekAgoDate = new Date(now);
weekAgoDate.setDate(weekAgoDate.getDate() - 7);
const mm2 = String(weekAgoDate.getMonth() + 1).padStart(2, '0');
const dd2 = String(weekAgoDate.getDate()).padStart(2, '0');
const weekAgo = `${weekAgoDate.getFullYear()}-${mm2}-${dd2}`;
if (s && !s.value) s.value = weekAgo;
if (e && !e.value) e.value = today;
function saveFilters(){
if (!form) return;
const data = {
statuses: Array.from(form.querySelectorAll('input[name="statuses"]:checked')).map(i=>i.value),
m_ids: Array.from(form.querySelectorAll('input[name="m_ids"]:checked')).map(i=>i.value),
start_date: s ? s.value : '',
is_synced: (form.querySelector('select[name="is_synced"]')||{}).value || ''
};
try { localStorage.setItem('registry_filters', JSON.stringify(data)); } catch(_){}
}
function restoreFilters(){
if (!form) return false;
const qs = new URLSearchParams(window.location.search);
if (qs.get('filtered') === '1') return false;
let raw = null; try { raw = localStorage.getItem('registry_filters'); } catch(_){}
if (!raw) return false;
let data = null; try { data = JSON.parse(raw); } catch(_){}
if (!data) return false;
if (Array.isArray(data.statuses)){
form.querySelectorAll('input[name="statuses"]').forEach(i=>{ i.checked = data.statuses.includes(i.value); });
}
if (Array.isArray(data.m_ids)){
form.querySelectorAll('input[name="m_ids"]').forEach(i=>{ i.checked = data.m_ids.includes(i.value); });
}
if (s) s.value = data.start_date || weekAgo;
if (e) e.value = today;
const sel = form.querySelector('select[name="is_synced"]');
if (sel && data.is_synced !== undefined) sel.value = data.is_synced;
const filtered = form.querySelector('input[name="filtered"]');
if (filtered) filtered.value = '1';
form.submit();
return true;
}
if (form){
form.addEventListener('change', saveFilters, true);
restoreFilters();
}
});
</script>
</div>

View File

@@ -4,7 +4,7 @@
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<h3 class="text-accent mb-0"><i class="bi bi-kanban me-2"></i>Планирование</h3>
<h3 class="text-accent mb-0"><i class="bi bi-kanban me-2"></i>Сделки</h3>
<form method="get" class="d-flex align-items-center gap-2">
<span class="small text-muted">Сделки:</span>
<div class="d-flex flex-wrap gap-1">
@@ -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');

View File

@@ -20,7 +20,7 @@
<a class="btn btn-outline-secondary btn-sm" href="{% url 'planning' %}">
<i class="bi bi-arrow-left me-1"></i>Назад
</a>
<a class="btn btn-outline-accent btn-sm" href="{% url 'task_add' %}?deal={{ deal.id }}">
<a class="btn btn-outline-accent btn-sm" href="{% url 'task_add' %}?deal={{ deal.id }}&next={% url 'planning_deal' deal.id %}">
<i class="bi bi-plus-lg me-1"></i>Добавить деталь
</a>
</div>
@@ -43,7 +43,7 @@
</thead>
<tbody>
{% for t in tasks %}
<tr>
<tr class="task-row" style="cursor:pointer" data-href="{% url 'task_items' t.id %}">
<td class="fw-bold">{{ t.drawing_name|default:"Б/ч" }}</td>
<td class="small text-muted">{{ t.material.full_name|default:t.material.name }}</td>
<td class="small">{{ t.size_value }}</td>
@@ -88,12 +88,13 @@
<div class="small text-muted mb-2" id="modalTaskTitle"></div>
<div class="mb-3">
<label class="form-label small text-muted">Станок</label>
<select class="form-select border-secondary" name="machine_id" required>
<label class="form-label small text-muted d-block">Станок</label>
<div class="d-flex flex-wrap gap-1" id="machineToggleGroup">
{% for m in machines %}
<option value="{{ m.id }}">{{ m.name }}</option>
<input type="radio" class="btn-check" name="machine_id" id="m_{{ m.id }}" value="{{ m.id }}" required>
<label class="btn btn-outline-accent btn-sm" for="m_{{ m.id }}">{{ m.name }}</label>
{% endfor %}
</select>
</div>
</div>
<div class="mb-2">
@@ -113,10 +114,18 @@
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('tr.task-row[data-href]').forEach(function (row) {
row.addEventListener('click', function (e) {
if (e.target && e.target.closest('button')) return;
const href = row.getAttribute('data-href');
if (href) window.location.href = href;
});
});
const modal = document.getElementById('addToPlanModal');
if (!modal) return;
modal.addEventListener('show.bs.modal', function (event) {
modal.addEventListener('shown.bs.modal', function (event) {
const btn = event.relatedTarget;
const taskId = btn.getAttribute('data-task-id');
const name = btn.getAttribute('data-task-name');
@@ -128,8 +137,42 @@ document.addEventListener('DOMContentLoaded', function () {
const qty = document.getElementById('modalQty');
qty.value = '';
if (rem && !isNaN(parseInt(rem, 10))) qty.max = Math.max(1, parseInt(rem, 10));
else qty.removeAttribute('max');
let remInt = null;
if (rem && !isNaN(parseInt(rem, 10))) {
remInt = Math.max(1, parseInt(rem, 10));
qty.max = remInt;
qty.value = String(remInt);
} else {
qty.removeAttribute('max');
}
qty.focus({ preventScroll: true });
qty.select();
qty.onkeydown = function (e) {
if (e.key === 'Enter') {
e.preventDefault();
const form = document.querySelector('#addToPlanModal form');
if (form) form.requestSubmit();
}
};
const radios = Array.from(document.querySelectorAll('input[name="machine_id"]'));
const savedMachine = (() => { try { return localStorage.getItem('planning_machine_id'); } catch (_) { return null; } })();
let selected = null;
if (savedMachine) {
selected = radios.find(r => r.value === savedMachine);
}
if (!selected && radios.length) selected = radios[0];
if (selected) selected.checked = true;
radios.forEach(r => {
r.onchange = function () {
try { localStorage.setItem('planning_machine_id', r.value); } catch (_) {}
};
});
});
});
</script>

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сохранено</title>
</head>
<body>
<script>
(function () {
try {
if (window.opener && typeof window.opener.shiftflowReceivePopup === 'function') {
window.opener.shiftflowReceivePopup('{{ target|escapejs }}', '{{ value }}', '{{ label|escapejs }}');
}
} catch (e) {}
window.close();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ view.model._meta.verbose_name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="p-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">{{ view.model._meta.verbose_name }}</h5>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="window.close()">Закрыть</button>
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="target" value="{{ target }}">
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for e in form.non_field_errors %}<div>{{ e }}</div>{% endfor %}
</div>
{% endif %}
{% for field in form %}
<div class="mb-3">
<label class="form-label small text-muted">{{ field.label }}</label>
{% if field.field.widget.input_type == "checkbox" %}
<div class="form-check">
{{ field }}
</div>
{% else %}
{{ field }}
{% endif %}
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
{% endfor %}
<div class="d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-outline-secondary" onclick="window.close()">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</body>
</html>

View File

@@ -6,13 +6,20 @@
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-plus-circle me-2"></i>Новое задание</h3>
{% if request.GET.next %}
<a class="btn btn-outline-secondary btn-sm" href="{{ request.GET.next }}">
<i class="bi bi-arrow-left me-1"></i>Назад
</a>
{% else %}
<a class="btn btn-outline-secondary btn-sm" href="{% url 'planning' %}">
<i class="bi bi-arrow-left me-1"></i>Назад
</a>
{% endif %}
</div>
<form method="post" enctype="multipart/form-data" class="card-body p-4">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.GET.next }}">
{% if form.non_field_errors %}
<div class="alert alert-danger">

View File

@@ -0,0 +1,92 @@
{% extends 'base.html' %}
{% block content %}
<div class="card shadow border-secondary mb-3">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<div>
<h3 class="text-accent mb-1">
<i class="bi bi-list-task me-2"></i>{{ task.drawing_name|default:"Б/ч" }}
</h3>
<div class="small text-muted">
Сделка {{ task.deal.number }}{% if task.deal.company %} · {{ task.deal.company.name }}{% endif %}
</div>
</div>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'planning_deal' task.deal.id %}">
<i class="bi bi-arrow-left me-1"></i>Назад к сделке
</a>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Дата</th>
<th>Сделка</th>
<th>Станок</th>
<th>Наименование</th>
<th>Габариты</th>
<th>План / Факт</th>
<th>Материал</th>
<th class="text-center">Файлы</th>
<th class="text-center">1С</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr class="clickable-row" data-href="{% url 'item_detail' it.pk %}">
<td class="small">{{ it.date|date:"d.m.y" }}</td>
<td><span class="text-accent fw-bold">{{ it.task.deal.number|default:"-" }}</span></td>
<td><span class="badge bg-dark border border-secondary">{{ it.machine.name }}</span></td>
<td class="fw-bold">{{ it.task.drawing_name|default:"Б/ч" }}</td>
<td class="small">{{ it.task.size_value|default:"-" }}</td>
<td>
<span class="text-info fw-bold">{{ it.quantity_plan }}</span> /
<span class="text-success">{{ it.quantity_fact }}</span>
</td>
<td class="small text-muted">{{ it.task.material.full_name|default:it.task.material.name|default:"-" }}</td>
<td class="text-center">
{% if it.task.drawing_file %}
<a href="{{ it.task.drawing_file.url }}" target="_blank" class="btn btn-sm btn-outline-info p-1 stop-prop" title="DXF/STEP">
<i class="bi bi-file-earmark-code"></i>
</a>
{% endif %}
{% if it.task.extra_drawing %}
<a href="{{ it.task.extra_drawing.url }}" target="_blank" class="btn btn-sm btn-outline-danger p-1 stop-prop" title="Чертеж PDF">
<i class="bi bi-file-pdf"></i>
</a>
{% endif %}
</td>
<td class="text-center">
{% if it.is_synced_1c %}
<i class="bi bi-check-circle-fill text-success" title="Учтено"></i>
{% else %}
<i class="bi bi-clock-history text-muted" title="Ожидает"></i>
{% endif %}
</td>
<td>
<span class="badge {% if it.status == 'work' %}bg-primary{% elif it.status == 'done' %}bg-success{% elif it.status == 'partial' %}bg-success-subtle text-success-emphasis border border-success-subtle{% else %}bg-secondary{% endif %}">{{ it.get_status_display }}</span>
</td>
</tr>
{% empty %}
<tr><td colspan="10" class="text-center p-5 text-muted">Пунктов сменки не найдено</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
const rows = document.querySelectorAll(".clickable-row");
rows.forEach(row => {
row.addEventListener("click", function(e) {
if (e.target.closest('.stop-prop')) return;
window.location.href = this.dataset.href;
});
});
});
</script>
{% endblock %}

View File

@@ -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/<int:pk>/', DealPlanningView.as_view(), name='planning_deal'),
path('planning/task/<int:pk>/items/', TaskItemsView.as_view(), name='task_items'),
path('customers/', CustomersView.as_view(), name='customers'),
path('customers/<int:pk>/', 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/<int:pk>/json/', DealDetailView.as_view(), name='deal_json'),

View File

@@ -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,14 +690,18 @@ 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 role == 'operator' and self.object.status == 'work':
if not material_taken:
errors.append('Заполни поле "Взятый материал"')
if not usable_waste:
@@ -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)
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':

View File

@@ -17,7 +17,10 @@
{% if user_role in 'admin,technologist' %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'planning' %}active{% endif %}" href="{% url 'planning' %}">Планирование</a>
<a class="nav-link {% if request.resolver_match.url_name == 'planning' or request.resolver_match.url_name == 'planning_deal' %}active{% endif %}" href="{% url 'planning' %}">Сделки</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'customers' or request.resolver_match.url_name == 'customer_deals' %}active{% endif %}" href="{% url 'customers' %}">Заказчик</a>
</li>
{% endif %}