Files

345 lines
16 KiB
HTML
Raw Permalink Normal View History

2026-06-09 10:41:13 +00:00
{% 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 %}