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

345 lines
16 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" />
<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 %}