diff --git a/aos-shots/v19-calendar-next.png b/aos-shots/v19-calendar-next.png new file mode 100644 index 0000000..5902fcd Binary files /dev/null and b/aos-shots/v19-calendar-next.png differ diff --git a/aos-shots/v19-calendar.png b/aos-shots/v19-calendar.png new file mode 100644 index 0000000..5902fcd Binary files /dev/null and b/aos-shots/v19-calendar.png differ diff --git a/src/app/api/aos/calendar/route.ts b/src/app/api/aos/calendar/route.ts new file mode 100644 index 0000000..22e63d5 --- /dev/null +++ b/src/app/api/aos/calendar/route.ts @@ -0,0 +1,231 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/calendar?month=YYYY-MM +// Aggregates ALL deadlines across modules into a unified calendar: +// - VAT returns (submission due date = 10th of following month) +// - Contract expiry/renewal dates +// - License expiry dates +// - Asset renewal dates +// - Project due dates +// - Invoice due dates +// - Ticket SLA deadlines +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const monthParam = searchParams.get('month') // YYYY-MM + const now = new Date() + + // Determine the calendar range: current month ± 1 month for context + let year = now.getFullYear() + let month = now.getMonth() // 0-indexed + if (monthParam) { + const [y, m] = monthParam.split('-').map(Number) + if (y && m) { year = y; month = m - 1 } + } + + const monthStart = new Date(year, month, 1) + const monthEnd = new Date(year, month + 1, 0, 23, 59, 59) + // Expand range to include ±7 days for calendar grid overlap + const rangeStart = new Date(monthStart) + rangeStart.setDate(rangeStart.getDate() - 7) + const rangeEnd = new Date(monthEnd) + rangeEnd.setDate(rangeEnd.getDate() + 7) + + type Deadline = { + id: string + date: string + module: string + type: string + title: string + detail: string + urgency: 'past' | 'today' | 'soon' | 'future' + daysUntil: number + } + const deadlines: Deadline[] = [] + + // 1. VAT returns — due on the 10th of the following month + const vatReturns = await db.vatReturn.findMany({ where: { status: { in: ['computed', 'prepared', 'submitted'] } } }) + for (const v of vatReturns) { + // Due date: 10th of the month AFTER the period + const dueDate = new Date(v.periodYear, v.periodMonth, 10) // periodMonth is 1-indexed, so month=periodMonth means next month + const days = Math.round((dueDate.getTime() - now.getTime()) / 86400000) + deadlines.push({ + id: `vat-${v.id}`, + date: dueDate.toISOString(), + module: 'accounting', + type: 'USt-Voranmeldung', + title: `UStVA ${String(v.periodMonth).padStart(2, '0')}/${v.periodYear}`, + detail: `Zahllast: ${(v.payableCents / 100).toFixed(2)} € · Status: ${v.status}`, + urgency: days < 0 ? 'past' : days === 0 ? 'today' : days <= 7 ? 'soon' : 'future', + daysUntil: days, + }) + } + + // 2. Contract expiry + const contracts = await db.contract.findMany({ where: { status: 'active', endDate: { not: null } } }) + for (const c of contracts) { + const days = Math.round((c.endDate!.getTime() - now.getTime()) / 86400000) + deadlines.push({ + id: `con-${c.id}`, + date: c.endDate!.toISOString(), + module: 'contracts', + type: 'Vertrag-Ablauf', + title: `${c.number} läuft ab`, + detail: c.title, + urgency: days < 0 ? 'past' : days === 0 ? 'today' : days <= 14 ? 'soon' : 'future', + daysUntil: days, + }) + } + + // 3. License expiry + const licenses = await db.license.findMany({ where: { status: 'active', validUntil: { not: null } }, include: { company: true } }) + for (const l of licenses) { + const days = Math.round((l.validUntil!.getTime() - now.getTime()) / 86400000) + deadlines.push({ + id: `lic-${l.id}`, + date: l.validUntil!.toISOString(), + module: 'licenses', + type: 'Lizenz-Ablauf', + title: `${l.product} (${l.company.name})`, + detail: `Key: ${l.key.slice(0, 20)}…`, + urgency: days < 0 ? 'past' : days === 0 ? 'today' : days <= 14 ? 'soon' : 'future', + daysUntil: days, + }) + } + + // 4. Asset renewals + const assets = await db.asset.findMany({ where: { status: { in: ['active', 'expiring'] } } }) + for (const a of assets) { + const days = Math.round((a.renewalDate.getTime() - now.getTime()) / 86400000) + deadlines.push({ + id: `asset-${a.id}`, + date: a.renewalDate.toISOString(), + module: 'licenses', + type: 'Asset-Erneuerung', + title: a.name, + detail: `${a.type} · ${a.provider ?? '—'}`, + urgency: days < 0 ? 'past' : days === 0 ? 'today' : days <= 14 ? 'soon' : 'future', + daysUntil: days, + }) + } + + // 5. Project due dates + const projects = await db.project.findMany({ where: { dueDate: { not: null }, status: { in: ['planning', 'active', 'review'] } } }) + for (const p of projects) { + const days = Math.round((p.dueDate!.getTime() - now.getTime()) / 86400000) + deadlines.push({ + id: `proj-${p.id}`, + date: p.dueDate!.toISOString(), + module: 'projects', + type: 'Projekt-Deadline', + title: p.name, + detail: `${p.progress}% fertig · ${p.status}`, + urgency: days < 0 ? 'past' : days === 0 ? 'today' : days <= 7 ? 'soon' : 'future', + daysUntil: days, + }) + } + + // 6. Invoice due dates + const invoices = await db.invoice.findMany({ where: { status: { in: ['sent', 'overdue'] } }, include: { company: true } }) + for (const i of invoices) { + const days = Math.round((i.dueDate.getTime() - now.getTime()) / 86400000) + deadlines.push({ + id: `inv-${i.id}`, + date: i.dueDate.toISOString(), + module: 'finance', + type: 'Rechnung-Fällig', + title: `${i.number} (${i.company.name})`, + detail: `${(i.grossCents / 100).toFixed(2)} €`, + urgency: days < 0 ? 'past' : days === 0 ? 'today' : days <= 7 ? 'soon' : 'future', + daysUntil: days, + }) + } + + // 7. Ticket SLA deadlines (resolve-by) + const tickets = await db.ticket.findMany({ where: { status: { in: ['open', 'pending'] } } }) + for (const t of tickets) { + const slaDeadline = new Date(t.createdAt.getTime() + t.slaResolveHours * 3600000) + const days = Math.round((slaDeadline.getTime() - now.getTime()) / 86400000) + deadlines.push({ + id: `tkt-${t.id}`, + date: slaDeadline.toISOString(), + module: 'support', + type: 'Ticket-SLA', + title: `${t.number}: ${t.subject}`, + detail: `SLA-Lösung in ${t.slaResolveHours} h (Priorität: ${t.priority})`, + urgency: days < 0 ? 'past' : days === 0 ? 'today' : days <= 1 ? 'soon' : 'future', + daysUntil: days, + }) + } + + // 8. Portal approvals (pending — no hard deadline, but track) + const approvals = await db.portalApproval.findMany({ where: { status: 'pending' } }) + for (const a of approvals) { + deadlines.push({ + id: `appr-${a.id}`, + date: a.createdAt.toISOString(), + module: 'portal', + type: 'Freigabe-wartend', + title: a.title, + detail: `Eingereicht ${new Date(a.createdAt).toLocaleDateString('de-DE')}`, + urgency: 'future', + daysUntil: Math.round((now.getTime() - a.createdAt.getTime()) / 86400000), + }) + } + + // Sort by date + deadlines.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + + // Build calendar grid: weeks starting Monday + const firstDay = new Date(year, month, 1) + const lastDay = new Date(year, month + 1, 0) + // Monday = 0 in our grid (JS Sunday = 0) + const startOffset = (firstDay.getDay() + 6) % 7 // days from Monday + const gridStart = new Date(firstDay) + gridStart.setDate(gridStart.getDate() - startOffset) + + const weeks: { date: Date; isCurrentMonth: boolean; deadlines: Deadline[] }[][] = [] + const gridDate = new Date(gridStart) + for (let w = 0; w < 6; w++) { + const week: { date: Date; isCurrentMonth: boolean; deadlines: Deadline[] }[] = [] + for (let d = 0; d < 7; d++) { + const dayDeadlines = deadlines.filter((dl) => { + const dlDate = new Date(dl.date) + return dlDate.getDate() === gridDate.getDate() && dlDate.getMonth() === gridDate.getMonth() && dlDate.getFullYear() === gridDate.getFullYear() + }) + week.push({ + date: new Date(gridDate), + isCurrentMonth: gridDate.getMonth() === month, + deadlines: dayDeadlines, + }) + gridDate.setDate(gridDate.getDate() + 1) + } + weeks.push(week) + // stop if we've passed the last day and the week is complete + if (gridDate > lastDay && gridDate.getMonth() !== month) break + } + + // Upcoming (next 14 days, excluding past) + const upcoming = deadlines.filter((d) => d.daysUntil >= 0 && d.daysUntil <= 14) + const overdue = deadlines.filter((d) => d.daysUntil < 0) + + const stats = { + total: deadlines.length, + overdue: overdue.length, + today: deadlines.filter((d) => d.daysUntil === 0).length, + thisWeek: deadlines.filter((d) => d.daysUntil > 0 && d.daysUntil <= 7).length, + byModule: deadlines.reduce((acc, d) => { acc[d.module] = (acc[d.module] ?? 0) + 1; return acc }, {} as Record), + } + + return NextResponse.json({ + month: `${year}-${String(month + 1).padStart(2, '0')}`, + monthLabel: new Date(year, month, 1).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }), + weeks, + upcoming, + overdue, + stats, + modules: ['accounting', 'contracts', 'licenses', 'projects', 'finance', 'support', 'portal'], + }) +} diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index b2116f8..544e74e 100644 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -8,6 +8,7 @@ import { CommandMenu } from './command-menu' import { CompanyDrawer } from './company-drawer' import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet' import { DashboardView } from './views/dashboard' +import { CalendarView } from './views/calendar' import { ReportingView } from './views/reporting' import { CrmView } from './views/crm' import { LeadsView } from './views/leads' @@ -55,6 +56,7 @@ export function AppShell() {
{view === 'dashboard' && } + {view === 'calendar' && } {view === 'reporting' && } {view === 'crm' && } {view === 'leads' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index d61fcc4..00ec527 100644 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -24,6 +24,7 @@ import { Webhook, HelpCircle, SlidersHorizontal, + CalendarDays, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { @@ -39,6 +40,7 @@ import { toast } from 'sonner' const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: string }[] = [ { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, hint: 'Übersicht & KPIs' }, + { id: 'calendar', label: 'Fristen', icon: CalendarDays, hint: 'Kalender & Deadlines' }, { id: 'reporting', label: 'Reporting', icon: BarChart3, hint: 'Pipeline, Auslastung, MRR' }, { id: 'crm', label: 'CRM', icon: Users, hint: 'Firmen & Pipeline' }, { id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' }, diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index 415f0ab..e7eeebc 100644 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -23,6 +23,7 @@ import { ExternalLink, SlidersHorizontal, HelpCircle, + CalendarDays, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { translate } from '@/lib/i18n' @@ -38,6 +39,7 @@ const NAV: { group: 'nav.overview', items: [ { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, permission: '*' }, + { id: 'calendar', label: 'Fristen', icon: CalendarDays, permission: '*' }, { id: 'reporting', label: 'Reporting', icon: BarChart3, permission: '*' }, ], }, diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 405282d..0616e9e 100644 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -19,6 +19,7 @@ import { const TITLES: Record = { dashboard: { title: 'Dashboard', crumb: 'Übersicht' }, + calendar: { title: 'Fristen', crumb: 'Übersicht / Fristen' }, reporting: { title: 'Reporting', crumb: 'Übersicht / Reporting' }, crm: { title: 'CRM', crumb: 'Vertrieb / CRM' }, leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' }, diff --git a/src/components/aos/views/calendar.tsx b/src/components/aos/views/calendar.tsx new file mode 100644 index 0000000..9d0fe16 --- /dev/null +++ b/src/components/aos/views/calendar.tsx @@ -0,0 +1,290 @@ +'use client' + +import { useState } from 'react' +import { useFetch } from '@/hooks/use-fetch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { ScrollArea } from '@/components/ui/scroll-area' +import { PageHeader } from '../primitives' +import { useAos } from '@/store/aos' +import { cn } from '@/lib/utils' +import { formatDate, formatRelative } from '@/lib/format' +import { + ChevronLeft, + ChevronRight, + CalendarDays, + AlertTriangle, + Clock, + CheckCircle2, + Landmark, + FileSignature, + KeyRound, + FolderKanban, + Receipt, + LifeBuoy, + CheckCircle, +} from 'lucide-react' + +interface Deadline { + id: string + date: string + module: string + type: string + title: string + detail: string + urgency: 'past' | 'today' | 'soon' | 'future' + daysUntil: number +} +interface CalendarDay { + date: string + isCurrentMonth: boolean + deadlines: Deadline[] +} +interface CalendarData { + month: string + monthLabel: string + weeks: CalendarDay[][] // fixed typo + upcoming: Deadline[] + overdue: Deadline[] + stats: { total: number; overdue: number; today: number; thisWeek: number; byModule: Record } + modules: string[] +} + +const MODULE_META: Record = { + accounting: { label: 'Buchhaltung', icon: Landmark, view: 'accounting' }, + contracts: { label: 'Verträge', icon: FileSignature, view: 'contracts' }, + licenses: { label: 'Lizenzen', icon: KeyRound, view: 'licenses' }, + projects: { label: 'Projekte', icon: FolderKanban, view: 'projects' }, + finance: { label: 'Finanzen', icon: Receipt, view: 'finance' }, + support: { label: 'Support', icon: LifeBuoy, view: 'support' }, + portal: { label: 'Portal', icon: CheckCircle, view: 'dashboard' }, +} + +const URGENCY_META: Record = { + past: { label: 'Überfällig', cls: 'border-foreground/60 bg-foreground/5', dot: 'bg-foreground' }, + today: { label: 'Heute', cls: 'border-foreground/60 bg-foreground/10', dot: 'bg-foreground' }, + soon: { label: 'Bald', cls: 'border-foreground/40 bg-foreground/5', dot: 'bg-foreground/70' }, + future: { label: 'Geplant', cls: 'border-border', dot: 'bg-muted-foreground/50' }, +} + +const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] + +export function CalendarView() { + const now = new Date() + const [monthParam, setMonthParam] = useState(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`) + const { data, loading } = useFetch(`/api/aos/calendar?month=${monthParam}`) + const setView = useAos((s) => s.setView) + + const navigateMonth = (delta: number) => { + const [y, m] = monthParam.split('-').map(Number) + const d = new Date(y, m - 1 + delta, 1) + setMonthParam(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) + } + + const today = new Date() + const isToday = (dateStr: string) => { + const d = new Date(dateStr) + return d.getDate() === today.getDate() && d.getMonth() === today.getMonth() && d.getFullYear() === today.getFullYear() + } + + if (loading || !data) { + return ( +
+ + +
+ ) + } + + const { weeks, upcoming, overdue, stats, monthLabel } = data + + return ( +
+ + + {/* KPIs */} +
+ +

Überfällig

+

{stats.overdue}

+
+ +

Heute

+

{stats.today}

+
+ +

Diese Woche

+

{stats.thisWeek}

+
+ +

Gesamt

+

{stats.total}

+
+
+ +
+ {/* Calendar grid */} + + + {monthLabel} +
+ + + +
+
+ + {/* Weekday header */} +
+ {WEEKDAYS.map((d) => ( +
{d}
+ ))} +
+ {/* Calendar grid */} +
+ {weeks.flat().map((day, i) => { + const dayDate = new Date(day.date) + const isTodayCell = isToday(day.date) + return ( +
+
+ {dayDate.getDate()} +
+
+ {day.deadlines.slice(0, 3).map((dl) => { + const meta = MODULE_META[dl.module] ?? { label: dl.module, icon: Clock, view: 'dashboard' } + const Icon = meta.icon + const urg = URGENCY_META[dl.urgency] + return ( + + ) + })} + {day.deadlines.length > 3 && ( +

+{day.deadlines.length - 3} weitere

+ )} +
+
+ ) + })} +
+
+
+ + {/* Upcoming + Overdue list */} +
+ {/* Overdue */} + {overdue.length > 0 && ( + + + + + Überfällig ({overdue.length}) + + + + + {overdue.map((d) => setView((MODULE_META[d.module]?.view ?? 'dashboard') as never)} />)} + + + + )} + + {/* Upcoming */} + + + + + Anstehend ({upcoming.length}) + + + + + {upcoming.length === 0 &&

Keine anstehenden Fristen.

} + {upcoming.map((d) => setView((MODULE_META[d.module]?.view ?? 'dashboard') as never)} />)} +
+
+
+ + {/* By module */} + + + Verteilung nach Modul + + + {Object.entries(stats.byModule).map(([mod, count]) => { + const meta = MODULE_META[mod] ?? { label: mod, icon: Clock, view: 'dashboard' } + const Icon = meta.icon + return ( +
+ + {meta.label} + {count} +
+ ) + })} +
+
+
+
+
+ ) +} + +function DeadlineItem({ d, onClick }: { d: Deadline; onClick: () => void }) { + const meta = MODULE_META[d.module] ?? { label: d.module, icon: Clock, view: 'dashboard' } + const Icon = meta.icon + const urg = URGENCY_META[d.urgency] + return ( + + ) +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index e0e4633..92bb8ed 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -20,6 +20,7 @@ const de: Dict = { 'nav.system': 'System', // Nav items 'nav.dashboard': 'Dashboard', + 'nav.calendar': 'Fristen', 'nav.reporting': 'Reporting', 'nav.crm': 'CRM', 'nav.leads': 'Leads Finder', @@ -75,6 +76,7 @@ const en: Dict = { 'nav.operations': 'Operations', 'nav.system': 'System', 'nav.dashboard': 'Dashboard', + 'nav.calendar': 'Fristen', 'nav.reporting': 'Reporting', 'nav.crm': 'CRM', 'nav.leads': 'Leads Finder', @@ -126,6 +128,7 @@ const tr: Dict = { 'nav.operations': 'Operasyon', 'nav.system': 'Sistem', 'nav.dashboard': 'Panel', + 'nav.calendar': 'Son Tarihler', 'nav.reporting': 'Raporlama', 'nav.crm': 'CRM', 'nav.leads': 'Müşteri Adayı Bulucu', diff --git a/src/store/aos.ts b/src/store/aos.ts index ccc319c..032cb9a 100644 --- a/src/store/aos.ts +++ b/src/store/aos.ts @@ -19,6 +19,7 @@ export type ViewId = | 'audit' | 'api' | 'kb' + | 'calendar' | 'reporting' | 'settings' | 'settings_global'