diff --git a/app/webui/components/deal-kanban.js b/app/webui/components/deal-kanban.js new file mode 100644 index 0000000..a8c7ba0 --- /dev/null +++ b/app/webui/components/deal-kanban.js @@ -0,0 +1,207 @@ +/** + * 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;