effective price is only highlighted if changed
This commit is contained in:
@@ -242,7 +242,11 @@ negotiated-prices = Negotiated prices
|
||||
negotiated-prices-hint = Set a price for a specific product for this business account. The customer always pays the lower of the public and negotiated price.
|
||||
manage-prices = Manage prices
|
||||
public-price = Public price
|
||||
business-price = Business price
|
||||
negotiated-price = Negotiated price
|
||||
set-negotiated-price = Set price
|
||||
negotiated-price-hint = Set a negotiated price for this product for this business account. The customer always pays the lowest of the public, business and negotiated price.
|
||||
negotiated-remove-confirm = Remove this negotiated price?
|
||||
effective-price = Effective price
|
||||
admin-discount-profiles = Discount profiles
|
||||
admin-discount-profiles-desc = Create reusable discount layers (a % over chosen products) and assign them to business accounts.
|
||||
|
||||
@@ -242,7 +242,11 @@ negotiated-prices = Dohodnuté ceny
|
||||
negotiated-prices-hint = Nastavte cenu pre konkrétny produkt pre tento firemný účet. Zákazník vždy zaplatí najnižšiu z verejnej a dohodnutej ceny.
|
||||
manage-prices = Spravovať ceny
|
||||
public-price = Verejná cena
|
||||
business-price = Firemná cena
|
||||
negotiated-price = Dohodnutá cena
|
||||
set-negotiated-price = Nastaviť cenu
|
||||
negotiated-price-hint = Nastavte dohodnutú cenu tohto produktu pre tento firemný účet. Zákazník vždy zaplatí najnižšiu z verejnej, firemnej a dohodnutej ceny.
|
||||
negotiated-remove-confirm = Zrušiť túto dohodnutú cenu?
|
||||
effective-price = Výsledná cena
|
||||
admin-discount-profiles = Zľavové profily
|
||||
admin-discount-profiles-desc = Vytvorte opakovane použiteľné zľavové vrstvy (% na vybrané produkty) a priraďte ich firemným účtom.
|
||||
|
||||
File diff suppressed because one or more lines are too long
97
assets/views/admin/customers/price_form.html
Normal file
97
assets/views/admin/customers/price_form.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="set-negotiated-price", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
|
||||
{{ ui::badge(label=t(key="negotiated-price", lang=lang | default(value='sk')), variant="info") }}
|
||||
</div>
|
||||
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ customer.name }}</p>
|
||||
</div>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="back", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id, size="px-3 py-2 text-sm") }}
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="mt-4 max-w-md">{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.id }}"
|
||||
x-data="{
|
||||
price: '{{ negotiated }}',
|
||||
regular: {{ product.regular_cents }},
|
||||
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
|
||||
get afterCents() { let f = this.num(this.price); return f === null ? null : Math.round(f * 100); },
|
||||
money(c) { return (c / 100).toFixed(2); },
|
||||
get valid() { let a = this.afterCents; return a !== null && a > 0; }
|
||||
}"
|
||||
class="mt-6 max-w-md space-y-5 rounded-radius border-2 border-secondary/60 bg-surface p-6 dark:border-secondary-dark/60 dark:bg-surface-dark-alt">
|
||||
{{ ui::csrf_field() }}
|
||||
|
||||
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-price-hint", lang=lang | default(value='sk')) }}</p>
|
||||
|
||||
<!-- reference prices -->
|
||||
<div class="space-y-2 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40">
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="business-price", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums {% if product.business_reduced %}font-medium text-danger{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.business_price }} {{ product.currency }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="effective-price", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} {{ product.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- negotiated price input -->
|
||||
<div class="space-y-1.5">
|
||||
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="negotiated-price", lang=lang | default(value='sk')) }}</label>
|
||||
{{ ui::input(name="price", id="price", value=negotiated, placeholder="0.00", attrs='inputmode="decimal" x-model="price"') }}
|
||||
</div>
|
||||
|
||||
<!-- live preview -->
|
||||
<div x-show="afterCents !== null" x-cloak
|
||||
class="space-y-2 rounded-radius border border-outline bg-surface-alt px-4 py-3 dark:border-outline-dark dark:bg-surface-dark/40">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-price", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="text-lg font-semibold tabular-nums" :class="valid ? 'text-secondary dark:text-secondary-dark' : 'text-on-surface/40 dark:text-on-surface-dark/40'">
|
||||
<span x-text="money(afterCents)"></span> {{ product.currency }}
|
||||
</span>
|
||||
</div>
|
||||
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-must-be-positive", lang=lang | default(value='sk')) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
{{ ui::button(variant="secondary", label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
||||
{% if has_negotiated %}
|
||||
{{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", attrs=`formaction="/admin/customers/` ~ customer.id ~ `/prices/` ~ product.id ~ `/remove" onclick="return confirm('` ~ t(key="negotiated-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if collision %}
|
||||
<!-- collision resolution: two assigned profiles cover this product -->
|
||||
<section class="mt-4 max-w-md rounded-radius border border-warning/60 bg-surface p-6 dark:bg-surface-dark-alt">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="automated-price", lang=lang | default(value='sk')) }}</h2>
|
||||
{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}
|
||||
</div>
|
||||
<form method="post" action="/admin/customers/{{ customer.id }}/resolutions/{{ product.id }}" class="mt-3 flex items-center gap-2">
|
||||
{{ ui::csrf_field() }}
|
||||
<select name="profile_id" class="rounded-radius border border-outline bg-surface-alt px-2 py-1.5 text-sm dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
|
||||
{% for c in covering %}
|
||||
<option value="{{ c.id }}" {% if c.id == auto_profile_id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ ui::button(label=t(key="resolve", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-sm") }}
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
@@ -49,10 +49,9 @@
|
||||
<thead class="{{ ui::thead_cls() }}">
|
||||
<tr>
|
||||
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="public-price", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="automated-price", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="negotiated-price", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk')), align="text-right") }}
|
||||
{{ ui::th(label=t(key="business-price", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk'))) }}
|
||||
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="{{ ui::tbody_cls() }}">
|
||||
@@ -60,46 +59,28 @@
|
||||
<tr class="{{ ui::row_cls() }}">
|
||||
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</td>
|
||||
<td class="px-4 py-3 tabular-nums">
|
||||
{% if product.on_public_sale %}
|
||||
<span class="font-medium text-danger">{{ product.public_price }} {{ product.currency }}</span>
|
||||
{% if product.business_reduced %}
|
||||
<span class="font-medium text-danger">{{ product.business_price }} {{ product.currency }}</span>
|
||||
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }}</span>
|
||||
{% else %}
|
||||
{{ product.public_price }} {{ product.currency }}
|
||||
{{ product.business_price }} {{ product.currency }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 tabular-nums">
|
||||
{% if product.auto_price %}
|
||||
<div>{{ product.auto_price }} {{ product.currency }}</div>
|
||||
{% if product.collision %}
|
||||
<div class="mt-1">{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}</div>
|
||||
<form method="post" action="/admin/customers/{{ customer.id }}/resolutions/{{ product.product_id }}" class="mt-1 flex items-center gap-1">
|
||||
{{ ui::csrf_field() }}
|
||||
<select name="profile_id" class="rounded-radius border border-outline bg-surface-alt px-2 py-1 text-xs dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
|
||||
{% for c in product.covering %}
|
||||
<option value="{{ c.id }}" {% if c.id == product.auto_profile_id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ ui::button(label=t(key="resolve", lang=lang | default(value='sk')), type="submit", size="px-2 py-1 text-xs") }}
|
||||
</form>
|
||||
{% elif product.auto_profile_name %}
|
||||
<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ product.auto_profile_name }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-on-surface/40 dark:text-on-surface-dark/40">—</span>
|
||||
{% endif %}
|
||||
<span class="font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} {{ product.currency }}</span>
|
||||
{% if product.collision %}<span class="ml-1">{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.product_id }}" class="flex items-center gap-2">
|
||||
{{ ui::csrf_field() }}
|
||||
{{ ui::input(name="price", value=product.manual_price | default(value=""), placeholder="0.00", width="w-28", attrs='inputmode="decimal"') }}
|
||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||
{% if product.manual_price %}
|
||||
{{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs", attrs='formaction="/admin/customers/' ~ customer.id ~ '/prices/' ~ product.product_id ~ '/remove"') }}
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="set-negotiated-price", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id ~ "/prices/" ~ product.product_id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||
{% if product.has_negotiated %}
|
||||
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.product_id }}/remove"
|
||||
onsubmit="return confirm('{{ t(key="negotiated-remove-confirm", lang=lang | default(value='sk')) }}')">
|
||||
{{ ui::csrf_field() }}
|
||||
{{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||
</form>
|
||||
{% endif %}
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums">
|
||||
<span class="font-medium {% if product.is_business %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} {{ product.currency }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -31,6 +31,7 @@ use crate::{
|
||||
};
|
||||
|
||||
const COMPANY: &str = "company";
|
||||
const BUSINESS_AUDIENCE: &str = "business";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PriceForm {
|
||||
@@ -117,8 +118,6 @@ async fn show(
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let assigned = assigned_profile_ids(&ctx, company.id).await?;
|
||||
let profile_name: HashMap<i32, String> =
|
||||
all_profiles.iter().map(|p| (p.id, p.name.clone())).collect();
|
||||
let profiles_json: Vec<serde_json::Value> = all_profiles
|
||||
.iter()
|
||||
.map(|p| {
|
||||
@@ -136,32 +135,30 @@ async fn show(
|
||||
.order_by_asc(products::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
// Two prices per product:
|
||||
// - the generic business price a freshly-registered company sees (business
|
||||
// baseline + business-audience profiles, no per-company deals), and
|
||||
// - this company's effective price (its negotiated price + assigned profiles).
|
||||
// The effective price is highlighted only when it differs from the generic one.
|
||||
let business = pricing::audience_price_many(&ctx, &list, BUSINESS_AUDIENCE).await?;
|
||||
let details = pricing::detail_many(&ctx, &list, Some(&company)).await?;
|
||||
|
||||
let rows: Vec<serde_json::Value> = list
|
||||
.iter()
|
||||
.zip(business.iter())
|
||||
.zip(details.iter())
|
||||
.map(|(product, d)| {
|
||||
let covering: Vec<serde_json::Value> = d
|
||||
.covering_profile_ids
|
||||
.iter()
|
||||
.map(|pid| json!({ "id": pid, "name": profile_name.get(pid) }))
|
||||
.collect();
|
||||
.map(|((product, b), d)| {
|
||||
json!({
|
||||
"product_id": product.id,
|
||||
"name": product.name,
|
||||
"currency": product.currency,
|
||||
"regular_price": format_price(d.regular_cents),
|
||||
"public_price": format_price(d.public_cents),
|
||||
"on_public_sale": product.on_sale(),
|
||||
"manual_price": d.manual_cents.map(format_price),
|
||||
"auto_price": d.auto_cents.map(format_price),
|
||||
"auto_profile_name": d.auto_profile_id.and_then(|pid| profile_name.get(&pid)),
|
||||
"auto_profile_id": d.auto_profile_id,
|
||||
"business_price": format_price(b.price_cents),
|
||||
"business_reduced": b.price_cents < d.regular_cents,
|
||||
"has_negotiated": d.manual_cents.is_some(),
|
||||
"collision": d.collision,
|
||||
"covering": covering,
|
||||
"effective_price": format_price(d.price_cents),
|
||||
"is_business": d.is_business,
|
||||
"effective_differs": d.price_cents != b.price_cents,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -179,6 +176,75 @@ async fn show(
|
||||
)
|
||||
}
|
||||
|
||||
/// Dedicated per-product page for the negotiated price (and, when two assigned
|
||||
/// profiles collide, the resolution selector). Mirrors the catalog "Set discount"
|
||||
/// page but for a single company.
|
||||
#[debug_handler]
|
||||
async fn price_edit(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path((id, product_id)): Path<(i32, i32)>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let company = company_by_id(&ctx, id).await?;
|
||||
let product = products::Entity::find_by_id(product_id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
let business =
|
||||
pricing::audience_price_many(&ctx, std::slice::from_ref(&product), BUSINESS_AUDIENCE)
|
||||
.await?;
|
||||
let business_cents = business[0].price_cents;
|
||||
let detail = pricing::detail_many(&ctx, std::slice::from_ref(&product), Some(&company)).await?;
|
||||
let d = &detail[0];
|
||||
|
||||
// Names for the covering profiles, used by the collision resolution selector.
|
||||
let covering: Vec<serde_json::Value> = if d.covering_profile_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let profiles = discount_profiles::Entity::find()
|
||||
.filter(discount_profiles::Column::Id.is_in(d.covering_profile_ids.clone()))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let name: HashMap<i32, String> =
|
||||
profiles.iter().map(|p| (p.id, p.name.clone())).collect();
|
||||
d.covering_profile_ids
|
||||
.iter()
|
||||
.map(|pid| json!({ "id": pid, "name": name.get(pid) }))
|
||||
.collect()
|
||||
};
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"admin/customers/price_form.html",
|
||||
json!({
|
||||
"customer": { "id": company.id, "name": company.name },
|
||||
"product": {
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"currency": product.currency,
|
||||
"regular_price": format_price(d.regular_cents),
|
||||
"regular_cents": d.regular_cents,
|
||||
"business_price": format_price(business_cents),
|
||||
"business_reduced": business_cents < d.regular_cents,
|
||||
"effective_price": format_price(d.price_cents),
|
||||
"effective_differs": d.price_cents != business_cents,
|
||||
},
|
||||
"negotiated": d.manual_cents.map(format_price).unwrap_or_default(),
|
||||
"has_negotiated": d.manual_cents.is_some(),
|
||||
"collision": d.collision,
|
||||
"covering": covering,
|
||||
"auto_profile_id": d.auto_profile_id,
|
||||
"error": params.get("error"),
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn set_price(
|
||||
auth: auth::JWT,
|
||||
@@ -199,7 +265,7 @@ async fn set_price(
|
||||
Ok(cents) if cents > 0 => cents,
|
||||
_ => {
|
||||
return format::redirect(&format!(
|
||||
"/admin/customers/{id}?error=discount-must-be-positive"
|
||||
"/admin/customers/{id}/prices/{product_id}/edit?error=discount-must-be-positive"
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -288,6 +354,10 @@ pub fn routes() -> Routes {
|
||||
.add("/admin/customers", get(index))
|
||||
.add("/admin/customers/{id}", get(show))
|
||||
.add("/admin/customers/{id}/profiles", post(sync_profiles))
|
||||
.add(
|
||||
"/admin/customers/{id}/prices/{product_id}/edit",
|
||||
get(price_edit),
|
||||
)
|
||||
.add("/admin/customers/{id}/prices/{product_id}", post(set_price))
|
||||
.add(
|
||||
"/admin/customers/{id}/prices/{product_id}/remove",
|
||||
|
||||
Reference in New Issue
Block a user