208 lines
7.4 KiB
JavaScript
208 lines
7.4 KiB
JavaScript
/**
|
|
* 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;
|