Files
foodlinkk-command-center/cockpit/templates/products.html
T
2026-06-09 10:41:13 +00:00

262 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}