feat(phase-4c): frontend, 13 pages, alpine+tailwind, tests
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Account-List Component (Alpine.js)
|
||||
*
|
||||
* Used in: accounts.html
|
||||
*
|
||||
* Features:
|
||||
* - Paginated table of accounts
|
||||
* - Search by name (?q=)
|
||||
* - Filter by industry (?industry=) and size (?size=)
|
||||
* - "+ New Account" modal that POSTs to /api/v1/accounts/
|
||||
* - Row click → /accounts-detail.html?id=…
|
||||
*
|
||||
* Backend: GET /api/v1/accounts/?skip=&limit=&q=&industry=&size=&owner_id=
|
||||
* POST /api/v1/accounts/ → AccountOut
|
||||
*
|
||||
* R-5: x-text only. All user-controlled strings go through x-text or
|
||||
* textContent (never x-html / innerHTML).
|
||||
*/
|
||||
|
||||
import { api } from '/js/api.js';
|
||||
|
||||
export function accountList() {
|
||||
return {
|
||||
// State
|
||||
accounts: [],
|
||||
total: 0,
|
||||
skip: 0,
|
||||
limit: 20,
|
||||
q: '',
|
||||
industry: '',
|
||||
size: '',
|
||||
ownerId: '',
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// Create-modal state
|
||||
showCreate: false,
|
||||
createForm: this._initCreateForm(),
|
||||
createError: '',
|
||||
creating: false,
|
||||
|
||||
// Lookup values (could be hard-coded; kept here for clarity)
|
||||
industries: ['technology', 'finance', 'healthcare', 'retail', 'manufacturing', 'other'],
|
||||
sizes: ['startup', 'smb', 'midmarket', 'enterprise'],
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('skip', String(this.skip));
|
||||
params.set('limit', String(this.limit));
|
||||
if (this.q) params.set('q', this.q);
|
||||
if (this.industry) params.set('industry', this.industry);
|
||||
if (this.size) params.set('size', this.size);
|
||||
if (this.ownerId) params.set('owner_id', this.ownerId);
|
||||
|
||||
const res = await api.get(`/accounts/?${params.toString()}`);
|
||||
this.accounts = Array.isArray(res) ? res : (res && res.items) || [];
|
||||
this.total = (res && res.total) || this.accounts.length;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Fehler beim Laden der Accounts.';
|
||||
this._notify('error', this.error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async search() {
|
||||
this.skip = 0;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async nextPage() {
|
||||
if (this.skip + this.limit < this.total) {
|
||||
this.skip += this.limit;
|
||||
await this.load();
|
||||
}
|
||||
},
|
||||
|
||||
async prevPage() {
|
||||
if (this.skip - this.limit >= 0) {
|
||||
this.skip -= this.limit;
|
||||
await this.load();
|
||||
}
|
||||
},
|
||||
|
||||
get pageInfo() {
|
||||
const from = this.total === 0 ? 0 : this.skip + 1;
|
||||
const to = Math.min(this.skip + this.limit, this.total);
|
||||
return `${from}–${to} von ${this.total}`;
|
||||
},
|
||||
|
||||
// === Create modal ===
|
||||
openCreate() {
|
||||
this.createForm = this._initCreateForm();
|
||||
this.createError = '';
|
||||
this.showCreate = true;
|
||||
},
|
||||
closeCreate() {
|
||||
this.showCreate = false;
|
||||
},
|
||||
async submitCreate() {
|
||||
this.creating = true;
|
||||
this.createError = '';
|
||||
try {
|
||||
const payload = {
|
||||
name: this.createForm.name,
|
||||
};
|
||||
if (this.createForm.website) payload.website = this.createForm.website;
|
||||
if (this.createForm.industry) payload.industry = this.createForm.industry;
|
||||
if (this.createForm.size) payload.size = this.createForm.size;
|
||||
|
||||
await api.post('/accounts/', payload);
|
||||
this._notify('success', 'Account angelegt.');
|
||||
this.showCreate = false;
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
this.createError = err.message || 'Fehler beim Anlegen.';
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
|
||||
// === Internals ===
|
||||
_initCreateForm() {
|
||||
return { name: '', website: '', industry: '', size: '' };
|
||||
},
|
||||
_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('accountList', accountList);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default accountList;
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Activity-List Component (Alpine.js)
|
||||
*
|
||||
* Used in: activities.html
|
||||
*
|
||||
* Features:
|
||||
* - Paginated table of activities
|
||||
* - Filter by type, owner, overdue, completed
|
||||
* - "+ New Activity" modal that POSTs to /api/v1/activities/
|
||||
* - Row click opens a detail modal with a "Complete" button
|
||||
*
|
||||
* Backend: GET /api/v1/activities/?skip=&limit=&type=&owner_id=&overdue=&completed=
|
||||
* POST /api/v1/activities/
|
||||
* PATCH /api/v1/activities/{id}/complete
|
||||
*
|
||||
* R-5: x-text only. Even activity bodies go through x-text, never x-html.
|
||||
*/
|
||||
|
||||
import { api } from '/js/api.js';
|
||||
|
||||
export const ACTIVITY_TYPES = ['call', 'email', 'meeting', 'task', 'note'];
|
||||
|
||||
export const TYPE_BADGE = {
|
||||
call: 'badge-blue',
|
||||
email: 'badge-yellow',
|
||||
meeting: 'badge-green',
|
||||
task: 'badge-gray',
|
||||
note: 'badge-gray',
|
||||
};
|
||||
|
||||
export function activityList() {
|
||||
return {
|
||||
// State
|
||||
activities: [],
|
||||
total: 0,
|
||||
skip: 0,
|
||||
limit: 20,
|
||||
type: '',
|
||||
ownerId: '',
|
||||
overdue: '', // '', 'true', 'false'
|
||||
completed: '', // '', 'true', 'false'
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// Create-modal state
|
||||
showCreate: false,
|
||||
createForm: this._initForm(),
|
||||
createError: '',
|
||||
creating: false,
|
||||
|
||||
// Detail-modal state
|
||||
showDetail: false,
|
||||
selected: null,
|
||||
completing: false,
|
||||
|
||||
// Lookups
|
||||
types: ACTIVITY_TYPES.slice(),
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('skip', String(this.skip));
|
||||
params.set('limit', String(this.limit));
|
||||
if (this.type) params.set('type', this.type);
|
||||
if (this.ownerId) params.set('owner_id', this.ownerId);
|
||||
if (this.overdue) params.set('overdue', this.overdue);
|
||||
if (this.completed) params.set('completed', this.completed);
|
||||
|
||||
const res = await api.get(`/activities/?${params.toString()}`);
|
||||
this.activities = Array.isArray(res) ? res : (res && res.items) || [];
|
||||
this.total = (res && res.total) || this.activities.length;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Fehler beim Laden der Activities.';
|
||||
this._notify('error', this.error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async applyFilters() {
|
||||
this.skip = 0;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async nextPage() {
|
||||
if (this.skip + this.limit < this.total) {
|
||||
this.skip += this.limit;
|
||||
await this.load();
|
||||
}
|
||||
},
|
||||
async prevPage() {
|
||||
if (this.skip - this.limit >= 0) {
|
||||
this.skip -= this.limit;
|
||||
await this.load();
|
||||
}
|
||||
},
|
||||
get pageInfo() {
|
||||
const from = this.total === 0 ? 0 : this.skip + 1;
|
||||
const to = Math.min(this.skip + this.limit, this.total);
|
||||
return `${from}–${to} von ${this.total}`;
|
||||
},
|
||||
|
||||
// === Row helpers ===
|
||||
isOverdue(a) {
|
||||
if (a.completed_at) return false;
|
||||
if (!a.due_date) return false;
|
||||
return new Date(a.due_date) < new Date();
|
||||
},
|
||||
isCompleted(a) { return !!a.completed_at; },
|
||||
typeBadge(t) { return TYPE_BADGE[t] || 'badge-gray'; },
|
||||
|
||||
formatDateTime(iso) {
|
||||
if (!iso) return '';
|
||||
try { return new Date(iso).toLocaleString('de-DE'); }
|
||||
catch { return iso; }
|
||||
},
|
||||
|
||||
// === Create modal ===
|
||||
openCreate() {
|
||||
this.createForm = this._initForm();
|
||||
this.createError = '';
|
||||
this.showCreate = true;
|
||||
},
|
||||
closeCreate() { this.showCreate = false; },
|
||||
async submitCreate() {
|
||||
this.creating = true;
|
||||
this.createError = '';
|
||||
try {
|
||||
const payload = {
|
||||
type: this.createForm.type,
|
||||
subject: this.createForm.subject,
|
||||
};
|
||||
if (this.createForm.body) payload.body = this.createForm.body;
|
||||
if (this.createForm.due_date) payload.due_date = this.createForm.due_date;
|
||||
if (this.createForm.account_id) payload.account_id = Number(this.createForm.account_id);
|
||||
if (this.createForm.contact_id) payload.contact_id = Number(this.createForm.contact_id);
|
||||
if (this.createForm.deal_id) payload.deal_id = Number(this.createForm.deal_id);
|
||||
|
||||
await api.post('/activities/', payload);
|
||||
this._notify('success', 'Activity angelegt.');
|
||||
this.showCreate = false;
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
this.createError = err.message || 'Fehler beim Anlegen.';
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
|
||||
// === Detail / Complete ===
|
||||
openDetail(a) {
|
||||
this.selected = a;
|
||||
this.showDetail = true;
|
||||
},
|
||||
closeDetail() {
|
||||
this.showDetail = false;
|
||||
this.selected = null;
|
||||
},
|
||||
async completeSelected() {
|
||||
if (!this.selected) return;
|
||||
this.completing = true;
|
||||
try {
|
||||
await api.patch(`/activities/${this.selected.id}/complete`, { outcome: '' });
|
||||
this._notify('success', 'Activity abgeschlossen.');
|
||||
this.closeDetail();
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
this._notify('error', err.message || 'Fehler beim Abschließen.');
|
||||
} finally {
|
||||
this.completing = false;
|
||||
}
|
||||
},
|
||||
|
||||
// === Internals ===
|
||||
_initForm() {
|
||||
return {
|
||||
type: 'task',
|
||||
subject: '',
|
||||
body: '',
|
||||
due_date: '',
|
||||
account_id: '',
|
||||
contact_id: '',
|
||||
deal_id: '',
|
||||
};
|
||||
},
|
||||
_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('activityList', activityList);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default activityList;
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* app-shell.js — Layout-Shell for the protected area.
|
||||
*
|
||||
* Each protected page (dashboard, accounts, etc.) renders the sidebar +
|
||||
* topbar via x-data="appShell('<page-key>')". The shell owns:
|
||||
* - the active nav state
|
||||
* - the title text (derived from the page key)
|
||||
* - the Logout button handler (delegates to the auth store)
|
||||
* - the admin-only nav items (uses $store.auth.isAdmin)
|
||||
*
|
||||
* The auth-gate itself (redirect to /index.html when no JWT) is enforced
|
||||
* by store.js on the `Alpine.store('auth').init()` call, which every
|
||||
* protected page invokes from its outermost x-data.
|
||||
*/
|
||||
|
||||
const TITLES = {
|
||||
'dashboard': 'Dashboard',
|
||||
'accounts': 'Accounts',
|
||||
'accounts-detail': 'Account-Detail',
|
||||
'contacts': 'Contacts',
|
||||
'contacts-detail': 'Contact-Detail',
|
||||
'pipeline': 'Pipeline',
|
||||
'activities': 'Activities',
|
||||
'settings-profile': 'Mein Profil',
|
||||
'settings-users': 'User-Verwaltung',
|
||||
'settings-org': 'Org-Einstellungen',
|
||||
};
|
||||
|
||||
|
||||
export function appShell(active) {
|
||||
return {
|
||||
active: active || 'dashboard',
|
||||
|
||||
get title() {
|
||||
return TITLES[this.active] || 'CRM';
|
||||
},
|
||||
|
||||
async logout() {
|
||||
await Alpine.store('auth').logout();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Global registration for the inline `x-data="appShell('…')"` usage.
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('appShell', (active) => appShell(active));
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Dashboard-KPIs + Activity-Feed Component (Alpine.js)
|
||||
*
|
||||
* Used in: dashboard.html and app.html (default landing page)
|
||||
*
|
||||
* Loads:
|
||||
* GET /api/v1/dashboard/kpis → { open_deals_count, pipeline_value, won_this_month, conversion_rate }
|
||||
* GET /api/v1/dashboard/feed?limit=20 → ActivityFeedItem[]
|
||||
*
|
||||
* Renders: 4 KPI cards + an activity feed list.
|
||||
*
|
||||
* R-5: x-text only. User-controlled strings are not interpolated as HTML.
|
||||
*/
|
||||
|
||||
import { api } from '/js/api.js';
|
||||
|
||||
export function dashboardKpis() {
|
||||
return {
|
||||
loading: true,
|
||||
kpis: null,
|
||||
feed: [],
|
||||
error: null,
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
// Fetch KPIs and feed in parallel
|
||||
const [kpis, feed] = await Promise.all([
|
||||
api.get('/dashboard/kpis'),
|
||||
api.get('/dashboard/feed?limit=20'),
|
||||
]);
|
||||
this.kpis = kpis;
|
||||
this.feed = Array.isArray(feed) ? feed : (feed && feed.items) || [];
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Fehler beim Laden der Dashboard-Daten.';
|
||||
if (window.Alpine) {
|
||||
Alpine.store('notifications').error(this.error);
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatCurrency(value) {
|
||||
if (value === null || value === undefined) return '–';
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num)) return '–';
|
||||
return num.toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
},
|
||||
|
||||
formatPercent(value) {
|
||||
if (value === null || value === undefined) return '–';
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num)) return '–';
|
||||
// Backend may return 0-1 or 0-100. Heuristic: >1 → already in %.
|
||||
const pct = num > 1 ? num : num * 100;
|
||||
return pct.toFixed(1) + ' %';
|
||||
},
|
||||
|
||||
formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('de-DE');
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Register globally for `x-data="dashboardKpis()"`
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('alpine:init', () => {
|
||||
if (window.Alpine) {
|
||||
window.Alpine.data('dashboardKpis', dashboardKpis);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default dashboardKpis;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user