262 lines
12 KiB
HTML
262 lines
12 KiB
HTML
{% extends "base.html" %}
|
||
{% block extra_head %}
|
||
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
|
||
{% endblock %}
|
||
{% block content %}
|
||
<div class="clients-shell" x-data="productsHub()" x-init="init()" @foodlinkk:locale.window="onLocale()">
|
||
<header class="page-header">
|
||
<div>
|
||
<h1 x-text="$t('products.title')">Productcatalogus</h1>
|
||
<p class="subtitle page-tip" x-text="$t('products.subtitle')"></p>
|
||
</div>
|
||
<div class="retail-actions">
|
||
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy" x-text="$t('common.refresh')"></button>
|
||
<a class="btn btn-sm" href="/clients" x-text="$t('nav.clients')"></a>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="clients-kpi-row">
|
||
<div class="clients-kpi"><span x-text="$t('products.total')"></span><strong x-text="stats.total||0"></strong></div>
|
||
<div class="clients-kpi"><span x-text="$t('products.active')"></span><strong x-text="stats.active||0"></strong></div>
|
||
<div class="clients-kpi"><span x-text="$t('products.avg_margin')"></span><strong x-text="(stats.avg_margin||0)+'%'"></strong></div>
|
||
<div class="clients-kpi"><span x-text="$t('products.with_client')"></span><strong x-text="stats.with_client||0"></strong></div>
|
||
</div>
|
||
|
||
<div class="clients-tabs">
|
||
<button class="clients-tab" :class="{active: tab==='grid'}" @click="tab='grid'" x-text="$t('products.tab_grid')"></button>
|
||
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('products.tab_list')"></button>
|
||
<button class="clients-tab" :class="{active: tab==='margin'}" @click="tab='margin'" x-text="$t('products.tab_margin')"></button>
|
||
<div class="clients-tab-actions">
|
||
<button class="btn btn-primary btn-sm" @click="openModal()" x-text="'+ ' + $t('products.new')"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div x-show="tab==='grid'" x-cloak>
|
||
<div class="agents-neo-grid">
|
||
<template x-for="p in filtered" :key="p.id">
|
||
<article class="agent-soul-card" @click="openDetail(p)">
|
||
<strong x-text="p.name"></strong>
|
||
<div class="clients-card-meta">
|
||
<span class="clients-tag" x-text="statusLabel(p.status)"></span>
|
||
<span class="clients-tag mrr" x-show="p.margin_pct" x-text="(p.margin_pct||0)+'%'"></span>
|
||
</div>
|
||
<div class="clients-card-meta" x-show="p.client_name" x-text="p.client_name"></div>
|
||
<div class="clients-card-meta" x-show="p.shelf_target" x-text="p.shelf_target"></div>
|
||
</article>
|
||
</template>
|
||
</div>
|
||
<p class="clients-empty" x-show="!filtered.length" x-text="$t('products.no_products')"></p>
|
||
</div>
|
||
|
||
<div x-show="tab==='list'" x-cloak>
|
||
<div class="clients-toolbar">
|
||
<input type="search" :placeholder="$t('common.search')" x-model="filterQ" @input="applyFilter()" />
|
||
<select x-model="filterStatus" @change="applyFilter()">
|
||
<option value="" x-text="$t('common.all')"></option>
|
||
<template x-for="s in statuses" :key="s.id">
|
||
<option :value="s.id" x-text="s.label"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
<table class="data-table">
|
||
<thead><tr>
|
||
<th x-text="$t('products.name')"></th><th x-text="$t('products.client')"></th>
|
||
<th x-text="$t('products.status')"></th><th x-text="$t('products.margin_pct')"></th>
|
||
<th x-text="$t('products.moq')"></th><th></th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<template x-for="p in filtered" :key="'l'+p.id">
|
||
<tr class="clickable-row" @click="openDetail(p)">
|
||
<td><strong x-text="p.name"></strong></td>
|
||
<td x-text="p.client_name||'—'"></td>
|
||
<td><span class="badge" x-text="statusLabel(p.status)"></span></td>
|
||
<td x-text="p.margin_pct!=null?p.margin_pct+'%':'—'"></td>
|
||
<td x-text="p.moq||'—'"></td>
|
||
<td>
|
||
<button class="btn btn-sm" @click.stop="editProduct(p)" x-text="$t('common.edit')"></button>
|
||
<button class="btn btn-sm btn-reject" @click.stop="remove(p.id)">×</button>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div x-show="tab==='margin'" x-cloak>
|
||
<section class="panel store-edit-panel">
|
||
<div class="clients-form-grid" style="max-width:480px">
|
||
<label><span class="filter-label" x-text="$t('products.calc_cost')"></span>
|
||
<input type="number" class="form-input" x-model="margin.cost" step="0.01" /></label>
|
||
<label><span class="filter-label" x-text="$t('products.calc_sell')"></span>
|
||
<input type="number" class="form-input" x-model="margin.sell" step="0.01" /></label>
|
||
</div>
|
||
<button class="btn btn-secondary btn-sm" style="margin-top:0.75rem" @click="calcMargin()" x-text="$t('products.calc_run')"></button>
|
||
<div x-show="margin.result!=null" style="margin-top:1rem">
|
||
<p><span x-text="$t('products.calc_margin')"></span>: <strong x-text="margin.result+'%'"></strong></p>
|
||
<p x-show="margin.profit!=null"><span x-text="$t('products.calc_profit')"></span>: <strong x-text="'€'+margin.profit"></strong></p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
|
||
<div class="modal-card" style="max-width:560px">
|
||
<h3 x-text="form.id ? $t('common.edit') : $t('products.new')"></h3>
|
||
<div class="clients-form-grid">
|
||
<label class="full"><span class="filter-label" x-text="$t('products.name')"></span>
|
||
<input class="form-input" x-model="form.name" /></label>
|
||
<label><span class="filter-label" x-text="$t('products.status')"></span>
|
||
<select class="form-input" x-model="form.status">
|
||
<template x-for="s in statuses" :key="'fs'+s.id"><option :value="s.id" x-text="s.label"></option></template>
|
||
</select></label>
|
||
<label><span class="filter-label" x-text="$t('products.client')"></span>
|
||
<select class="form-input" x-model="form.client_id">
|
||
<option value="" x-text="$t('common.none')"></option>
|
||
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
|
||
</select></label>
|
||
<label><span class="filter-label" x-text="$t('products.margin_pct')"></span>
|
||
<input type="number" class="form-input" x-model="form.margin_pct" step="0.1" /></label>
|
||
<label><span class="filter-label" x-text="$t('products.moq')"></span>
|
||
<input type="number" class="form-input" x-model="form.moq" /></label>
|
||
<label><span class="filter-label" x-text="$t('products.launch_date')"></span>
|
||
<input type="date" class="form-input" x-model="form.launch_date" /></label>
|
||
<label class="full"><span class="filter-label" x-text="$t('products.shelf_target')"></span>
|
||
<input class="form-input" x-model="form.shelf_target" placeholder="Jumbo NL · AH regio" /></label>
|
||
</div>
|
||
<div class="btn-group" style="margin-top:1rem">
|
||
<button class="btn btn-primary" @click="save()" :disabled="!form.name.trim()" x-text="$t('common.save')"></button>
|
||
<button class="btn btn-secondary" @click="modal=false" x-text="$t('common.cancel')"></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<aside class="clients-drawer" :class="{ open: drawer }">
|
||
<div class="clients-drawer-head">
|
||
<div><h2 x-text="detail?.product?.name||''"></h2>
|
||
<p class="muted" x-text="statusLabel(detail?.product?.status)"></p></div>
|
||
<button class="drawer-close" @click="drawer=false">×</button>
|
||
</div>
|
||
<template x-if="detail?.product">
|
||
<div class="clients-card-meta">
|
||
<div x-show="detail.product.client_name"><strong x-text="$t('products.drawer_deals')"></strong>: <span x-text="detail.product.client_name"></span></div>
|
||
<div x-show="detail.product.margin_pct"><strong x-text="$t('products.margin_pct')"></strong>: <span x-text="detail.product.margin_pct+'%'"></span></div>
|
||
<div x-show="detail.product.moq"><strong x-text="$t('products.moq')"></strong>: <span x-text="detail.product.moq"></span></div>
|
||
<div x-show="detail.product.shelf_target"><strong x-text="$t('products.shelf_target')"></strong>: <span x-text="detail.product.shelf_target"></span></div>
|
||
<div x-show="detail.product.launch_date"><strong x-text="$t('products.launch_date')"></strong>: <span x-text="detail.product.launch_date"></span></div>
|
||
<button class="btn btn-sm" style="margin-top:0.75rem" @click="editProduct(detail.product)" x-text="$t('common.edit')"></button>
|
||
</div>
|
||
</template>
|
||
</aside>
|
||
</div>
|
||
{% endblock %}
|
||
{% block scripts %}
|
||
<script>
|
||
function productsHub() {
|
||
return {
|
||
tab: 'grid', busy: false, modal: false, drawer: false,
|
||
stats: {}, products: [], filtered: [], clients: [], detail: null,
|
||
filterQ: '', filterStatus: '',
|
||
form: {}, margin: { cost: 0, sell: 0, result: null, profit: null },
|
||
statuses: [],
|
||
|
||
onLocale() { this.buildStatuses(); },
|
||
buildStatuses() {
|
||
this.statuses = [
|
||
{ id: 'concept', label: this.$t('products.status_concept') },
|
||
{ id: 'development', label: this.$t('products.status_development') },
|
||
{ id: 'active', label: this.$t('products.status_active') },
|
||
{ id: 'discontinued', label: this.$t('products.status_discontinued') },
|
||
];
|
||
},
|
||
statusLabel(id) {
|
||
return (this.statuses.find(s => s.id === id) || {}).label || id || '—';
|
||
},
|
||
|
||
async init() {
|
||
this.buildStatuses();
|
||
document.addEventListener('foodlinkk:locale', () => this.buildStatuses());
|
||
await this.loadAll();
|
||
},
|
||
|
||
async loadAll() {
|
||
this.busy = true;
|
||
try {
|
||
const [statsR, prodR, cliR] = await Promise.all([
|
||
Cockpit.api('/products/stats'),
|
||
Cockpit.api('/products'),
|
||
Cockpit.api('/clients'),
|
||
]);
|
||
this.stats = statsR.stats || {};
|
||
this.products = prodR.items || [];
|
||
this.clients = cliR.items || [];
|
||
this.applyFilter();
|
||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||
this.busy = false;
|
||
},
|
||
|
||
applyFilter() {
|
||
const q = (this.filterQ || '').toLowerCase();
|
||
this.filtered = this.products.filter(p => {
|
||
if (this.filterStatus && p.status !== this.filterStatus) return false;
|
||
if (!q) return true;
|
||
return [p.name, p.client_name, p.shelf_target, p.status].some(
|
||
f => f && String(f).toLowerCase().includes(q)
|
||
);
|
||
});
|
||
},
|
||
|
||
openModal() {
|
||
this.form = { id: null, name: '', status: 'concept', client_id: '', margin_pct: '', moq: '', shelf_target: '', launch_date: '' };
|
||
this.modal = true;
|
||
},
|
||
editProduct(p) {
|
||
this.form = {
|
||
id: p.id, name: p.name || '', status: p.status || 'concept',
|
||
client_id: p.client_id || '', margin_pct: p.margin_pct ?? '',
|
||
moq: p.moq ?? '', shelf_target: p.shelf_target || '', launch_date: p.launch_date || '',
|
||
};
|
||
this.modal = true; this.drawer = false;
|
||
},
|
||
|
||
async save() {
|
||
const body = {
|
||
name: this.form.name.trim(),
|
||
status: this.form.status,
|
||
client_id: this.form.client_id ? parseInt(this.form.client_id, 10) : null,
|
||
margin_pct: this.form.margin_pct !== '' ? parseFloat(this.form.margin_pct) : null,
|
||
moq: this.form.moq !== '' ? parseInt(this.form.moq, 10) : null,
|
||
shelf_target: this.form.shelf_target || null,
|
||
launch_date: this.form.launch_date || null,
|
||
};
|
||
try {
|
||
if (this.form.id) await Cockpit.api('/products/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
|
||
else await Cockpit.api('/products', { method: 'POST', body: JSON.stringify(body) });
|
||
Cockpit.toast(window.I18n.t('common.saved'), 'success');
|
||
this.modal = false;
|
||
await this.loadAll();
|
||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||
},
|
||
|
||
async remove(id) {
|
||
if (!Cockpit.confirmDelete()) return;
|
||
await Cockpit.api('/products/' + id, { method: 'DELETE' });
|
||
await this.loadAll();
|
||
},
|
||
|
||
async openDetail(p) {
|
||
const d = await Cockpit.api('/products/' + p.id + '/detail');
|
||
this.detail = d;
|
||
this.drawer = true;
|
||
},
|
||
|
||
async calcMargin() {
|
||
const r = await Cockpit.api('/products/margin-calc', {
|
||
method: 'POST', body: JSON.stringify({ cost: +this.margin.cost, sell: +this.margin.sell }),
|
||
});
|
||
this.margin.result = r.margin_pct;
|
||
this.margin.profit = r.profit;
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|