/** * Deal-Kanban Component (Alpine.js) * * Used in: pipeline.html * * Loads all deals and groups them client-side by stage into 6 Kanban columns: * lead → qualified → proposal → negotiation → won → lost * * Drag-and-drop: uses HTML5 Drag API. On drop, PATCH /api/v1/deals/{id}/stage * with the new stage. Backend: { stage, reason? } per DealStageUpdate schema. * * The pipeline-summary endpoint (GET /deals/pipeline) is also fetched for * aggregate counts/values shown in each column header. * * R-5: x-text only. No x-html anywhere — including dynamic deal titles. */ import { api } from '/js/api.js'; export const STAGES = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']; export const STAGE_LABELS = { lead: 'Lead', qualified: 'Qualified', proposal: 'Proposal', negotiation: 'Negotiation', won: 'Won', lost: 'Lost', }; export const STAGE_BADGE = { lead: 'badge-gray', qualified: 'badge-blue', proposal: 'badge-yellow', negotiation: 'badge-yellow', won: 'badge-green', lost: 'badge-red', }; export function dealKanban() { return { // State stages: STAGES.slice(), dealsByStage: {}, // { stage: DealOut[] } pipelineSummary: [], // [{stage, count, total_value}] from /deals/pipeline ownerId: '', dragOverStage: null, loading: false, error: null, async init() { await this.load(); }, async load() { this.loading = true; this.error = null; try { // Build URL with optional owner_id filter let url = '/deals/?skip=0&limit=200'; if (this.ownerId) url += `&owner_id=${encodeURIComponent(this.ownerId)}`; const [deals, summary] = await Promise.all([ api.get(url), api.get('/deals/pipeline').catch(() => []), // optional ]); const list = Array.isArray(deals) ? deals : (deals && deals.items) || []; this.dealsByStage = {}; this.stages.forEach(s => { this.dealsByStage[s] = []; }); for (const d of list) { if (this.dealsByStage[d.stage]) { this.dealsByStage[d.stage].push(d); } } this.pipelineSummary = Array.isArray(summary) ? summary : []; } catch (err) { this.error = err.message || 'Fehler beim Laden der Pipeline.'; this._notify('error', this.error); } finally { this.loading = false; } }, async filterByOwner() { await this.load(); }, // === Drag-and-Drop === onDragStart(event, dealId) { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(dealId)); }, onDragOver(event, stage) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; this.dragOverStage = stage; }, onDragLeave(stage) { if (this.dragOverStage === stage) this.dragOverStage = null; }, async onDrop(event, newStage) { event.preventDefault(); this.dragOverStage = null; const dealId = event.dataTransfer.getData('text/plain'); if (!dealId) return; // Find the deal in its current column let currentStage = null; let deal = null; for (const s of this.stages) { const idx = this.dealsByStage[s].findIndex(d => String(d.id) === String(dealId)); if (idx >= 0) { currentStage = s; deal = this.dealsByStage[s][idx]; break; } } if (!deal || currentStage === newStage) return; // Optimistic UI move this.dealsByStage[currentStage] = this.dealsByStage[currentStage].filter(d => d.id !== deal.id); const oldDeal = { ...deal }; deal.stage = newStage; this.dealsByStage[newStage].push(deal); // Persist to backend try { await api.patch(`/deals/${dealId}/stage`, { stage: newStage }); this._notify('success', `Deal "${oldDeal.title}" → ${STAGE_LABELS[newStage]}`); } catch (err) { // Revert on failure this.dealsByStage[newStage] = this.dealsByStage[newStage].filter(d => d.id !== deal.id); oldDeal.stage = currentStage; this.dealsByStage[currentStage].push(oldDeal); this._notify('error', err.message || 'Stage-Update fehlgeschlagen.'); } }, // === Create modal === showCreate: false, createForm: this._initForm(), creating: false, createError: '', openCreate() { this.createForm = this._initForm(); this.createError = ''; this.showCreate = true; }, closeCreate() { this.showCreate = false; }, async submitCreate() { this.creating = true; this.createError = ''; try { const payload = { title: this.createForm.title, value: Number(this.createForm.value) || 0, currency: this.createForm.currency || 'EUR', stage: this.createForm.stage || 'lead', account_id: Number(this.createForm.account_id), }; if (this.createForm.close_date) payload.close_date = this.createForm.close_date; await api.post('/deals/', payload); this._notify('success', 'Deal angelegt.'); this.showCreate = false; await this.load(); } catch (err) { this.createError = err.message || 'Fehler beim Anlegen.'; } finally { this.creating = false; } }, // === Helpers === stageLabel(s) { return STAGE_LABELS[s] || s; }, stageBadge(s) { return STAGE_BADGE[s] || 'badge-gray'; }, columnCount(s) { return (this.dealsByStage[s] || []).length; }, columnValue(s) { const sum = (this.dealsByStage[s] || []).reduce( (acc, d) => acc + Number(d.value || 0), 0); return sum.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }); }, formatCurrency(v) { return Number(v || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0, }); }, formatDate(iso) { if (!iso) return ''; try { return new Date(iso).toLocaleDateString('de-DE'); } catch { return iso; } }, _initForm() { return { title: '', value: 0, currency: 'EUR', stage: 'lead', account_id: '', close_date: '' }; }, _notify(type, message) { if (window.Alpine) Alpine.store('notifications').push(message, type); }, }; } if (typeof window !== 'undefined') { document.addEventListener('alpine:init', () => { if (window.Alpine) { window.Alpine.data('dealKanban', dealKanban); } }); } export default dealKanban;