Add qoute step.

This commit is contained in:
aminhashemi92 2025-08-21 09:18:51 +03:30
parent b71ea45681
commit 6ff4740d04
30 changed files with 3362 additions and 376 deletions

View file

@ -46,21 +46,95 @@ class StepDependencyAdmin(admin.ModelAdmin):
@admin.register(ProcessInstance)
class ProcessInstanceAdmin(SimpleHistoryAdmin):
list_display = ['name', 'process', 'requester', 'current_step', 'status', 'started_at', 'progress_display']
list_filter = ['process', 'status', 'started_at']
search_fields = ['name', 'process__name', 'requester__username', 'requester__first_name']
readonly_fields = ['deleted_at', 'started_at', 'completed_at']
ordering = ['-started_at']
verbose_name = "درخواست"
verbose_name_plural = "درخواست‌ها"
list_display = [
'code',
'slug',
'well_display',
'representative',
'requester',
'process',
'status_display',
'priority_display',
'created',
'progress_display'
]
list_filter = [
'process',
'status',
'priority',
'created',
'well__representative'
]
search_fields = [
'code',
'slug',
'process__name',
'requester__username',
'requester__first_name',
'well__water_subscription_number',
'representative__username'
]
readonly_fields = [
'deleted_at',
'created',
'updated',
'completed_at'
]
autocomplete_fields = [
'well',
'representative',
'requester',
'process',
'current_step'
]
ordering = ['-created']
fieldsets = (
('اطلاعات اصلی', {
'fields': ('code', 'slug', 'description', 'process')
}),
('اطلاعات چاه', {
'fields': ('well', 'representative')
}),
('اطلاعات درخواست', {
'fields': ('requester', 'priority')
}),
('وضعیت و پیشرفت', {
'fields': ('status', 'current_step')
}),
('تاریخ‌ها', {
'fields': ('created', 'updated', 'completed_at'),
'classes': ('collapse',)
}),
)
def well_display(self, obj):
if obj.well:
return f"{obj.well.water_subscription_number}"
return "-"
well_display.short_description = "چاه"
def status_display(self, obj):
return mark_safe(obj.get_status_display_with_color())
status_display.short_description = "وضعیت"
def priority_display(self, obj):
return mark_safe(obj.get_priority_display_with_color())
priority_display.short_description = "اولویت"
def progress_display(self, obj):
total_steps = obj.process.steps.count()
completed_steps = obj.step_instances.filter(status='completed').count()
percentage = (completed_steps / total_steps * 100) if total_steps > 0 else 0
percentage_int = int(percentage)
return format_html(
'<div class="progress" style="width: 100px;"><div class="progress-bar" style="width: {}%">{}/{} ({}%)</div></div>',
percentage_int, completed_steps, total_steps, percentage_int
)
if obj.process:
total_steps = obj.process.steps.count()
completed_steps = obj.step_instances.filter(status='completed').count()
percentage = (completed_steps / total_steps * 100) if total_steps > 0 else 0
percentage_int = int(percentage)
return format_html(
'<div class="progress" style="width: 100px;"><div class="progress-bar" style="width: {}%">{}/{} ({}%)</div></div>',
percentage_int, completed_steps, total_steps, percentage_int
)
return "-"
progress_display.short_description = "پیشرفت"
@admin.register(StepInstance)

View file

@ -4,9 +4,16 @@ from .models import ProcessInstance, StepInstance
class ProcessInstanceForm(forms.ModelForm):
class Meta:
model = ProcessInstance
fields = ['name']
fields = ['description', 'process', 'well', 'representative', 'requester', 'priority', 'status', 'current_step']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'})
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'process': forms.Select(attrs={'class': 'form-control'}),
'well': forms.Select(attrs={'class': 'form-control'}),
'representative': forms.Select(attrs={'class': 'form-control'}),
'requester': forms.Select(attrs={'class': 'form-control'}),
'priority': forms.Select(attrs={'class': 'form-control'}),
'status': forms.Select(attrs={'class': 'form-control'}),
'current_step': forms.Select(attrs={'class': 'form-control'}),
}
class StepInstanceForm(forms.ModelForm):

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-08-07 09:08
# Generated by Django 5.2.4 on 2025-08-14 09:02
import django.db.models.deletion
import simple_history.models
@ -11,6 +11,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('wells', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@ -130,18 +131,21 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
('name', models.CharField(max_length=100, verbose_name='نام')),
('code', models.CharField(help_text='کد ۵ رقمی یکتا برای هر درخواست', max_length=5, unique=True, verbose_name='کد درخواست')),
('description', models.TextField(blank=True, null=True, verbose_name='توضیحات درخواست')),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('in_progress', 'در حال انجام'), ('completed', 'تکمیل شده'), ('cancelled', 'لغو شده'), ('rejected', 'رد شده')], default='pending', max_length=20, verbose_name='وضعیت')),
('started_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ شروع')),
('priority', models.CharField(choices=[('low', 'کم'), ('medium', 'متوسط'), ('high', 'زیاد'), ('urgent', 'فوری')], default='medium', max_length=20, verbose_name='اولویت')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تکمیل')),
('process', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='instances', to='processes.process', verbose_name='فرآیند')),
('requester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='درخواست کننده')),
('process', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='instances', to='processes.process', verbose_name='فرآیند')),
('representative', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='representative_instances', to=settings.AUTH_USER_MODEL, verbose_name='نماینده چاه')),
('requester', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='درخواست کننده')),
('well', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='process_instances', to='wells.well', verbose_name='چاه')),
('current_step', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='processes.processstep', verbose_name='مرحله فعلی')),
],
options={
'verbose_name': 'نمونه فرآیند',
'verbose_name_plural': 'نمونه\u200cهای فرآیند',
'ordering': ['-started_at'],
'verbose_name': 'درخواست',
'verbose_name_plural': 'درخواست\u200cها',
'ordering': ['-created'],
},
),
migrations.CreateModel(
@ -169,37 +173,6 @@ class Migration(migrations.Migration):
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalProcessInstance',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('slug', models.SlugField(max_length=100, verbose_name='اسلاگ')),
('name', models.CharField(max_length=100, verbose_name='نام')),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('in_progress', 'در حال انجام'), ('completed', 'تکمیل شده'), ('cancelled', 'لغو شده'), ('rejected', 'رد شده')], default='pending', max_length=20, verbose_name='وضعیت')),
('started_at', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ شروع')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تکمیل')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('requester', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='درخواست کننده')),
('process', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='processes.process', verbose_name='فرآیند')),
('current_step', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='processes.processstep', verbose_name='مرحله فعلی')),
],
options={
'verbose_name': 'historical نمونه فرآیند',
'verbose_name_plural': 'historical نمونه\u200cهای فرآیند',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='StepInstance',
fields=[

View file

@ -1,8 +1,11 @@
from django.db import models
from django.contrib.auth import get_user_model
from common.models import NameSlugModel
from common.models import NameSlugModel, SluggedModel
from simple_history.models import HistoricalRecords
from django.core.exceptions import ValidationError
from django.utils import timezone
from _helpers.utils import generate_unique_slug
import random
User = get_user_model()
@ -21,6 +24,7 @@ class Process(NameSlugModel):
def __str__(self):
return self.name
class ProcessStep(NameSlugModel):
"""مدل مراحل فرآیند"""
process = models.ForeignKey(Process, on_delete=models.CASCADE, related_name='steps', verbose_name="فرآیند")
@ -95,35 +99,169 @@ class StepDependency(models.Model):
if self.dependent_step.order <= self.dependency_step.order:
raise ValidationError("مرحله وابسته باید بعد از مرحله مورد نیاز باشد")
class ProcessInstance(NameSlugModel):
class ProcessInstance(SluggedModel):
code = models.CharField(
max_length=5,
unique=True,
verbose_name="کد درخواست",
help_text="کد ۵ رقمی یکتا برای هر درخواست"
)
"""مدل نمونه فرآیند (برای هر درخواست)"""
process = models.ForeignKey(Process, on_delete=models.CASCADE, related_name='instances', verbose_name="فرآیند")
requester = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="درخواست کننده")
current_step = models.ForeignKey('ProcessStep', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="مرحله فعلی")
PRIORITY_CHOICES = [
('low', 'کم'),
('medium', 'متوسط'),
('high', 'زیاد'),
('urgent', 'فوری'),
]
STATUS_CHOICES = [
('pending', 'در انتظار'),
('in_progress', 'در حال انجام'),
('completed', 'تکمیل شده'),
('cancelled', 'لغو شده'),
('rejected', 'رد شده'),
]
description = models.TextField(
verbose_name="توضیحات درخواست",
blank=True,
null=True
)
process = models.ForeignKey(
Process,
on_delete=models.CASCADE,
related_name='instances',
verbose_name="فرآیند",
null=True,
blank=True
)
well = models.ForeignKey(
'wells.Well',
on_delete=models.CASCADE,
related_name='process_instances',
verbose_name="چاه",
)
representative = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name='representative_instances',
verbose_name="نماینده چاه",
null=True,
)
requester = models.ForeignKey(
User,
on_delete=models.SET_NULL,
verbose_name="درخواست کننده",
null=True,
blank=True
)
current_step = models.ForeignKey(
ProcessStep,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="مرحله فعلی",
)
status = models.CharField(
max_length=20,
choices=[
('pending', 'در انتظار'),
('in_progress', 'در حال انجام'),
('completed', 'تکمیل شده'),
('cancelled', 'لغو شده'),
('rejected', 'رد شده'),
],
choices=STATUS_CHOICES,
default='pending',
verbose_name="وضعیت"
)
started_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ شروع")
completed_at = models.DateTimeField(null=True, blank=True, verbose_name="تاریخ تکمیل")
history = HistoricalRecords()
priority = models.CharField(
max_length=20,
choices=PRIORITY_CHOICES,
default='medium',
verbose_name="اولویت"
)
completed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="تاریخ تکمیل"
)
class Meta:
verbose_name = "نمونه فرآیند"
verbose_name_plural = "نمونه‌های فرآیند"
ordering = ['-started_at']
verbose_name = "درخواست"
verbose_name_plural = "درخواست‌ها"
ordering = ['-created']
def __str__(self):
if self.well:
return f"{self.process.name} - {self.well.water_subscription_number}"
return f"{self.process.name} - {self.requester.get_full_name()}"
def clean(self):
"""اعتبارسنجی مدل"""
if self.well and self.representative and self.well.representative != self.representative:
raise ValidationError("نماینده درخواست باید همان نماینده ثبت شده در چاه باشد")
if self.well and self.representative and self.requester == self.representative:
raise ValidationError("درخواست کننده نمی‌تواند نماینده چاه باشد")
def save(self, *args, **kwargs):
# Generate unique 5-digit numeric code if missing
if not self.code:
# Try a few times to avoid rare collisions
for _ in range(10):
candidate = f"{random.randint(10000, 99999)}"
if not ProcessInstance.objects.filter(code=candidate).exists():
self.code = candidate
break
# As a fallback if collision persists (very unlikely)
if not self.code:
self.code = f"{random.randint(10000, 99999)}"
if not self.slug:
slug_text = f"{self.process.name}-{self.well.water_subscription_number if self.well else 'unknown'}-{timezone.now().strftime('%Y%m%d')}"
self.slug = generate_unique_slug(slug_text)
if self.status == 'completed' and not self.completed_at:
self.completed_at = timezone.now()
super().save(*args, **kwargs)
def get_status_display_with_color(self):
"""نمایش وضعیت با رنگ"""
status_colors = {
'pending': 'info',
'in_progress': 'primary',
'completed': 'success',
'rejected': 'danger',
'cancelled': 'warning',
}
color = status_colors.get(self.status, 'secondary')
return '<span class="badge bg-{}">{}</span>'.format(color, self.get_status_display())
def get_priority_display_with_color(self):
"""نمایش اولویت با رنگ"""
priority_colors = {
'low': 'success',
'medium': 'info',
'high': 'warning',
'urgent': 'danger',
}
color = priority_colors.get(self.priority, 'secondary')
return '<span class="badge bg-{}">{}</span>'.format(color, self.get_priority_display())
def can_edit(self, user):
"""بررسی امکان ویرایش درخواست"""
if self.status == 'pending' and self.requester == user:
return True
if self.representative == user and self.status in ['pending']:
return True
return False
def get_available_steps(self):
"""دریافت مراحل قابل دسترس"""
available_steps = []
@ -134,7 +272,6 @@ class ProcessInstance(NameSlugModel):
def can_access_step(self, step):
"""بررسی امکان دسترسی به مرحله"""
# بررسی وابستگی‌ها
dependencies = step.get_dependencies()
for dependency_id in dependencies:
step_instance = self.step_instances.filter(step_id=dependency_id).first()
@ -144,7 +281,6 @@ class ProcessInstance(NameSlugModel):
def can_edit_step(self, step):
"""بررسی امکان ویرایش مرحله"""
# اگر مرحله مسدود کننده باشد و مراحل بعدی تکمیل شده باشند
if step.blocks_previous:
later_steps = self.step_instances.filter(
step__order__gt=step.order,
@ -187,12 +323,10 @@ class StepInstance(models.Model):
def save(self, *args, **kwargs):
"""ذخیره با اعتبارسنجی"""
# بررسی وابستگی‌ها
if self.status == 'in_progress' or self.status == 'completed':
if not self.process_instance.can_access_step(self.step):
raise ValidationError("مراحل وابسته تکمیل نشده‌اند")
# بررسی امکان ویرایش
if self.status == 'completed' and not self.process_instance.can_edit_step(self.step):
raise ValidationError("این مرحله قابل ویرایش نیست")
@ -252,7 +386,6 @@ class StepRejection(models.Model):
def save(self, *args, **kwargs):
"""ذخیره با تغییر وضعیت مرحله"""
# تغییر وضعیت مرحله به رد شده
self.step_instance.status = 'rejected'
self.step_instance.save()
super().save(*args, **kwargs)

View file

@ -0,0 +1,54 @@
{% load static %}
<div class="bs-stepper-header">
{% for item in steps_context %}
{% with step=item.step status=item.status can_access=item.can_access is_selected=item.is_selected is_todo=item.is_todo %}
<div class="step
{% if not can_access %}disabled
{% elif status == 'completed' %}completed
{% elif is_todo %}active
{% endif %}
{% if is_selected %} selected{% endif %}"
data-target="#step-{{ step.id }}">
{% if can_access %}
<a href="{% url 'processes:step_detail' instance.id step.id %}"
class="step-trigger text-decoration-none p-0"
aria-selected="{% if is_selected %}true{% else %}false{% endif %}">
{% else %}
<span class="step-trigger">
{% endif %}
<span class="bs-stepper-circle">{{ forloop.counter }}</span>
<span class="bs-stepper-label mt-1">
<span class="bs-stepper-title">{{ step.name }}</span>
<span class="bs-stepper-subtitle">{{ step.description|default:' ' }}</span>
</span>
{% if can_access %}
</a>
{% else %}
</span>
{% endif %}
</div>
{% endwith %}
{% if not forloop.last %}<div class="line"></div>{% endif %}
{% endfor %}
</div>
<style>
.step.disabled .step-trigger { opacity: 0.5; cursor: not-allowed; }
.step.disabled .bs-stepper-circle { background-color: #6c757d; }
/* تکمیل شده */
.step.completed .bs-stepper-circle { background-color: #28a745 !important; color: #fff !important; border-color: #28a745 !important; }
.step.completed .bs-stepper-title { color: #28a745 !important; }
/* مرحله‌ای که باید انجام شود (فعلی سیستم) */
.step.active .bs-stepper-circle { background-color: #696cff; color: #fff; }
.step.active .bs-stepper-title { color: #696cff; }
/* مرحله انتخاب‌شده (نمایش فعلی صفحه) */
.step.selected { outline: 1px solid #696cff; border-radius: 8px; }
.step.selected .bs-stepper-circle { box-shadow: 0 0 0 3px rgba(13,202,240,.25); }
</style>

View file

@ -0,0 +1,774 @@
{% extends '_base.html' %}
{% load static %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
{% endblock sidebar %}
{% block navbar %}
{% include 'navbars/admin.html' %}
{% endblock navbar %}
{% block title %}درخواست‌ها{% endblock %}
{% block style %}
<!-- DataTables CSS -->
<link rel="stylesheet" href="{% static 'assets/vendor/libs/datatables-bs5/datatables.bootstrap5.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/libs/datatables-responsive-bs5/responsive.bootstrap5.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/libs/datatables-buttons-bs5/buttons.bootstrap5.css' %}">
<!-- Persian Date Picker CSS -->
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
{% endblock style %}
{% block content %}
{% include '_toasts.html' %}
<div class="container-xxl flex-grow-1 container-p-y">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">درخواست‌ها</h4>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#requestModal">
<i class="bx bx-plus"></i>
درخواست جدید
</button>
</div>
<div class="card">
<div class="table-responsive">
<table id="requestsTable" class="table table-striped">
<thead>
<tr>
<th>شناسه</th>
<th>فرآیند</th>
<th>شماره اشتراک آب</th>
<th>نماینده</th>
<th>درخواست‌کننده</th>
<th>اولویت</th>
<th>وضعیت</th>
<th>تاریخ ایجاد</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
{% for inst in instances %}
<tr>
<td>{{ inst.code }}</td>
<td>{{ inst.process.name }}</td>
<td>{{ inst.well.water_subscription_number }}</td>
<td>{% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %}</td>
<td>{% if inst.requester %}{{ inst.requester.get_full_name }}{% else %}-{% endif %}</td>
<td>{{ inst.get_priority_display }}</td>
<td>{{ inst.get_status_display }}</td>
<td>{{ inst.jcreated }}</td>
<td>
<div class="d-inline-block">
<a href="javascript:;" class="btn btn-icon dropdown-toggle hide-arrow" data-bs-toggle="dropdown">
<i class="icon-base bx bx-dots-vertical-rounded"></i>
</a>
<ul class="dropdown-menu dropdown-menu-end m-0">
<li>
<a href="{% url 'processes:instance_steps' inst.id %}" class="dropdown-item">
<i class="bx bx-show me-1"></i>مشاهده جزئیات
</a>
</li>
<div class="dropdown-divider"></div>
<li>
<a href="#" class="dropdown-item text-danger" data-instance-id="{{ inst.id }}" data-instance-code="{{ inst.code }}" onclick="deleteRequest(this.getAttribute('data-instance-id'), this.getAttribute('data-instance-code'))">
<i class="bx bx-trash me-1"></i>حذف
</a>
</li>
</ul>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="text-center text-muted">موردی ثبت نشده است</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="requestModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">درخواست جدید</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="requestForm">
{% csrf_token %}
<div class="row g-3">
<div class="col-sm-12">
<label class="form-label">فرآیند</label>
<select class="form-select" name="process" id="req_process" required>
{% for process in processes %}
<option value="{{ process.id }}">{{ process.name }}</option>
{% endfor %}
</select>
</div>
<hr class="mt-3 border border-dashed">
<div class="col-sm-12">
<label class="form-label">شماره اشتراک آب</label>
<div class="input-group">
<input type="text" class="form-control" id="req_water_sub" placeholder="مثال: 12345" required>
<button class="btn btn-outline-secondary" type="button" id="btnLookupWell">
بررسی/افزودن چاه
</button>
</div>
<div class="form-text" id="wellStatus"></div>
</div>
<!-- Well form fields (from WellForm) -->
<div id="wellFormBlock" class="col-sm-12" style="display:none;">
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label" for="id_electricity_subscription_number">{{ well_form.electricity_subscription_number.label }}</label>
{{ well_form.electricity_subscription_number }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_water_meter_manufacturer">{{ well_form.water_meter_manufacturer.label }}</label>
<div class="input-group">
<select name="water_meter_manufacturer" class="form-select" id="id_water_meter_manufacturer">
<option value="" selected="">انتخاب شرکت سازنده</option>
{% for manufacturer in manufacturers %}
<option value="{{ manufacturer.id }}">{{ manufacturer.name }}</option>
{% endfor %}
</select>
<input type="text" class="form-control" id="id_new_manufacturer" name="new_manufacturer" placeholder="شرکت سازنده جدید" style="display:none;">
<button class="btn btn-outline-primary" type="button" id="btnToggleManufacturer">
<i class="bx bx-plus"></i>
</button>
</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="id_water_meter_serial_number">{{ well_form.water_meter_serial_number.label }}</label>
{{ well_form.water_meter_serial_number }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_water_meter_old_serial_number">{{ well_form.water_meter_old_serial_number.label }}</label>
{{ well_form.water_meter_old_serial_number }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_utm_x">{{ well_form.utm_x.label }}</label>
{{ well_form.utm_x }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_utm_y">{{ well_form.utm_y.label }}</label>
{{ well_form.utm_y }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_utm_zone">{{ well_form.utm_zone.label }}</label>
{{ well_form.utm_zone }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_utm_hemisphere">{{ well_form.utm_hemisphere.label }}</label>
{{ well_form.utm_hemisphere }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_well_power">{{ well_form.well_power.label }}</label>
{{ well_form.well_power }}
</div>
<div class="col-sm-6"></div>
<div class="col-sm-6">
<label class="form-label" for="id_reference_letter_number">{{ well_form.reference_letter_number.label }}</label>
{{ well_form.reference_letter_number }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_reference_letter_date">{{ well_form.reference_letter_date.label }}</label>
<input type="text" class="form-control" id="id_reference_letter_date" name="reference_letter_date" placeholder="انتخاب تاریخ" readonly>
</div>
<div class="col-sm-12">
<label class="form-label" for="id_representative_letter_file">{{ well_form.representative_letter_file.label }}</label>
{{ well_form.representative_letter_file }}
<!-- نمایش فایل موجود -->
<div id="current-file-display" style="display: none; margin-top: 10px;">
<div class="alert alert-info d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="bx bx-file me-2"></i>
<span id="current-file-name" class="text-truncate" style="max-width: 200px;" title=""></span>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeCurrentFile()">
<i class="bx bx-trash me-1"></i>حذف
</button>
</div>
</div>
<input type="hidden" id="remove-file" name="remove_file" value="false">
</div>
</div>
</div>
<hr class="mt-3 border border-dashed">
<div class="col-sm-12">
<label class="form-label">کد ملی نماینده</label>
<div class="input-group">
<input type="text" class="form-control" id="rep_national_code" placeholder="مثال: 0012345678">
<button class="btn btn-outline-secondary" type="button" id="btnLookupRep">
بررسی/افزودن نماینده
</button>
</div>
<div class="form-text" id="repStatus"></div>
</div>
<div id="repNewFields" class="col-sm-12" style="display:none;">
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label" for="id_first_name">{{ customer_form.first_name.label }}</label>
{{ customer_form.first_name }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_last_name">{{ customer_form.last_name.label }}</label>
{{ customer_form.last_name }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_phone_number_1">{{ customer_form.phone_number_1.label }}</label>
{{ customer_form.phone_number_1 }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_phone_number_2">{{ customer_form.phone_number_2.label }}</label>
{{ customer_form.phone_number_2 }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_national_code">{{ customer_form.national_code.label }}</label>
{{ customer_form.national_code }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_card_number">{{ customer_form.card_number.label }}</label>
{{ customer_form.card_number }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_account_number">{{ customer_form.account_number.label }}</label>
{{ customer_form.account_number }}
</div>
<div class="col-sm-12">
<label class="form-label" for="id_address">{{ customer_form.address.label }}</label>
{{ customer_form.address }}
</div>
</div>
</div>
<hr class="mt-3 border border-dashed">
<div class="col-sm-12">
<label class="form-label">توضیحات</label>
<textarea class="form-control" rows="3" id="req_description"></textarea>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">بستن</button>
<button type="button" class="btn btn-primary" id="btnSaveRequest" disabled>ذخیره</button>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-labelledby="deleteConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteConfirmModalLabel">تایید حذف</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="deleteConfirmText">آیا از حذف این درخواست اطمینان دارید؟</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">حذف</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<!-- DataTables JS -->
<script src="{% static 'assets/vendor/libs/datatables-bs5/datatables-bootstrap5.js' %}"></script>
<!-- Persian DataTable defaults -->
<script src="{% static 'assets/js/persian-datatable.js' %}"></script>
<!-- Persian Date Picker JS -->
<script src="https://unpkg.com/persian-date@latest/dist/persian-date.min.js"></script>
<script src="https://unpkg.com/persian-datepicker@latest/dist/js/persian-datepicker.min.js"></script>
<script>
// Function to initialize Persian Date Picker
function initPersianDatePicker() {
if ($.fn.persianDatepicker && $('#id_reference_letter_date').length) {
try {
$('#id_reference_letter_date').persianDatepicker({
format: 'YYYY/MM/DD',
initialValue: false,
autoClose: true,
persianDigit: false,
observer: true,
calendar: {
persian: {
locale: 'fa',
leapYearMode: 'astronomical'
}
},
onSelect: function(unix) {
// تبدیل تاریخ شمسی به میلادی برای ارسال به سرور
const gregorianDate = new Date(unix);
const year = gregorianDate.getFullYear();
const month = String(gregorianDate.getMonth() + 1).padStart(2, '0');
const day = String(gregorianDate.getDate()).padStart(2, '0');
const gregorianDateString = `${year}-${month}-${day}`;
// نمایش تاریخ شمسی در فیلد
if (window.persianDate) {
const persianDate = new window.persianDate(unix);
const persianDateString = persianDate.format('YYYY/MM/DD');
$('#id_reference_letter_date').val(persianDateString);
} else {
// اگر persianDate در دسترس نبود، تاریخ میلادی را نمایش بده
$('#id_reference_letter_date').val(gregorianDateString);
}
// ذخیره تاریخ میلادی در فیلد مخفی برای ارسال به سرور
$('#id_reference_letter_date').attr('data-gregorian', gregorianDateString);
}
});
} catch (e) {
console.error('Error initializing Persian Date Picker:', e);
}
}
}
$(function() {
// if ($.fn.DataTable) {
// try {
// $('#requestsTable').DataTable({
// pageLength: 10,
// order: [[0, 'desc']]
// });
// } catch (e) {
// console.error('DataTable init failed', e);
// }
// } else {
// console.warn('DataTables library not loaded');
// }
let currentWellId = null;
let currentRepId = null;
let wellChecked = false;
let repChecked = false;
function setStatus(el, text, type) {
$(el).text(text).removeClass('text-danger text-success text-muted').addClass(type ? 'text-' + type : 'text-muted');
}
function checkSaveButton() {
const canSave = wellChecked && repChecked;
$('#btnSaveRequest').prop('disabled', !canSave);
}
// Inline error helpers
function clearInlineErrors() {
$('#requestModal .is-invalid').removeClass('is-invalid');
$('#requestModal .invalid-feedback.inline-error').remove();
}
function applyErrorTo(selector, message) {
const $el = $(selector);
if (!$el.length) return false;
$el.addClass('is-invalid');
const $feedback = $('<div class="invalid-feedback inline-error"></div>').text(message);
const $grp = $el.closest('.input-group');
if ($grp.length) {
$feedback.insertAfter($grp);
} else {
$feedback.insertAfter($el);
}
return true;
}
function mapWellFieldToSelector(field) {
switch (field) {
case 'water_subscription_number': return '#req_water_sub';
case 'electricity_subscription_number': return '#id_electricity_subscription_number';
case 'water_meter_serial_number': return '#id_water_meter_serial_number';
case 'water_meter_old_serial_number': return '#id_water_meter_old_serial_number';
case 'water_meter_manufacturer': return '#id_water_meter_manufacturer';
case 'new_manufacturer': return '#id_new_manufacturer';
case 'utm_x': return '#id_utm_x';
case 'utm_y': return '#id_utm_y';
case 'utm_zone': return '#id_utm_zone';
case 'utm_hemisphere': return '#id_utm_hemisphere';
case 'well_power': return '#id_well_power';
case 'reference_letter_number': return '#id_reference_letter_number';
case 'reference_letter_date': return '#id_reference_letter_date';
case 'representative_letter_file': return '#id_representative_letter_file';
case 'representative': return '#rep_national_code';
default: return '#id_' + field;
}
}
function mapCustomerFieldToSelector(field) {
switch (field) {
case 'national_code': return $('#id_national_code').length ? '#id_national_code' : '#rep_national_code';
case 'first_name': return '#id_first_name';
case 'last_name': return '#id_last_name';
case 'phone_number_1': return '#id_phone_number_1';
case 'phone_number_2': return '#id_phone_number_2';
case 'card_number': return '#id_card_number';
case 'account_number': return '#id_account_number';
case 'address': return '#id_address';
default: return '#id_' + field;
}
}
function showInlineErrors(errors) {
if (!errors) return;
let nonFieldWell = '';
let nonFieldCustomer = '';
if (errors.well) {
for (const key in errors.well) {
const msgs = Array.isArray(errors.well[key]) ? errors.well[key] : [errors.well[key]];
if (key === '__all__' || key === 'non_field_errors') { nonFieldWell = msgs.join('، '); continue; }
const sel = mapWellFieldToSelector(key);
applyErrorTo(sel, msgs[0]);
}
}
if (errors.customer) {
for (const key in errors.customer) {
const msgs = Array.isArray(errors.customer[key]) ? errors.customer[key] : [errors.customer[key]];
if (key === '__all__' || key === 'non_field_errors') { nonFieldCustomer = msgs.join('، '); continue; }
const sel = mapCustomerFieldToSelector(key);
applyErrorTo(sel, msgs[0]);
}
}
if (nonFieldWell) setStatus('#wellStatus', nonFieldWell, 'danger');
if (nonFieldCustomer) setStatus('#repStatus', nonFieldCustomer, 'danger');
}
$('#btnLookupWell').on('click', function() {
const sub = $('#req_water_sub').val().trim();
if (!sub) { setStatus('#wellStatus', 'لطفا شماره اشتراک آب را وارد کنید', 'danger'); return; }
setStatus('#wellStatus', 'در حال بررسی...', 'muted');
wellChecked = true;
checkSaveButton();
$.get('{% url "processes:lookup_well_by_subscription" %}', { water_subscription_number: sub })
.done(function(resp){
if (resp.exists) {
currentWellId = resp.well.id;
$('#wellFormBlock').show();
// Initialize Persian Date Picker after well form is shown
setTimeout(initPersianDatePicker, 100);
// Prefill well form
$('#id_electricity_subscription_number').val(resp.well.electricity_subscription_number || '');
$('#id_water_meter_serial_number').val(resp.well.water_meter_serial_number || '');
$('#id_water_meter_old_serial_number').val(resp.well.water_meter_old_serial_number || '');
$('#id_water_meter_manufacturer').val(resp.well.water_meter_manufacturer || '');
$('#id_utm_x').val(resp.well.utm_x || '');
$('#id_utm_y').val(resp.well.utm_y || '');
$('#id_utm_zone').val(resp.well.utm_zone || '');
$('#id_utm_hemisphere').val(resp.well.utm_hemisphere || '');
$('#id_well_power').val(resp.well.well_power || '');
$('#id_reference_letter_number').val(resp.well.reference_letter_number || '');
// Prefill date: show Persian in input, keep Gregorian in data attribute
if (resp.well.reference_letter_date) {
try {
if (window.persianDate) {
const gregorianDate = new Date(resp.well.reference_letter_date);
const persianDateObj = new window.persianDate(gregorianDate);
const persianDateString = persianDateObj.format('YYYY/MM/DD');
$('#id_reference_letter_date').val(persianDateString);
} else {
$('#id_reference_letter_date').val(resp.well.reference_letter_date);
}
$('#id_reference_letter_date').attr('data-gregorian', resp.well.reference_letter_date);
} catch (e) {
$('#id_reference_letter_date').val(resp.well.reference_letter_date);
}
} else {
$('#id_reference_letter_date').val('');
$('#id_reference_letter_date').removeAttr('data-gregorian');
}
// Existing representative letter file display
if (resp.well.representative_letter_file_url) {
$('#current-file-display').show();
const fileName = resp.well.representative_letter_file_name || 'فایل موجود';
$('#current-file-name').text(fileName).attr('title', fileName);
$('#id_representative_letter_file').hide();
$('#remove-file').val('false');
} else {
$('#current-file-display').hide();
$('#id_representative_letter_file').show();
$('#remove-file').val('false');
}
setStatus('#wellStatus', 'چاه یافت شد', 'success');
} else {
currentWellId = null;
$('#wellFormBlock').show();
$('#wellFormBlock').find('input, select').val('');
$('#id_reference_letter_date').removeAttr('data-gregorian');
// Reset file UI for new well
$('#current-file-display').hide();
$('#id_representative_letter_file').show().val('');
$('#remove-file').val('false');
// Initialize Persian Date Picker after well form is shown
setTimeout(initPersianDatePicker, 100);
setStatus('#wellStatus', 'چاه یافت نشد. با ذخیره، ایجاد خواهد شد.', 'danger');
}
})
.fail(function(){ setStatus('#wellStatus', 'خطا در بررسی چاه', 'danger'); });
});
$('#btnLookupRep').on('click', function() {
const nc = $('#rep_national_code').val().trim();
if (!nc) { setStatus('#repStatus', 'لطفا کد ملی نماینده را وارد کنید', 'danger'); return; }
setStatus('#repStatus', 'در حال بررسی...', 'muted');
repChecked = true;
checkSaveButton();
$.get('{% url "processes:lookup_representative_by_national_code" %}', { national_code: nc })
.done(function(resp){
if (resp.exists) {
currentRepId = resp.user.id;
$('#repNewFields').show();
// Prefill customer form fields for editing
$('#id_first_name').val(resp.user.first_name || '');
$('#id_last_name').val(resp.user.last_name || '');
if (resp.user.profile) {
$('#id_national_code').val(resp.user.profile.national_code || nc);
$('#id_phone_number_1').val(resp.user.profile.phone_number_1 || '');
$('#id_phone_number_2').val(resp.user.profile.phone_number_2 || '');
$('#id_card_number').val(resp.user.profile.card_number || '');
$('#id_account_number').val(resp.user.profile.account_number || '');
$('#id_address').val(resp.user.profile.address || '');
} else {
$('#id_national_code').val(nc);
$('#id_phone_number_1').val('');
$('#id_phone_number_2').val('');
$('#id_card_number').val('');
$('#id_account_number').val('');
$('#id_address').val('');
}
setStatus('#repStatus', 'نماینده یافت شد.', 'success');
} else {
currentRepId = null;
$('#repNewFields').show();
// Clear form and prefill national code
$('#id_first_name').val('');
$('#id_last_name').val('');
$('#id_national_code').val(nc);
$('#id_phone_number_1').val('');
$('#id_phone_number_2').val('');
$('#id_card_number').val('');
$('#id_account_number').val('');
$('#id_address').val('');
setStatus('#repStatus', 'نماینده یافت نشد. لطفا اطلاعات را تکمیل کنید.', 'danger');
}
})
.fail(function(){ setStatus('#repStatus', 'خطا در بررسی نماینده', 'danger'); });
});
$('#btnSaveRequest').on('click', function(){
const formData = new FormData();
formData.append('csrfmiddlewaretoken', $('input[name=csrfmiddlewaretoken]').val());
formData.append('process', $('#req_process').val());
formData.append('description', $('#req_description').val());
formData.append('water_subscription_number', $('#req_water_sub').val().trim());
if (currentWellId) formData.append('well_id', currentWellId);
if (currentRepId) formData.append('representative_id', currentRepId);
// Send fields using CustomerForm names if visible
const ncField = $('#id_national_code').length ? $('#id_national_code').val() : '';
formData.append('national_code', (ncField || $('#rep_national_code').val().trim()));
formData.append('first_name', $('#id_first_name').val() || '');
formData.append('last_name', $('#id_last_name').val() || '');
formData.append('phone_number_1', $('#id_phone_number_1').val() || '');
formData.append('phone_number_2', $('#id_phone_number_2').val() || '');
formData.append('card_number', $('#id_card_number').val() || '');
formData.append('account_number', $('#id_account_number').val() || '');
formData.append('address', $('#id_address').val() || '');
// Include WellForm fields so edits are saved
if ($('#wellFormBlock').is(':visible')) {
formData.append('electricity_subscription_number', $('#id_electricity_subscription_number').val() || '');
formData.append('water_meter_serial_number', $('#id_water_meter_serial_number').val() || '');
formData.append('water_meter_old_serial_number', $('#id_water_meter_old_serial_number').val() || '');
formData.append('water_meter_manufacturer', $('#id_water_meter_manufacturer').is(':visible') ? ($('#id_water_meter_manufacturer').val() || '') : '');
formData.append('new_manufacturer', $('#id_new_manufacturer').is(':visible') ? ($('#id_new_manufacturer').val() || '') : '');
formData.append('utm_x', $('#id_utm_x').val() || '');
formData.append('utm_y', $('#id_utm_y').val() || '');
formData.append('utm_zone', $('#id_utm_zone').val() || '');
formData.append('utm_hemisphere', $('#id_utm_hemisphere').val() || '');
formData.append('well_power', $('#id_well_power').val() || '');
formData.append('reference_letter_number', $('#id_reference_letter_number').val() || '');
// Use gregorian date if available, otherwise use the field value
const gregorianDate = $('#id_reference_letter_date').attr('data-gregorian');
formData.append('reference_letter_date', gregorianDate || $('#id_reference_letter_date').val() || '');
// Remove flag
formData.append('remove_file', $('#remove-file').val() || 'false');
const repFile = document.getElementById('id_representative_letter_file');
if (repFile && repFile.files && repFile.files[0]) {
formData.append('representative_letter_file', repFile.files[0]);
}
}
const $btn = $(this).prop('disabled', true).text('در حال ذخیره...');
$.ajax({
url: '{% url "processes:create_request_with_entities" %}',
method: 'POST',
data: formData,
processData: false,
contentType: false,
}).done(function(resp){
if (resp.ok) {
showToast('درخواست با موفقیت ثبت شد', 'success');
if (resp.redirect) {
setTimeout(function(){ window.location.href = resp.redirect; }, 800);
} else {
setTimeout(function(){ location.reload(); }, 1200);
}
} else {
const msg = buildErrorMessage(resp);
showToast(msg, 'danger');
}
}).fail(function(xhr){
let msg = 'خطا در ذخیره';
try {
const resp = JSON.parse(xhr.responseText);
msg = buildErrorMessage(resp) || msg;
} catch(e) {}
showToast(msg, 'danger');
}).always(function(){
$btn.prop('disabled', false).text('ذخیره');
});
});
function buildErrorMessage(resp){
if (!resp) return '';
if (resp.error) return resp.error;
if (resp.errors) {
// Collect form-related errors
const parts = [];
if (resp.errors.customer) {
parts.push('خطای نماینده: ' + flattenErrors(resp.errors.customer));
}
if (resp.errors.well) {
parts.push('خطای چاه: ' + flattenErrors(resp.errors.well));
}
return parts.join(' | ');
}
return '';
}
function flattenErrors(errorsObj){
if (typeof errorsObj === 'string') return errorsObj;
try {
const parts = [];
for (const k in errorsObj){
const v = errorsObj[k];
if (Array.isArray(v)) parts.push(`${k}: ${v[0]}`);
else if (typeof v === 'string') parts.push(`${k}: ${v}`);
}
return parts.join('، ');
} catch(e){
return '';
}
}
$('#btnToggleManufacturer').on('click', function() {
const $select = $('#id_water_meter_manufacturer');
const $input = $('#id_new_manufacturer');
const $btn = $(this);
if ($select.is(':visible')) {
$select.hide();
$input.show().focus();
$btn.html('<i class="bx bx-check"></i>');
} else {
$input.hide();
$select.show();
$btn.html('<i class="bx bx-plus"></i>');
}
});
$('#requestModal').on('hidden.bs.modal', function(){
$('#requestForm')[0].reset();
$('#wellFormBlock').hide();
$('#repNewFields').hide();
$('#id_reference_letter_date').removeAttr('data-gregorian');
// Reset file UI
$('#current-file-display').hide();
$('#id_representative_letter_file').show().val('');
$('#remove-file').val('false');
setStatus('#wellStatus', '', '');
setStatus('#repStatus', '', '');
currentWellId = null;
currentRepId = null;
wellChecked = false;
repChecked = false;
checkSaveButton();
clearInlineErrors(); // Clear inline errors on modal close
});
// Handle selecting a new file: hide existing file display and cancel removal flag
$('#id_representative_letter_file').on('change', function() {
if (this.files && this.files.length > 0) {
$('#current-file-display').hide();
$('#remove-file').val('false');
}
});
// Expose remove function
window.removeCurrentFile = function() {
$('#current-file-display').hide();
$('#remove-file').val('true');
$('#id_representative_letter_file').show().val('');
};
// Delete request function
window.deleteRequest = function(instanceId, instanceCode) {
// Set modal content
document.getElementById('deleteConfirmText').textContent = `آیا از حذف درخواست ${instanceCode} اطمینان دارید؟`;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
modal.show();
// Handle confirm button click
document.getElementById('confirmDeleteBtn').onclick = function() {
$.ajax({
url: '{% url "processes:delete_request" 0 %}'.replace('0', instanceId),
type: 'POST',
data: {
'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()
},
success: function(response) {
if (response.success) {
showToast(response.message, 'success');
modal.hide();
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
showToast(response.message, 'danger');
}
},
error: function() {
showToast('خطا در ارتباط با سرور', 'danger');
}
});
};
};
});
</script>
{% endblock %}

View file

@ -0,0 +1,97 @@
{% extends '_base.html' %}
{% load static %}
{% load processes_tags %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
{% endblock sidebar %}
{% block navbar %}
{% include 'navbars/admin.html' %}
{% endblock navbar %}
{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
{% block style %}
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
{% endblock %}
{% block content %}
{% include '_toasts.html' %}
<div class="container-xxl flex-grow-1 container-p-y">
<div class="row">
<div class="col-12 mb-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small>
</div>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div>
<div class="bs-stepper wizard-vertical vertical mt-2">
{% stepper_header instance step %}
<div class="bs-stepper-content">
<div class="content active dstepper-block">
<div class="content-header mb-3">
<h6 class="mb-0">{{ step.name }}</h6>
<small>{{ step.description|default:' ' }}</small>
</div>
<div class="row g-3">
<div class="col-12">
<div class="alert alert-info">
<h6>وضعیت مرحله:
{% if step_instance %}
{{ step_instance.get_status_display_with_color|safe }}
{% else %}
<span class="badge bg-secondary">در انتظار</span>
{% endif %}
</h6>
<p class="mb-0">فرم این مرحله بعداً پیاده‌سازی می‌شود.</p>
{% if step_instance and step_instance.notes %}
<hr>
<strong>یادداشت‌ها:</strong>
<p class="mb-0">{{ step_instance.notes }}</p>
{% endif %}
</div>
</div>
<div class="col-12 d-flex justify-content-between">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}"
class="btn btn-label-secondary">
<i class="bx bx-chevron-left bx-sm ms-sm-n2"></i>
<span class="align-middle d-sm-inline-block d-none">قبلی</span>
</a>
{% else %}
<span></span>
{% endif %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}"
class="btn btn-primary">
<span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span>
<i class="bx bx-chevron-right bx-sm me-sm-n2"></i>
</a>
{% else %}
<button class="btn btn-success" type="button">اتمام</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script src="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.js' %}"></script>
{% endblock %}

View file

View file

@ -0,0 +1,48 @@
from django import template
from django.utils.safestring import mark_safe
from ..models import ProcessInstance, StepInstance
register = template.Library()
@register.filter(name='get_item')
def get_item(mapping, key):
try:
return mapping.get(key)
except Exception:
return None
@register.inclusion_tag('processes/includes/stepper_header.html')
def stepper_header(instance, current_step=None):
"""رندر کردن header مراحل برای instance"""
if not isinstance(instance, ProcessInstance):
return {}
steps = list(instance.process.steps.all().order_by('order'))
step_instances = instance.step_instances.select_related('step').all()
step_id_to_status = {si.step_id: si.status for si in step_instances}
steps_context = []
for step in steps:
step_instance = next((si for si in step_instances if si.step_id == step.id), None)
status = step_id_to_status.get(step.id, 'pending')
# بررسی دسترسی به مرحله
can_access = instance.can_access_step(step)
# مرحله انتخاب‌شده (نمایش فعلی)
is_selected = bool(current_step and step.id == current_step.id)
# مرحله‌ای که باید انجام شود (مرحله جاری در instance)
is_todo = bool(instance.current_step and step.id == instance.current_step.id)
steps_context.append({
'step': step,
'status': status,
'can_access': can_access,
'is_selected': is_selected,
'is_todo': is_todo,
'step_instance': step_instance,
})
return {
'instance': instance,
'steps_context': steps_context,
}

View file

@ -4,9 +4,21 @@ from . import views
app_name = 'processes'
urlpatterns = [
# Requests UI
path('requests/', views.request_list, name='request_list'),
path('requests/create/', views.create_request_with_entities, name='create_request_with_entities'),
path('requests/lookup/well/', views.lookup_well_by_subscription, name='lookup_well_by_subscription'),
path('requests/lookup/representative/', views.lookup_representative_by_national_code, name='lookup_representative_by_national_code'),
path('requests/<int:instance_id>/delete/', views.delete_request, name='delete_request'),
# New step-based architecture
path('instance/<int:instance_id>/steps/', views.instance_steps, name='instance_steps'),
path('instance/<int:instance_id>/step/<int:step_id>/', views.step_detail, name='step_detail'),
# Legacy process views
path('', views.process_list, name='process_list'),
path('<int:process_id>/', views.process_detail, name='process_detail'),
path('<int:process_id>/start/', views.start_process, name='start_process'),
path('instance/<int:instance_id>/', views.instance_detail, name='instance_detail'),
path('my-processes/', views.my_processes, name='my_processes'),
]
]

View file

@ -1,10 +1,21 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
import json
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_POST, require_GET
from django.db import transaction
from django.contrib.auth import get_user_model
from .models import Process, ProcessInstance, StepInstance
from wells.models import Well
from accounts.models import Profile
from .forms import ProcessInstanceForm
from accounts.forms import CustomerForm
from wells.forms import WellForm
from wells.models import WaterMeterManufacturer
@login_required
def process_list(request):
@ -22,6 +33,272 @@ def process_detail(request, process_id):
'process': process
})
@login_required
def request_list(request):
"""نمایش لیست درخواست‌ها با جدول و مدال ایجاد"""
instances = ProcessInstance.objects.select_related('well', 'representative', 'requester').filter(is_deleted=False).order_by('-created')
processes = Process.objects.filter(is_active=True)
manufacturers = WaterMeterManufacturer.objects.all().order_by('name')
return render(request, 'processes/request_list.html', {
'instances': instances,
'customer_form': CustomerForm(),
'well_form': WellForm(),
'processes': processes,
'manufacturers': manufacturers
})
@require_GET
@login_required
def lookup_well_by_subscription(request):
sub = request.GET.get('water_subscription_number', '').strip()
if not sub:
return JsonResponse({'ok': False, 'error': 'شماره اشتراک الزامی است'}, status=400)
try:
well = Well.objects.select_related('representative', 'water_meter_manufacturer').get(water_subscription_number=sub)
data = {
'id': well.id,
'water_subscription_number': well.water_subscription_number,
'electricity_subscription_number': well.electricity_subscription_number,
'water_meter_serial_number': well.water_meter_serial_number,
'water_meter_old_serial_number': well.water_meter_old_serial_number,
'water_meter_manufacturer': well.water_meter_manufacturer.id if well.water_meter_manufacturer else None,
'utm_x': str(well.utm_x) if well.utm_x is not None else None,
'utm_y': str(well.utm_y) if well.utm_y is not None else None,
'utm_zone': well.utm_zone,
'utm_hemisphere': well.utm_hemisphere,
'well_power': well.well_power,
'reference_letter_number': well.reference_letter_number,
'reference_letter_date': well.reference_letter_date.isoformat() if well.reference_letter_date else None,
'representative_letter_file_url': well.representative_letter_file.url if well.representative_letter_file else '',
'representative_letter_file_name': well.representative_letter_file.name.split('/')[-1] if well.representative_letter_file else '',
'representative_id': well.representative.id if well.representative else None,
'representative_full_name': well.representative.get_full_name() if well.representative else None,
}
return JsonResponse({'ok': True, 'exists': True, 'well': data})
except Well.DoesNotExist:
return JsonResponse({'ok': True, 'exists': False})
@require_GET
@login_required
def lookup_representative_by_national_code(request):
national_code = request.GET.get('national_code', '').strip()
if not national_code:
return JsonResponse({'ok': False, 'error': 'کد ملی الزامی است'}, status=400)
profile = Profile.objects.select_related('user').filter(national_code=national_code).first()
if not profile:
return JsonResponse({'ok': True, 'exists': False})
user = profile.user
return JsonResponse({
'ok': True,
'exists': True,
'user': {
'id': user.id,
'username': user.username,
'first_name': user.first_name,
'last_name': user.last_name,
'full_name': user.get_full_name(),
'profile': {
'national_code': profile.national_code,
'phone_number_1': profile.phone_number_1,
'phone_number_2': profile.phone_number_2,
'card_number': profile.card_number,
'account_number': profile.account_number,
'address': profile.address,
}
}
})
@require_POST
@login_required
@transaction.atomic
def create_request_with_entities(request):
"""ایجاد/به‌روزرسانی چاه و نماینده و سپس ایجاد درخواست"""
User = get_user_model()
process_id = request.POST.get('process')
process = Process.objects.get(id=process_id)
description = request.POST.get('description', '')
# Well fields
water_subscription_number = request.POST.get('water_subscription_number')
well_id = request.POST.get('well_id') # optional if existing
# Representative fields
representative_id = request.POST.get('representative_id')
# Prefer plain CustomerForm keys; fallback to representative_* keys
representative_national_code = request.POST.get('national_code') or request.POST.get('representative_national_code')
representative_first_name = request.POST.get('first_name') or request.POST.get('representative_first_name')
representative_last_name = request.POST.get('last_name') or request.POST.get('representative_last_name')
representative_username = request.POST.get('username') or request.POST.get('representative_username')
representative_phone_number_1 = request.POST.get('phone_number_1') or request.POST.get('representative_phone_number_1')
representative_phone_number_2 = request.POST.get('phone_number_2') or request.POST.get('representative_phone_number_2')
representative_card_number = request.POST.get('card_number') or request.POST.get('representative_card_number')
representative_account_number = request.POST.get('account_number') or request.POST.get('representative_account_number')
representative_address = request.POST.get('address') or request.POST.get('representative_address')
if not process_id:
return JsonResponse({'ok': False, 'errors': {'request': {'process': ['فرآیند الزامی است']}}}, status=400)
if not water_subscription_number:
return JsonResponse({'ok': False, 'errors': {'well': {'water_subscription_number': ['شماره اشتراک آب الزامی است']}}}, status=400)
if not representative_id and not representative_national_code:
return JsonResponse({'ok': False, 'errors': {'customer': {'national_code': ['کد ملی نماینده را وارد کنید یا دکمه بررسی/افزودن نماینده را بزنید']}}}, status=400)
representative_user = None
representative_profile = None
if representative_id:
representative_profile = Profile.objects.select_related('user').filter(user_id=representative_id).first()
if not representative_profile:
return JsonResponse({'ok': False, 'errors': {'customer': {'__all__': ['نماینده انتخاب‌شده یافت نشد']}}}, status=400)
representative_user = representative_profile.user
# Optionally update if fields provided
changed = False
if representative_first_name:
representative_user.first_name = representative_first_name
changed = True
if representative_last_name:
representative_user.last_name = representative_last_name
changed = True
if representative_username:
representative_user.username = representative_username
changed = True
if changed:
representative_user.save()
if representative_national_code:
representative_profile.national_code = representative_national_code
if representative_phone_number_1 is not None:
representative_profile.phone_number_1 = representative_phone_number_1
if representative_phone_number_2 is not None:
representative_profile.phone_number_2 = representative_phone_number_2
if representative_card_number is not None:
representative_profile.card_number = representative_card_number
if representative_account_number is not None:
representative_profile.account_number = representative_account_number
if representative_address is not None:
representative_profile.address = representative_address
representative_profile.save()
else:
# Use CustomerForm to validate/create/update representative profile by national code
profile_instance = None
if representative_national_code:
profile_instance = Profile.objects.filter(national_code=representative_national_code).first()
customer_data = {
'first_name': representative_first_name or '',
'last_name': representative_last_name or '',
'phone_number_1': representative_phone_number_1 or '',
'phone_number_2': representative_phone_number_2 or '',
'national_code': representative_national_code or '',
'address': representative_address or '',
'card_number': representative_card_number or '',
'account_number': representative_account_number or '',
}
customer_form = CustomerForm(customer_data, instance=profile_instance)
customer_form.request = request
if not customer_form.is_valid():
return JsonResponse({'ok': False, 'errors': {'customer': customer_form.errors}}, status=400)
representative_profile = customer_form.save()
representative_user = representative_profile.user
# Resolve/create/update well
# Build WellForm data from POST
well = None
if well_id:
well = Well.objects.filter(id=well_id).first()
if not well:
return JsonResponse({'ok': False, 'error': 'شناسه چاه نامعتبر است'}, status=400)
else:
existing = Well.objects.filter(water_subscription_number=water_subscription_number).first()
if existing:
well = existing
well_data = request.POST.copy()
# Ensure representative set from created/selected user if not provided
if representative_user and not well_data.get('representative'):
well_data['representative'] = str(representative_user.id)
if not well_data.get('water_subscription_number'):
well_data['water_subscription_number'] = water_subscription_number
# Preserve existing values on partial updates
if well:
for field_name in WellForm.Meta.fields:
if field_name in ('representative_letter_file',):
# File field handled via request.FILES; skip if not provided
continue
incoming = well_data.get(field_name, None)
if incoming is None or incoming == '':
current_value = getattr(well, field_name, None)
if current_value is None:
continue
# Convert FK to id
if hasattr(current_value, 'pk'):
well_data[field_name] = str(current_value.pk)
else:
# Convert dates/decimals/others to string
try:
well_data[field_name] = current_value.isoformat() # dates
except AttributeError:
well_data[field_name] = str(current_value)
well_form = WellForm(well_data, request.FILES, instance=well)
if not well_form.is_valid():
return JsonResponse({'ok': False, 'errors': {'well': well_form.errors}}, status=400)
# Save with ability to remove existing file
well = well_form.save(commit=False)
try:
if request.POST.get('remove_file') == 'true' and getattr(well, 'representative_letter_file', None):
well.representative_letter_file.delete(save=False)
well.representative_letter_file = None
except Exception:
pass
well.save()
# Auto fill geo ownership from current user profile if available
current_profile = getattr(request.user, 'profile', None)
if current_profile:
if hasattr(well, 'affairs'):
well.affairs = current_profile.affairs
if hasattr(well, 'county'):
well.county = current_profile.county
if hasattr(well, 'broker'):
well.broker = current_profile.broker
well.save()
# Create request instance
instance = ProcessInstance.objects.create(
process=process,
description=description,
well=well,
representative=representative_user,
requester=request.user,
status='pending',
priority='medium',
)
# ایجاد نمونه‌های مرحله بر اساس مراحل فرآیند و تنظیم مرحله فعلی
for step in process.steps.all().order_by('order'):
StepInstance.objects.create(
process_instance=instance,
step=step
)
first_step = process.steps.all().order_by('order').first()
if first_step:
instance.current_step = first_step
instance.status = 'in_progress'
instance.save()
redirect_url = reverse('processes:instance_steps', args=[instance.id])
return JsonResponse({'ok': True, 'instance_id': instance.id, 'redirect': redirect_url})
@require_POST
@login_required
def delete_request(request, instance_id):
"""حذف درخواست"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
code = instance.code
instance.delete()
return JsonResponse({
'success': True,
'message': f'درخواست {code} با موفقیت حذف شد'
})
@login_required
def start_process(request, process_id):
"""شروع فرآیند جدید"""
@ -67,6 +344,61 @@ def instance_detail(request, instance_id):
'instance': instance
})
@login_required
def step_detail(request, instance_id, step_id):
"""نمایش جزئیات مرحله خاص"""
instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id
)
step = get_object_or_404(instance.process.steps, id=step_id)
# بررسی دسترسی به مرحله
if not instance.can_access_step(step):
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
return redirect('processes:request_list')
# هدایت به view مناسب بر اساس نوع مرحله
if step.order == 1: # مرحله اول - انتخاب اقلام
return redirect('invoices:quote_step', instance_id=instance.id, step_id=step.id)
elif step.order == 2: # مرحله دوم - صدور پیش‌فاکتور
return redirect('invoices:quote_preview_step', instance_id=instance.id, step_id=step.id)
elif step.order == 3: # مرحله سوم - ثبت فیش‌های واریزی
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
# برای سایر مراحل، template عمومی نمایش داده می‌شود
step_instance = instance.step_instances.filter(step=step).first()
# Navigation logic
previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first()
return render(request, 'processes/step_detail.html', {
'instance': instance,
'step': step,
'step_instance': step_instance,
'previous_step': previous_step,
'next_step': next_step,
})
@login_required
def instance_steps(request, instance_id):
"""هدایت به مرحله فعلی instance"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
if not instance.current_step:
# اگر مرحله فعلی تعریف نشده، به اولین مرحله برو
first_step = instance.process.steps.first()
if first_step:
instance.current_step = first_step
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=first_step.id)
else:
messages.error(request, 'هیچ مرحله‌ای برای این فرآیند تعریف نشده است.')
return redirect('processes:request_list')
return redirect('processes:step_detail', instance_id=instance.id, step_id=instance.current_step.id)
@login_required
def my_processes(request):
"""نمایش فرآیندهای کاربر"""
@ -76,4 +408,5 @@ def my_processes(request):
return render(request, 'processes/my_processes.html', {
'my_instances': my_instances,
'assigned_steps': assigned_steps
})
})