4146cf4f-64d9-48ee-a4ec-195ac8b92e10
This commit is contained in:
1 parent
37cb23d245
commit
468e7ad123
10 files changed
+532
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
@@ -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<string, number>),
|
||||||
|
}
|
||||||
|
|
||||||
|
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'],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { CommandMenu } from './command-menu'
|
|||||||
import { CompanyDrawer } from './company-drawer'
|
import { CompanyDrawer } from './company-drawer'
|
||||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
|
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
|
||||||
import { DashboardView } from './views/dashboard'
|
import { DashboardView } from './views/dashboard'
|
||||||
|
import { CalendarView } from './views/calendar'
|
||||||
import { ReportingView } from './views/reporting'
|
import { ReportingView } from './views/reporting'
|
||||||
import { CrmView } from './views/crm'
|
import { CrmView } from './views/crm'
|
||||||
import { LeadsView } from './views/leads'
|
import { LeadsView } from './views/leads'
|
||||||
@@ -55,6 +56,7 @@ export function AppShell() {
|
|||||||
<TopBar />
|
<TopBar />
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{view === 'dashboard' && <DashboardView />}
|
{view === 'dashboard' && <DashboardView />}
|
||||||
|
{view === 'calendar' && <CalendarView />}
|
||||||
{view === 'reporting' && <ReportingView />}
|
{view === 'reporting' && <ReportingView />}
|
||||||
{view === 'crm' && <CrmView />}
|
{view === 'crm' && <CrmView />}
|
||||||
{view === 'leads' && <LeadsView />}
|
{view === 'leads' && <LeadsView />}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Webhook,
|
Webhook,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
CalendarDays,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +40,7 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: string }[] = [
|
const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: string }[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, hint: 'Übersicht & KPIs' },
|
{ 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: 'reporting', label: 'Reporting', icon: BarChart3, hint: 'Pipeline, Auslastung, MRR' },
|
||||||
{ id: 'crm', label: 'CRM', icon: Users, hint: 'Firmen & Pipeline' },
|
{ id: 'crm', label: 'CRM', icon: Users, hint: 'Firmen & Pipeline' },
|
||||||
{ id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' },
|
{ id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' },
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
CalendarDays,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import { translate } from '@/lib/i18n'
|
import { translate } from '@/lib/i18n'
|
||||||
@@ -38,6 +39,7 @@ const NAV: {
|
|||||||
group: 'nav.overview',
|
group: 'nav.overview',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, permission: '*' },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, permission: '*' },
|
||||||
|
{ id: 'calendar', label: 'Fristen', icon: CalendarDays, permission: '*' },
|
||||||
{ id: 'reporting', label: 'Reporting', icon: BarChart3, permission: '*' },
|
{ id: 'reporting', label: 'Reporting', icon: BarChart3, permission: '*' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
|
|
||||||
const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||||
dashboard: { title: 'Dashboard', crumb: 'Übersicht' },
|
dashboard: { title: 'Dashboard', crumb: 'Übersicht' },
|
||||||
|
calendar: { title: 'Fristen', crumb: 'Übersicht / Fristen' },
|
||||||
reporting: { title: 'Reporting', crumb: 'Übersicht / Reporting' },
|
reporting: { title: 'Reporting', crumb: 'Übersicht / Reporting' },
|
||||||
crm: { title: 'CRM', crumb: 'Vertrieb / CRM' },
|
crm: { title: 'CRM', crumb: 'Vertrieb / CRM' },
|
||||||
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
|
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
|
||||||
|
|||||||
@@ -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<string, number> }
|
||||||
|
modules: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODULE_META: Record<string, { label: string; icon: typeof Landmark; view: string }> = {
|
||||||
|
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<string, { label: string; cls: string; dot: string }> = {
|
||||||
|
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<CalendarData>(`/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 (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<Skeleton className="h-9 w-32" />
|
||||||
|
<Skeleton className="h-96" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { weeks, upcoming, overdue, stats, monthLabel } = data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Fristen"
|
||||||
|
description="Einheitliche Kalender-Ansicht aller Deadlines: USt-Voranmeldungen, Vertrags-/Lizenzablauf, Projekt-Deadlines, Rechnungs-Fälligkeiten, Ticket-SLAs."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* KPIs */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Überfällig</p><AlertTriangle className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.overdue}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Heute</p><Clock className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.today}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Diese Woche</p><CalendarDays className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.thisWeek}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Gesamt</p><CheckCircle2 className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.total}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
|
<CardTitle className="text-base">{monthLabel}</CardTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="size-8" onClick={() => navigateMonth(-1)}>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setMonthParam(`${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`)}>
|
||||||
|
Heute
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8" onClick={() => navigateMonth(1)}>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* Weekday header */}
|
||||||
|
<div className="grid grid-cols-7 border-y border-border bg-muted/30">
|
||||||
|
{WEEKDAYS.map((d) => (
|
||||||
|
<div key={d} className="px-2 py-1.5 text-center text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{weeks.flat().map((day, i) => {
|
||||||
|
const dayDate = new Date(day.date)
|
||||||
|
const isTodayCell = isToday(day.date)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[80px] border-b border-r border-border p-1',
|
||||||
|
!day.isCurrentMonth && 'bg-muted/20',
|
||||||
|
(i + 1) % 7 === 0 && 'border-r-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'mb-1 flex h-5 w-5 items-center justify-center rounded text-[10px] font-medium',
|
||||||
|
isTodayCell ? 'bg-foreground text-background' : day.isCurrentMonth ? 'text-foreground' : 'text-muted-foreground/50'
|
||||||
|
)}>
|
||||||
|
{dayDate.getDate()}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={dl.id}
|
||||||
|
onClick={() => setView(meta.view as typeof setView extends (v: infer V) => void ? V : never)}
|
||||||
|
className={cn('flex w-full items-center gap-1 rounded px-1 py-0.5 text-left transition hover:bg-muted', urg.cls)}
|
||||||
|
title={dl.title}
|
||||||
|
>
|
||||||
|
<Icon className="size-2.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate text-[9px] leading-tight">{dl.title}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{day.deadlines.length > 3 && (
|
||||||
|
<p className="px-1 text-[9px] text-muted-foreground">+{day.deadlines.length - 3} weitere</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Upcoming + Overdue list */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Overdue */}
|
||||||
|
{overdue.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="size-3.5 text-foreground" />
|
||||||
|
Überfällig ({overdue.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1.5">
|
||||||
|
<ScrollArea className="max-h-48">
|
||||||
|
{overdue.map((d) => <DeadlineItem key={d.id} d={d} onClick={() => setView((MODULE_META[d.module]?.view ?? 'dashboard') as never)} />)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upcoming */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="size-3.5 text-muted-foreground" />
|
||||||
|
Anstehend ({upcoming.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1.5">
|
||||||
|
<ScrollArea className="max-h-96">
|
||||||
|
{upcoming.length === 0 && <p className="py-4 text-center text-xs text-muted-foreground">Keine anstehenden Fristen.</p>}
|
||||||
|
{upcoming.map((d) => <DeadlineItem key={d.id} d={d} onClick={() => setView((MODULE_META[d.module]?.view ?? 'dashboard') as never)} />)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* By module */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Verteilung nach Modul</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1.5">
|
||||||
|
{Object.entries(stats.byModule).map(([mod, count]) => {
|
||||||
|
const meta = MODULE_META[mod] ?? { label: mod, icon: Clock, view: 'dashboard' }
|
||||||
|
const Icon = meta.icon
|
||||||
|
return (
|
||||||
|
<div key={mod} className="flex items-center gap-2 text-xs">
|
||||||
|
<Icon className="size-3 text-muted-foreground" />
|
||||||
|
<span className="flex-1">{meta.label}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">{count}</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn('flex w-full items-start gap-2 rounded-md border p-2 text-left transition hover:bg-muted/50', urg.cls)}
|
||||||
|
>
|
||||||
|
<Icon className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[11px] font-medium">{d.title}</p>
|
||||||
|
<p className="truncate text-[10px] text-muted-foreground">{d.detail}</p>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-[9px] text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<span className={cn('size-1 rounded-full', urg.dot)} />
|
||||||
|
{d.type}
|
||||||
|
</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatDate(d.date)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className={cn(d.daysUntil < 0 && 'text-foreground font-medium')}>
|
||||||
|
{d.daysUntil === 0 ? 'heute' : d.daysUntil < 0 ? `${Math.abs(d.daysUntil)} d drüber` : `in ${d.daysUntil} d`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const de: Dict = {
|
|||||||
'nav.system': 'System',
|
'nav.system': 'System',
|
||||||
// Nav items
|
// Nav items
|
||||||
'nav.dashboard': 'Dashboard',
|
'nav.dashboard': 'Dashboard',
|
||||||
|
'nav.calendar': 'Fristen',
|
||||||
'nav.reporting': 'Reporting',
|
'nav.reporting': 'Reporting',
|
||||||
'nav.crm': 'CRM',
|
'nav.crm': 'CRM',
|
||||||
'nav.leads': 'Leads Finder',
|
'nav.leads': 'Leads Finder',
|
||||||
@@ -75,6 +76,7 @@ const en: Dict = {
|
|||||||
'nav.operations': 'Operations',
|
'nav.operations': 'Operations',
|
||||||
'nav.system': 'System',
|
'nav.system': 'System',
|
||||||
'nav.dashboard': 'Dashboard',
|
'nav.dashboard': 'Dashboard',
|
||||||
|
'nav.calendar': 'Fristen',
|
||||||
'nav.reporting': 'Reporting',
|
'nav.reporting': 'Reporting',
|
||||||
'nav.crm': 'CRM',
|
'nav.crm': 'CRM',
|
||||||
'nav.leads': 'Leads Finder',
|
'nav.leads': 'Leads Finder',
|
||||||
@@ -126,6 +128,7 @@ const tr: Dict = {
|
|||||||
'nav.operations': 'Operasyon',
|
'nav.operations': 'Operasyon',
|
||||||
'nav.system': 'Sistem',
|
'nav.system': 'Sistem',
|
||||||
'nav.dashboard': 'Panel',
|
'nav.dashboard': 'Panel',
|
||||||
|
'nav.calendar': 'Son Tarihler',
|
||||||
'nav.reporting': 'Raporlama',
|
'nav.reporting': 'Raporlama',
|
||||||
'nav.crm': 'CRM',
|
'nav.crm': 'CRM',
|
||||||
'nav.leads': 'Müşteri Adayı Bulucu',
|
'nav.leads': 'Müşteri Adayı Bulucu',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type ViewId =
|
|||||||
| 'audit'
|
| 'audit'
|
||||||
| 'api'
|
| 'api'
|
||||||
| 'kb'
|
| 'kb'
|
||||||
|
| 'calendar'
|
||||||
| 'reporting'
|
| 'reporting'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'settings_global'
|
| 'settings_global'
|
||||||
|
|||||||
Reference in new issue
Block a user