345 lines
16 KiB
HTML
345 lines
16 KiB
HTML
{% extends "base.html" %}
|
||
{% block extra_head %}
|
||
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
|
||
<link rel="stylesheet" href="/static/css/projects-hub.css?v=1" />
|
||
{% endblock %}
|
||
{% block content %}
|
||
<div class="clients-shell" x-data="projectsHub()" x-init="init()">
|
||
<header class="page-header herman-hero">
|
||
<div>
|
||
<h1>Projecten <span class="live-badge">Food Business</span></h1>
|
||
<p class="hero-sub">Gestructureerd per klant · NAS-mappen · foto's · documenten · packaging</p>
|
||
</div>
|
||
<div class="retail-actions">
|
||
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
|
||
<a class="btn btn-sm" href="/documents">Documenten →</a>
|
||
<a class="btn btn-sm" href="/packaging">Packaging →</a>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="clients-kpi-row" style="grid-template-columns:repeat(4,1fr)">
|
||
<div class="clients-kpi"><span>Totaal projecten</span><strong x-text="stats.total||0"></strong></div>
|
||
<div class="clients-kpi"><span>Met klant</span><strong x-text="stats.with_client||0"></strong></div>
|
||
<div class="clients-kpi"><span>NAS mappen</span><strong x-text="stats.with_nas||0"></strong></div>
|
||
<div class="clients-kpi"><span>Assets gekoppeld</span><strong x-text="stats.assets||0"></strong></div>
|
||
</div>
|
||
|
||
<div class="clients-tabs">
|
||
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'">📋 Overzicht</button>
|
||
<button class="clients-tab" :class="{active: tab==='create'}" @click="tab='create'">➕ Nieuw project</button>
|
||
<button class="clients-tab" :class="{active: tab==='byclient'}" @click="tab='byclient'">👥 Per klant</button>
|
||
</div>
|
||
|
||
<div x-show="tab==='create'" x-cloak>
|
||
<section class="panel store-edit-panel">
|
||
<h2 style="margin-top:0">Nieuw food-business project</h2>
|
||
<p class="page-tip">Kies een type — per klant wordt automatisch een aparte NAS-map aangemaakt (niet alles op één hoop).</p>
|
||
<div class="project-type-grid">
|
||
<template x-for="t in types" :key="t.id">
|
||
<div class="project-type-card" :class="{selected: form.project_type===t.id}" @click="form.project_type=t.id">
|
||
<span class="icon" x-text="t.icon"></span>
|
||
<span class="label" x-text="t.label_nl"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="clients-form-grid">
|
||
<label class="full"><span class="filter-label">Projectnaam *</span>
|
||
<input class="form-input" x-model="form.name" placeholder="bv. Jumbo listing halal maaltijden Q3" /></label>
|
||
<label><span class="filter-label">Klant *</span>
|
||
<select class="form-input" x-model="form.client_id">
|
||
<option value="">— Selecteer klant —</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">Prioriteit</span>
|
||
<select class="form-input" x-model="form.priority">
|
||
<option value="low">Laag</option><option value="normal">Normaal</option>
|
||
<option value="high">Hoog</option><option value="urgent">Urgent</option>
|
||
</select></label>
|
||
<label class="full"><span class="filter-label">Beschrijving</span>
|
||
<textarea class="form-input" rows="3" x-model="form.description" placeholder="Doel, deliverables, deadline…"></textarea></label>
|
||
</div>
|
||
<button class="btn btn-primary" @click="create()" :disabled="!form.name.trim()">Project + NAS-map aanmaken</button>
|
||
</section>
|
||
</div>
|
||
|
||
<div x-show="tab==='list'" x-cloak>
|
||
<div class="clients-toolbar">
|
||
<input type="search" placeholder="Zoek project…" x-model="filterQ" @input="applyFilter()" />
|
||
<select x-model="filterType" @change="applyFilter()">
|
||
<option value="">Alle types</option>
|
||
<template x-for="t in types" :key="'f'+t.id"><option :value="t.id" x-text="t.label_nl"></option></template>
|
||
</select>
|
||
<select x-model="filterClient" @change="applyFilter()">
|
||
<option value="">Alle klanten</option>
|
||
<template x-for="c in clients" :key="'fc'+c.id"><option :value="c.id" x-text="c.name"></option></template>
|
||
</select>
|
||
</div>
|
||
<table class="data-table">
|
||
<thead><tr>
|
||
<th>Naam</th><th>Type</th><th>Klant</th><th>Assets</th><th>NAS</th><th>Status</th><th></th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<template x-for="p in filtered" :key="p.id">
|
||
<tr class="clickable-row" @click="open(p.id)">
|
||
<td><strong x-text="p.name"></strong></td>
|
||
<td x-text="typeLabel(p.project_type)"></td>
|
||
<td x-text="p.client_name||'—'"></td>
|
||
<td x-text="p.asset_count||0"></td>
|
||
<td><span class="clients-tag" x-show="p.nas_path">✓ map</span><span x-show="!p.nas_path">—</span></td>
|
||
<td x-text="p.status"></td>
|
||
<td><button class="btn btn-sm" @click.stop="open(p.id)">Open</button></td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div x-show="tab==='byclient'" x-cloak>
|
||
<template x-for="c in clientsWithProjects" :key="c.id">
|
||
<section class="panel" style="margin-bottom:0.75rem">
|
||
<h3 x-text="c.name"></h3>
|
||
<p class="muted" style="font-size:0.75rem" x-show="c.nas_folder" x-text="'NAS: '+c.nas_folder"></p>
|
||
<template x-for="p in c.projects" :key="p.id">
|
||
<div class="feed-card clickable-row" @click="open(p.id)">
|
||
<strong x-text="p.name"></strong>
|
||
<small x-text="typeLabel(p.project_type)+' · '+(p.asset_count||0)+' assets'"></small>
|
||
</div>
|
||
</template>
|
||
<p x-show="!c.projects.length" class="muted">Geen projecten — maak er een aan voor deze klant.</p>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
|
||
<aside class="clients-drawer" :class="{ open: drawer }" style="width:min(560px,96vw)">
|
||
<div class="clients-drawer-head">
|
||
<div>
|
||
<h2 x-text="selected?.name||''"></h2>
|
||
<p class="muted" x-text="typeLabel(selected?.project_type)+' · '+(selected?.client_name||'Geen klant')"></p>
|
||
</div>
|
||
<button class="drawer-close" @click="drawer=false">×</button>
|
||
</div>
|
||
<template x-if="selected">
|
||
<div>
|
||
<div class="clients-tabs" style="margin-bottom:0.75rem">
|
||
<button class="clients-tab" :class="{active: detailTab==='info'}" @click="detailTab='info'">Info</button>
|
||
<button class="clients-tab" :class="{active: detailTab==='assets'}" @click="detailTab='assets'">Assets</button>
|
||
<button class="clients-tab" :class="{active: detailTab==='photos'}" @click="detailTab='photos'; loadPhotos()">Foto's</button>
|
||
<button class="clients-tab" :class="{active: detailTab==='files'}" @click="detailTab='files'; loadNasFiles()">NAS files</button>
|
||
</div>
|
||
|
||
<div x-show="detailTab==='info'">
|
||
<p x-text="selected.description||'Geen beschrijving'"></p>
|
||
<div x-show="selected.nas_path">
|
||
<strong style="font-size:0.72rem;color:#94a3b8">NAS projectmap</strong>
|
||
<div class="nas-path-box" x-text="selected.nas_path"></div>
|
||
<p class="muted" style="font-size:0.7rem">Structuur: photos/ · documents/ · packaging/ · exports/ · briefs/</p>
|
||
</div>
|
||
<div x-show="selected.nas_client_root">
|
||
<strong style="font-size:0.72rem;color:#94a3b8">Klant NAS root</strong>
|
||
<div class="nas-path-box" x-text="selected.nas_client_root"></div>
|
||
</div>
|
||
<div class="btn-group" style="margin-top:0.75rem">
|
||
<button class="btn btn-sm" @click="ensureNas()" x-show="!selected.nas_path">NAS-map aanmaken</button>
|
||
<a class="btn btn-sm" :href="'/packaging?project_id='+(selected?.id||'')">📦 Packaging Studio</a>
|
||
<a class="btn btn-sm" href="/documents">Documenten hub →</a>
|
||
<a class="btn btn-sm" href="/clients">Klant CRM →</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div x-show="detailTab==='assets'">
|
||
<div class="asset-grid">
|
||
<template x-for="a in (selected.assets||[])" :key="a.id">
|
||
<div class="asset-card">
|
||
<div class="type" x-text="a.asset_type"></div>
|
||
<strong x-text="a.title"></strong>
|
||
<small x-text="(a.source_agent||a.created_by||'')+' · '+(a.created_at||'').substring(0,10)"></small>
|
||
<div class="muted" style="font-size:0.68rem;word-break:break-all" x-show="a.file_path" x-text="a.file_path"></div>
|
||
<div class="btn-group" style="margin-top:0.45rem" x-show="a.asset_type==='packaging' && a.ref_id">
|
||
<a class="btn btn-sm" :href="'/packaging?project_id='+(selected?.id||'')">📦 Open in Studio</a>
|
||
<a class="btn btn-sm btn-ghost" :href="'/api/packaging/download/'+a.ref_id+'?format=pdf'" target="_blank">PDF</a>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<p x-show="!(selected.assets||[]).length" class="muted">Nog geen assets — koppel foto's of NAS-bestanden.</p>
|
||
</div>
|
||
|
||
<div x-show="detailTab==='photos'">
|
||
<p class="page-tip">Klik een foto om te koppelen aan dit project.</p>
|
||
<div class="photo-pick-grid">
|
||
<template x-for="ph in photos" :key="ph.id">
|
||
<div class="photo-pick-item" @click="linkPhoto(ph.id)">
|
||
<img :src="'/api/admin/photos/' + ph.id + '/image.jpg'" :alt="ph.filename" loading="lazy" />
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<p x-show="!photos.length" class="muted">Geen foto's — gebruik Browser/Photos om te importeren.</p>
|
||
</div>
|
||
|
||
<div x-show="detailTab==='files'">
|
||
<p class="page-tip">Koppel een NAS-bestand aan dit project (gestructureerd in projectmap).</p>
|
||
<div class="clients-toolbar">
|
||
<input type="search" placeholder="Zoek bestand…" x-model="fileQ" @input="filterNasFiles()" />
|
||
</div>
|
||
<template x-for="f in nasFilesFiltered.slice(0,40)" :key="f.storage_path||f.path">
|
||
<div class="feed-card clickable-row" @click="linkNasFile(f)">
|
||
<strong x-text="f.filename||f.name"></strong>
|
||
<small x-text="f.storage_path||f.path||''"></small>
|
||
</div>
|
||
</template>
|
||
<p x-show="!nasFiles.length" class="muted">Geen NAS-bestanden gevonden — check Documenten hub.</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</aside>
|
||
</div>
|
||
{% endblock %}
|
||
{% block scripts %}
|
||
<script>
|
||
function projectsHub() {
|
||
return {
|
||
tab: 'list', detailTab: 'info', busy: false, drawer: false,
|
||
stats: {}, projects: [], filtered: [], clients: [], types: [],
|
||
selected: null, photos: [], nasFiles: [], nasFilesFiltered: [], fileQ: '',
|
||
filterQ: '', filterType: '', filterClient: '',
|
||
form: { name: '', client_id: '', description: '', project_type: 'general', priority: 'normal' },
|
||
|
||
get clientsWithProjects() {
|
||
return this.clients.map(c => ({
|
||
...c,
|
||
projects: this.projects.filter(p => p.client_id === c.id),
|
||
})).filter(c => c.projects.length || true);
|
||
},
|
||
|
||
typeLabel(id) {
|
||
return (this.types.find(t => t.id === id) || {}).label_nl || id || '—';
|
||
},
|
||
|
||
async init() {
|
||
const q = new URLSearchParams(location.search).get('id');
|
||
await Promise.all([this.loadAll(), this.loadClients(), this.loadTypes()]);
|
||
if (q) this.open(parseInt(q, 10));
|
||
},
|
||
|
||
async loadTypes() {
|
||
const r = await fetch('/api/projects/types').then(x => x.json());
|
||
this.types = r.items || [];
|
||
},
|
||
|
||
async loadAll() {
|
||
this.busy = true;
|
||
try {
|
||
const [statsR, projR] = await Promise.all([
|
||
fetch('/api/projects/stats').then(x => x.json()),
|
||
fetch('/api/projects?limit=200').then(x => x.json()),
|
||
]);
|
||
this.stats = statsR.stats || {};
|
||
this.projects = projR.items || [];
|
||
this.applyFilter();
|
||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||
this.busy = false;
|
||
},
|
||
|
||
async loadClients() {
|
||
try {
|
||
const r = await Cockpit.api('/clients');
|
||
this.clients = r.items || [];
|
||
} catch (e) { this.clients = []; }
|
||
},
|
||
|
||
applyFilter() {
|
||
const q = (this.filterQ || '').toLowerCase();
|
||
this.filtered = this.projects.filter(p => {
|
||
if (this.filterType && p.project_type !== this.filterType) return false;
|
||
if (this.filterClient && String(p.client_id) !== String(this.filterClient)) return false;
|
||
if (!q) return true;
|
||
return [p.name, p.client_name, p.description].some(f => f && String(f).toLowerCase().includes(q));
|
||
});
|
||
},
|
||
|
||
async create() {
|
||
if (!this.form.name.trim()) return Cockpit.toast('Naam verplicht', 'error');
|
||
if (!this.form.client_id) return Cockpit.toast('Selecteer een klant voor NAS-structuur', 'warn');
|
||
await fetch('/api/projects', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: this.form.name,
|
||
client_id: parseInt(this.form.client_id, 10),
|
||
description: this.form.description,
|
||
project_type: this.form.project_type,
|
||
priority: this.form.priority,
|
||
}),
|
||
}).then(r => { if (!r.ok) throw new Error('Aanmaken mislukt'); return r.json(); });
|
||
this.form = { name: '', client_id: '', description: '', project_type: 'general', priority: 'normal' };
|
||
this.tab = 'list';
|
||
await this.loadAll();
|
||
Cockpit.toast('Project + NAS-map aangemaakt', 'success');
|
||
},
|
||
|
||
async open(id) {
|
||
const r = await fetch('/api/projects/' + id).then(x => x.json());
|
||
this.selected = r.project;
|
||
this.detailTab = 'info';
|
||
this.drawer = true;
|
||
},
|
||
|
||
async ensureNas() {
|
||
if (!this.selected) return;
|
||
const r = await fetch('/api/projects/' + this.selected.id + '/ensure-nas', { method: 'POST' }).then(x => x.json());
|
||
Cockpit.toast('NAS-map aangemaakt', 'success');
|
||
await this.open(this.selected.id);
|
||
},
|
||
|
||
async loadPhotos() {
|
||
try {
|
||
const r = await Cockpit.api('/photos?limit=60');
|
||
this.photos = r.items || r.photos || [];
|
||
} catch (e) { this.photos = []; }
|
||
},
|
||
|
||
async linkPhoto(photoId) {
|
||
if (!this.selected) return;
|
||
await Cockpit.api('/api/projects/photos/' + photoId + '/link', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ project_id: this.selected.id }),
|
||
});
|
||
Cockpit.toast('Foto gekoppeld', 'success');
|
||
await this.open(this.selected.id);
|
||
await this.loadAll();
|
||
},
|
||
|
||
async loadNasFiles() {
|
||
try {
|
||
const r = await Cockpit.api('/documents/share-files?limit=200');
|
||
this.nasFiles = r.items || r.files || [];
|
||
this.filterNasFiles();
|
||
} catch (e) { this.nasFiles = []; this.nasFilesFiltered = []; }
|
||
},
|
||
|
||
filterNasFiles() {
|
||
const q = (this.fileQ || '').toLowerCase();
|
||
this.nasFilesFiltered = this.nasFiles.filter(f => {
|
||
const name = (f.filename || f.name || '').toLowerCase();
|
||
const path = (f.storage_path || f.path || '').toLowerCase();
|
||
return !q || name.includes(q) || path.includes(q);
|
||
});
|
||
},
|
||
|
||
async linkNasFile(f) {
|
||
if (!this.selected) return;
|
||
const path = f.storage_path || f.path;
|
||
const title = f.filename || f.name || path;
|
||
await fetch('/api/projects/' + this.selected.id + '/link-nas-file', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title, file_path: path, asset_type: 'document' }),
|
||
});
|
||
Cockpit.toast('Bestand gekoppeld', 'success');
|
||
await this.open(this.selected.id);
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|