Refactor to generic ERP base

Remove influencer-specific features (campaigns, TikTok, gifts, shadow ledger,
contracts, briefings) and consolidate into a clean ERP foundation with core
bookkeeping, invoicing, receipts, tax reporting, and calendar functionality.
Reorganize database migrations into a clean numbered sequence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-13 15:39:13 +01:00
co-authored by Claude Opus 4.6
parent a25d10a528
commit 0aecf5b41c
184 changed files with 1418 additions and 24763 deletions
+5 -106
View File
@@ -4,27 +4,23 @@
*/
import { createEvents, type EventAttributes, type DateArray } from 'ics'
import type { Deadline, Invoice, Campaign, Exclusivity } from '@/types'
import type { Deadline, Invoice } from '@/types'
export interface FeedOptions {
includeTaxDeadlines: boolean
includeInvoices: boolean
includeCampaigns: boolean
includeExclusivity: boolean
}
export interface CalendarData {
deadlines: Deadline[]
invoices: Invoice[]
campaigns: Campaign[]
exclusivities: Exclusivity[]
}
/**
* Generate a stable UID for calendar events
* This ensures updates to events are recognized by calendar apps
*/
function getEventUID(type: string, id: string, domain: string = 'influencer-biz.se'): string {
function getEventUID(type: string, id: string, domain: string = 'erp-base.se'): string {
return `${type}-${id}@${domain}`
}
@@ -139,94 +135,6 @@ function formatInvoiceDescription(invoice: Invoice): string {
return lines.join('\n')
}
/**
* Generate campaign deliverable events
*/
function generateCampaignEvents(campaigns: Campaign[]): EventAttributes[] {
const events: EventAttributes[] = []
for (const campaign of campaigns) {
// Add deliverable deadlines
for (const deliverable of campaign.deliverables || []) {
if (!deliverable.due_date) continue
const event: EventAttributes = {
uid: getEventUID('deliverable', deliverable.id),
title: `${campaign.name} - ${deliverable.title}`,
start: dateToArray(deliverable.due_date),
duration: { days: 1 },
description: formatDeliverableDescription(campaign, deliverable),
categories: ['Kampanj', 'Leverans'],
status: deliverable.status === 'published' ? 'CONFIRMED' : 'TENTATIVE',
alarms: createAlarms([1]),
}
events.push(event)
}
}
return events
}
/**
* Format deliverable description for calendar
*/
function formatDeliverableDescription(
campaign: Campaign,
deliverable: { title: string; platform: string; description?: string | null }
): string {
const lines: string[] = []
lines.push(`Kampanj: ${campaign.name}`)
lines.push(`Plattform: ${deliverable.platform}`)
if (deliverable.description) {
lines.push(`\n${deliverable.description}`)
}
return lines.join('\n')
}
/**
* Generate exclusivity period events (as all-day spans)
*/
function generateExclusivityEvents(exclusivities: Exclusivity[]): EventAttributes[] {
return exclusivities.map((exclusivity) => {
const campaignName = exclusivity.campaign?.name || 'Kampanj'
const event: EventAttributes = {
uid: getEventUID('exclusivity', exclusivity.id),
title: `Exklusivitet: ${campaignName}`,
start: dateToArray(exclusivity.start_date),
end: dateToArray(exclusivity.end_date),
description: formatExclusivityDescription(exclusivity),
categories: ['Exklusivitet'],
status: 'CONFIRMED',
// No alarms for exclusivity periods
}
return event
})
}
/**
* Format exclusivity description for calendar
*/
function formatExclusivityDescription(exclusivity: Exclusivity): string {
const lines: string[] = []
if (exclusivity.categories.length > 0) {
lines.push(`Kategorier: ${exclusivity.categories.join(', ')}`)
}
if (exclusivity.excluded_brands.length > 0) {
lines.push(`Uteslutna varumärken: ${exclusivity.excluded_brands.join(', ')}`)
}
if (exclusivity.notes) {
lines.push(`\n${exclusivity.notes}`)
}
return lines.join('\n')
}
/**
* Generate complete calendar feed
*/
@@ -239,25 +147,16 @@ export function generateCalendarFeed(
if (options.includeTaxDeadlines) {
const taxDeadlines = data.deadlines.filter((d) => d.deadline_type === 'tax')
events.push(...generateDeadlineEvents(taxDeadlines))
}
if (options.includeCampaigns) {
// Include non-tax deadlines related to campaigns
const campaignDeadlines = data.deadlines.filter(
(d) => d.deadline_type !== 'tax' && (d.campaign_id || d.deliverable_id)
)
events.push(...generateDeadlineEvents(campaignDeadlines))
events.push(...generateCampaignEvents(data.campaigns))
// Include non-tax deadlines too
const otherDeadlines = data.deadlines.filter((d) => d.deadline_type !== 'tax')
events.push(...generateDeadlineEvents(otherDeadlines))
}
if (options.includeInvoices) {
events.push(...generateInvoiceEvents(data.invoices))
}
if (options.includeExclusivity) {
events.push(...generateExclusivityEvents(data.exclusivities))
}
return new Promise((resolve, reject) => {
createEvents(events, (error, value) => {
if (error) {
+3 -195
View File
@@ -1,4 +1,4 @@
import { Invoice, Deadline, PaymentCalendarDay, Deliverable, Exclusivity, Campaign } from '@/types'
import { Invoice, Deadline, PaymentCalendarDay } from '@/types'
// Swedish day names (Monday first)
export const SWEDISH_DAYS = ['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön']
@@ -223,16 +223,13 @@ export function getUpcomingDeadlines(deadlines: Deadline[], days: number = 7): D
.sort((a, b) => a.due_date.localeCompare(b.due_date))
}
// Swedish deadline type labels (extended for campaigns)
// Swedish deadline type labels
export const DEADLINE_TYPE_LABELS: Record<string, string> = {
delivery: 'Leverans',
approval: 'Godkännande',
invoicing: 'Fakturering',
report: 'Rapport',
revision: 'Revision',
assets: 'Material',
spark_ad: 'Spark Ad',
statistics: 'Statistik',
other: 'Övrigt'
}
@@ -319,140 +316,22 @@ export function countDeadlinesByStatus(deadlines: Deadline[]): Record<DeadlineSt
return counts
}
// ============================================================
// Campaign-aware calendar functions
// ============================================================
// Get deliverables for a month
export function getDeliverablesForMonth(deliverables: Deliverable[], year: number, month: number): Deliverable[] {
const monthStr = String(month + 1).padStart(2, '0')
const prefix = `${year}-${monthStr}`
return deliverables.filter(d => d.due_date?.startsWith(prefix))
}
// Group deliverables by due date
export function groupDeliverablesByDate(deliverables: Deliverable[]): Map<string, Deliverable[]> {
const grouped = new Map<string, Deliverable[]>()
for (const deliverable of deliverables) {
if (!deliverable.due_date) continue
const dateKey = deliverable.due_date
const existing = grouped.get(dateKey) || []
existing.push(deliverable)
grouped.set(dateKey, existing)
}
return grouped
}
// Check if deliverable is overdue
export function isDeliverableOverdue(deliverable: Deliverable): boolean {
if (['approved', 'published'].includes(deliverable.status)) return false
if (!deliverable.due_date) return false
const today = startOfDay(new Date())
const dueDate = parseDate(deliverable.due_date)
return isBefore(dueDate, today)
}
// Get upcoming deliverables
export function getUpcomingDeliverables(deliverables: Deliverable[], days: number = 7): Deliverable[] {
const today = startOfDay(new Date())
const endDate = new Date(today)
endDate.setDate(endDate.getDate() + days)
return deliverables
.filter(d => {
if (['approved', 'published'].includes(d.status)) return false
if (!d.due_date) return false
const dueDate = parseDate(d.due_date)
return dueDate >= today && dueDate <= endDate
})
.sort((a, b) => (a.due_date || '').localeCompare(b.due_date || ''))
}
// Get exclusivities active on a specific date
export function getExclusivitiesForDate(exclusivities: Exclusivity[], date: string): Exclusivity[] {
return exclusivities.filter(e =>
e.start_date <= date && e.end_date >= date
)
}
// Get exclusivities for a month (overlapping with month)
export function getExclusivitiesForMonth(exclusivities: Exclusivity[], year: number, month: number): Exclusivity[] {
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`
const lastDay = new Date(year, month + 1, 0).getDate()
const monthEnd = `${year}-${String(month + 1).padStart(2, '0')}-${lastDay}`
return exclusivities.filter(e =>
e.start_date <= monthEnd && e.end_date >= monthStart
)
}
// Get campaigns with deliverables in a date range
export function getCampaignsWithDeliverablesInRange(
campaigns: Campaign[],
startDate: string,
endDate: string
): Campaign[] {
return campaigns.filter(campaign =>
campaign.deliverables?.some(d =>
d.due_date &&
d.due_date >= startDate &&
d.due_date <= endDate &&
!['approved', 'published'].includes(d.status)
)
)
}
// Calculate campaign deliverable summary for a date
export function getCampaignDaySummary(
campaigns: Campaign[],
date: string
): {
deliverables: (Deliverable & { campaignName: string })[]
totalCount: number
overdueCount: number
} {
const deliverables: (Deliverable & { campaignName: string })[] = []
let overdueCount = 0
const today = formatDateISO(new Date())
for (const campaign of campaigns) {
for (const d of campaign.deliverables || []) {
if (d.due_date === date && !['approved', 'published'].includes(d.status)) {
deliverables.push({ ...d, campaignName: campaign.name })
if (date < today) overdueCount++
}
}
}
return {
deliverables,
totalCount: deliverables.length,
overdueCount
}
}
// Get all calendar events for a month (unified view)
export interface CalendarEvent {
id: string
type: 'invoice' | 'deadline' | 'deliverable' | 'exclusivity_start' | 'exclusivity_end' | 'campaign_start' | 'campaign_end'
type: 'invoice' | 'deadline'
date: string
title: string
subtitle?: string
status?: string
priority?: string
isOverdue?: boolean
campaignId?: string
customerId?: string
}
export function getCalendarEventsForMonth(
invoices: Invoice[],
deadlines: Deadline[],
campaigns: Campaign[],
year: number,
month: number
): CalendarEvent[] {
@@ -488,82 +367,11 @@ export function getCalendarEventsForMonth(
subtitle: d.customer?.name,
priority: d.priority,
isOverdue: d.due_date < today,
campaignId: d.campaign_id || undefined,
customerId: d.customer_id || undefined
})
}
}
// Deliverables from campaigns
for (const campaign of campaigns) {
// Campaign start/end dates
if (campaign.start_date?.startsWith(prefix)) {
events.push({
id: `camp-start-${campaign.id}`,
type: 'campaign_start',
date: campaign.start_date,
title: `Start: ${campaign.name}`,
subtitle: campaign.customer?.name,
campaignId: campaign.id,
customerId: campaign.customer_id || undefined
})
}
if (campaign.end_date?.startsWith(prefix)) {
events.push({
id: `camp-end-${campaign.id}`,
type: 'campaign_end',
date: campaign.end_date,
title: `Slut: ${campaign.name}`,
subtitle: campaign.customer?.name,
campaignId: campaign.id,
customerId: campaign.customer_id || undefined
})
}
// Deliverables
for (const d of campaign.deliverables || []) {
if (d.due_date?.startsWith(prefix) && !['approved', 'published'].includes(d.status)) {
events.push({
id: `del-${d.id}`,
type: 'deliverable',
date: d.due_date,
title: d.title,
subtitle: campaign.name,
status: d.status,
isOverdue: d.due_date < today,
campaignId: campaign.id,
customerId: campaign.customer_id || undefined
})
}
}
// Exclusivities
for (const e of campaign.exclusivities || []) {
if (e.start_date.startsWith(prefix)) {
events.push({
id: `exc-start-${e.id}`,
type: 'exclusivity_start',
date: e.start_date,
title: `Exklusivitet startar`,
subtitle: `${campaign.name}: ${e.categories.slice(0, 2).join(', ')}${e.categories.length > 2 ? '...' : ''}`,
campaignId: campaign.id
})
}
if (e.end_date.startsWith(prefix)) {
events.push({
id: `exc-end-${e.id}`,
type: 'exclusivity_end',
date: e.end_date,
title: `Exklusivitet slutar`,
subtitle: `${campaign.name}: ${e.categories.slice(0, 2).join(', ')}${e.categories.length > 2 ? '...' : ''}`,
campaignId: campaign.id
})
}
}
}
// Sort by date
events.sort((a, b) => a.date.localeCompare(b.date))