diff --git a/crm/forms.py b/crm/forms.py index aeb11b69..aff887cb 100644 --- a/crm/forms.py +++ b/crm/forms.py @@ -245,17 +245,20 @@ class Meta: "company": CompanyChoices} def __init__(self, *args, **kwargs): + mission_id = kwargs.pop("mission_id", None) super(MissionContactForm, self).__init__(*args, **kwargs) - self.helper.layout = Layout(Div(Column(FieldWithButtons("contact", HTML( - "" % reverse( - "crm:contact_create"))), - css_class="col-md-6"), - Column(FieldWithButtons("company", HTML( - "" % reverse( - "crm:company"))), - css_class="col-md-6"), - css_class="row"), - self.submit) + if mission_id: + self.inline_helper.form_action = reverse("crm:linked_mission_contact_create", args=[mission_id]) + self.inline_helper.layout = Layout(Div( + Column(FieldWithButtons("contact", + HTML(""""""), + css_id="contact_input_group"), + css_class="col-md-6"), + Column(FieldWithButtons("company", + HTML(""""""), + css_id="company_input_group"), + css_class="col-md-6"), + css_class="row")) class BusinessBrokerForm(PydiciCrispyModelForm): diff --git a/crm/models.py b/crm/models.py index bb83aec1..0cceabcf 100644 --- a/crm/models.py +++ b/crm/models.py @@ -233,7 +233,7 @@ def relationData(self): for missionContact in self.missioncontact_set.all(): for mission in missionContact.mission_set.all(): missionNode = GNode("mission-%s" % mission.id, """ -  %s """ % (mission.short_name(), + %s """ % (mission.short_name(), mission.get_absolute_url(), mission.mission_id())) nodes.add(missionNode) @@ -488,7 +488,6 @@ def __str__(self): def get_absolute_url(self): return reverse("crm:contact_detail", args=[self.contact.id, ]) - class Meta: ordering = ["company", "contact"] verbose_name = _("Mission contact") diff --git a/crm/urls.py b/crm/urls.py index 535aab40..c69b8747 100644 --- a/crm/urls.py +++ b/crm/urls.py @@ -13,6 +13,7 @@ re_path(r'^contact/add/$', v.ContactCreate.as_view(), name='contact_create'), re_path(r'^contact/(?P\d+)/update$', v.ContactUpdate.as_view(), name='contact_update'), re_path(r'^mission/contact/add/$', v.MissionContactCreate.as_view(), name='mission_contact_create'), + re_path(r'^mission/(?P\d+)/addcontact/add/$', v.linked_mission_contact_create, name='linked_mission_contact_create'), re_path(r'^mission/contact/(?P\d+)/update$', v.MissionContactUpdate.as_view(), name='mission_contact_update'), re_path(r'^businessbroker/add/$', v.BusinessBrokerCreate.as_view(), name='businessbroker_create'), re_path(r'^businessbroker/(?P\d+)/update$', v.BusinessBrokerUpdate.as_view(), name='businessbroker_update'), diff --git a/crm/views.py b/crm/views.py index d9fa56ef..da068060 100644 --- a/crm/views.py +++ b/crm/views.py @@ -30,11 +30,12 @@ from crm.utils import get_subsidiary_from_session from people.models import Consultant, ConsultantProfile from leads.models import Lead -from staffing.models import Timesheet +from staffing.models import Timesheet, Mission from leads.utils import leads_state_stat from core.decorator import pydici_non_public, pydici_feature, PydiciNonPublicdMixin, PydiciFeatureMixin from core.utils import COLORS, get_parameter from billing.models import ClientBill +from staffing.views import mission_contacts class ContactReturnToMixin(object): @@ -68,7 +69,7 @@ def get_initial(self): return {} -class ContactUpdate(PydiciNonPublicdMixin, ThirdPartyMixin, ContactReturnToMixin, UpdateView): +class ContactUpdate(PydiciNonPublicdMixin, ThirdPartyMixin, UpdateView): model = Contact template_name = "core/form.html" form_class = ContactForm @@ -77,7 +78,6 @@ class ContactUpdate(PydiciNonPublicdMixin, ThirdPartyMixin, ContactReturnToMixin class ContactDelete(PydiciNonPublicdMixin, FeatureContactsWriteMixin, DeleteView): model = Contact template_name = "core/delete.html" - form_class = ContactForm success_url = reverse_lazy("crm:contact_list") def form_valid(self, form): @@ -104,6 +104,39 @@ def contact_list(request): "user": request.user}) +def linked_mission_contact_create(request, mission_id): + missionContactForm = None + contactForm = None + companyForm = None + if request.POST: + missionContactForm = MissionContactForm(request.POST, prefix="mission-contact") + contactForm = ContactForm(request.POST, prefix="contact") + companyForm = CompanyForm(request.POST, prefix="company") + + if contactForm.is_valid(): + contact = contactForm.save() + missionContactForm.data = missionContactForm.data.copy() + missionContactForm.data["mission-contact-contact"] = contact.id + + if companyForm.is_valid(): + company = companyForm.save() + missionContactForm.data = missionContactForm.data.copy() + missionContactForm.data["mission-contact-company"] = company.id + + if missionContactForm.is_valid(): + mission_contact = missionContactForm.save() + mission = Mission.objects.get(id=mission_id) + mission.contacts.add(mission_contact) + mission.save() + request.method = "GET" # Fake request to return unbound form + return mission_contacts(request, mission_id) + + return render(request, "crm/_mission_contact_form.html", {"mission_id": mission_id, + "missionContactForm": missionContactForm or MissionContactForm(mission_id=mission_id, prefix="mission-contact"), + "contactForm": contactForm or ContactForm(prefix="contact"), + "companyForm": companyForm or CompanyForm(prefix="company")}) + + class MissionContactCreate(PydiciNonPublicdMixin, FeatureContactsWriteMixin, ContactReturnToMixin, CreateView): model = MissionContact template_name = "core/form.html" @@ -124,6 +157,7 @@ class BusinessBrokerCreate(PydiciNonPublicdMixin, FeatureContactsWriteMixin, Cre def get_success_url(self): return reverse_lazy("crm:businessbroker_list") + class BusinessBrokerUpdate(PydiciNonPublicdMixin, FeatureContactsWriteMixin, UpdateView): model = BusinessBroker template_name = "core/form.html" @@ -377,7 +411,7 @@ def company_detail(request, company_id): # Gather contacts for this company business_contacts = Contact.objects.filter(client__organisation__company=company).distinct() - mission_contacts = Contact.objects.filter(missioncontact__company=company).distinct() + mission_contacts = Contact.objects.filter(missioncontact__mission__lead__client__organisation__company=company).distinct() administrative_contacts = AdministrativeContact.objects.filter(company=company) # Won rate @@ -434,6 +468,8 @@ def company_detail(request, company_id): "contacts_count" : business_contacts.count() + mission_contacts.count() + administrative_contacts.count(), "clients": Client.objects.filter(organisation__company=company).select_related(), "lead_data_url": reverse('leads:client_company_lead_table_DT', args=[company.id,]), + "supplier_lead_data_url": reverse('leads:supplier_company_lead_table_DT', args=[company.id, ]), + "businessbroker_lead_data_url": reverse('leads:businessbroker_lead_table_DT', args=[company.id,]), "mission_data_url": reverse('staffing:client_company_mission_table_DT', args=[company.id,]), "mission_datatable_options": ''' "columnDefs": [{ "orderable": false, "targets": [4, 8, 9, 10] }, { className: "hidden-xs hidden-sm hidden-md", "targets": [6, 7,8]}], diff --git a/expense/forms.py b/expense/forms.py index 2b9c6c17..80c532b4 100644 --- a/expense/forms.py +++ b/expense/forms.py @@ -51,6 +51,17 @@ def get_queryset(self): return expenses +class ExpenseVATForm(forms.ModelForm): + class Meta: + model = Expense + fields = ("vat",) + + def __init__(self, *args, **kwargs): + super(ExpenseVATForm, self).__init__(*args, **kwargs) + self.fields["vat"].label = False + self.fields["vat"].widget.attrs["autofocus"] = True + + class ExpenseForm(forms.ModelForm): """Expense form based on Expense model""" class Meta: @@ -60,7 +71,6 @@ class Meta: "comment": Textarea(attrs={'cols': 17, 'rows': 2}), # Reduce height and increase width } - def __init__(self, *args, **kwargs): subcontractor = kwargs.pop("subcontractor") super(ExpenseForm, self).__init__(*args, **kwargs) diff --git a/expense/models.py b/expense/models.py index d50c8d8a..a62f4036 100644 --- a/expense/models.py +++ b/expense/models.py @@ -136,14 +136,16 @@ def receipt_data(self): if self.receipt: content_type = self.receipt_content_type() data = BytesIO() - for chunk in self.receipt.chunks(): - data.write(chunk) - - data = b64encode(data.getvalue()).decode() - if content_type == "application/pdf": - response = "" % data - else: - response = "" % (content_type, data) + try: + for chunk in self.receipt.chunks(): + data.write(chunk) + data = b64encode(data.getvalue()).decode() + if content_type == "application/pdf": + response = "" % data + else: + response = "" % (content_type, data) + except FileNotFoundError: + response = "Expense file not found" return response @@ -151,7 +153,6 @@ def receipt_content_type(self): if self.receipt: return mimetypes.guess_type(self.receipt.name)[0] or "application/stream" - def get_absolute_url(self): return reverse('expense:expense', args=[str(self.id)]) diff --git a/expense/tables.py b/expense/tables.py index a41207c8..34cabdcc 100644 --- a/expense/tables.py +++ b/expense/tables.py @@ -6,10 +6,8 @@ """ from django.utils.translation import gettext as _ -from django.urls import reverse from django.db.models import Q from django.utils.safestring import mark_safe -from django.utils.encoding import smart_str from django.template import Template, RequestContext from django.template.loader import get_template from django_datatables_view.base_datatable_view import BaseDatatableView @@ -22,7 +20,7 @@ from core.templatetags.pydici_filters import link_to_consultant from core.utils import TABLES2_HIDE_COL_MD, to_int_or_round from core.decorator import PydiciFeatureMixin, PydiciNonPublicdMixin, PydiciSubcontractordMixin -from expense.utils import expense_transition_to_state_display, user_expense_perm +from expense.utils import expense_transition_to_state_display, user_expense_perm, can_edit_expense, expense_next_states class ExpenseTableDT(PydiciSubcontractordMixin, PydiciFeatureMixin, BaseDatatableView): @@ -37,11 +35,14 @@ class ExpenseTableDT(PydiciSubcontractordMixin, PydiciFeatureMixin, BaseDatatabl state_template = get_template("expense/_expense_state_column.html") ko_sign = mark_safe("""No""") ok_sign = mark_safe("""Yes""") + vat_template = get_template("expense/_expense_vat_column.html") def get_initial_queryset(self): expense_administrator, expense_subsidiary_manager, expense_manager, expense_paymaster, expense_requester = user_expense_perm(self.request.user) consultant = Consultant.objects.get(trigramme__iexact=self.request.user.username) + self.can_edit_vat = expense_administrator or expense_paymaster # Used for vat column render + if expense_subsidiary_manager: user_team = consultant.user_team(subsidiary=True) elif expense_manager: @@ -110,7 +111,7 @@ def render_column(self, row, column): elif column == "amount": return to_int_or_round(row.amount, 2) elif column == "vat": - return """
{1}
""".format(row.id, row.vat) + return self.vat_template.render(context={"expense": row, "can_edit_vat": self.can_edit_vat}, request=self.request) else: return super(ExpenseTableDT, self).render_column(row, column) @@ -125,11 +126,16 @@ class ExpenseTable(tables.Table): state = tables.TemplateColumn(template_name="expense/_expense_state_column.html", orderable=False) expense_date = tables.TemplateColumn("""{{ record.expense_date }}""") # Title attr is just used to have an easy to parse hidden value for sorting update_date = tables.TemplateColumn("""{{ record.update_date }}""", attrs=TABLES2_HIDE_COL_MD) # Title attr is just used to have an easy to parse hidden value for sorting - vat = tables.TemplateColumn("""{% load l10n %}
{{record.vat}}
""") + transitions_template = get_template("expense/_expense_transitions_column.html") + vat_template = get_template("expense/_expense_vat_column.html") + def render_user(self, value): return link_to_consultant(value) + def render_vat(self, record): + return self.vat_template.render(context={"expense": record, "can_edit_vat": True}) + class Meta: model = Expense sequence = ("id", "user", "description", "lead", "amount", "vat", "chargeable", "corporate_card", "receipt", "state", "expense_date", "update_date", "comment") @@ -142,35 +148,18 @@ class Meta: class ExpenseWorkflowTable(ExpenseTable): transitions = tables.Column(accessor="pk") - def render_transitions(self, record): - result = [] - for transition in self.transitionsData[record.id]: - result.append("""%s""" - % (expense_transition_to_state_display(transition), reverse("expense:update_expense_state", args=[record.id, transition]), expense_transition_to_state_display(transition)[0:2])) - if self.expenseEditPerm[record.id]: - result.append("%s" - % (smart_str(_("Edit")), - reverse("expense:expenses", kwargs={"expense_id": record.id}), - # Translators: Ed is the short term for Edit - smart_str(_("Ed")))) - result.append("%s" % - (smart_str(_("Delete")), - reverse("expense:expense_delete", kwargs={"expense_id": record.id}), - # Translators: De is the short term for Delete - smart_str(_("De")))) - result.append("%s" % - (smart_str(_("Clone")), - reverse("expense:clone_expense", kwargs={"clone_from": record.id}), - # Translators: Cl is the short term for Clone - smart_str(_("Cl")))) - return mark_safe(" ".join(result)) - class Meta: sequence = ("id", "user", "description", "lead", "amount", "chargeable", "corporate_card", "receipt", "state", "transitions", "expense_date", "update_date", "comment") fields = sequence class UserExpenseWorkflowTable(ExpenseWorkflowTable): + def render_transitions(self, record): + return self.transitions_template.render(context={"record": record, + "transitions": [], + "expense_edit_perm": can_edit_expense(record, self.request.user)}, + request=self.request) + class Meta: attrs = {"class": "pydici-tables2 table table-hover table-striped table-sm", "id": "user_expense_workflow_table"} prefix = "user_expense_workflow_table" @@ -180,6 +169,14 @@ class Meta: class ManagedExpenseWorkflowTable(ExpenseWorkflowTable): description = tables.TemplateColumn("""{% load l10n %} {{ record.description }}""", attrs={"td": {"class": "description"}}) + + def render_transitions(self, record): + transitions = [(t, expense_transition_to_state_display(t), expense_transition_to_state_display(t)[0:2]) for t in expense_next_states(record, self.request.user)] + return self.transitions_template.render(context={"record": record, + "transitions": transitions, + "expense_edit_perm": can_edit_expense(record, self.request.user)}, + request=self.request) + class Meta: attrs = {"class": "pydici-tables2 table table-hover table-striped table-sm", "id": "managed_expense_workflow_table"} prefix = "managed_expense_workflow_table" diff --git a/expense/urls.py b/expense/urls.py index aaeced5d..fce650b0 100644 --- a/expense/urls.py +++ b/expense/urls.py @@ -14,8 +14,8 @@ re_path(r'^(?P\d+)/receipt$', v.expense_receipt, name="expense_receipt"), re_path(r'^(?P\d+)/delete$', v.expense_delete, name="expense_delete"), re_path(r'^(?P\d+)/change$', v.expenses, name="expenses"), + re_path(r'^(?P\d+)/expense_vat$', v.update_expense_vat, name="update_expense_vat"), re_path(r'^(?P\d+)/(?P\w+)$', v.update_expense_state, name="update_expense_state"), - re_path(r'^expense_vat$', v.update_expense_vat, name="update_expense_vat"), re_path(r'^clone/(?P\d+)$', v.expenses, name="clone_expense"), re_path(r'^mission/(?P\d+)$', v.lead_expenses, name="lead_expenses"), re_path(r'^history/?$', v.expenses_history, name="expenses_history"), diff --git a/expense/views.py b/expense/views.py index 4499d5e2..708ffefd 100644 --- a/expense/views.py +++ b/expense/views.py @@ -6,7 +6,6 @@ """ from datetime import date -import json from io import BytesIO import decimal @@ -19,13 +18,13 @@ from django.shortcuts import render, redirect from django.contrib import messages -from expense.forms import ExpenseForm, ExpensePaymentForm +from expense.forms import ExpenseForm, ExpensePaymentForm, ExpenseVATForm from expense.models import Expense, ExpensePayment from expense.tables import ExpenseTable, UserExpenseWorkflowTable, ManagedExpenseWorkflowTable from leads.models import Lead from people.models import Consultant from core.decorator import pydici_non_public, pydici_feature, pydici_subcontractor -from expense.utils import expense_next_states, can_edit_expense, in_terminal_state, user_expense_perm +from expense.utils import expense_next_states, can_edit_expense, in_terminal_state, user_expense_perm, expense_transition_to_state_display from people.utils import users_are_in_same_company from crm.utils import get_subsidiary_from_session @@ -53,10 +52,14 @@ def expense(request, expense_id): (expense_subsidiary_manager and users_are_in_same_company(expense.user, request.user))): return HttpResponseRedirect(reverse("core:forbidden")) + transitions = [(t, expense_transition_to_state_display(t), expense_transition_to_state_display(t)[0:2]) for t in + expense_next_states(expense, request.user)] + return render(request, "expense/expense.html", {"expense": expense, "can_edit": can_edit_expense(expense, request.user), "can_edit_vat": expense_administrator or expense_paymaster, + "transitions": transitions, "user": request.user}) @@ -137,13 +140,9 @@ def expenses(request, expense_id=None, clone_from=None): managed_expenses = team_expenses userExpenseTable = UserExpenseWorkflowTable(user_expenses) - userExpenseTable.transitionsData = dict([(e.id, []) for e in user_expenses]) # Inject expense allowed transitions. Always empty for own expense - userExpenseTable.expenseEditPerm = dict([(e.id, can_edit_expense(e, request.user)) for e in user_expenses]) # Inject expense edit permissions RequestConfig(request, paginate={"per_page": 50}).configure(userExpenseTable) managedExpenseTable = ManagedExpenseWorkflowTable(managed_expenses) - managedExpenseTable.transitionsData = dict([(e.id, expense_next_states(e, request.user)) for e in managed_expenses]) # Inject expense allowed transitions - managedExpenseTable.expenseEditPerm = dict([(e.id, can_edit_expense(e, request.user)) for e in managed_expenses]) # Inject expense edit permissions RequestConfig(request, paginate={"per_page": 100}).configure(managedExpenseTable) return render(request, "expense/expenses.html", {"user_expense_table": userExpenseTable, @@ -213,7 +212,7 @@ def expenses_history(request): { className: "hidden-xs hidden-sm hidden-md", "targets": [2, 10, 12, 13]}, { className: "description", "targets": [3]}, { className: "amount", "targets": [5]}], - "fnDrawCallback": function( oSettings ) {make_vat_editable(); }''', + "drawCallback": function( oSettings ) {htmx.process(document.body); }''', "can_edit_vat": expense_administrator or expense_paymaster, "user": request.user}) @@ -253,57 +252,60 @@ def chargeable_expenses(request): @pydici_feature("reports") def update_expense_state(request, expense_id, target_state): """Do workflow transition for that expense.""" - error = False message = "" try: expense = Expense.objects.get(id=expense_id) except Expense.DoesNotExist: - message = _("Expense %s does not exist" % expense_id) - error = True - - if not error: - next_states = expense_next_states(expense, request.user) - if target_state in next_states: - expense.state = target_state - if in_terminal_state(expense): - expense.workflow_in_progress = False - expense.save() - message = _("Successfully update expense") - else: - message = ("Transition %s is not allowed" % target_state) - error = True + return HttpResponse(_("Expense %s does not exist" % expense_id), status=404) - response = {"message": message, - "expense_id": expense_id, - "error": error} - - return HttpResponse(json.dumps(response), content_type="application/json") + next_states = expense_next_states(expense, request.user) + if target_state in next_states: + expense.state = target_state + if in_terminal_state(expense): + expense.workflow_in_progress = False + expense.save() + transitions = [(t, expense_transition_to_state_display(t), expense_transition_to_state_display(t)[0:2]) for t in + expense_next_states(expense, request.user)] + return render(request, "expense/_expense_transitions_column.html", + {"record": expense, + "transitions": transitions, + "expense_edit_perm": can_edit_expense(expense, request.user)}) + else: + message = ("Transition %s is not allowed" % target_state) + return HttpResponse(message) @pydici_non_public @pydici_feature("management") -def update_expense_vat(request): +def update_expense_vat(request, expense_id): """Update expense VAT.""" - - expense_administrator, expense_subsidiary_manager, expense_manager, expense_paymaster, expense_requester = user_expense_perm(request.user) + expense_administrator, expense_subsidiary_manager, expense_manager, expense_paymaster, expense_requester = user_expense_perm( + request.user) if not (expense_administrator or expense_paymaster): return HttpResponseForbidden() try: - expense_id = request.POST["id"] - value = request.POST["value"].replace(",", ".") expense = Expense.objects.get(id=expense_id) - expense.vat = decimal.Decimal(value) - expense.save() - message = value except Expense.DoesNotExist: - message = _("Expense %s does not exist" % expense_id) - except (ValueError, decimal.InvalidOperation): - message = _("Incorrect value") + return HttpResponse(_("Expense does not exist"), status=404) - return HttpResponse(message) + if request.method == "GET": + form = ExpenseVATForm(instance=expense) + return HttpResponse("
%s
" % ( + reverse("expense:update_expense_vat", args=[expense_id]), form)) + + else: + form = ExpenseVATForm(request.POST, instance=expense) + if form.is_valid(): + form.save() + form = ExpenseVATForm(instance=expense) + return render(request, "expense/_expense_vat_column.html", + {"expense": expense, + "can_edit_vat": expense_administrator or expense_paymaster, + "form": form, + "user": request.user}) @pydici_non_public diff --git a/leads/forms.py b/leads/forms.py index 0577aaa0..52c78f52 100644 --- a/leads/forms.py +++ b/leads/forms.py @@ -12,11 +12,11 @@ from django.utils.encoding import smart_str from django import forms -from crispy_forms.layout import Layout, Div, Column, Fieldset, Field, HTML, Row +from crispy_forms.layout import Layout, Column, Fieldset, Field, HTML, Row from crispy_forms.bootstrap import AppendedText, TabHolder, Tab, FieldWithButtons -from django_select2.forms import ModelSelect2Widget +from django_select2.forms import ModelSelect2Widget, ModelSelect2TagWidget from taggit.forms import TagField - +from taggit.models import Tag from leads.models import Lead from people.models import Consultant, SalesMan @@ -24,6 +24,7 @@ from people.forms import ConsultantChoices, ConsultantMChoices, SalesManChoices from crm.forms import ClientChoices, BusinessBrokerChoices from core.forms import PydiciCrispyModelForm, PydiciSelect2WidgetMixin +from core.utils import capitalize class LeadChoices(PydiciSelect2WidgetMixin, ModelSelect2Widget): @@ -66,6 +67,45 @@ def get_queryset(self): return qs.distinct() +class LeadTagChoices(PydiciSelect2WidgetMixin, ModelSelect2TagWidget): + model = Tag + search_fields = ["name__icontains"] + queryset = Tag.objects.all() + + def __init__(self, *args, **kwargs): + self.lead = kwargs.pop("lead", None) + super(LeadTagChoices, self).__init__(*args, **kwargs) + + def get_queryset(self): + qs = super(LeadTagChoices, self).get_queryset() + if self.lead: + qs = qs.exclude(lead__id=self.lead.id) # Exclude existing tags + return qs + + def value_from_datadict(self, data, files, name): + """Create objects for given non-pimary-key values. Return list of all primary keys.""" + cleaned_values = [] + values = set(super().value_from_datadict(data, files, name)) + for value in values: + try: # Tag exists + cleaned_values.append(self.queryset.get(pk=int(value)).pk) + except (ValueError, TypeError) or self.model.DoesNotExist: + # We need to create it needed + tag, created = self.queryset.get_or_create(name=capitalize(value)) + cleaned_values.append(tag.pk) + + return cleaned_values + + +class LeadTagForm(forms.Form): + + def __init__(self, *args, **kwargs): + lead = kwargs.pop("lead", None) + super(LeadTagForm, self).__init__(*args, **kwargs) + self.fields["tag"] = forms.ModelMultipleChoiceField(widget=LeadTagChoices(lead=lead, attrs={"data-placeholder": _("New tags"), "style": "min-width: 200px;"}), + queryset=Tag.objects, label=False) + + class LeadForm(PydiciCrispyModelForm): class Meta: model = Lead @@ -120,7 +160,6 @@ def clean_sales(self): # We can't tolerate that sale amount is not known at this step of the process raise ValidationError(_("Sales amount must be defined at this step of the commercial process")) - def clean_start_date(self): """Ensure start_date amount is defined at lead when commercial proposition has been sent""" if self.cleaned_data["start_date"] or self.data["state"] in ('QUALIF', 'WRITE_OFFER', 'SLEEPING', 'LOST', 'FORGIVEN'): diff --git a/leads/models.py b/leads/models.py index 3bfb5b8d..786fd536 100644 --- a/leads/models.py +++ b/leads/models.py @@ -286,6 +286,17 @@ def getDocURL(self): else: return "" + def suggested_tags(self): + """Find suggested tags for this lead except if it has already at least two tags""" + from leads.learn import predict_tags # Late import to avoid circular dependency + tags = self.tags.all() + if tags.count() < 3: + suggestedTags = set(predict_tags(self)) + suggestedTags -= set(tags) + else: + suggestedTags = [] + return suggestedTags + def get_absolute_url(self): return reverse('leads:detail', args=[str(self.id)]) diff --git a/leads/tables.py b/leads/tables.py index 50e8f1ba..50f14f14 100644 --- a/leads/tables.py +++ b/leads/tables.py @@ -166,3 +166,18 @@ def render_column(self, row, column): else: return super(LeadToBill, self).render_column(row, column) + +class SupplierLeadTableDT(LeadTableDT): + def get_initial_queryset(self): + qs = Lead.objects.filter(mission__timesheet__consultant__subcontractor_company__company_id=self.kwargs["supplier_id"]).distinct() + return qs.select_related("client__contact", "client__organisation__company", "responsible", "subsidiary") + + +class BusinessBrokerLeadTableDT(LeadTableDT): + """Business broker or paying authority leads""" + def get_initial_queryset(self): + qs = Lead.objects.filter(Q(business_broker__company_id=self.kwargs["businessbroker_id"]) | Q(paying_authority__company_id=self.kwargs["businessbroker_id"])) + qs = qs.distinct() + return qs.select_related("client__contact", "client__organisation__company", "responsible", "subsidiary") + + diff --git a/leads/urls.py b/leads/urls.py index 9dbab8a3..76bda06b 100644 --- a/leads/urls.py +++ b/leads/urls.py @@ -5,7 +5,7 @@ """ from django.urls import re_path -from leads.tables import LeadTableDT, ActiveLeadTableDT, RecentArchivedLeadTableDT, ClientCompanyLeadTableDT, LeadToBill, TagTableDT +import leads.tables as t import leads.views as v @@ -14,7 +14,8 @@ re_path(r'^tag/(?P\d+)/$', v.tag, name="tag"), re_path(r'^tags/(?P\d+)$', v.tags, name="tags"), re_path(r'^tag/add$', v.add_tag, name="add_tag"), - re_path(r'^tag/remove/(?P\d+)/(?P\d+)$', v.remove_tag, name="remove_tag"), + re_path(r'^tag/add/(?P\d+)/(?P\d+)?$', v.add_tag, name="add_tag"), + re_path(r'^tag/remove/(?P\d+)/(?P\d+)$', v.remove_tag, name="remove_tag"), re_path(r'^tag/manage$', v.manage_tags, name="manage_tags"), re_path(r'^(?P\d+)/$', v.detail, name="detail"), re_path(r'^leads$', v.leads, name="leads"), @@ -32,10 +33,12 @@ re_path(r'^pivotable/(?P\d+)/$', v.leads_pivotable, name="leads-pivotable-year"), re_path(r'^pivotable/all$', v.leads_pivotable, {"year": "all"}, name="leads-pivotable-all"), re_path(r'^pivotable/lead/(?P\d+)$', v.lead_pivotable, name="lead_pivotable"), - re_path(r'^datatable/all-lead/data/$', LeadTableDT.as_view(), name='lead_table_DT'), - re_path(r'^datatable/active-lead/data/$', ActiveLeadTableDT.as_view(), name='active_lead_table_DT'), - re_path(r'^datatable/recent-archived-lead/data/$', RecentArchivedLeadTableDT.as_view(), name='recent_archived_lead_table_DT'), - re_path(r'^datatable/clientcompany-lead/(?P\d+)/data/$', ClientCompanyLeadTableDT.as_view(), name='client_company_lead_table_DT'), - re_path(r'^datatable/leads-to-bill/data/$', LeadToBill.as_view(), name='leads_to_bill_table_DT'), - re_path(r'^datatable/all-tags/data/$', TagTableDT.as_view(), name='tag_table_DT'), + re_path(r'^datatable/all-lead/data/$', t.LeadTableDT.as_view(), name='lead_table_DT'), + re_path(r'^datatable/active-lead/data/$', t.ActiveLeadTableDT.as_view(), name='active_lead_table_DT'), + re_path(r'^datatable/recent-archived-lead/data/$', t.RecentArchivedLeadTableDT.as_view(), name='recent_archived_lead_table_DT'), + re_path(r'^datatable/clientcompany-lead/(?P\d+)/data/$', t.ClientCompanyLeadTableDT.as_view(), name='client_company_lead_table_DT'), + re_path(r'^datatable/suppliercompany-lead/(?P\d+)/data/$', t.SupplierLeadTableDT.as_view(), name='supplier_company_lead_table_DT'), + re_path(r'^datatable/businessbroker-lead/(?P\d+)/data/$', t.BusinessBrokerLeadTableDT.as_view(), name='businessbroker_lead_table_DT'), + re_path(r'^datatable/leads-to-bill/data/$', t.LeadToBill.as_view(), name='leads_to_bill_table_DT'), + re_path(r'^datatable/all-tags/data/$', t.TagTableDT.as_view(), name='tag_table_DT'), ] diff --git a/leads/views.py b/leads/views.py index b1421c15..4f168081 100644 --- a/leads/views.py +++ b/leads/views.py @@ -29,11 +29,11 @@ from core.utils import sortedValues, COLORS, get_parameter, moving_average, nextMonth from crm.utils import get_subsidiary_from_session from leads.models import Lead -from leads.forms import LeadForm +from leads.forms import LeadForm, LeadTagForm from leads.utils import post_save_lead, leads_state_stat from leads.learn import compute_leads_state, compute_lead_similarity -from leads.learn import predict_tags, predict_similar -from core.utils import capitalize, getLeadDirs, createProjectTree, get_fiscal_years_from_qs, to_int_or_round +from leads.learn import predict_similar +from core.utils import getLeadDirs, createProjectTree, get_fiscal_years_from_qs, to_int_or_round from core.decorator import pydici_non_public, pydici_feature from people.models import Consultant from people.tasks import compute_consultant_tasks @@ -88,15 +88,6 @@ def detail(request, lead_id): previous_lead = None active_count = None - # Find suggested tags for this lead except if it has already at least two tags - tags = lead.tags.all() - if tags.count() < 3: - suggestedTags = set(predict_tags(lead)) - suggestedTags -= set(tags) - else: - suggestedTags = [] - - return render(request, "leads/lead_detail.html", {"lead": lead, "active_count": active_count, @@ -105,9 +96,9 @@ def detail(request, lead_id): "previous_lead": previous_lead, "link_root": reverse("core:index"), "completion_url": reverse("leads:tags", args=[lead.id, ]), - "suggested_tags": suggestedTags, "similar_leads": predict_similar(lead), "enable_doc_tab": bool(settings.DOCUMENT_PROJECT_PATH), + "lead_tag_form": LeadTagForm(lead=lead), "user": request.user}) @pydici_non_public @@ -260,33 +251,40 @@ def tag(request, tag_id): @pydici_non_public @pydici_feature("leads") @permission_required("leads.change_lead") -def add_tag(request): - """Add a tag to a lead. Create the tag if needed""" - answer = {"tag_created": True, "tag_url": "", "tag_name": ""} - if request.POST["tag"]: - tagName = capitalize(request.POST["tag"]) - lead = Lead.objects.get(id=int(request.POST["lead_id"])) - if tagName in lead.tags.all().values_list("name", flat=True): - answer["tag_created"] = False - lead.tags.add(tagName) - if lead.state not in ("WON", "LOST", "FORGIVEN"): - compute_leads_state.delay(relearn=False, leads_id=[lead.id,]) # Update (in background) lead proba state as tag are used in computation - compute_lead_similarity.delay() # update lead similarity model in background - compute_consultant_tasks.delay(lead.responsible.id) # update consultants tasks in background - tag = Tag.objects.filter(name=tagName)[0] # We should have only one, but in case of bad data, just take the first one - answer["tag_url"] = reverse("leads:tag", args=[tag.id, ]) - answer["tag_remove_url"] = reverse("leads:remove_tag", args=[tag.id, lead.id]) - answer["tag_name"] = tag.name - answer["id"] = tag.id - return HttpResponse(json.dumps(answer), content_type="application/json") +def add_tag(request, lead_id, tag_id=None): + """Add a tag by id (PUT) to a lead or create (through POST) a new one and attach it and return tag banner.""" + try: + lead = Lead.objects.get(id=lead_id) + except Lead.DoesNotExist: + return Http404() + if request.method == "POST": + form = LeadTagForm(request.POST) + if form.is_valid(): + tags = form.cleaned_data["tag"] + lead.tags.add(*tags) + else: # Returns forms with errors + return render(request, "leads/_tags_banner.html", {"lead": lead, "lead_tag_form": form}) + elif tag_id: # PUT, we add an existing tag + try: + tag = Tag.objects.get(id=tag_id) + lead.tags.add(tag) + except Tag.DoesNotExist: + return HttpResponse(_("Invalid tag"), status=400) + else: + return HttpResponse(_("No tag provided"), status=400) + + if lead.state not in ("WON", "LOST", "FORGIVEN"): + compute_leads_state.delay(relearn=False, leads_id=[lead.id,]) # Update (in background) lead proba state as tag are used in computation + compute_lead_similarity.delay() # update lead similarity model in background + compute_consultant_tasks.delay(lead.responsible.id) # update consultants tasks in background + return render(request, "leads/_tags_banner.html", {"lead": lead, "lead_tag_form": LeadTagForm(lead=lead) }) @pydici_non_public @pydici_feature("leads") @permission_required("leads.change_lead") -def remove_tag(request, tag_id, lead_id): - """Remove a tag to a lead""" - answer = {"error": False, "id": tag_id} +def remove_tag(request, lead_id, tag_id): + """Remove a tag to a lead and return tag banner""" try: tag = Tag.objects.get(id=tag_id) lead = Lead.objects.get(id=lead_id) @@ -295,8 +293,8 @@ def remove_tag(request, tag_id, lead_id): compute_leads_state.delay(relearn=False, leads_id=[lead.id, ]) # Update (in background) lead proba state as tag are used in computation compute_lead_similarity.delay() # update lead similarity model in background except (Tag.DoesNotExist, Lead.DoesNotExist): - answer["error"] = True - return HttpResponse(json.dumps(answer), content_type="application/json") + return Http404() + return render(request, "leads/_tags_banner.html", {"lead": lead, "lead_tag_form": LeadTagForm(lead=lead)}) @pydici_non_public @@ -329,6 +327,7 @@ def manage_tags(request): @pydici_feature("leads") def tags(request, lead_id): """@return: all tags that contains q parameter and are not already associated to this lead as a simple text list""" + #TODO: remove this function tags = Tag.objects.all().exclude(lead__id=lead_id) # Exclude existing tags tags = tags.filter(name__icontains=request.GET["term"]) tags = tags.values_list("name", flat=True) diff --git a/locale/fr/LC_MESSAGES/django.mo b/locale/fr/LC_MESSAGES/django.mo index 618f0843..2ade2a90 100644 Binary files a/locale/fr/LC_MESSAGES/django.mo and b/locale/fr/LC_MESSAGES/django.mo differ diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index c77e227d..ff0315d8 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-10-06 16:40+0200\n" -"PO-Revision-Date: 2024-10-06 16:39+0200\n" +"POT-Creation-Date: 2024-10-21 00:02+0200\n" +"PO-Revision-Date: 2024-10-21 00:02+0200\n" "Last-Translator: Sébastien Renard \n" "Language-Team: French \n" "Language: fr\n" @@ -20,11 +20,11 @@ msgstr "" #: billing/admin.py:27 billing/admin.py:40 billing/forms.py:71 #: billing/models.py:355 core/models.py:82 expense/models.py:102 -#: leads/models.py:140 leads/views.py:199 staffing/models.py:28 +#: leads/models.py:140 leads/views.py:190 staffing/models.py:28 #: staffing/models.py:39 staffing/models.py:74 staffing/models.py:476 -#: templates/billing/bill.html:82 templates/expense/expense.html:27 -#: templates/expense/expense_archive.html:24 -#: templates/expense/expense_list.html:15 templates/leads/lead_detail.html:51 +#: templates/billing/bill.html:82 templates/expense/expense.html:22 +#: templates/expense/expense_archive.html:19 +#: templates/expense/expense_list.html:15 templates/leads/lead_detail.html:52 #: templates/staffing/mission.html:34 msgid "Description" msgstr "Description" @@ -38,14 +38,14 @@ msgid "Dates" msgstr "Dates" #: billing/admin.py:30 billing/admin.py:43 billing/models.py:172 -#: billing/models.py:265 leads/models.py:78 leads/models.py:300 -#: leads/views.py:200 templates/billing/_lead_billing.html:38 +#: billing/models.py:265 leads/models.py:78 leads/models.py:311 +#: leads/views.py:191 templates/billing/_lead_billing.html:38 #: templates/billing/_lead_billing.html:95 #: templates/billing/client_bill_detail.html:49 #: templates/billing/client_bills_archive.html:18 #: templates/billing/client_bills_in_creation.html:17 #: templates/billing/supplier_bills_archive.html:18 -#: templates/expense/expense.html:76 templates/expense/expense_archive.html:31 +#: templates/expense/expense.html:71 templates/expense/expense_archive.html:26 #: templates/expense/expense_list.html:21 msgid "State" msgstr "État" @@ -99,13 +99,13 @@ msgid "Consultant must be defined for time spent mission" msgstr "Il faut définir le consultant pour les missions au temps passé" #: billing/forms.py:171 billing/models.py:351 billing/utils.py:175 -#: expense/models.py:159 templates/billing/_lead_billing.html:23 -#: templates/expense/expense.html:13 templates/expense/expenses.html:8 +#: expense/models.py:160 templates/billing/_lead_billing.html:23 +#: templates/expense/expense.html:9 templates/expense/expenses.html:8 msgid "Expense" msgstr "Note de frais" #: billing/forms.py:172 billing/models.py:352 expense/models.py:107 -#: templates/expense/expense.html:51 templates/expense/expense_archive.html:33 +#: templates/expense/expense.html:46 templates/expense/expense_archive.html:28 #: templates/expense/expense_list.html:22 msgid "Expense date" msgstr "Date de dépense" @@ -121,9 +121,9 @@ msgstr "" "Si aucune note de frais n'est sélectionnée, indiquez le montant ou le " "montant TTC" -#: billing/models.py:74 billing/views.py:152 expense/forms.py:71 -#: expense/forms.py:76 expense/models.py:104 leads/models.py:294 -#: staffing/models.py:72 staffing/views.py:1428 +#: billing/models.py:74 billing/views.py:152 expense/forms.py:81 +#: expense/forms.py:86 expense/models.py:104 leads/models.py:305 +#: staffing/models.py:72 staffing/views.py:1411 #: templates/billing/bill_review.html:207 #: templates/billing/client_bill_detail.html:25 #: templates/billing/client_bills_archive.html:15 @@ -134,7 +134,7 @@ msgstr "" #: templates/billing/supplier_bills_validation.html:76 #: templates/core/index.html:13 templates/core/index.html:38 #: templates/core/index.html:62 templates/core/index.html:86 -#: templates/expense/expense.html:72 templates/expense/expense_archive.html:25 +#: templates/expense/expense.html:67 templates/expense/expense_archive.html:20 #: templates/expense/expense_list.html:17 msgid "Lead" msgstr "Affaire" @@ -162,14 +162,14 @@ msgstr "N° facture" #: templates/billing/supplier_bills_archive.html:16 #: templates/billing/supplier_bills_validation.html:25 #: templates/billing/supplier_bills_validation.html:80 -#: templates/expense/expense.html:47 templates/expense/expense_archive.html:32 +#: templates/expense/expense.html:42 templates/expense/expense_archive.html:27 #: templates/leads/review.html:53 templates/leads/review.html:86 msgid "Creation date" msgstr "Date de création" -#: billing/models.py:77 leads/views.py:200 +#: billing/models.py:77 leads/views.py:191 #: templates/billing/_lead_billing.html:41 -#: templates/billing/_lead_billing.html:98 templates/leads/lead_detail.html:168 +#: templates/billing/_lead_billing.html:98 templates/leads/lead_detail.html:141 #: templates/leads/review.html:54 msgid "Due date" msgstr "Date d'échéance" @@ -179,7 +179,7 @@ msgstr "Date d'échéance" #: templates/billing/_lead_billing.html:99 #: templates/billing/client_bills_archive.html:17 #: templates/billing/supplier_bills_archive.html:17 -#: templates/expense/expense_payments.html:50 +#: templates/expense/expense_payments.html:46 msgid "Payment date" msgstr "Date de paiement" @@ -240,7 +240,7 @@ msgid "Sent" msgstr "Envoyée" #: billing/models.py:168 billing/models.py:262 expense/models.py:35 -#: templates/expense/_expense_state_column.html:5 +#: templates/expense/_expense_state_column.html:6 msgid "Paid" msgstr "Payée" @@ -274,7 +274,7 @@ msgstr "Commentaires client" #: billing/models.py:179 leads/models.py:83 staffing/models.py:92 #: templates/billing/client_bill_detail.html:33 -#: templates/leads/lead_detail.html:143 +#: templates/leads/lead_detail.html:116 msgid "Client deal id" msgstr "Référence affaire client" @@ -334,8 +334,8 @@ msgstr "Libellé" #: billing/tests.py:268 billing/tests.py:277 billing/tests.py:283 #: billing/tests.py:289 billing/utils.py:164 billing/utils.py:182 #: billing/utils.py:198 billing/utils.py:218 billing/utils.py:225 -#: core/views.py:376 core/views.py:397 crm/views.py:485 staffing/views.py:1633 -#: staffing/views.py:1672 staffing/views.py:1687 +#: core/views.py:376 core/views.py:397 crm/views.py:521 staffing/views.py:1615 +#: staffing/views.py:1654 staffing/views.py:1669 #: templates/billing/_lead_billing.html:24 #: templates/billing/client_billing_control_pivotable.html:56 #: templates/billing/client_billing_control_pivotable.html:62 @@ -353,22 +353,24 @@ msgstr "Libellé" msgid "amount" msgstr "montant" -#: billing/utils.py:152 leads/views.py:621 staffing/views.py:2051 +#: billing/utils.py:152 leads/views.py:620 staffing/views.py:2043 #: templates/billing/client_billing_control_pivotable.html:52 #: templates/crm/clientcompany_detail.html:297 +#: templates/crm/clientcompany_detail.html:321 +#: templates/crm/clientcompany_detail.html:345 #: templates/expense/expense_list.html:16 templates/leads/leads.html:17 #: templates/leads/leads_pivotable.html:80 #: templates/leads/leads_to_bill.html:16 msgid "deal id" msgstr "N° affaire" -#: billing/utils.py:153 leads/views.py:623 staffing/views.py:2053 +#: billing/utils.py:153 leads/views.py:622 staffing/views.py:2045 msgid "client organisation" msgstr "organisation client" -#: billing/utils.py:154 billing/views.py:155 leads/views.py:624 -#: staffing/views.py:2054 staffing/views.py:2087 staffing/views.py:2103 -#: staffing/views.py:2104 +#: billing/utils.py:154 billing/views.py:155 leads/views.py:623 +#: staffing/views.py:2046 staffing/views.py:2079 staffing/views.py:2095 +#: staffing/views.py:2096 #: templates/billing/client_billing_control_pivotable.html:52 #: templates/billing/client_billing_control_pivotable.html:67 #: templates/billing/payment_delay.html:50 @@ -381,19 +383,19 @@ msgstr "organisation client" msgid "client company" msgstr "entreprise" -#: billing/utils.py:155 leads/views.py:628 staffing/views.py:2057 +#: billing/utils.py:155 leads/views.py:627 staffing/views.py:2049 #: templates/staffing/turnover_pivotable.html:71 msgid "broker" msgstr "apporteur" -#: billing/utils.py:155 staffing/views.py:2057 +#: billing/utils.py:155 staffing/views.py:2049 msgid "Direct" msgstr "Direct" -#: billing/utils.py:156 core/views.py:373 core/views.py:394 crm/views.py:482 -#: leads/views.py:632 staffing/models.py:456 staffing/views.py:1630 -#: staffing/views.py:1669 staffing/views.py:1684 staffing/views.py:1732 -#: staffing/views.py:2058 staffing/views.py:2091 staffing/views.py:2144 +#: billing/utils.py:156 core/views.py:373 core/views.py:394 crm/views.py:518 +#: leads/views.py:631 staffing/models.py:456 staffing/views.py:1612 +#: staffing/views.py:1651 staffing/views.py:1666 staffing/views.py:1714 +#: staffing/views.py:2050 staffing/views.py:2083 staffing/views.py:2136 #: templates/billing/bill_review.html:34 templates/billing/bill_review.html:82 #: templates/billing/bill_review.html:127 #: templates/billing/bill_review.html:173 @@ -409,6 +411,8 @@ msgstr "Direct" #: templates/crm/_clientcompany_rates_margin.html:102 #: templates/crm/clientcompany_detail.html:267 #: templates/crm/clientcompany_detail.html:298 +#: templates/crm/clientcompany_detail.html:322 +#: templates/crm/clientcompany_detail.html:346 #: templates/leads/lead_pivotable.html:38 templates/leads/leads.html:18 #: templates/leads/leads_pivotable.html:58 #: templates/leads/leads_pivotable.html:65 @@ -434,9 +438,11 @@ msgstr "Direct" msgid "subsidiary" msgstr "filiale" -#: billing/utils.py:157 billing/utils.py:190 leads/views.py:627 -#: staffing/views.py:2055 templates/crm/clientcompany_detail.html:268 -#: templates/crm/clientcompany_detail.html:299 templates/leads/leads.html:19 +#: billing/utils.py:157 billing/utils.py:190 leads/views.py:626 +#: staffing/views.py:2047 templates/crm/clientcompany_detail.html:268 +#: templates/crm/clientcompany_detail.html:299 +#: templates/crm/clientcompany_detail.html:323 +#: templates/crm/clientcompany_detail.html:347 templates/leads/leads.html:19 #: templates/leads/leads_pivotable.html:58 #: templates/leads/leads_pivotable.html:65 #: templates/leads/leads_to_bill.html:18 @@ -449,9 +455,9 @@ msgid "manager" msgstr "manager" #: billing/utils.py:159 billing/utils.py:216 billing/utils.py:227 -#: crm/views.py:480 staffing/models.py:455 staffing/views.py:1629 -#: staffing/views.py:1667 staffing/views.py:1682 staffing/views.py:1731 -#: staffing/views.py:2143 templates/billing/_lead_billing.html:20 +#: crm/views.py:516 staffing/models.py:455 staffing/views.py:1611 +#: staffing/views.py:1649 staffing/views.py:1664 staffing/views.py:1713 +#: staffing/views.py:2135 templates/billing/_lead_billing.html:20 #: templates/billing/pre_billing.html:92 templates/billing/pre_billing.html:159 #: templates/crm/_clientcompany_rates_margin.html:102 #: templates/crm/_clientcompany_rates_margin.html:108 @@ -468,8 +474,8 @@ msgid "consultant" msgstr "consultant" #: billing/utils.py:165 billing/utils.py:179 billing/utils.py:196 -#: billing/utils.py:217 billing/utils.py:224 staffing/views.py:1729 -#: staffing/views.py:2084 staffing/views.py:2092 staffing/views.py:2142 +#: billing/utils.py:217 billing/utils.py:224 staffing/views.py:1711 +#: staffing/views.py:2076 staffing/views.py:2084 staffing/views.py:2134 #: templates/billing/_lead_billing.html:21 #: templates/billing/client_billing_control_pivotable.html:52 #: templates/billing/client_billing_control_pivotable.html:55 @@ -491,8 +497,8 @@ msgstr "mois" #: billing/utils.py:166 billing/utils.py:180 billing/utils.py:197 #: billing/utils.py:219 billing/utils.py:226 core/views.py:372 -#: core/views.py:393 staffing/views.py:1534 staffing/views.py:1631 -#: staffing/views.py:1670 staffing/views.py:1685 staffing/views.py:1730 +#: core/views.py:393 staffing/views.py:1517 staffing/views.py:1613 +#: staffing/views.py:1652 staffing/views.py:1667 staffing/views.py:1712 #: templates/billing/_lead_billing.html:20 #: templates/billing/client_billing_control_pivotable.html:53 #: templates/core/risks.html:41 templates/core/risks.html:48 @@ -519,7 +525,7 @@ msgid "mission" msgstr "mission" #: billing/utils.py:170 billing/utils.py:181 billing/utils.py:191 -#: staffing/models.py:457 staffing/views.py:2056 +#: staffing/models.py:457 staffing/views.py:2048 #: templates/billing/_lead_billing.html:20 #: templates/billing/client_billing_control_pivotable.html:52 #: templates/billing/client_billing_control_pivotable.html:74 @@ -541,7 +547,7 @@ msgstr "Frais refacturables" msgid "Done work" msgstr "Montant réalisé" -#: billing/views.py:153 leads/forms.py:74 leads/models.py:73 +#: billing/views.py:153 leads/forms.py:114 leads/models.py:73 #: staffing/models.py:86 templates/billing/bill_review.html:36 #: templates/billing/bill_review.html:208 #: templates/billing/payment_delay.html:89 @@ -549,9 +555,9 @@ msgstr "Montant réalisé" #: templates/billing/supplier_bills_validation.html:77 #: templates/core/index.html:14 templates/core/index.html:39 #: templates/core/index.html:63 templates/core/index.html:87 -#: templates/leads/lead_detail.html:150 templates/leads/review.html:48 +#: templates/leads/lead_detail.html:123 templates/leads/review.html:48 #: templates/leads/review.html:81 templates/staffing/fixed_price_report.html:18 -#: templates/staffing/mission.html:97 +#: templates/staffing/mission.html:98 msgid "Responsible" msgstr "Responsable" @@ -566,25 +572,25 @@ msgstr "Responsable" #: templates/billing/payment_delay.html:82 #: templates/billing/payment_delay.html:97 #: templates/billing/supplier_bills_archive.html:14 -#: templates/leads/lead_detail.html:147 templates/leads/review.html:47 +#: templates/leads/lead_detail.html:120 templates/leads/review.html:47 #: templates/leads/review.html:80 templates/staffing/fixed_price_report.html:17 -#: templates/staffing/mission.html:94 +#: templates/staffing/mission.html:95 msgid "Subsidiary" msgstr "Filiale" -#: billing/views.py:156 leads/forms.py:77 leads/models.py:75 +#: billing/views.py:156 leads/forms.py:117 leads/models.py:75 #: templates/billing/payment_delay.html:66 -#: templates/billing/payment_delay.html:74 templates/leads/lead_detail.html:164 +#: templates/billing/payment_delay.html:74 templates/leads/lead_detail.html:137 msgid "Paying authority" msgstr "Organisme payeur" -#: billing/views.py:157 staffing/models.py:76 staffing/views.py:1428 +#: billing/views.py:157 staffing/models.py:76 staffing/views.py:1411 #: templates/billing/payment_delay.html:50 #: templates/billing/payment_delay.html:58 #: templates/billing/payment_delay.html:66 #: templates/billing/payment_delay.html:74 -#: templates/billing/payment_delay.html:89 templates/leads/lead_detail.html:232 -#: templates/staffing/mission.html:124 templates/staffing/mission.html:168 +#: templates/billing/payment_delay.html:89 templates/leads/lead_detail.html:205 +#: templates/staffing/mission.html:125 templates/staffing/mission.html:169 msgid "Billing mode" msgstr "Mode de facturation" @@ -606,7 +612,9 @@ msgstr "retard de paiement" #: billing/views.py:161 templates/billing/payment_delay.html:83 #: templates/billing/payment_delay.html:98 -#: templates/crm/clientcompany_detail.html:302 templates/leads/leads.html:22 +#: templates/crm/clientcompany_detail.html:302 +#: templates/crm/clientcompany_detail.html:326 +#: templates/crm/clientcompany_detail.html:350 templates/leads/leads.html:22 #: templates/leads/leads_to_bill.html:19 msgid "creation date" msgstr "date de création" @@ -654,14 +662,14 @@ msgstr "" "Impossible de supprimer une facture dans l'état %s. Vous pouvez par contre " "l'annuler." -#: billing/views.py:606 people/utils.py:32 staffing/views.py:607 -#: staffing/views.py:825 staffing/views.py:1562 +#: billing/views.py:606 people/utils.py:32 staffing/views.py:592 +#: staffing/views.py:810 staffing/views.py:1544 #, python-format msgid "team %(manager_name)s" msgstr "équipe %(manager_name)s" -#: billing/views.py:618 people/utils.py:22 staffing/views.py:626 -#: staffing/views.py:837 staffing/views.py:1607 +#: billing/views.py:618 people/utils.py:22 staffing/views.py:611 +#: staffing/views.py:822 staffing/views.py:1589 msgid "Everybody" msgstr "Tout le monde" @@ -800,13 +808,14 @@ msgstr "On s'est déjà vu ?" msgid "sorry, I don't know you" msgstr "Désolé, on ne se connaît pas" -#: core/forms.py:53 expense/forms.py:68 staffing/forms.py:217 +#: core/forms.py:53 expense/forms.py:78 staffing/forms.py:217 #: templates/billing/client_bill_form.html:29 #: templates/billing/supplier_bill_form.html:20 +#: templates/crm/_mission_contact_form.html:12 #: templates/crm/client-popup.html:11 #: templates/staffing/consultant_staffing.html:63 #: templates/staffing/consultant_timesheet.html:153 -#: templates/staffing/mission_contacts.html:39 +#: templates/staffing/mission_contacts.html:43 #: templates/staffing/mission_staffing.html:77 msgid "Save" msgstr "Enregistrer" @@ -959,7 +968,7 @@ msgstr "Clé" msgid "Value" msgstr "Valeur" -#: core/models.py:81 staffing/models.py:75 templates/staffing/mission.html:91 +#: core/models.py:81 staffing/models.py:75 templates/staffing/mission.html:92 msgid "Type" msgstr "Type" @@ -991,17 +1000,19 @@ msgstr "n° d'affaire" msgid "deal" msgstr "affaire" -#: core/views.py:377 core/views.py:398 crm/models.py:482 crm/models.py:511 -#: staffing/views.py:1534 templates/core/risks.html:42 +#: core/views.py:377 core/views.py:398 crm/models.py:482 crm/models.py:510 +#: staffing/views.py:1517 templates/core/risks.html:42 #: templates/crm/_contact_list.html:10 #: templates/crm/businessbroker_list.html:13 templates/crm/contact_list.html:13 #: templates/crm/supplier_list.html:13 msgid "company" msgstr "société" -#: core/views.py:378 core/views.py:399 crm/views.py:483 crm/views.py:543 +#: core/views.py:378 core/views.py:399 crm/views.py:519 crm/views.py:579 #: templates/crm/_clientcompany_rates_margin.html:114 #: templates/crm/clientcompany_detail.html:295 +#: templates/crm/clientcompany_detail.html:319 +#: templates/crm/clientcompany_detail.html:343 #: templates/crm/company_pivotable.html:49 templates/leads/leads.html:15 #: templates/leads/leads_to_bill.html:14 msgid "client" @@ -1016,7 +1027,7 @@ msgid "Yes" msgstr "Oui" #: core/views.py:424 templates/core/delete.html:17 -#: templates/expense/expense.html:88 +#: templates/expense/expense.html:83 msgid "No" msgstr "Non" @@ -1065,16 +1076,16 @@ msgid "Leave blank to use standard name" msgstr "Laissez vide pour utiliser le nom standard" #: crm/forms.py:208 crm/models.py:142 -#: templates/crm/clientcompany_detail.html:363 -#: templates/staffing/mission_contacts.html:13 +#: templates/crm/clientcompany_detail.html:410 +#: templates/staffing/mission_contacts.html:17 msgid "Company" msgstr "Société" #: crm/forms.py:228 crm/models.py:301 crm/models.py:328 crm/models.py:359 -#: crm/models.py:483 crm/models.py:516 +#: crm/models.py:483 crm/models.py:515 #: templates/crm/_clientcompany_rates_margin.html:15 #: templates/crm/_clientcompany_rates_margin.html:48 -#: templates/leads/lead_detail.html:135 +#: templates/leads/lead_detail.html:108 #: templates/people/subcontractor_detail.html:8 msgid "Contact" msgstr "Contact" @@ -1103,13 +1114,13 @@ msgstr "n° d'immatriculation" msgid "VAT id" msgstr "n° TVA" -#: crm/models.py:60 crm/models.py:82 crm/models.py:184 crm/models.py:500 -#: expense/models.py:67 leads/models.py:65 leads/views.py:199 +#: crm/models.py:60 crm/models.py:82 crm/models.py:184 crm/models.py:499 +#: expense/models.py:67 leads/models.py:65 leads/views.py:190 #: people/models.py:31 people/models.py:327 -#: templates/crm/clientcompany_detail.html:362 +#: templates/crm/clientcompany_detail.html:409 #: templates/leads/manage_tags.html:22 templates/leads/review.html:45 #: templates/leads/review.html:78 templates/staffing/fixed_price_report.html:15 -#: templates/staffing/mission_contacts.html:12 +#: templates/staffing/mission_contacts.html:16 msgid "Name" msgstr "Nom" @@ -1117,7 +1128,7 @@ msgstr "Nom" msgid "Code" msgstr "Code" -#: crm/models.py:88 crm/models.py:106 crm/models.py:151 staffing/views.py:2060 +#: crm/models.py:88 crm/models.py:106 crm/models.py:151 staffing/views.py:2052 #: templates/crm/clientcompany_ranking.html:14 #: templates/staffing/turnover_pivotable.html:109 #: templates/staffing/turnover_pivotable.html:117 @@ -1170,23 +1181,23 @@ msgid "Billing language" msgstr "Langue des factures" #: crm/models.py:186 people/models.py:332 -#: templates/staffing/mission_contacts.html:16 +#: templates/staffing/mission_contacts.html:20 msgid "Phone" msgstr "Téléphone" -#: crm/models.py:187 templates/crm/clientcompany_detail.html:367 -#: templates/staffing/mission_contacts.html:17 +#: crm/models.py:187 templates/crm/clientcompany_detail.html:414 +#: templates/staffing/mission_contacts.html:21 msgid "Mobile phone" msgstr "Téléphone mobile" -#: crm/models.py:188 templates/crm/clientcompany_detail.html:368 -#: templates/staffing/mission_contacts.html:18 +#: crm/models.py:188 templates/crm/clientcompany_detail.html:415 +#: templates/staffing/mission_contacts.html:22 msgid "Fax" msgstr "Fax" -#: crm/models.py:189 crm/models.py:512 -#: templates/crm/clientcompany_detail.html:364 -#: templates/staffing/mission_contacts.html:14 +#: crm/models.py:189 crm/models.py:511 +#: templates/crm/clientcompany_detail.html:411 +#: templates/staffing/mission_contacts.html:18 msgid "Function" msgstr "Fonction" @@ -1207,7 +1218,7 @@ msgstr "%s affaires" msgid "Broker company" msgstr "Société de l'apporteur" -#: crm/models.py:321 leads/forms.py:76 leads/models.py:74 +#: crm/models.py:321 leads/forms.py:116 leads/models.py:74 msgid "Business broker" msgstr "Apporteur d'affaires" @@ -1269,7 +1280,7 @@ msgstr "Alignement stratégique" msgid "Active" msgstr "Active" -#: crm/models.py:476 leads/models.py:80 leads/views.py:199 +#: crm/models.py:476 leads/models.py:80 leads/views.py:190 #: templates/billing/bill_review.html:35 templates/billing/bill_review.html:83 #: templates/billing/bill_review.html:128 #: templates/billing/bill_review.html:174 @@ -1283,23 +1294,23 @@ msgstr "Active" msgid "Client" msgstr "Client" -#: crm/models.py:494 +#: crm/models.py:493 msgid "Mission contact" msgstr "Contact mission" -#: crm/models.py:513 templates/crm/clientcompany_detail.html:366 +#: crm/models.py:512 templates/crm/clientcompany_detail.html:413 msgid "Phone Switchboard" msgstr "Standard téléphonique" -#: crm/models.py:514 +#: crm/models.py:513 msgid "Generic email" msgstr "Courriel générique" -#: crm/models.py:515 +#: crm/models.py:514 msgid "Generic fax" msgstr "Fax générique" -#: crm/models.py:554 +#: crm/models.py:553 msgid "Administrative contact" msgstr "Contact administratif" @@ -1307,23 +1318,23 @@ msgstr "Contact administratif" msgid "Contact removed successfully" msgstr "Contact supprimé avec succès " -#: crm/views.py:400 +#: crm/views.py:434 msgid "overdue" msgstr "en retard" -#: crm/views.py:401 +#: crm/views.py:435 msgid "soon due" msgstr "bientôt en retard" -#: crm/views.py:402 +#: crm/views.py:436 msgid "last 12 months" msgstr "12 derniers mois" -#: crm/views.py:481 staffing/views.py:1668 staffing/views.py:1683 +#: crm/views.py:517 staffing/views.py:1650 staffing/views.py:1665 msgid "profile" msgstr "profile" -#: crm/views.py:484 staffing/views.py:1671 staffing/views.py:1686 +#: crm/views.py:520 staffing/views.py:1653 staffing/views.py:1668 #: templates/crm/_clientcompany_rates_margin.html:101 #: templates/crm/_clientcompany_rates_margin.html:107 #: templates/crm/_clientcompany_rates_margin.html:113 @@ -1333,38 +1344,38 @@ msgstr "profile" msgid "period" msgstr "période" -#: crm/views.py:614 +#: crm/views.py:650 msgid "Others" msgstr "Autres" -#: expense/forms.py:88 +#: expense/forms.py:98 msgid "Attached file must be less than 10 Mb" msgstr "Le fichier joint doit faire moins de 10 Mo" -#: expense/forms.py:92 +#: expense/forms.py:102 #, python-format msgid "Use a valid extension (%s)" msgstr "Utilisez une extension valide (%s)" -#: expense/forms.py:98 +#: expense/forms.py:108 msgid "You must define a lead if expense is chargeable" msgstr "Vous devez définir une affaire si les frais sont refacturables" -#: expense/forms.py:104 expense/models.py:160 +#: expense/forms.py:114 expense/models.py:161 #: templates/core/_pydici_menu.html:143 templates/core/_pydici_menu.html:146 #: templates/core/_pydici_menu.html:238 templates/staffing/mission.html:43 msgid "Expenses" msgstr "Notes de frais" -#: expense/forms.py:104 +#: expense/forms.py:114 msgid "Select expenses to pay..." msgstr "Choisissez les notes de frais à rembourser..." -#: expense/forms.py:105 +#: expense/forms.py:115 msgid "payment date" msgstr "date de paiement" -#: expense/forms.py:124 +#: expense/forms.py:134 msgid "All expenses of a payment must belongs to same user" msgstr "" "Toutes les notes de frais d'un remboursement doivent appartenir au même " @@ -1414,13 +1425,13 @@ msgstr "Catégories de dépenses" msgid "Expenses payment" msgstr "Remboursement de frais" -#: expense/models.py:97 templates/expense/expense_payments.html:12 -#: templates/expense/expense_payments.html:43 +#: expense/models.py:97 templates/expense/expense_payments.html:8 +#: templates/expense/expense_payments.html:39 msgid "Expenses payments" msgstr "Remboursements de frais" -#: expense/models.py:105 templates/expense/expense.html:68 -#: templates/expense/expense_archive.html:29 +#: expense/models.py:105 templates/expense/expense.html:63 +#: templates/expense/expense_archive.html:24 #: templates/expense/expense_list.html:19 msgid "Chargeable" msgstr "Refacturable" @@ -1432,31 +1443,31 @@ msgid "Date" msgstr "Date" #: expense/models.py:108 leads/models.py:85 leads/models.py:132 -#: leads/views.py:201 staffing/models.py:82 templates/core/index.html:18 +#: leads/views.py:192 staffing/models.py:82 templates/core/index.html:18 #: templates/core/index.html:43 templates/core/index.html:67 #: templates/core/index.html:91 msgid "Updated" msgstr "Mise à jour" -#: expense/models.py:109 expense/tables.py:191 templates/billing/bill.html:166 +#: expense/models.py:109 expense/tables.py:188 templates/billing/bill.html:166 #: templates/billing/client_bill_detail.html:37 -#: templates/expense/expense.html:39 templates/expense/expense_archive.html:26 +#: templates/expense/expense.html:34 templates/expense/expense_archive.html:21 #: templates/expense/expense_list.html:18 -#: templates/expense/expense_payments.html:49 +#: templates/expense/expense_payments.html:45 msgid "Amount" msgstr "Montant" -#: expense/models.py:110 templates/expense/expense.html:43 -#: templates/expense/expense_archive.html:27 +#: expense/models.py:110 templates/expense/expense.html:38 +#: templates/expense/expense_archive.html:22 msgid "VAT (€)" msgstr "TVA (€)" -#: expense/models.py:111 templates/expense/expense.html:35 -#: templates/expense/expense_archive.html:23 +#: expense/models.py:111 templates/expense/expense.html:30 +#: templates/expense/expense_archive.html:18 msgid "Category" msgstr "Catégorie" -#: expense/models.py:112 templates/expense/expense_archive.html:28 +#: expense/models.py:112 templates/expense/expense_archive.html:23 #: templates/expense/expense_list.html:20 msgid "Receipt" msgstr "Justificatif" @@ -1465,7 +1476,7 @@ msgstr "Justificatif" msgid "Paid with corporate card" msgstr "Payé avec la carte société" -#: expense/models.py:117 leads/views.py:629 +#: expense/models.py:117 leads/views.py:628 #: templates/leads/leads_pivotable.html:52 #: templates/leads/leads_pivotable.html:67 #: templates/leads/leads_pivotable.html:75 @@ -1473,157 +1484,129 @@ msgstr "Payé avec la carte société" msgid "state" msgstr "état" -#: expense/tables.py:122 expense/tables.py:190 people/models.py:273 -#: staffing/views.py:1429 templates/billing/bill.html:164 -#: templates/expense/expense.html:31 templates/expense/expense_archive.html:22 -#: templates/expense/expense_payments.html:48 -#: templates/staffing/mission_consultants.html:9 +#: expense/tables.py:123 expense/tables.py:187 people/models.py:273 +#: staffing/views.py:1412 templates/billing/bill.html:164 +#: templates/expense/expense.html:26 templates/expense/expense_archive.html:17 +#: templates/expense/expense_payments.html:44 +#: templates/staffing/mission_consultants.html:10 #: templates/staffing/mission_staffing.html:22 #: templates/staffing/optimise_pdc.html:85 msgid "Consultant" msgstr "Consultant" -#: expense/tables.py:152 -msgid "Edit" -msgstr "Modifier" - -#. Translators: Ed is the short term for Edit -#: expense/tables.py:155 -msgid "Ed" -msgstr "Mo" - -#: expense/tables.py:157 -msgid "Delete" -msgstr "Supprimer" - -#. Translators: De is the short term for Delete -#: expense/tables.py:160 -msgid "De" -msgstr "Su" - -#: expense/tables.py:162 -msgid "Clone" -msgstr "Cloner" - -#. Translators: Cl is the short term for Clone -#: expense/tables.py:165 -msgid "Cl" -msgstr "Cl" - -#: expense/tables.py:193 +#: expense/tables.py:190 msgid "detail" msgstr "détail" -#: expense/tables.py:194 +#: expense/tables.py:191 msgid "change" msgstr "modifier" -#: expense/views.py:87 expense/views.py:186 +#: expense/views.py:90 expense/views.py:185 msgid "You are not allowed to edit that expense" msgstr "Vous n'êtes pas autorisé à modifier cette note de frais" -#: expense/views.py:91 expense/views.py:190 expense/views.py:262 -#: expense/views.py:302 +#: expense/views.py:94 expense/views.py:189 expense/views.py:260 #, python-format msgid "Expense %s does not exist" msgstr "La note de frais %s n'existe pas" -#: expense/views.py:195 +#: expense/views.py:194 #, python-format msgid "Expense %s has been deleted" msgstr "La note de frais %s a été supprimée" -#: expense/views.py:272 -msgid "Successfully update expense" -msgstr "Note de frais mise à jour avec succès" - -#: expense/views.py:304 staffing/views.py:1811 -msgid "Incorrect value" -msgstr "Valeur incorrecte" +#: expense/views.py:292 +msgid "Expense does not exist" +msgstr "La note de frais n'existe pas" -#: expense/views.py:384 +#: expense/views.py:386 #, python-format msgid "Expense payment %s does not exist" msgstr "Le remboursement de frais %s n'existe pas" -#: leads/admin.py:21 leads/forms.py:94 +#: leads/admin.py:21 leads/forms.py:134 msgid "State and tracking" msgstr "État et suivi" -#: leads/admin.py:22 leads/forms.py:101 +#: leads/admin.py:22 leads/forms.py:141 msgid "Commercial" msgstr "Commercial" -#: leads/admin.py:23 leads/forms.py:107 leads/views.py:200 +#: leads/admin.py:23 leads/forms.py:147 leads/views.py:191 #: staffing/models.py:507 templates/billing/bill_review.html:209 #: templates/leads/review.html:49 templates/leads/review.html:82 msgid "Staffing" msgstr "Staffing" -#: leads/forms.py:75 leads/models.py:70 leads/views.py:199 people/models.py:344 -#: templates/core/index.html:15 templates/core/index.html:40 -#: templates/core/index.html:64 templates/core/index.html:88 -#: templates/leads/lead_detail.html:154 +#: leads/forms.py:105 +msgid "New tags" +msgstr "Nouvelles étiquettes" + +#: leads/forms.py:115 leads/models.py:70 leads/views.py:190 +#: people/models.py:344 templates/core/index.html:15 +#: templates/core/index.html:40 templates/core/index.html:64 +#: templates/core/index.html:88 templates/leads/lead_detail.html:127 msgid "Salesman" msgstr "Commercial" -#: leads/forms.py:76 +#: leads/forms.py:116 msgid "If the leads was brought by a third party" msgstr "Si l'affaire a été apportée par un tiers" -#: leads/forms.py:77 +#: leads/forms.py:117 msgid "If payment is done by a third party" msgstr "Si la facturation est faite via un tiers" -#: leads/forms.py:85 staffing/forms.py:351 +#: leads/forms.py:125 staffing/forms.py:351 msgid "Identification" msgstr "Identification" -#: leads/forms.py:86 +#: leads/forms.py:126 msgid "Name of the lead. don't include client name" msgstr "Nom de l'affaire. Ne pas inclure le nom du client" -#: leads/forms.py:93 +#: leads/forms.py:133 msgid "Next commercial action to be done" msgstr "Prochaine action commercial à prendre" -#: leads/forms.py:96 staffing/forms.py:369 +#: leads/forms.py:136 staffing/forms.py:369 msgid "Leave blank to auto generate" msgstr "Laissez vide pour auto générer" -#: leads/forms.py:97 +#: leads/forms.py:137 msgid "Due date for next step" msgstr "Échéance de la prochaine étape" -#: leads/forms.py:98 +#: leads/forms.py:138 msgid "Internal client reference" msgstr "Référence interne du client" -#: leads/forms.py:99 +#: leads/forms.py:139 msgid "Date of the operational start" msgstr "Date du démarrage effectif" -#: leads/forms.py:107 +#: leads/forms.py:147 msgid "People that could contribute..." msgstr "Personnes qui pourraient contribuer..." -#: leads/forms.py:108 +#: leads/forms.py:148 msgid "People outside company that could contribute..." msgstr "Personnes externes à la société qui pourraient contribuer..." -#: leads/forms.py:121 +#: leads/forms.py:161 msgid "Sales amount must be defined at this step of the commercial process" msgstr "" "Le montant de l'affaire doit être connu à cette étape du processus d'avant-" "vente" -#: leads/forms.py:131 +#: leads/forms.py:170 msgid "Start date must be defined at this step of the commercial process" msgstr "" "La date de démarrage de l'affaire doit être connue à cette étape du " "processus d'avant-vente" -#: leads/forms.py:142 +#: leads/forms.py:181 msgid "" "Deal id must be unique. Use another value or let the field blank for " "automatic computation" @@ -1665,12 +1648,12 @@ msgstr "Abandonnée" msgid "Sleeping" msgstr "En sommeil" -#: leads/models.py:67 templates/leads/lead_detail.html:213 -#: templates/staffing/mission.html:144 +#: leads/models.py:67 templates/leads/lead_detail.html:186 +#: templates/staffing/mission.html:145 msgid "Administrative notes" msgstr "Commentaires administratifs" -#: leads/models.py:68 templates/leads/lead_detail.html:186 +#: leads/models.py:68 templates/leads/lead_detail.html:159 msgid "Action" msgstr "Action" @@ -1682,7 +1665,7 @@ msgstr "Montant (k€)" msgid "External staffing" msgstr "Staffing externe" -#: leads/models.py:76 leads/views.py:199 people/models.py:320 +#: leads/models.py:76 leads/views.py:190 people/models.py:320 msgid "Starting" msgstr "Démarrage" @@ -1690,13 +1673,13 @@ msgstr "Démarrage" msgid "Due" msgstr "Échéance" -#: leads/models.py:81 leads/views.py:200 +#: leads/models.py:81 leads/views.py:191 msgid "Creation" msgstr "Création" -#: leads/models.py:82 staffing/views.py:1054 staffing/views.py:1428 +#: leads/models.py:82 staffing/views.py:1037 staffing/views.py:1411 #: templates/billing/client_bills_archive.html:14 -#: templates/leads/lead_detail.html:139 templates/leads/review.html:46 +#: templates/leads/lead_detail.html:112 templates/leads/review.html:46 #: templates/leads/review.html:79 msgid "Deal id" msgstr "N° affaire" @@ -1705,7 +1688,7 @@ msgstr "N° affaire" msgid "Send lead by email" msgstr "Envoyer par courriel" -#: leads/models.py:301 +#: leads/models.py:312 msgid "Score" msgstr "Score" @@ -1742,61 +1725,71 @@ msgstr "La probabilité de la mission a été mise à 100 %" msgid "According mission has been archived" msgstr "La mission correspondante a été archivée" -#: leads/views.py:195 +#: leads/views.py:186 msgid "leads.csv" msgstr "affaires.csv" -#: leads/views.py:199 +#: leads/views.py:190 msgid "Managed by" msgstr "Géré par" -#: leads/views.py:200 templates/billing/bill_review.html:210 +#: leads/views.py:191 templates/billing/bill_review.html:210 #: templates/core/index.html:16 templates/core/index.html:41 #: templates/core/index.html:65 templates/core/index.html:89 #: templates/crm/clientcompany_ranking.html:19 -#: templates/leads/lead_detail.html:177 +#: templates/leads/lead_detail.html:150 msgid "Sales (k€)" msgstr "C.A. (k€)" -#: leads/views.py:363 templates/core/_pydici_menu.html:39 +#: leads/views.py:272 +msgid "Invalid tag" +msgstr "Étiquette invalide" + +#: leads/views.py:274 +msgid "No tag provided" +msgstr "Aucune étiquette fournie" + +#: leads/views.py:362 templates/core/_pydici_menu.html:39 #: templates/leads/leads.html:11 msgid "All leads" msgstr "Toutes les affaires" -#: leads/views.py:363 templates/crm/graph_company_business_activity.html:30 +#: leads/views.py:362 templates/crm/graph_company_business_activity.html:30 msgid "Won leads" msgstr "Affaires gagnées" -#: leads/views.py:525 +#: leads/views.py:524 #, python-format msgid "Last %s days" msgstr "%s derniers jours" -#: leads/views.py:540 templates/leads/graph_leads_activity.html:49 +#: leads/views.py:539 templates/leads/graph_leads_activity.html:49 msgid "duration" msgstr "durée" -#: leads/views.py:541 templates/leads/graph_leads_activity.html:50 +#: leads/views.py:540 templates/leads/graph_leads_activity.html:50 msgid "average duration 6 months" msgstr "durée moyenne sur 6 mois" -#: leads/views.py:550 +#: leads/views.py:549 #, python-format msgid "%s leads in progress" msgstr "%s affaires en cours" -#: leads/views.py:602 templates/leads/leads_pivotable.html:59 +#: leads/views.py:601 templates/leads/leads_pivotable.html:59 msgid "sales (interval)" msgstr "prix (interval)" -#: leads/views.py:602 leads/views.py:625 +#: leads/views.py:601 leads/views.py:624 msgid "sales (k€)" msgstr "C.A. (k€)" -#: leads/views.py:622 staffing/views.py:1534 staffing/views.py:2052 +#: leads/views.py:621 staffing/views.py:1517 staffing/views.py:2044 #: templates/crm/_contact_list.html:9 #: templates/crm/clientcompany_detail.html:266 #: templates/crm/clientcompany_detail.html:296 +#: templates/crm/clientcompany_detail.html:320 +#: templates/crm/clientcompany_detail.html:344 #: templates/crm/contact_detail.html:20 templates/crm/contact_list.html:12 #: templates/leads/leads.html:16 templates/leads/leads_to_bill.html:15 #: templates/people/consultants_tasks.html:17 @@ -1804,7 +1797,7 @@ msgstr "C.A. (k€)" msgid "name" msgstr "nom" -#: leads/views.py:626 staffing/models.py:458 +#: leads/views.py:625 staffing/models.py:458 #: templates/crm/company_pivotable.html:50 #: templates/crm/company_pivotable.html:56 #: templates/crm/company_pivotable.html:62 @@ -1813,16 +1806,16 @@ msgstr "nom" msgid "date" msgstr "date" -#: leads/views.py:630 templates/leads/leads_pivotable.html:68 +#: leads/views.py:629 templates/leads/leads_pivotable.html:68 msgid "billed (€)" msgstr "facturé (€)" -#: leads/views.py:631 templates/leads/leads_pivotable.html:76 +#: leads/views.py:630 templates/leads/leads_pivotable.html:76 #: templates/leads/leads_pivotable.html:84 msgid "Over budget margin (€)" msgstr "Marge au delà du budget (€)" -#: leads/views.py:633 +#: leads/views.py:632 msgid "active" msgstr "active" @@ -1855,7 +1848,7 @@ msgstr "Profil" msgid "Subcontractor" msgstr "Sous-traitant" -#: people/models.py:317 staffing/views.py:1685 +#: people/models.py:317 staffing/views.py:1667 #: templates/people/consultant_detail.html:237 #: templates/people/consultant_detail.html:243 #: templates/staffing/_consultant_prod_tooltip.html:2 @@ -1863,7 +1856,7 @@ msgstr "Sous-traitant" msgid "daily rate" msgstr "taux journalier" -#: people/models.py:318 staffing/views.py:1670 staffing/views.py:2215 +#: people/models.py:318 staffing/views.py:1652 staffing/views.py:2207 #: templates/staffing/_consultant_prod_tooltip.html:1 #: templates/staffing/graph_timesheet_rates_bar.html:15 #: templates/staffing/graph_timesheet_rates_bar.html:25 @@ -2154,7 +2147,7 @@ msgstr "Charge" #: staffing/forms.py:212 templates/billing/_lead_billing.html:43 #: templates/billing/_lead_billing.html:100 #: templates/billing/client_bill_detail.html:53 -#: templates/expense/expense.html:80 templates/expense/expense_archive.html:35 +#: templates/expense/expense.html:75 templates/expense/expense_archive.html:30 msgid "Comment" msgstr "Remarques" @@ -2171,7 +2164,7 @@ msgstr "Dates de staffing" msgid "mission id: %s" msgstr "n° de mission : %s" -#: staffing/forms.py:324 staffing/views.py:1367 +#: staffing/forms.py:324 staffing/views.py:1350 msgid "Days without lunch ticket" msgstr "Jours sans ticket restaurant" @@ -2385,15 +2378,15 @@ msgctxt "masculine" msgid "None" msgstr "Aucun" -#: staffing/models.py:73 staffing/views.py:1428 +#: staffing/models.py:73 staffing/views.py:1411 #: templates/crm/clientcompany_detail.html:270 #: templates/staffing/_mission_table.html:20 #: templates/staffing/fixed_price_report.html:16 -#: templates/staffing/mission.html:100 +#: templates/staffing/mission.html:101 msgid "Mission id" msgstr "N° de mission" -#: staffing/models.py:77 templates/staffing/mission.html:127 +#: staffing/models.py:77 templates/staffing/mission.html:128 msgid "Management mode" msgstr "Mode de gestion" @@ -2420,7 +2413,7 @@ msgstr "code analytique" msgid "marketing product" msgstr "produit marketing" -#: staffing/models.py:89 templates/leads/lead_detail.html:171 +#: staffing/models.py:89 templates/leads/lead_detail.html:144 #: templates/leads/review.html:55 templates/leads/review.html:87 msgid "Start date" msgstr "Date de début" @@ -2434,7 +2427,7 @@ msgid "Min charge multiple per day" msgstr "Multiple mini de charge par jour" #: staffing/models.py:432 templates/crm/clientcompany_detail.html:253 -#: templates/staffing/mission.html:104 +#: templates/staffing/mission.html:105 msgid "undefined" msgstr "À définir" @@ -2467,10 +2460,10 @@ msgstr "prévu (jours)" msgid "forecast (€)" msgstr "prévu (€)" -#: staffing/models.py:470 staffing/views.py:1054 staffing/views.py:1329 -#: staffing/views.py:1428 templates/leads/lead_detail.html:227 +#: staffing/models.py:470 staffing/views.py:1037 staffing/views.py:1312 +#: staffing/views.py:1411 templates/leads/lead_detail.html:200 #: templates/staffing/consultant_staffing.html:11 -#: templates/staffing/mission.html:163 templates/staffing/optimise_pdc.html:84 +#: templates/staffing/mission.html:164 templates/staffing/optimise_pdc.html:84 #: templates/staffing/optimise_pdc.html:109 #: templates/staffing/pdc_detail.html:8 msgid "Mission" @@ -2499,14 +2492,14 @@ msgstr "Pas de ticket restaurant" msgid "Lunch ticket" msgstr "Ticket restaurant" -#: staffing/models.py:543 staffing/views.py:1429 +#: staffing/models.py:543 staffing/views.py:1412 #: templates/crm/_clientcompany_rates_margin.html:17 #: templates/crm/clientcompany_ranking.html:18 #: templates/staffing/rates_report.html:23 msgid "Daily rate" msgstr "Taux journalier" -#: staffing/models.py:544 staffing/views.py:1429 +#: staffing/models.py:544 staffing/views.py:1412 msgid "Bought daily rate" msgstr "Taux journalier d'achat" @@ -2551,14 +2544,14 @@ msgstr "Toutes les missions" #: staffing/tables.py:93 templates/billing/bill_review.html:220 #: templates/core/index.html:25 templates/core/index.html:50 #: templates/core/index.html:74 templates/core/index.html:98 -#: templates/crm/clientcompany_detail.html:322 -#: templates/leads/lead_detail.html:150 templates/leads/lead_detail.html:196 -#: templates/leads/lead_detail.html:241 templates/leads/lead_detail.html:243 +#: templates/crm/clientcompany_detail.html:369 +#: templates/leads/lead_detail.html:123 templates/leads/lead_detail.html:169 +#: templates/leads/lead_detail.html:214 templates/leads/lead_detail.html:216 #: templates/leads/lead_mail.html:8 templates/leads/lead_mail.html:12 #: templates/leads/lead_mail.txt:9 templates/leads/lead_mail.txt:13 #: templates/leads/mail.html:8 templates/leads/mail.txt:7 -#: templates/leads/mail.txt:8 templates/staffing/mission.html:124 -#: templates/staffing/mission.html:177 templates/staffing/mission.html:179 +#: templates/leads/mail.txt:8 templates/staffing/mission.html:125 +#: templates/staffing/mission.html:178 templates/staffing/mission.html:180 #: templates/staffing/mission_timesheet.html:90 msgid "To be defined" msgstr "À définir" @@ -2586,7 +2579,7 @@ msgstr "" msgid "Consultants" msgstr "Consultants" -#: staffing/utils.py:285 staffing/views.py:1055 staffing/views.py:1534 +#: staffing/utils.py:285 staffing/views.py:1038 staffing/views.py:1517 #: templates/billing/pre_billing.html:92 templates/billing/pre_billing.html:100 #: templates/billing/pre_billing.html:159 #: templates/billing/pre_billing.html:167 templates/staffing/pdc_detail.html:19 @@ -2610,11 +2603,11 @@ msgstr "La charge doit être un multiple (%s)" msgid "Charge cannot exceed forecast (%s)" msgstr "La charge ne peut pas excéder la prévision (%s)" -#: staffing/views.py:212 staffing/views.py:256 +#: staffing/views.py:196 staffing/views.py:240 msgid "Duplicate data error" msgstr "Erreur de donn es en doublon" -#: staffing/views.py:318 +#: staffing/views.py:303 #, python-format msgid "" "Staffing has been ignored for mission %(mission_name)s because " @@ -2624,7 +2617,7 @@ msgstr "" "date prévue %(staffing_date)s est antérieure à la fin de la mission " "(%(start_date)s)" -#: staffing/views.py:325 +#: staffing/views.py:310 #, python-format msgid "" "Staffing has been ignored for mission %(mission_name)s because " @@ -2634,29 +2627,29 @@ msgstr "" "date prévue %(staffing_date)s est postérieure à la fin de la mission " "(%(end_date)s)" -#: staffing/views.py:343 +#: staffing/views.py:328 msgid "Staffing has been updated" msgstr "Les prévisions de charge ont été mises à jour" -#: staffing/views.py:381 +#: staffing/views.py:366 #, python-format msgid "Staffing has been shifted by %s month" msgstr "Les prévisions de charge ont été décalées de %s mois" -#: staffing/views.py:405 +#: staffing/views.py:390 msgid "Only won leads" msgstr "Uniquement les affaires gagnées" -#: staffing/views.py:405 +#: staffing/views.py:390 msgid "Only consider won leads for staffing forecasting" msgstr "" "Ne prend en compte que les missions gagnées pour les prévisions de charge" -#: staffing/views.py:406 +#: staffing/views.py:391 msgid "Balanced staffing projection" msgstr "Projections de staffing pondérées" -#: staffing/views.py:406 +#: staffing/views.py:391 msgid "" "Add missions forcecast staffing even if still not won with a ponderation " "based on the mission won probability" @@ -2664,11 +2657,11 @@ msgstr "" "Ajoute les prévisions de charge des missions même si elles ne sont pas " "gagnées en pondérant avec la probabilité de gagner." -#: staffing/views.py:407 +#: staffing/views.py:392 msgid "Full staffing projection" msgstr "Projections de staffing complètes" -#: staffing/views.py:407 +#: staffing/views.py:392 msgid "" "Add missions forcecast staffing even if still not won without any " "ponderation. All forecast is considered." @@ -2676,93 +2669,101 @@ msgstr "" "Ajoute les prévisions de charge des missions même si elles ne sont pas " "gagnées sans aucun pondération. Toutes les prévisions sont prises en compte." -#: staffing/views.py:410 +#: staffing/views.py:395 msgid "Group by Manager" msgstr "Groupement par manager" -#: staffing/views.py:411 +#: staffing/views.py:396 msgid "Group by Level" msgstr "Groupement par position" -#: staffing/views.py:794 templates/staffing/prod_report.html:119 +#: staffing/views.py:779 templates/staffing/prod_report.html:119 msgid "Prod rate delta" msgstr "Écart de taux de prod." -#: staffing/views.py:795 templates/staffing/prod_report.html:120 +#: staffing/views.py:780 templates/staffing/prod_report.html:120 msgid "Daily rate delta" msgstr "Écart de taux journalier" -#: staffing/views.py:813 +#: staffing/views.py:798 msgid "Missing timesheet" msgstr "Pointages manquants" -#: staffing/views.py:1044 staffing/views.py:1392 staffing/views.py:1415 +#: staffing/views.py:859 +msgid "mission archived" +msgstr "mission archivée" + +#: staffing/views.py:861 +msgid "mission not found" +msgstr "mission inexistante" + +#: staffing/views.py:1027 staffing/views.py:1375 staffing/views.py:1398 msgid "timesheet.csv" msgstr "feuille-de-temps.csv" -#: staffing/views.py:1354 templates/billing/_lead_billing.html:66 -#: templates/billing/bill.html:179 templates/leads/lead_detail.html:256 +#: staffing/views.py:1337 templates/billing/_lead_billing.html:66 +#: templates/billing/bill.html:179 templates/leads/lead_detail.html:229 #: templates/staffing/mission_timesheet.html:38 #: templates/staffing/mission_timesheet.html:117 #: templates/staffing/prod_report.html:78 msgid "Total" msgstr "Total" -#: staffing/views.py:1428 +#: staffing/views.py:1411 msgid "Lead Price (k€)" msgstr "Montant affaire (k€)" -#: staffing/views.py:1428 +#: staffing/views.py:1411 msgid "Mission Price (k€)" msgstr "Montant mission (k€)" -#: staffing/views.py:1429 +#: staffing/views.py:1412 msgid "Past done days" msgstr "Jours réalisés dans le passé" -#: staffing/views.py:1429 templates/billing/bill_review.html:211 -#: templates/leads/lead_detail.html:229 templates/staffing/mission.html:165 +#: staffing/views.py:1412 templates/billing/bill_review.html:211 +#: templates/leads/lead_detail.html:202 templates/staffing/mission.html:166 #: templates/staffing/mission_timesheet.html:12 msgid "Done days" msgstr "Jours réalisés" -#: staffing/views.py:1429 templates/staffing/mission_timesheet.html:14 +#: staffing/views.py:1412 templates/staffing/mission_timesheet.html:14 msgid "Days to be done" msgstr "Jours à faire" -#: staffing/views.py:1480 +#: staffing/views.py:1463 msgid "holidays_timesheet.csv" msgstr "feuille-de-temps-congés.csv" -#: staffing/views.py:1534 +#: staffing/views.py:1517 msgid "trigramme" msgstr "trigramme" -#: staffing/views.py:1534 staffing/views.py:1733 +#: staffing/views.py:1517 staffing/views.py:1715 msgid "profil" msgstr "profil" -#: staffing/views.py:1534 +#: staffing/views.py:1517 msgid "start" msgstr "début" -#: staffing/views.py:1534 +#: staffing/views.py:1517 msgid "end" msgstr "fin" -#: staffing/views.py:1625 +#: staffing/views.py:1607 msgid "current" msgstr "actuel" -#: staffing/views.py:1625 +#: staffing/views.py:1607 msgid "next" msgstr "prochain" -#: staffing/views.py:1632 templates/staffing/rate_objective_report.html:41 +#: staffing/views.py:1614 templates/staffing/rate_objective_report.html:41 msgid "horizon" msgstr "horizon" -#: staffing/views.py:1734 staffing/views.py:2077 +#: staffing/views.py:1716 staffing/views.py:2069 #: templates/billing/pre_billing.html:92 templates/billing/pre_billing.html:159 #: templates/staffing/missions_report.html:67 #: templates/staffing/missions_report.html:73 @@ -2770,37 +2771,33 @@ msgstr "horizon" msgid "days" msgstr "jours" -#: staffing/views.py:1761 +#: staffing/views.py:1742 msgid "This lead has no mission defined" msgstr "Cette affaire n'a aucune mission définie" -#: staffing/views.py:1788 -msgid "You are not allowed to do that" -msgstr "Vous n'êtes pas autorisé à faire cela" +#: staffing/views.py:1776 +msgid "Mission or consultant does not exist" +msgstr "La mission ou le consultant n'existe pas" -#: staffing/views.py:1797 +#: staffing/views.py:1785 #, python-brace-format msgid "daily rate for {consultant}" msgstr "Taux journalier pour {consultant}" -#: staffing/views.py:1800 +#: staffing/views.py:1790 #, python-brace-format msgid "bought daily rate for {consultant}" msgstr "Taux journalier d'achat pour {consultant}" -#: staffing/views.py:1809 -msgid "Mission or consultant does not exist" -msgstr "La mission ou le consultant n'existe pas" - -#: staffing/views.py:1943 +#: staffing/views.py:1935 msgid "Predefined assignment must be in consultant list" msgstr "Les affectations prédéfinis doivent être dans la liste des consultants" -#: staffing/views.py:1947 +#: staffing/views.py:1939 msgid "Excluded consultant must be in consultant list" msgstr "Les affectations exclus être dans la liste des consultants" -#: staffing/views.py:1968 +#: staffing/views.py:1960 msgid "" "There's no solution. Add consultants, remove mission, exclusions or relax " "experience ratio constraint" @@ -2808,18 +2805,18 @@ msgstr "" "Aucune solution. Ajoutez des consultants, retirez des missions, des " "exclusions ou relâchez les contraintes de ratio d'expérience." -#: staffing/views.py:2059 templates/staffing/mission.html:104 +#: staffing/views.py:2051 templates/staffing/mission.html:105 #: templates/staffing/turnover_pivotable.html:101 #: templates/staffing/turnover_pivotable.html:118 #: templates/staffing/turnover_pivotable.html:125 msgid "Marketing product" msgstr "Produit marketing" -#: staffing/views.py:2059 staffing/views.py:2060 +#: staffing/views.py:2051 staffing/views.py:2052 msgid "Undefined" msgstr "À définir" -#: staffing/views.py:2076 staffing/views.py:2087 +#: staffing/views.py:2068 staffing/views.py:2079 #: templates/billing/graph_yearly_billing.html:37 #: templates/staffing/turnover_pivotable.html:91 #: templates/staffing/turnover_pivotable.html:97 @@ -2830,23 +2827,23 @@ msgstr "À définir" msgid "turnover (€)" msgstr "Chiffre d'affaire (€)" -#: staffing/views.py:2078 +#: staffing/views.py:2070 msgid "external subcontractor turnover (€)" msgstr "CA sous-traitants externes (€)" -#: staffing/views.py:2079 +#: staffing/views.py:2071 msgid "external subcontractor days" msgstr "jours sous-traitants externes" -#: staffing/views.py:2080 +#: staffing/views.py:2072 msgid "internal subcontractor turnover (€)" msgstr "CA sous-traitants internes (€)" -#: staffing/views.py:2081 +#: staffing/views.py:2073 msgid "internal subcontractor days" msgstr "jours sous-traitants internes" -#: staffing/views.py:2082 staffing/views.py:2097 +#: staffing/views.py:2074 staffing/views.py:2089 #: templates/staffing/turnover_pivotable.html:61 #: templates/staffing/turnover_pivotable.html:67 #: templates/staffing/turnover_pivotable.html:75 @@ -2855,39 +2852,39 @@ msgstr "jours sous-traitants internes" msgid "own turnover (€)" msgstr "Chiffre d'affaire propre (€)" -#: staffing/views.py:2083 +#: staffing/views.py:2075 msgid "own days" msgstr "Jours effectif propre" -#: staffing/views.py:2085 staffing/views.py:2093 +#: staffing/views.py:2077 staffing/views.py:2085 msgid "fiscal year" msgstr "Année fiscale" -#: staffing/views.py:2104 staffing/views.py:2106 +#: staffing/views.py:2096 staffing/views.py:2098 #: templates/staffing/turnover_pivotable.html:87 msgid "top client company" msgstr "top entreprise" -#: staffing/views.py:2106 +#: staffing/views.py:2098 msgid "others" msgstr "autres" -#: staffing/views.py:2145 staffing/views.py:2147 +#: staffing/views.py:2137 staffing/views.py:2139 #: templates/staffing/lunch_tickets_pivotable.html:59 msgid "days off previous month" msgstr "jours de congés du mois précédent" -#: staffing/views.py:2146 staffing/views.py:2147 +#: staffing/views.py:2138 staffing/views.py:2139 #: templates/staffing/lunch_tickets_pivotable.html:65 msgid "days without tickets previous month" msgstr "jours sans ticket restaurant le mois précédent" -#: staffing/views.py:2147 templates/staffing/lunch_tickets_pivotable.html:47 +#: staffing/views.py:2139 templates/staffing/lunch_tickets_pivotable.html:47 #: templates/staffing/lunch_tickets_pivotable.html:53 msgid "deserved tickets" msgstr "tickets mérités" -#: staffing/views.py:2301 +#: staffing/views.py:2293 msgid "Global" msgstr "Global" @@ -3519,35 +3516,35 @@ msgstr "" "un mail à un administrateur pour mettre à jour vos droits :

\n" #: templates/core/_datatables-dj-tables.html:26 -#: templates/core/_datatables.html:22 +#: templates/core/_datatables.html:23 msgid "Nothing found" msgstr "Aucun résultat" #: templates/core/_datatables-dj-tables.html:27 -#: templates/core/_datatables.html:23 +#: templates/core/_datatables.html:24 msgid "Showing _START_ to _END_ of _TOTAL_ records" msgstr "Affichage de _START_ à _END_ sur _TOTAL_ enregistrements" #: templates/core/_datatables-dj-tables.html:28 -#: templates/core/_datatables.html:24 +#: templates/core/_datatables.html:25 msgid "Showing 0 to 0 of 0 records" msgstr "Affichage de 0 à 0 sur 0 enregistrement" #: templates/core/_datatables-dj-tables.html:29 -#: templates/core/_datatables.html:25 +#: templates/core/_datatables.html:26 msgid "(filtered from _MAX_ total records)" msgstr "(filtrés parmi _MAX_ enregistrements au total)" #: templates/core/_datatables-dj-tables.html:30 -#: templates/core/_datatables.html:26 +#: templates/core/_datatables.html:27 msgid "Search:" msgstr "Rechercher :" -#: templates/core/_datatables.html:21 +#: templates/core/_datatables.html:22 msgid "Processing..." msgstr "En cours de traitement..." -#: templates/core/_datatables.html:27 +#: templates/core/_datatables.html:28 msgid "Show _MENU_ entries" msgstr "Afficher _MENU_ éléments" @@ -3692,7 +3689,7 @@ msgstr "Mon plan de charge" #: templates/core/_pydici_menu.html:80 templates/core/_pydici_menu.html:149 #: templates/core/_pydici_menu.html:179 #: templates/crm/clientcompany_detail.html:38 -#: templates/leads/lead_detail.html:55 templates/staffing/mission.html:45 +#: templates/leads/lead_detail.html:56 templates/staffing/mission.html:45 msgid "Reporting" msgstr "Rapports" @@ -3782,7 +3779,7 @@ msgid "New client" msgstr "Nouveau client" #: templates/core/_pydici_menu.html:123 -#: templates/staffing/mission_contacts.html:42 +#: templates/staffing/mission_contacts.html:49 msgid "New mission contact" msgstr "Nouveau contact mission" @@ -3791,7 +3788,7 @@ msgid "New business broker" msgstr "Nouvel apporteur d'affaires" #: templates/core/_pydici_menu.html:125 -#: templates/crm/clientcompany_detail.html:399 +#: templates/crm/clientcompany_detail.html:446 msgid "New administrative contact" msgstr "Nouveau contact administratif" @@ -3826,14 +3823,14 @@ msgstr "Revue des notes de frais refacturables" #: templates/core/_pydici_menu.html:151 #: templates/expense/expense_archive.html:6 -#: templates/expense/expense_payments.html:68 +#: templates/expense/expense_payments.html:64 #: templates/expense/expenses.html:52 msgid "Expenses history" msgstr "Historique des notes de frais" #: templates/core/_pydici_menu.html:159 #: templates/crm/clientcompany_detail.html:37 -#: templates/leads/lead_detail.html:53 templates/staffing/mission.html:47 +#: templates/leads/lead_detail.html:54 templates/staffing/mission.html:47 msgid "Billing" msgstr "Facturation" @@ -4140,10 +4137,8 @@ msgstr "Ajouter ou modifier un client" #: templates/crm/clientcompany_detail.html:16 #: templates/crm/clientcompany_detail.html:251 #: templates/crm/clientcompany_detail.html:253 -#: templates/expense/_make_vat_editable.html:8 -#: templates/leads/lead_detail.html:44 templates/leads/lead_detail.html:46 -#: templates/staffing/mission.html:217 -#: templates/staffing/mission_consultants.html:37 +#: templates/leads/lead_detail.html:45 templates/leads/lead_detail.html:47 +#: templates/staffing/mission.html:218 msgid "click to edit..." msgstr "Cliquez pour modifier..." @@ -4247,7 +4242,9 @@ msgid "nature" msgstr "nature" #: templates/crm/clientcompany_detail.html:271 -#: templates/crm/clientcompany_detail.html:300 templates/leads/leads.html:20 +#: templates/crm/clientcompany_detail.html:300 +#: templates/crm/clientcompany_detail.html:324 +#: templates/crm/clientcompany_detail.html:348 templates/leads/leads.html:20 #: templates/leads/leads_to_bill.html:20 #: templates/staffing/_mission_table.html:21 msgid "amount (k€)" @@ -4268,37 +4265,47 @@ msgstr "Prévisions mise à jour récemment" msgid "Archiving" msgstr "Archivage" -#: templates/crm/clientcompany_detail.html:301 templates/leads/leads.html:21 +#: templates/crm/clientcompany_detail.html:301 +#: templates/crm/clientcompany_detail.html:325 +#: templates/crm/clientcompany_detail.html:349 templates/leads/leads.html:21 msgid "status" msgstr "état" -#: templates/crm/clientcompany_detail.html:319 +#: templates/crm/clientcompany_detail.html:315 +msgid "Supplier Leads" +msgstr "Affaire en tant que fournisseur" + +#: templates/crm/clientcompany_detail.html:339 +msgid "Business broker Leads" +msgstr "Affaires apportées ou sous-traitées" + +#: templates/crm/clientcompany_detail.html:366 msgid "Business owner for this company:" msgstr "Responsable business pour cette société : " -#: templates/crm/clientcompany_detail.html:325 +#: templates/crm/clientcompany_detail.html:372 msgid "Consultants that work for this company:" msgstr "Les consultants qui ont travaillé pour cette société :" -#: templates/crm/clientcompany_detail.html:332 +#: templates/crm/clientcompany_detail.html:379 #: templates/people/consultant.html:19 msgid "subcontractor" msgstr "Sous-traitant" -#: templates/crm/clientcompany_detail.html:341 +#: templates/crm/clientcompany_detail.html:388 msgid "Business contacts" msgstr "Contacts business" -#: templates/crm/clientcompany_detail.html:349 +#: templates/crm/clientcompany_detail.html:396 msgid "Mission contacts" msgstr "Contacts mission" -#: templates/crm/clientcompany_detail.html:357 +#: templates/crm/clientcompany_detail.html:404 msgid "Administrative contacts" msgstr "Contacts administratifs" -#: templates/crm/clientcompany_detail.html:365 -#: templates/staffing/mission_contacts.html:15 +#: templates/crm/clientcompany_detail.html:412 +#: templates/staffing/mission_contacts.html:19 msgid "Email" msgstr "Courriel" @@ -4390,35 +4397,63 @@ msgstr "Fournisseurs" msgid "Yes, but not charged yet" msgstr "Oui, mais non refacturé" +#: templates/expense/_expense_transitions_column.html:19 +msgid "Edit" +msgstr "Modifier" + +#: templates/expense/_expense_transitions_column.html:20 +msgid "Ed" +msgstr "Mo" + +#: templates/expense/_expense_transitions_column.html:22 +msgid "Delete" +msgstr "Supprimer" + +#: templates/expense/_expense_transitions_column.html:23 +msgid "De" +msgstr "Su" + +#: templates/expense/_expense_transitions_column.html:27 +msgid "Clone" +msgstr "Cloner" + +#: templates/expense/_expense_transitions_column.html:28 +msgid "Cl" +msgstr "Cl" + #: templates/expense/chargeable_expenses.html:9 msgid "Non billed chargeable expenses" msgstr "Notes de frais refactuables non refacturées" -#: templates/expense/expense.html:19 +#: templates/expense/expense.html:14 #, python-format msgid "Expense n° %(id)s" msgstr "Frais n° %(id)s" -#: templates/expense/expense.html:55 templates/expense/expense_archive.html:34 -#: templates/expense/expense_list.html:23 templates/leads/lead_detail.html:174 +#: templates/expense/expense.html:50 templates/expense/expense_archive.html:29 +#: templates/expense/expense_list.html:23 templates/leads/lead_detail.html:147 #: templates/leads/review.html:56 templates/leads/review.html:88 msgid "Update date" msgstr "Date de mise à jour" -#: templates/expense/expense.html:59 templates/expense/expense_archive.html:30 +#: templates/expense/expense.html:54 templates/expense/expense_archive.html:25 msgid "Corporate card" msgstr "Carte société" -#: templates/expense/expense.html:84 +#: templates/expense/expense.html:79 msgid "Payment" msgstr "Paiement" -#: templates/expense/expense_archive.html:17 +#: templates/expense/expense.html:87 +msgid "Transitions" +msgstr "Transitions" + +#: templates/expense/expense_archive.html:12 msgid "Expenses archive" msgstr "Historique des notes de frais" -#: templates/expense/expense_archive.html:21 -#: templates/expense/expense_payments.html:47 +#: templates/expense/expense_archive.html:16 +#: templates/expense/expense_payments.html:43 msgid "#" msgstr "#" @@ -4430,35 +4465,35 @@ msgstr "Personne" msgid "No expense" msgstr "Aucune note de frais" -#: templates/expense/expense_payment_detail.html:16 +#: templates/expense/expense_payment_detail.html:12 msgid "Expense payment detail" msgstr "Détail du remboursement de frais" -#: templates/expense/expense_payment_detail.html:21 +#: templates/expense/expense_payment_detail.html:17 msgid "Expenses of payment n°" msgstr "Notes de frais du remboursement n°" -#: templates/expense/expense_payment_detail.html:26 +#: templates/expense/expense_payment_detail.html:22 msgid "Total: " msgstr "Total : " -#: templates/expense/expense_payments.html:19 +#: templates/expense/expense_payments.html:15 msgid "Expenses to pay" msgstr "Notes de frais à rembourser" -#: templates/expense/expense_payments.html:24 +#: templates/expense/expense_payments.html:20 msgid "No expense to pay" msgstr "Aucune note de frais à rembourser" -#: templates/expense/expense_payments.html:30 +#: templates/expense/expense_payments.html:26 msgid "Modify the expense payment" msgstr "Modifier le remboursement de frais" -#: templates/expense/expense_payments.html:32 +#: templates/expense/expense_payments.html:28 msgid "Add an expense payment" msgstr "Ajouter un remboursement de frais" -#: templates/expense/expense_payments.html:51 +#: templates/expense/expense_payments.html:47 msgid "Modification" msgstr "Modification" @@ -4490,6 +4525,14 @@ msgstr "Les livrables sont manquants. Merci de les ranger ici : " msgid "All lead tags" msgstr "Toutes les étiquettes des affaires" +#: templates/leads/_tags_banner.html:25 +msgid "Add" +msgstr "Ajouter" + +#: templates/leads/_tags_banner.html:33 +msgid "Suggested tags: " +msgstr "Étiquette(s) suggérée(s) : " + #: templates/leads/graph_leads_activity.html:13 msgid "New leads per week" msgstr "Nouvelles affaires par semaine" @@ -4560,139 +4603,131 @@ msgstr "taux de transformation (%%)" msgid "Add or modify a Lead" msgstr "Ajouter ou modifier une affaire" -#: templates/leads/lead_detail.html:23 +#: templates/leads/lead_detail.html:24 msgid "Lead detail" msgstr "Détail de l'affaire" -#: templates/leads/lead_detail.html:52 templates/people/consultant.html:41 +#: templates/leads/lead_detail.html:53 templates/people/consultant.html:41 #: templates/staffing/missions.html:6 msgid "Missions" msgstr "Missions" -#: templates/leads/lead_detail.html:54 templates/staffing/mission.html:44 +#: templates/leads/lead_detail.html:55 templates/staffing/mission.html:44 msgid "Documents" msgstr "Documents" -#: templates/leads/lead_detail.html:56 templates/staffing/mission.html:48 +#: templates/leads/lead_detail.html:57 templates/staffing/mission.html:48 msgid "History" msgstr "Historique" -#: templates/leads/lead_detail.html:84 -msgid "Suggested tags: " -msgstr "Étiquette(s) suggérée(s) : " - -#: templates/leads/lead_detail.html:118 +#: templates/leads/lead_detail.html:91 msgctxt "short" msgid "Estimation" msgstr "Estimation" -#: templates/leads/lead_detail.html:132 +#: templates/leads/lead_detail.html:105 msgid "Informations" msgstr "Informations" -#: templates/leads/lead_detail.html:136 +#: templates/leads/lead_detail.html:109 msgctxt "client" msgid "Unknown" msgstr "Inconnu" -#: templates/leads/lead_detail.html:159 +#: templates/leads/lead_detail.html:132 msgid "Broker" msgstr "Apporteur" -#: templates/leads/lead_detail.html:168 templates/leads/lead_detail.html:171 +#: templates/leads/lead_detail.html:141 templates/leads/lead_detail.html:144 msgid "Unknown date" msgstr "Date inconnue" -#: templates/leads/lead_detail.html:177 +#: templates/leads/lead_detail.html:150 msgid "Unknown amount" msgstr "Montant inconnu" -#: templates/leads/lead_detail.html:180 +#: templates/leads/lead_detail.html:153 msgid "Estimation" msgstr "Estimation" -#: templates/leads/lead_detail.html:183 +#: templates/leads/lead_detail.html:156 msgid "Potential resource(s)" msgstr "Ressources potentielles" -#: templates/leads/lead_detail.html:186 templates/leads/lead_mail.html:18 +#: templates/leads/lead_detail.html:159 templates/leads/lead_mail.html:18 #: templates/leads/mail.txt:10 msgid "Nothing" msgstr "Rien" -#: templates/leads/lead_detail.html:192 +#: templates/leads/lead_detail.html:165 #: templates/staffing/mission_timesheet.html:86 msgid "Profitability" msgstr "Rentabilité" -#: templates/leads/lead_detail.html:194 templates/staffing/mission.html:121 +#: templates/leads/lead_detail.html:167 templates/staffing/mission.html:122 #: templates/staffing/mission_timesheet.html:88 msgid "Sold" msgstr "Vendu" -#: templates/leads/lead_detail.html:198 templates/leads/lead_detail.html:248 +#: templates/leads/lead_detail.html:171 templates/leads/lead_detail.html:221 msgid "Unattributed" msgstr "Non attribué" -#: templates/leads/lead_detail.html:198 +#: templates/leads/lead_detail.html:171 msgid "Non attributed amount to missions" msgstr "Montant non attribué à des missions" -#: templates/leads/lead_detail.html:199 +#: templates/leads/lead_detail.html:172 #: templates/staffing/mission_timesheet.html:108 msgid "Margin over rate objective" msgstr "Marge sur objectifs de TJM" -#: templates/leads/lead_detail.html:200 +#: templates/leads/lead_detail.html:173 msgid "Full margin" msgstr "Marge total" -#: templates/leads/lead_detail.html:200 +#: templates/leads/lead_detail.html:173 msgid "Sum of objective margin and unused days for fixed price missions" msgstr "" "Somme des marges sur objectifs et des montants non utilisés pour les forfaits" -#: templates/leads/lead_detail.html:204 +#: templates/leads/lead_detail.html:177 msgid "Similar leads" msgstr "Affaires similaires" -#: templates/leads/lead_detail.html:222 +#: templates/leads/lead_detail.html:195 msgid "Missions of this lead:" msgstr "Missions de cette affaire : " -#: templates/leads/lead_detail.html:228 templates/staffing/mission.html:164 +#: templates/leads/lead_detail.html:201 templates/staffing/mission.html:165 msgid "id" msgstr "n°" -#: templates/leads/lead_detail.html:230 templates/staffing/mission.html:166 +#: templates/leads/lead_detail.html:203 templates/staffing/mission.html:167 msgid "Done work (k€)" msgstr "Montant réalisé (k€)" -#: templates/leads/lead_detail.html:231 +#: templates/leads/lead_detail.html:204 #: templates/staffing/fixed_price_report.html:19 -#: templates/staffing/mission.html:167 +#: templates/staffing/mission.html:168 msgid "Sold (k€)" msgstr "Vendu (k€)" -#: templates/leads/lead_detail.html:233 +#: templates/leads/lead_detail.html:206 #: templates/staffing/_mission_table.html:23 -#: templates/staffing/mission.html:169 +#: templates/staffing/mission.html:170 msgid "product" msgstr "produit" -#: templates/leads/lead_detail.html:268 +#: templates/leads/lead_detail.html:241 msgid "Create a new mission for this lead" msgstr "Créer une nouvelle mission pour cette affaire" -#: templates/leads/lead_detail.html:272 +#: templates/leads/lead_detail.html:245 msgid "A mission is created once a commercial proposal has been sent." msgstr "" "Une mission est créée une fois que la proposition commerciale est envoyée." -#: templates/leads/lead_detail.html:314 -msgid "Tag already exists" -msgstr "Étiquette existante" - #: templates/leads/lead_mail.html:4 templates/leads/lead_mail.txt:5 msgid "Contact: " msgstr "Contact : " @@ -5049,11 +5084,6 @@ msgstr "Afficher uniquement les missions actives" msgid "Display all missions" msgstr "Afficher toutes les missions" -#: templates/staffing/_mission_table.html:43 -#: templates/staffing/mission.html:232 -msgid "Archiving failed" -msgstr "Échec de l'archivage" - #: templates/staffing/_mission_table_archive_column.html:11 msgid "Archive" msgstr "Archiver" @@ -5301,43 +5331,43 @@ msgstr "Cette mission n'a plus de staffing défini dans le futur" msgid "Still to be billed" msgstr "Reste à facturer" -#: templates/staffing/mission.html:63 +#: templates/staffing/mission.html:64 msgid "Archive this mission" msgstr "Archiver cette mission" -#: templates/staffing/mission.html:70 +#: templates/staffing/mission.html:71 msgid "Staffing has not been updated recently" msgstr "Les prévisions n'ont pas été mises à jour récemment" -#: templates/staffing/mission.html:77 +#: templates/staffing/mission.html:78 msgid "Marketing product is not yet defined" msgstr "Le produit marketing de cette mission n'est pas défini" -#: templates/staffing/mission.html:81 +#: templates/staffing/mission.html:82 msgid "This mission is archived" msgstr "Cette mission est archivée" -#: templates/staffing/mission.html:108 +#: templates/staffing/mission.html:109 msgid "Analytic code" msgstr "Code analytique" -#: templates/staffing/mission.html:113 templates/staffing/mission.html:117 +#: templates/staffing/mission.html:114 templates/staffing/mission.html:118 msgid "Client lead id" msgstr "Référence affaire client" -#: templates/staffing/mission.html:130 +#: templates/staffing/mission.html:131 msgid "Probability" msgstr "Probabilité" -#: templates/staffing/mission.html:133 +#: templates/staffing/mission.html:134 msgid "Lead of this mission" msgstr "Affaire de cette mission" -#: templates/staffing/mission.html:140 +#: templates/staffing/mission.html:141 msgid "Lead's description" msgstr "Description de l'affaire" -#: templates/staffing/mission.html:159 +#: templates/staffing/mission.html:160 msgid "Other missions linked to this lead" msgstr "Autres missions liées à cette affaire" @@ -5345,24 +5375,24 @@ msgstr "Autres missions liées à cette affaire" msgid "Consultants currently implicated in this mission" msgstr "Consultants impliqués actuellement dans cette mission" -#: templates/staffing/mission_consultants.html:10 +#: templates/staffing/mission_consultants.html:11 msgid "Daily rate (€)" msgstr "Taux journalier (€)" -#: templates/staffing/mission_consultants.html:11 +#: templates/staffing/mission_consultants.html:12 msgid "Buy rate (€)" msgstr "Taux d'achat (€)" -#: templates/staffing/mission_consultants.html:13 +#: templates/staffing/mission_consultants.html:14 #, python-format msgid "Objective %(d)s (€)" msgstr "Taux budget %(d)s (€)" -#: templates/staffing/mission_contacts.html:32 +#: templates/staffing/mission_contacts.html:36 msgid "Add or edit" msgstr "Ajouter ou modifier" -#: templates/staffing/mission_contacts.html:45 +#: templates/staffing/mission_contacts.html:52 msgid "Close" msgstr "Fermer" @@ -5711,6 +5741,21 @@ msgstr "Proportion de sous traitance par produit" msgid "Sum ratio" msgstr "Ratio de sommes" +#~ msgid "Successfully update expense" +#~ msgstr "Note de frais mise à jour avec succès" + +#~ msgid "Incorrect value" +#~ msgstr "Valeur incorrecte" + +#~ msgid "You are not allowed to do that" +#~ msgstr "Vous n'êtes pas autorisé à faire cela" + +#~ msgid "Tag already exists" +#~ msgstr "Étiquette existante" + +#~ msgid "Archiving failed" +#~ msgstr "Échec de l'archivage" + #~ msgid "expert" #~ msgstr "expert" @@ -5751,9 +5796,6 @@ msgstr "Ratio de sommes" #~ msgid "Last modified or created leads" #~ msgstr "Dernières affaires modifiées ou créées" -#~ msgid "New leads" -#~ msgstr "Nouvelles affaires" - #~ msgid "Last new lead created" #~ msgstr "Dernières affaires créées" @@ -6579,9 +6621,6 @@ msgstr "Ratio de sommes" #~ msgid "Go" #~ msgstr "Envoyer" -#~ msgid "Add..." -#~ msgstr "Ajouter..." - #~ msgid "My last actions" #~ msgstr "Mes dernières actions" diff --git a/media/js/htmx-2.0.3.min.js b/media/js/htmx-2.0.3.min.js new file mode 100644 index 00000000..423cf011 --- /dev/null +++ b/media/js/htmx-2.0.3.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.3"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=de;Q.ajax=Rn;Q.find=r;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=h;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:dn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:i,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:dt,triggerEvent:de,triggerErrorEvent:fe,withExtensions:Ft};const o=["get","post","put","delete","patch"];const R=o.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function h(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function i(e,t){while(e&&!t(e)){e=c(e)}return e||null}function H(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;i(t,function(e){return!!(r=H(t,ue(e),n))});if(r!=="unset"){return r}}function d(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function N(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function A(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(A(e)){const t=N(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){C(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){C(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||d(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ue(e),ge(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(f(e),ge(t.substr(5)))]}else if(t==="next"){return[ue(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[pe(e,ge(t.substr(5)),!!n)]}else if(t==="previous"){return[ue(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[me(e,ge(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[m(e,!!n)]}else if(t==="host"){return[e.getRootNode().host]}else if(t.indexOf("global ")===0){return p(e,t.slice(7),true)}else{return M(f(m(e,!!n)).querySelectorAll(ge(t)))}}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){C('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(i(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substr(0,e.indexOf(":"));n=e.substr(e.indexOf(":")+1,e.length)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!de(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){de(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=r("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=r("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=r("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=r("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ne(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ae(f(e));de(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=$(d(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}de(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function w(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=w(e,Qe).trim();e.shift()}else{t=w(e,b)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{w(o,v);const l=o.length;const c=w(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};w(o,v);u.pollInterval=h(w(o,/[,\[\s]/));w(o,v);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}w(o,v);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=h(w(o,b))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=w(o,b);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const d=rt(o);if(d.length>0){s+=" "+d}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=h(w(o,b))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=w(o,b)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=w(o,b)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}w(o,v)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}w(o,v)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(d(e,"form")){return[{trigger:"submit"}]}else if(d(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(d(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){a(n);return}he(r,o,n,t)},n,e,true)})}}function dt(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(d(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ht(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(ht(l,e)){return}if(a||dt(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!d(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){de(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){de(l,"htmx:trigger");c(l,e)},u.delay)}else{de(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){de(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){de(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(o,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){a(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function Nt(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function At(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){de(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;de(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;de(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){de(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});de(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!d(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:Nn(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function dn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function hn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!hn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.substr(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const d=l.substr("focus-scroll:".length);r.focusScroll=d=="true"}else if(e==0){r.swapStyle=l}else{C("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||d(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||!e&&!y(r.source)){e=ve}return he(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return de(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function Nn(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const N=ee(a,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(de(r,"htmx:confirm",G)===false){oe(s);return e}}let d=r;let h=re(r,"hx-sync");let g=null;let F=false;if(h){const A=h.split(":");const I=A[0].trim();if(I==="this"){d=Se(r,"hx-sync")}else{d=ue(ae(r,I))}h=(A[1]||"drop").trim();u=ie(d);if(h==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(h==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(h==="replace"){de(d,"htmx:abort")}else if(h.indexOf("queue")===0){const W=h.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){de(d,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){he(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!de(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=dn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:Nn(w),unfilteredFormData:v,unfilteredParameters:Nn(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!de(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){de(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}de(r,"htmx:afterRequest",H);de(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){de(e,"htmx:afterRequest",H);de(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!de(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){de(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});de(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function An(e,t){const n=t.xhr;let r=null;let o=null;if(O(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(O(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(O(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){de(e,"htmx:restored",{document:ne(),triggerEvent:de})})}else{if(n){n(e)}}};E().setTimeout(function(){de(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3ffc9a13..cb0673ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ django-taggit==4.0 django-taggit-templatetags2==1.6.1 django-crispy-forms==2.0 crispy-bootstrap5==0.7 -Django-Select2==8.1.2 +Django-Select2==8.2.1 pymemcache==4.0 django-datatables-view==1.20.0 WeasyPrint==62.3 diff --git a/staffing/urls.py b/staffing/urls.py index b214acd5..9ec52f2b 100644 --- a/staffing/urls.py +++ b/staffing/urls.py @@ -48,7 +48,7 @@ re_path(r'^non-prod_report/?$', v.missions_report, {"nature": "NONPROD"}, name="nonprod-pivotable"), re_path(r'^non-prod_report/all$', v.missions_report, {"nature": "NONPROD", "year": "all"}, name="nonprod-pivotable-all"), re_path(r'^contacts/mission/(?P\d+)/$', v.mission_contacts, name="mission_contacts"), - re_path(r'^rate/?$', v.mission_consultant_rate, name="mission_consultant_rate"), + re_path(r'^rate/mission/(?P\d+)/consultant/(?P\d+)/$', v.mission_consultant_rate, name="mission_consultant_rate"), re_path(r'^pdc-detail/(?P\d+)/(?P\d+)/?$', v.pdc_detail, name="pdc_detail"), re_path(r'^datatable/all-missions/data/$', t.MissionsTableDT.as_view(), name='all_mission_table_DT'), re_path(r'^datatable/consultant-all-missions/(?P\d+)/data/$', t.MissionsTableDT.as_view(), name='consultant_all_mission_table_DT'), diff --git a/staffing/utils.py b/staffing/utils.py index 26bc6914..b894f417 100644 --- a/staffing/utils.py +++ b/staffing/utils.py @@ -397,3 +397,17 @@ def check_timesheet_validity(missions, consultant, month): if limited_individual_mode_offending_missions: return _("Charge cannot exceed forecast (%s)") %\ ", ".join([str(m) for m in limited_individual_mode_offending_missions]) + + +def compute_mission_consultant_rates(mission): + """helper function to compute mission rates for each consultant for tab display and htmx edit form""" + rates = {} + objective_rates = mission.consultant_objective_rates() + for consultant, rate in mission.consultant_rates().items(): + rates[consultant] = (rate, objective_rates.get(consultant)) + try: + objective_dates = [i[0] for i in list(objective_rates.values())[0]] + except IndexError: + # No consultant or no objective on mission timeframe + objective_dates = [] + return objective_dates, rates diff --git a/staffing/views.py b/staffing/views.py index 5f8f69e0..fa5e2a24 100644 --- a/staffing/views.py +++ b/staffing/views.py @@ -15,7 +15,7 @@ from django.core.cache import cache from django.shortcuts import render, redirect -from django.http import HttpResponseRedirect, HttpResponse, Http404 +from django.http import HttpResponseRedirect, HttpResponse, Http404, HttpResponseForbidden from django.contrib.auth.decorators import permission_required from django.forms.models import inlineformset_factory from django.forms import formset_factory @@ -46,10 +46,10 @@ from core.utils import working_days, nextMonth, previousMonth, daysOfMonth, previousWeek, nextWeek, monthWeekNumber, \ to_int_or_round, COLORS, cumulateList, user_has_feature, get_parameter, \ get_fiscal_years_from_qs, get_fiscal_year -from core.decorator import pydici_non_public, pydici_feature, PydiciNonPublicdMixin, pydici_subcontractor +from core.decorator import pydici_non_public, pydici_feature, PydiciNonPublicdMixin from staffing.utils import gatherTimesheetData, saveTimesheetData, saveFormsetAndLog, \ sortMissions, holidayDays, staffingDates, time_string_for_day_percent, \ - timesheet_report_data, timesheet_report_data_grouped, check_timesheet_validity + timesheet_report_data, timesheet_report_data_grouped, check_timesheet_validity, compute_mission_consultant_rates from staffing.forms import MissionForm, OptimiserForm, MissionOptimiserForm, MissionOptimiserFormsetHelper from staffing.optim import solve_pdc, solver_solution_format, compute_consultant_freetime, compute_consultant_rates, solver_apply_forecast from staffing.optim import OPTIM_NEWBIE_SENIOR_LIMIT, OPTIM_SENIOR_DIRECTOR_LIMIT @@ -123,27 +123,19 @@ def check_user_timesheet_access(user, consultant, timesheet_month): @pydici_non_public -def missions(request, only_active=True, consultant_id=None): +def missions(request, only_active=True): """List of missions""" - if consultant_id: - consultant = Consultant.objects.get(id=consultant_id) - if only_active: - data_url = reverse('staffing:consultant_active_mission_table_DT', args=(consultant_id,)) - else: - data_url = reverse('staffing:consultant_all_mission_table_DT', args=(consultant_id,)) + if only_active: + data_url = reverse('staffing:active_mission_table_DT') else: - consultant = None - if only_active: - data_url = reverse('staffing:active_mission_table_DT') - else: - data_url = reverse('staffing:all_mission_table_DT') + data_url = reverse('staffing:all_mission_table_DT') return render(request, "staffing/missions.html", {"all": not only_active, - "consultant": consultant, "data_url": data_url, "datatable_options": ''' "columnDefs": [{ "orderable": false, "targets": [4, 8, 9] }, { className: "hidden-xs hidden-sm hidden-md", "targets": [6,7,8,9]}], - "order": [[0, "asc"]] ''', + "order": [[0, "asc"]], + "drawCallback": function( oSettings ) {htmx.process(document.body); }''', "user": request.user}) @@ -163,15 +155,7 @@ def mission_home(request, mission_id): @pydici_non_public def mission_consultants(request, mission_id): mission = Mission.objects.get(id=mission_id) - rates = {} - objective_rates = mission.consultant_objective_rates() - for consultant, rate in mission.consultant_rates().items(): - rates[consultant] = (rate, objective_rates.get(consultant)) - try: - objective_dates = [i[0] for i in list(objective_rates.values())[0]] - except IndexError: - # No consultant or no objective on mission timeframe - objective_dates = [] + objective_dates, rates = compute_mission_consultant_rates(mission) return render(request, "staffing/mission_consultants.html", {"mission": mission, "objective_dates": objective_dates, @@ -284,7 +268,8 @@ def consultant_missions(request, only_active=True, consultant_id=None): "data_url": data_url, "datatable_options": ''' "columnDefs": [{ "orderable": false, "targets": [4, 8, 9] }, { className: "hidden-xs hidden-sm hidden-md", "targets": [6,7,8,9]}], - "order": [[3, "asc"]] + "order": [[3, "asc"]], + "drawCallback": function( oSettings ) {htmx.process(document.body); } ''', "user": request.user}) @@ -866,16 +851,14 @@ def fixed_price_missions_report(request): @pydici_non_public def deactivate_mission(request, mission_id): - """Deactivate the given mission""" + """Deactivate the given mission. Fragment for htmx call""" try: - error = False mission = Mission.objects.get(id=mission_id) mission.active = False mission.save() + return HttpResponse(_("mission archived")) except Mission.DoesNotExist: - error = True - return HttpResponse(json.dumps({"error": error, "id": mission_id}), - content_type="application/json") + return HttpResponse(_("mission not found")) @cache_control(no_store=True) @@ -1546,7 +1529,6 @@ def holiday_csv_timesheet(request, year=None, month=None): return response - @pydici_non_public @pydici_feature("management") def holidays_planning(request, year=None, month=None): @@ -1741,7 +1723,6 @@ def missions_report(request, year=None, nature="HOLIDAYS"): "derivedAttributes": [],}) - @pydici_non_public @pydici_feature("leads") @permission_required("staffing.add_mission") @@ -1780,35 +1761,48 @@ def create_new_mission_from_lead(request, lead_id): @pydici_non_public -def mission_consultant_rate(request): - """Select or create financial condition for this consultant/mission tuple and update it - This is intended to be used through a jquery jeditable call""" +def mission_consultant_rate(request, mission_id, consultant_id): + """Select or create financial condition for this consultant/mission tuple and update it with htmx""" if not (request.user.has_perm("staffing.add_financialcondition") and - request.user.has_perm("staffing.change_financialcondition")): - return HttpResponse(_("You are not allowed to do that")) + request.user.has_perm("staffing.change_financialcondition")): + return HttpResponseForbidden() + try: - sold, mission_id, consultant_id = request.POST["id"].split("-") mission = Mission.objects.get(id=mission_id) consultant = Consultant.objects.get(id=consultant_id) condition, created = FinancialCondition.objects.get_or_create(mission=mission, consultant=consultant, defaults={"daily_rate": 0}) - value = escape(request.POST["value"].replace(" ", "")) - if sold == "sold": + except (Mission.DoesNotExist, Consultant.DoesNotExist): + return HttpResponse(_("Mission or consultant does not exist"), status=404) + + if request.method == "GET": + edit = True + else: + edit = False + change = None + if request.POST.get("sold"): + value = request.POST["sold"] change = {_(f"daily rate for {consultant}"): [condition.daily_rate, value]} condition.daily_rate = value - else: + + if request.POST.get("bought"): + value = request.POST["bought"] change = {_(f"bought daily rate for {consultant}"): [condition.daily_rate, value]} condition.bought_daily_rate = value - condition.save() - if mission.responsible: - compute_consultant_tasks.delay(mission.responsible.id) - LogEntry.objects.log_create(instance=mission, actor=request.user, action=LogEntry.Action.UPDATE, changes=json.dumps(change)) - return HttpResponse(value) - except (Mission.DoesNotExist, Consultant.DoesNotExist): - return HttpResponse(_("Mission or consultant does not exist")) - except ValueError: - return HttpResponse(_("Incorrect value")) + if change: + try: + condition.save() + cache.delete("Mission.consultant_rates%s" % mission.id) # flush rate cache + except ValueError: + return HttpResponse(status=400) + if mission.responsible: + compute_consultant_tasks.delay(mission.responsible.id) + LogEntry.objects.log_create(instance=mission, actor=request.user, action=LogEntry.Action.UPDATE, changes=json.dumps(change)) + + objective_dates, rates = compute_mission_consultant_rates(mission) + return render(request, "staffing/_mission_consultants_rate.html", {"mission": mission, "consultant": consultant, + "rate": rates[consultant], "edit": edit}) @pydici_non_public @@ -1873,11 +1867,9 @@ def mission_contacts(request, mission_id): form = MissionContactsForm(request.POST, instance=mission) if form.is_valid(): form.save() - return HttpResponseRedirect(reverse("staffing:mission_home", args=[mission.id, ])) # Unbound form form = MissionContactsForm(instance=mission) - # TODO: add link to add mission contact missionContacts = mission.contacts.select_related().order_by("company") return render(request, "staffing/mission_contacts.html", {"mission": mission, diff --git a/templates/core/_datatables.html b/templates/core/_datatables.html index f957f918..537da518 100644 --- a/templates/core/_datatables.html +++ b/templates/core/_datatables.html @@ -12,8 +12,9 @@ "responsive": true, "fixedHeader": true, "ajax": "{{ data_url }}", - buttons: ['excel', 'csv'], - dom: "<'row'<'col-md-12'f>><'row'<'col-md-12'tr>><'row'<'col-md-3'l><'col-md-3'B><'col-md-6'p>>", + layout: { + topStart: ['pageLength', { buttons: [ 'excel', 'csv' ]}], + }, {% if datatable_options %}{{ datatable_options|safe }}, {% endif %} "language": { "decimal": ",", @@ -25,12 +26,7 @@ "infoFiltered": "{% trans '(filtered from _MAX_ total records)' %}", "search": "{% trans 'Search:' %}", "lengthMenu": "{% trans 'Show _MENU_ entries' %}", - paginate:{ - first:'<<', - last: '>>', - next: '>', - previous: '<' - }, + }, }); }); diff --git a/templates/core/_form.html b/templates/core/_form.html new file mode 100644 index 00000000..a7f0a246 --- /dev/null +++ b/templates/core/_form.html @@ -0,0 +1,5 @@ +{# Standard pydici single form fragment for generic views #} +{% load crispy_forms_tags %} + +{% crispy form form.helper %} +{% include "core/_datepicker.html" %} \ No newline at end of file diff --git a/templates/core/pydici.html b/templates/core/pydici.html index 5b3e3bb2..2ba7d375 100644 --- a/templates/core/pydici.html +++ b/templates/core/pydici.html @@ -23,7 +23,8 @@ - + + {% block extracss %}{% endblock %} @@ -40,6 +41,8 @@ + + @@ -47,7 +50,7 @@ {% endif %} - + {% block extrajs %}{% endblock %} diff --git a/templates/crm/_mission_contact_form.html b/templates/crm/_mission_contact_form.html new file mode 100644 index 00000000..6e40636b --- /dev/null +++ b/templates/crm/_mission_contact_form.html @@ -0,0 +1,13 @@ +{# form to create a new contact linked to a mission #} +{# context: missionContactForm and contactForm #} +{% load crispy_forms_tags %} +{% load i18n %} + +
+ {% crispy missionContactForm missionContactForm.inline_helper %} + {% crispy contactForm contactForm.inline_helper %} + {% crispy companyForm companyForm.inline_helper %} + +
\ No newline at end of file diff --git a/templates/crm/clientcompany_detail.html b/templates/crm/clientcompany_detail.html index f30bcc42..787d2c98 100644 --- a/templates/crm/clientcompany_detail.html +++ b/templates/crm/clientcompany_detail.html @@ -312,6 +312,53 @@

{% trans "Leads" %}

{% endwith %} {% endwith %} +

{% trans "Supplier Leads" %}

+ + + + + + + + + + + + + + + +
{% trans "client" %}{% trans "name" %}{% trans "deal id" %}{% trans "subsidiary" %}{% trans "responsible" %}{% trans "amount (k€)" %}{% trans "status" %}{% trans "creation date" %}
+ + {% with "supplier_lead_table" as table_id %} + {% with supplier_lead_data_url as data_url %} + {% include "core/_datatables.html" %} + {% endwith %} + {% endwith %} + +

{% trans "Business broker Leads" %}

+ + + + + + + + + + + + + + + +
{% trans "client" %}{% trans "name" %}{% trans "deal id" %}{% trans "subsidiary" %}{% trans "responsible" %}{% trans "amount (k€)" %}{% trans "status" %}{% trans "creation date" %}
+ + {% with "businessbroker_lead_table" as table_id %} + {% with businessbroker_lead_data_url as data_url %} + {% include "core/_datatables.html" %} + {% endwith %} + {% endwith %}
diff --git a/templates/expense/_expense_state_column.html b/templates/expense/_expense_state_column.html index f562105d..1d2017a2 100644 --- a/templates/expense/_expense_state_column.html +++ b/templates/expense/_expense_state_column.html @@ -1,8 +1,10 @@ {% load i18n %} +{% load l10n %} {# context : record (expense object) #} - +
{% if record.state == "PAID" %} {% trans "Paid" %} {% else %} {{ record.get_state_display }} -{% endif %} \ No newline at end of file +{% endif %} +
\ No newline at end of file diff --git a/templates/expense/_expense_transitions_column.html b/templates/expense/_expense_transitions_column.html new file mode 100644 index 00000000..7e393aef --- /dev/null +++ b/templates/expense/_expense_transitions_column.html @@ -0,0 +1,30 @@ +{% load i18n %} +{% load l10n %} +{# context : record (expense object) #} + +{# out of band swap for expense state #} + + + +
+{% for transition, transition_label, transition_short_label in transitions %} + + {{ transition_short_label }} + +{% endfor %} + +{% if expense_edit_perm %} + + {% trans "Ed" %} + + + {% trans "De" %} + +{% endif %} + + + {% trans "Cl" %} + +
\ No newline at end of file diff --git a/templates/expense/_expense_vat_column.html b/templates/expense/_expense_vat_column.html new file mode 100644 index 00000000..86cf1930 --- /dev/null +++ b/templates/expense/_expense_vat_column.html @@ -0,0 +1,11 @@ +{# display VAT as htmx editable field #} +{# context: expense object #} +{% load l10n %} + + {{ expense.vat }} + diff --git a/templates/expense/_make_vat_editable.html b/templates/expense/_make_vat_editable.html deleted file mode 100644 index 6495584c..00000000 --- a/templates/expense/_make_vat_editable.html +++ /dev/null @@ -1,18 +0,0 @@ -{# fragment to make vat editable through jquery jeditable #} -{% load i18n %} - - \ No newline at end of file diff --git a/templates/expense/expense.html b/templates/expense/expense.html index 14026c5b..347aac51 100644 --- a/templates/expense/expense.html +++ b/templates/expense/expense.html @@ -6,13 +6,8 @@ {% load render_table from django_tables2 %} {% load crispy_forms_tags %} -{% block extrajs %} - -{% endblock %} - {% block title %}{% trans "Expense" %}{% endblock %} - {% block content %}

@@ -41,7 +36,7 @@

{% trans "VAT (€)" %} -
{{ expense.vat }}
+ {% include "expense/_expense_vat_column.html" %} {% trans "Creation date" %} @@ -74,7 +69,7 @@

{% trans "State" %} - {{ expense.get_state_display }} +
{{ expense.get_state_display }}
{% trans "Comment" %} @@ -88,6 +83,10 @@

{% trans "No" %} {% endif %} + + {% trans "Transitions" %} + {% include "expense/_expense_transitions_column.html" with record=expense %} +

@@ -101,14 +100,4 @@

{% include "core/_object_history.html" %} {% endwith %} - -{% if can_edit_vat %} - {% include "expense/_make_vat_editable.html" %} - -{% endif %} - {% endblock %} \ No newline at end of file diff --git a/templates/expense/expense_archive.html b/templates/expense/expense_archive.html index 61c9219a..53b4d0eb 100644 --- a/templates/expense/expense_archive.html +++ b/templates/expense/expense_archive.html @@ -5,11 +5,6 @@ {% block title %}{% trans "Expenses history" %}{% endblock %} -{% block extrajs %} - -{% endblock %} - - {% block content %}
@@ -47,11 +42,6 @@

{% trans "Expenses archive" %}

- {% include "expense/_expense_receipt_modal.html" %} - -{% include "expense/_make_vat_editable.html" %} - - {% endblock %} \ No newline at end of file diff --git a/templates/expense/expense_payment_detail.html b/templates/expense/expense_payment_detail.html index cbff19a7..d5d09b49 100644 --- a/templates/expense/expense_payment_detail.html +++ b/templates/expense/expense_payment_detail.html @@ -4,10 +4,6 @@ {% load pydici_filters %} {% load render_table from django_tables2 %} -{% block extrajs %} - -{% endblock %} - {% block extrastyle %} @@ -26,15 +22,6 @@

{% trans "Expenses of payment n°" %}{{ expense_payment.id }} ({{ expense_pa

{% trans "Total: " %} {{ expense_payment.amount }} €

{% endif %} -{% if can_edit_vat %} - {% include "expense/_make_vat_editable.html" %} - -{% endif %} - {% endblock %} \ No newline at end of file diff --git a/templates/expense/expense_payments.html b/templates/expense/expense_payments.html index ed358f88..66f9ba51 100644 --- a/templates/expense/expense_payments.html +++ b/templates/expense/expense_payments.html @@ -5,10 +5,6 @@ {% load render_table from django_tables2 %} {% load crispy_forms_tags %} -{% block extrajs %} - -{% endblock %} - {% block title %}{% trans "Expenses payments" %}{% endblock %} {% block content %} @@ -69,14 +65,4 @@

{% trans "Expenses payments" %}

{% include "core/_datepicker.html" %} -{% if can_edit_vat %} - {% include "expense/_make_vat_editable.html" %} - -{% endif %} - - {% endblock %} \ No newline at end of file diff --git a/templates/expense/expenses.html b/templates/expense/expenses.html index 23f127f8..7b38a262 100644 --- a/templates/expense/expenses.html +++ b/templates/expense/expenses.html @@ -55,20 +55,4 @@

{% trans "Expenses I manage" %}

{% include "expense/_expense_receipt_modal.html" %} - - {% endblock %} \ No newline at end of file diff --git a/templates/leads/_tags_banner.html b/templates/leads/_tags_banner.html new file mode 100644 index 00000000..e7d1f414 --- /dev/null +++ b/templates/leads/_tags_banner.html @@ -0,0 +1,38 @@ +{# lead tag banner #} +{# Context/Argument: lead #} +{% load i18n %} +{% load l10n %} + +
+
+
+
+ {% for tag in lead.tags.all %} +
+ {{ tag }} + +   +
+ {% endfor %} +
+
+ + {% if perms.leads.change_lead %} +
+
+
+
{{ lead_tag_form }}
+
+
+
+
+ {% endif %} +
+ {% if perms.leads.change_lead %} + {% for tag in lead.suggested_tags %} + {% if forloop.first %}{% trans "Suggested tags: " %}{% endif %} + {{ tag }} + {% endfor %} + {% endif %} +
+ diff --git a/templates/leads/lead_detail.html b/templates/leads/lead_detail.html index e51166de..7980b15a 100644 --- a/templates/leads/lead_detail.html +++ b/templates/leads/lead_detail.html @@ -10,6 +10,7 @@ {% include "core/_billboard.html" %} {% include "core/_pivotable_header.html" %} + {% endblock %} @@ -60,35 +61,7 @@

-
-
- - {% if perms.leads.change_lead %} -
-
- -
-
- {% endif %} -
- {% if perms.leads.change_lead %} -
- {% if suggested_tags %} - {% trans "Suggested tags: " %} - {% for tag in suggested_tags %} - {{ tag }}   - {% endfor %} - {% endif %} -
- {% endif %} -
+ {% include "leads/_tags_banner.html" %}
{% with lead as lead %}{% include "leads/_lead_checkdoc.html" %}{% endwith %} @@ -289,54 +262,6 @@

{% trans "Administrative notes" %}

diff --git a/templates/staffing/mission_consultants.html b/templates/staffing/mission_consultants.html index caee2b32..9380f63a 100644 --- a/templates/staffing/mission_consultants.html +++ b/templates/staffing/mission_consultants.html @@ -5,6 +5,7 @@

{% trans "Consultants currently implicated in this mission" %}

+ @@ -13,38 +14,15 @@

{% trans "Consultants currently implicated in this mission" %}<

{% endfor %} + + {% for consultant, rate in rates.items %} - - - - - {% with o_rates=rate.1 %} - {% for date, o_rate in o_rates %} - - {% endfor %} - {% endwith %} - + {% include "staffing/_mission_consultants_rate.html" %} {% endfor %} +
{% trans "Consultant" %} {% trans "Daily rate (€)" %}{% blocktranslate with d=date|date:"M Y" %}Objective {{ d }} (€){% endblocktranslate %}
{% include "people/__consultant_name.html" %}
{{ rate.0.0|unlocalize }}
{% if consultant.subcontractor %}
{{ rate.0.1|unlocalize }}
{% endif %}
{{ o_rate|default_if_none:"-" }}
\ No newline at end of file + $(document).ready(function() { htmx.process(document.body); }); + diff --git a/templates/staffing/mission_contacts.html b/templates/staffing/mission_contacts.html index bcc944f6..e3b5e121 100644 --- a/templates/staffing/mission_contacts.html +++ b/templates/staffing/mission_contacts.html @@ -2,6 +2,10 @@ {# of a page loaded in ajax #} {% load i18n %} +