feat: complete tOS project with HR, LEAN, Dashboard and Integrations modules

Full enterprise web operating system including:
- Next.js 14 frontend with App Router, i18n (DE/EN), shadcn/ui
- NestJS 10 backend with Prisma, JWT auth, Swagger docs
- Keycloak 24 SSO with role-based access control
- HR module (employees, time tracking, absences, org chart)
- LEAN module (3S planning, morning meeting SQCDM, skill matrix)
- Integrations module (PlentyONE, Zulip, Todoist, FreeScout, Nextcloud, ecoDMS, GembaDocs)
- Dashboard with customizable drag & drop widget grid
- Docker Compose infrastructure (PostgreSQL 16, Redis 7, Keycloak 24)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 19:37:55 +01:00
parent a2b6612e9e
commit fe305f6fc8
509 changed files with 81111 additions and 1 deletions

14
apps/web/.env.example Normal file
View File

@@ -0,0 +1,14 @@
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
# API
NEXT_PUBLIC_API_URL=http://localhost:3001/api
# NextAuth
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-super-secret-key-change-in-production
# Keycloak (must match realm-export.json client "tos-frontend")
KEYCLOAK_CLIENT_ID=tos-frontend
KEYCLOAK_CLIENT_SECRET=your-keycloak-frontend-client-secret
KEYCLOAK_ISSUER=http://localhost:8080/realms/tOS

14
apps/web/.eslintrc.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": ["next/core-web-vitals"],
"rules": {
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"prefer-const": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}

48
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Next.js
.next/
out/
build
# Production
dist
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts
# IDE
.idea
.vscode
*.swp
*.swo
# Turbo
.turbo

20
apps/web/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

1052
apps/web/messages/de.json Normal file

File diff suppressed because it is too large Load Diff

1052
apps/web/messages/en.json Normal file

File diff suppressed because it is too large Load Diff

30
apps/web/next.config.mjs Normal file
View File

@@ -0,0 +1,30 @@
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/i18n.ts');
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable React strict mode for better development experience
reactStrictMode: true,
// Configure image optimization
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gravatar.com',
},
],
},
// Transpile packages for monorepo
transpilePackages: ['@tos/shared'],
// Experimental features
experimental: {
// Optimize package imports for better tree-shaking
optimizePackageImports: ['lucide-react', '@radix-ui/react-icons'],
},
};
export default withNextIntl(nextConfig);

74
apps/web/package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"name": "@tos/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tos/shared": "workspace:*",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-table": "^8.20.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"framer-motion": "^11.5.6",
"lucide-react": "^0.447.0",
"next": "14.2.13",
"next-auth": "^4.24.7",
"next-intl": "^3.20.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-hook-form": "^7.53.0",
"recharts": "^2.12.7",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8",
"zustand": "^5.0.0-rc.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.5.5",
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.13",
"jsdom": "^25.0.1",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1 @@
placeholder

View File

@@ -0,0 +1,272 @@
'use client';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { Users, Building2, Shield, Activity, TrendingUp, TrendingDown } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
interface StatCardProps {
title: string;
value: string | number;
description?: string;
icon: React.ComponentType<{ className?: string }>;
trend?: 'up' | 'down' | 'neutral';
delay?: number;
}
function StatCard({ title, value, description, icon: Icon, trend, delay = 0 }: StatCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay }}
>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{description && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
{trend === 'up' && <TrendingUp className="h-3 w-3 text-green-500" />}
{trend === 'down' && <TrendingDown className="h-3 w-3 text-red-500" />}
{description}
</p>
)}
</CardContent>
</Card>
</motion.div>
);
}
/**
* Admin Dashboard Content - System overview and statistics
*/
export function AdminDashboardContent() {
const t = useTranslations('admin');
// Mock data - would come from API
const stats = [
{
title: 'Benutzer gesamt',
value: '127',
description: '+5 in den letzten 30 Tagen',
icon: Users,
trend: 'up' as const,
},
{
title: 'Aktive Benutzer',
value: '98',
description: '77% aller Benutzer',
icon: Activity,
trend: 'neutral' as const,
},
{
title: 'Abteilungen',
value: '12',
description: 'In 3 Standorten',
icon: Building2,
trend: 'neutral' as const,
},
{
title: 'Administratoren',
value: '4',
description: 'Vollzugriff auf das System',
icon: Shield,
trend: 'neutral' as const,
},
];
return (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat, index) => (
<StatCard key={stat.title} {...stat} delay={index * 0.1} />
))}
</div>
{/* Quick Actions and Recent Activity */}
<div className="grid gap-6 md:grid-cols-2">
{/* Quick Actions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<Card>
<CardHeader>
<CardTitle>Schnellaktionen</CardTitle>
<CardDescription>Haeufig verwendete Administrationsaufgaben</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<QuickActionItem
icon={Users}
title="Neuen Benutzer anlegen"
description="Benutzer aus Keycloak synchronisieren"
/>
<QuickActionItem
icon={Building2}
title="Abteilung erstellen"
description="Neue Organisationseinheit hinzufuegen"
/>
<QuickActionItem
icon={Shield}
title="Rollen verwalten"
description="Berechtigungen konfigurieren"
/>
</CardContent>
</Card>
</motion.div>
{/* Recent Activity */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5 }}
>
<Card>
<CardHeader>
<CardTitle>Letzte Aktivitaeten</CardTitle>
<CardDescription>Aktuelle Aenderungen im System</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ActivityItem
action="Benutzer erstellt"
target="max.mustermann@example.com"
time="vor 2 Stunden"
actor="admin@example.com"
/>
<ActivityItem
action="Rolle zugewiesen"
target="anna.schmidt@example.com"
time="vor 5 Stunden"
actor="admin@example.com"
/>
<ActivityItem
action="Abteilung geaendert"
target="IT-Entwicklung"
time="Gestern"
actor="admin@example.com"
/>
<ActivityItem
action="Benutzer deaktiviert"
target="old.user@example.com"
time="vor 3 Tagen"
actor="admin@example.com"
/>
</CardContent>
</Card>
</motion.div>
</div>
{/* System Status */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.6 }}
>
<Card>
<CardHeader>
<CardTitle>Systemstatus</CardTitle>
<CardDescription>Uebersicht ueber verbundene Dienste</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3">
<SystemStatusItem name="Keycloak" status="online" latency="45ms" />
<SystemStatusItem name="Datenbank" status="online" latency="12ms" />
<SystemStatusItem name="API Server" status="online" latency="8ms" />
</div>
</CardContent>
</Card>
</motion.div>
</div>
);
}
function QuickActionItem({
icon: Icon,
title,
description,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
}) {
return (
<button className="flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</button>
);
}
function ActivityItem({
action,
target,
time,
actor,
}: {
action: string;
target: string;
time: string;
actor: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-1 h-2 w-2 rounded-full bg-primary" />
<div className="flex-1 space-y-1">
<p className="text-sm">
<span className="font-medium">{action}:</span> {target}
</p>
<p className="text-xs text-muted-foreground">
{time} von {actor}
</p>
</div>
</div>
);
}
function SystemStatusItem({
name,
status,
latency,
}: {
name: string;
status: 'online' | 'offline' | 'degraded';
latency: string;
}) {
const statusColors = {
online: 'bg-green-500',
offline: 'bg-red-500',
degraded: 'bg-yellow-500',
};
const statusLabels = {
online: 'Online',
offline: 'Offline',
degraded: 'Eingeschraenkt',
};
return (
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-3">
<div className={`h-2 w-2 rounded-full ${statusColors[status]}`} />
<span className="font-medium">{name}</span>
</div>
<div className="text-right">
<p className="text-sm">{statusLabels[status]}</p>
<p className="text-xs text-muted-foreground">{latency}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,221 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { ColumnDef } from '@tanstack/react-table';
import {
MoreHorizontal,
Building2,
Edit,
Trash2,
Users,
Plus,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DataTable, DataTableColumnHeader } from '@/components/ui/data-table';
import type { Department } from '@/types';
interface DepartmentsContentProps {
locale: string;
}
// Extended department type for display
interface DepartmentWithStats extends Department {
employeeCount: number;
managerName?: string;
location?: string;
}
// Mock department data
const mockDepartments: DepartmentWithStats[] = [
{
id: '1',
name: 'IT & Entwicklung',
description: 'Software-Entwicklung und IT-Infrastruktur',
employeeCount: 24,
managerName: 'Peter Mueller',
location: 'Hauptsitz',
},
{
id: '2',
name: 'Personal',
description: 'Personalwesen und Recruiting',
employeeCount: 8,
managerName: 'Anna Schmidt',
location: 'Hauptsitz',
},
{
id: '3',
name: 'Vertrieb',
description: 'Vertrieb und Kundenbetreuung',
employeeCount: 32,
managerName: 'Thomas Weber',
location: 'Niederlassung Sued',
},
{
id: '4',
name: 'Marketing',
description: 'Marketing und Kommunikation',
employeeCount: 12,
location: 'Hauptsitz',
},
{
id: '5',
name: 'Lager & Logistik',
description: 'Lagerverwaltung und Versand',
employeeCount: 45,
managerName: 'Klaus Fischer',
location: 'Lager Nord',
},
{
id: '6',
name: 'Finanzen',
description: 'Buchhaltung und Controlling',
employeeCount: 6,
managerName: 'Maria Braun',
location: 'Hauptsitz',
},
];
/**
* Departments management content with DataTable
*/
export function DepartmentsContent({ locale }: DepartmentsContentProps) {
const t = useTranslations('admin');
const [departments] = useState<DepartmentWithStats[]>(mockDepartments);
const columns: ColumnDef<DepartmentWithStats>[] = [
{
accessorKey: 'name',
header: ({ column }) => <DataTableColumnHeader column={column} title="Abteilung" />,
cell: ({ row }) => {
const dept = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Building2 className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">{dept.name}</p>
{dept.description && (
<p className="text-xs text-muted-foreground max-w-[300px] truncate">
{dept.description}
</p>
)}
</div>
</div>
);
},
},
{
accessorKey: 'managerName',
header: ({ column }) => <DataTableColumnHeader column={column} title="Leitung" />,
cell: ({ row }) => {
const manager = row.original.managerName;
return manager ? (
<span>{manager}</span>
) : (
<Badge variant="outline" className="text-muted-foreground">
Nicht zugewiesen
</Badge>
);
},
},
{
accessorKey: 'employeeCount',
header: ({ column }) => <DataTableColumnHeader column={column} title="Mitarbeiter" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span>{row.original.employeeCount}</span>
</div>
),
},
{
accessorKey: 'location',
header: ({ column }) => <DataTableColumnHeader column={column} title="Standort" />,
cell: ({ row }) => (
<Badge variant="secondary">{row.original.location || 'Nicht zugewiesen'}</Badge>
),
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const dept = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Aktionen oeffnen</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Aktionen</DropdownMenuLabel>
<DropdownMenuItem>
<Edit className="mr-2 h-4 w-4" />
Bearbeiten
</DropdownMenuItem>
<DropdownMenuItem>
<Users className="mr-2 h-4 w-4" />
Mitarbeiter verwalten
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Loeschen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
const totalEmployees = departments.reduce((sum, d) => sum + d.employeeCount, 0);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
{/* Actions Bar */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Abteilungsverwaltung</h2>
<p className="text-sm text-muted-foreground">
{departments.length} Abteilungen mit {totalEmployees} Mitarbeitern
</p>
</div>
<Button className="gap-2">
<Plus className="h-4 w-4" />
Abteilung erstellen
</Button>
</div>
{/* Data Table */}
<DataTable
columns={columns}
data={departments}
searchColumn="name"
searchPlaceholder="Abteilung suchen..."
/>
</motion.div>
);
}

View File

@@ -0,0 +1,23 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { DepartmentsContent } from './departments-content';
interface DepartmentsPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('admin');
return {
title: `${t('departments')} - ${t('title')}`,
};
}
/**
* Admin Departments page - Server Component
*/
export default function DepartmentsPage({ params: { locale } }: DepartmentsPageProps) {
return <DepartmentsContent locale={locale} />;
}

View File

@@ -0,0 +1,324 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import {
Plug,
Shield,
Building2,
MessageSquare,
CheckSquare,
Headphones,
Cloud,
FileText,
ClipboardCheck,
Settings,
Eye,
EyeOff,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { IntegrationStatusBadge, ConnectionTestButton } from '@/components/integrations';
import { useAllIntegrationStatuses } from '@/hooks/integrations';
import { useToast } from '@/hooks/use-toast';
import type { IntegrationType } from '@/types/integrations';
interface AdminIntegrationsContentProps {
locale: string;
}
/** Integration metadata */
const integrationMeta: Record<
IntegrationType,
{ icon: LucideIcon; nameKey: string; descKey: string }
> = {
plentyone: { icon: Building2, nameKey: 'plentyOne', descKey: 'plentyOneDesc' },
zulip: { icon: MessageSquare, nameKey: 'zulip', descKey: 'zulipDesc' },
todoist: { icon: CheckSquare, nameKey: 'todoist', descKey: 'todoistDesc' },
freescout: { icon: Headphones, nameKey: 'freeScout', descKey: 'freeScoutDesc' },
nextcloud: { icon: Cloud, nameKey: 'nextcloud', descKey: 'nextcloudDesc' },
ecodms: { icon: FileText, nameKey: 'ecoDms', descKey: 'ecoDmsDesc' },
gembadocs: { icon: ClipboardCheck, nameKey: 'gembaDocs', descKey: 'gembaDocsDesc' },
};
/** Credential field configuration per integration */
const credentialFields: Record<IntegrationType, { key: string; labelKey: string; type: 'text' | 'password' | 'url' }[]> = {
plentyone: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'username', labelKey: 'username', type: 'text' },
{ key: 'password', labelKey: 'password', type: 'password' },
],
zulip: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'email', labelKey: 'username', type: 'text' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
],
todoist: [
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
],
freescout: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
],
nextcloud: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'username', labelKey: 'username', type: 'text' },
{ key: 'password', labelKey: 'password', type: 'password' },
],
ecodms: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
],
gembadocs: [
{ key: 'apiUrl', labelKey: 'apiUrl', type: 'url' },
{ key: 'apiKey', labelKey: 'apiKey', type: 'password' },
],
};
/**
* Admin integrations management content
*/
export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentProps) {
const t = useTranslations('integrations');
const tAdmin = useTranslations('admin');
const { toast } = useToast();
const { data: integrations, isLoading } = useAllIntegrationStatuses();
const [visiblePasswords, setVisiblePasswords] = useState<Record<string, boolean>>({});
const [enabledState, setEnabledState] = useState<Record<string, boolean>>({});
const [formData, setFormData] = useState<Record<string, Record<string, string>>>({});
const togglePasswordVisibility = (fieldKey: string) => {
setVisiblePasswords((prev) => ({
...prev,
[fieldKey]: !prev[fieldKey],
}));
};
const handleFieldChange = (integrationType: IntegrationType, fieldKey: string, value: string) => {
setFormData((prev) => ({
...prev,
[integrationType]: {
...prev[integrationType],
[fieldKey]: value,
},
}));
};
const handleSave = (integrationType: IntegrationType) => {
toast({
title: t('saveSettings'),
description: t('settingsSaved' as never, { name: t(integrationMeta[integrationType].nameKey as never) }),
});
};
return (
<div className="container mx-auto space-y-8 py-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Shield className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{tAdmin('integrationManagement')}</h1>
<p className="text-muted-foreground">
{tAdmin('integrationManagementDesc')}
</p>
</div>
</div>
{/* Integration Tabs */}
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, j) => (
<Skeleton key={j} className="h-10 w-full" />
))}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Tabs defaultValue="plentyone" className="space-y-4">
<TabsList className="flex-wrap">
{integrations?.map((config) => {
const meta = integrationMeta[config.type];
const Icon = meta.icon;
return (
<TabsTrigger key={config.type} value={config.type} className="gap-2">
<Icon className="h-4 w-4" />
{t(meta.nameKey as never)}
</TabsTrigger>
);
})}
</TabsList>
{integrations?.map((config) => {
const meta = integrationMeta[config.type];
const Icon = meta.icon;
const fields = credentialFields[config.type];
return (
<TabsContent key={config.type} value={config.type}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg',
(enabledState[config.type] ?? config.enabled) ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
<Icon className="h-5 w-5" />
</div>
<div>
<CardTitle className="flex items-center gap-2">
{t(meta.nameKey as never)}
<IntegrationStatusBadge status={config.status} />
</CardTitle>
<CardDescription>{t(meta.descKey as never)}</CardDescription>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor={`enable-${config.type}`}>
{(enabledState[config.type] ?? config.enabled) ? t('disable') : t('enable')}
</Label>
<Switch
id={`enable-${config.type}`}
checked={enabledState[config.type] ?? config.enabled}
onCheckedChange={(checked) =>
setEnabledState((prev) => ({ ...prev, [config.type]: checked }))
}
/>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Credentials */}
<div>
<h3 className="mb-4 text-lg font-medium">{t('credentials')}</h3>
<div className="grid gap-4 md:grid-cols-2">
{fields.map((field) => {
const fieldId = `${config.type}-${field.key}`;
const isPassword = field.type === 'password';
const isVisible = visiblePasswords[fieldId];
return (
<div key={field.key} className="space-y-2">
<Label htmlFor={fieldId}>{t(field.labelKey as never)}</Label>
<div className="relative">
<Input
id={fieldId}
type={isPassword && !isVisible ? 'password' : 'text'}
placeholder={isPassword ? '********' : ''}
value={formData[config.type]?.[field.key] ?? ''}
onChange={(e) =>
handleFieldChange(config.type, field.key, e.target.value)
}
className={cn(isPassword && 'pr-10')}
/>
{isPassword && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => togglePasswordVisibility(fieldId)}
>
{isVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Sync Settings */}
<div>
<h3 className="mb-4 text-lg font-medium">{t('synchronization' as never)}</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`sync-interval-${config.type}`}>
{t('syncInterval')}
</Label>
<Select defaultValue={config.syncInterval.toString()}>
<SelectTrigger id={`sync-interval-${config.type}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 {t('minutes')}</SelectItem>
<SelectItem value="5">5 {t('minutes')}</SelectItem>
<SelectItem value="15">15 {t('minutes')}</SelectItem>
<SelectItem value="30">30 {t('minutes')}</SelectItem>
<SelectItem value="60">60 {t('minutes')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between border-t pt-6">
<ConnectionTestButton integrationType={config.type} />
<Button onClick={() => handleSave(config.type)}>
<Settings className="mr-2 h-4 w-4" />
{t('saveSettings')}
</Button>
</CardFooter>
</Card>
</motion.div>
</TabsContent>
);
})}
</Tabs>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
import { AdminIntegrationsContent } from './admin-integrations-content';
export const metadata: Metadata = {
title: 'Integration-Verwaltung | Admin | tOS',
description: 'Verwalten Sie die Integration-Konfigurationen',
};
interface AdminIntegrationsPageProps {
params: {
locale: string;
};
}
/**
* Admin page for managing integration configurations
*/
export default function AdminIntegrationsPage({ params }: AdminIntegrationsPageProps) {
return <AdminIntegrationsContent locale={params.locale} />;
}

View File

@@ -0,0 +1,101 @@
'use client';
import { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { Shield, Users, Building2, Settings, Activity } from 'lucide-react';
import { cn } from '@/lib/utils';
interface AdminLayoutProps {
children: ReactNode;
params: { locale: string };
}
const adminTabs = [
{ key: 'overview', href: '/admin', icon: Shield },
{ key: 'users', href: '/admin/users', icon: Users },
{ key: 'departments', href: '/admin/departments', icon: Building2 },
{ key: 'auditLog', href: '/admin/audit-log', icon: Activity },
{ key: 'settings', href: '/admin/settings', icon: Settings },
];
/**
* Admin layout with tab navigation
*/
export default function AdminLayout({ children, params: { locale } }: AdminLayoutProps) {
const pathname = usePathname();
const t = useTranslations('admin');
const isActive = (href: string) => {
const localePath = `/${locale}${href}`;
if (href === '/admin') {
return pathname === localePath;
}
return pathname.startsWith(localePath);
};
return (
<div className="space-y-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">Systemverwaltung und Benutzeradministration</p>
</motion.div>
{/* Tab Navigation */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="border-b"
>
<nav className="flex gap-1 overflow-x-auto pb-px">
{adminTabs.map((tab) => {
const Icon = tab.icon;
const active = isActive(tab.href);
return (
<Link
key={tab.key}
href={`/${locale}${tab.href}`}
className={cn(
'relative flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors',
'hover:text-foreground focus-visible:outline-none',
active ? 'text-foreground' : 'text-muted-foreground'
)}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{t(tab.key)}</span>
{/* Active indicator */}
{active && (
<motion.div
layoutId="admin-tab-indicator"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"
transition={{ duration: 0.2 }}
/>
)}
</Link>
);
})}
</nav>
</motion.div>
{/* Content */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{children}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { AdminDashboardContent } from './admin-dashboard-content';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('admin');
return {
title: t('title'),
};
}
/**
* Admin dashboard page - Server Component
*/
export default function AdminPage() {
return <AdminDashboardContent />;
}

View File

@@ -0,0 +1,23 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { UsersContent } from './users-content';
interface UsersPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('admin');
return {
title: `${t('users')} - ${t('title')}`,
};
}
/**
* Admin Users page - Server Component
*/
export default function UsersPage({ params: { locale } }: UsersPageProps) {
return <UsersContent locale={locale} />;
}

View File

@@ -0,0 +1,284 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { ColumnDef } from '@tanstack/react-table';
import {
MoreHorizontal,
UserPlus,
Mail,
Shield,
Edit,
Trash2,
UserCheck,
UserX,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DataTable, DataTableColumnHeader } from '@/components/ui/data-table';
import type { User, UserRole } from '@/types';
interface UsersContentProps {
locale: string;
}
// Mock user data
const mockUsers: (User & { lastActive: string })[] = [
{
id: '1',
email: 'admin@example.com',
firstName: 'Admin',
lastName: 'User',
displayName: 'Admin User',
roles: ['admin'],
isActive: true,
createdAt: new Date('2023-01-15'),
updatedAt: new Date('2024-01-20'),
lastActive: 'vor 5 Minuten',
},
{
id: '2',
email: 'max.mustermann@example.com',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
roles: ['employee'],
departmentId: '1',
isActive: true,
createdAt: new Date('2023-06-01'),
updatedAt: new Date('2024-01-18'),
lastActive: 'vor 2 Stunden',
},
{
id: '3',
email: 'anna.schmidt@example.com',
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
roles: ['department_head', 'employee'],
departmentId: '2',
isActive: true,
createdAt: new Date('2023-03-10'),
updatedAt: new Date('2024-01-19'),
lastActive: 'vor 1 Tag',
},
{
id: '4',
email: 'peter.mueller@example.com',
firstName: 'Peter',
lastName: 'Mueller',
displayName: 'Peter Mueller',
roles: ['manager', 'employee'],
departmentId: '1',
isActive: true,
createdAt: new Date('2022-11-05'),
updatedAt: new Date('2024-01-15'),
lastActive: 'vor 3 Tagen',
},
{
id: '5',
email: 'inactive.user@example.com',
firstName: 'Inactive',
lastName: 'User',
displayName: 'Inactive User',
roles: ['employee'],
isActive: false,
createdAt: new Date('2023-02-20'),
updatedAt: new Date('2023-12-01'),
lastActive: 'vor 30 Tagen',
},
];
type UserWithLastActive = User & { lastActive: string };
const roleColors: Record<UserRole, 'default' | 'secondary' | 'destructive' | 'outline'> = {
admin: 'destructive',
manager: 'default',
department_head: 'secondary',
employee: 'outline',
};
/**
* Users management content with DataTable
*/
export function UsersContent({ locale }: UsersContentProps) {
const t = useTranslations('admin');
const [users] = useState<UserWithLastActive[]>(mockUsers);
const columns: ColumnDef<UserWithLastActive>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Alle auswaehlen"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Zeile auswaehlen"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'displayName',
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const user = row.original;
const initials = `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
return (
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{user.displayName}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
);
},
},
{
accessorKey: 'roles',
header: ({ column }) => <DataTableColumnHeader column={column} title="Rollen" />,
cell: ({ row }) => {
const roles = row.original.roles;
return (
<div className="flex flex-wrap gap-1">
{roles.map((role) => (
<Badge key={role} variant={roleColors[role]} className="text-xs">
{role}
</Badge>
))}
</div>
);
},
filterFn: (row, id, value) => {
return row.original.roles.some((role) => value.includes(role));
},
},
{
accessorKey: 'isActive',
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const isActive = row.original.isActive;
return (
<Badge variant={isActive ? 'success' : 'secondary'}>
{isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
);
},
},
{
accessorKey: 'lastActive',
header: ({ column }) => <DataTableColumnHeader column={column} title="Letzte Aktivitaet" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.lastActive}</span>
),
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const user = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Aktionen oeffnen</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Aktionen</DropdownMenuLabel>
<DropdownMenuItem>
<Edit className="mr-2 h-4 w-4" />
Bearbeiten
</DropdownMenuItem>
<DropdownMenuItem>
<Mail className="mr-2 h-4 w-4" />
E-Mail senden
</DropdownMenuItem>
<DropdownMenuItem>
<Shield className="mr-2 h-4 w-4" />
Rollen verwalten
</DropdownMenuItem>
<DropdownMenuSeparator />
{user.isActive ? (
<DropdownMenuItem className="text-yellow-600">
<UserX className="mr-2 h-4 w-4" />
Deaktivieren
</DropdownMenuItem>
) : (
<DropdownMenuItem className="text-green-600">
<UserCheck className="mr-2 h-4 w-4" />
Aktivieren
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Loeschen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
{/* Actions Bar */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Benutzerverwaltung</h2>
<p className="text-sm text-muted-foreground">
{users.length} Benutzer im System
</p>
</div>
<Button className="gap-2">
<UserPlus className="h-4 w-4" />
Benutzer hinzufuegen
</Button>
</div>
{/* Data Table */}
<DataTable
columns={columns}
data={users}
searchColumn="displayName"
searchPlaceholder="Benutzer suchen..."
/>
</motion.div>
);
}

View File

@@ -0,0 +1,270 @@
'use client';
import { useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { Edit3, RotateCcw, Check, Users, CheckCircle2, Calendar, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { TooltipProvider } from '@/components/ui/tooltip';
import { useDashboardStore } from '@/stores/dashboard-store';
import {
WidgetGrid,
WidgetGridEmpty,
AddWidgetDialog,
ClockWidget,
WelcomeWidget,
QuickActionsWidget,
StatsWidget,
CalendarWidget,
ActivityWidget,
OrdersWidget,
ChatWidget,
TasksWidget,
TicketsWidget,
FilesWidget,
DocumentsWidget,
GembaDocsWidget,
type WidgetItem,
type StatConfig,
} from '@/components/dashboard';
interface DashboardContentProps {
locale: string;
}
/** Sample stats config - trendTextKey references i18n key under widgets.stats */
const SAMPLE_STATS_CONFIG: Record<string, Omit<StatConfig, 'trendText'> & { trendTextKey: string }> = {
presentToday: {
id: 'presentToday',
labelKey: 'presentToday',
value: '42',
trend: 'up',
trendTextKey: 'trendPresentToday',
icon: Users,
},
openTasks: {
id: 'openTasks',
labelKey: 'openTasks',
value: '12',
trend: 'neutral',
trendTextKey: 'trendOpenTasks',
icon: CheckCircle2,
},
upcomingMeetings: {
id: 'upcomingMeetings',
labelKey: 'upcomingMeetings',
value: '8',
trend: 'neutral',
trendTextKey: 'trendUpcomingMeetings',
icon: Calendar,
},
openTickets: {
id: 'openTickets',
labelKey: 'openTickets',
value: '23',
trend: 'down',
trendTextKey: 'trendOpenTickets',
icon: AlertCircle,
},
};
/**
* Dashboard Content - Main dashboard view with customizable widgets
*/
export function DashboardContent({ locale }: DashboardContentProps) {
const { data: session } = useSession();
const t = useTranslations('dashboard');
const tWidgets = useTranslations('widgets');
const {
widgets,
isEditing,
setWidgets,
addWidget,
removeWidget,
toggleEditMode,
resetToDefault,
} = useDashboardStore();
const userRoles = session?.user?.roles || [];
// Resolve SAMPLE_STATS with translated trendText
const sampleStats: Record<string, StatConfig> = Object.fromEntries(
Object.entries(SAMPLE_STATS_CONFIG).map(([key, { trendTextKey, ...rest }]) => [
key,
{ ...rest, trendText: tWidgets(`stats.${trendTextKey}`) },
])
);
// Handle widget order change from drag and drop
const handleWidgetsChange = useCallback(
(newWidgets: WidgetItem[]) => {
setWidgets(newWidgets);
},
[setWidgets]
);
// Handle adding a new widget
const handleAddWidget = useCallback(
(type: string) => {
addWidget(type);
},
[addWidget]
);
// Handle removing a widget
const handleRemoveWidget = useCallback(
(id: string) => {
removeWidget(id);
},
[removeWidget]
);
// Render a widget based on its type
const renderWidget = useCallback(
(item: WidgetItem, editing: boolean) => {
const commonProps = {
id: item.id,
isEditing: editing,
onRemove: editing ? () => handleRemoveWidget(item.id) : undefined,
locale,
};
switch (item.type) {
case 'clock':
return <ClockWidget {...commonProps} />;
case 'welcome':
return <WelcomeWidget {...commonProps} />;
case 'quickActions':
return <QuickActionsWidget {...commonProps} />;
case 'stats': {
const statKey = (item.settings?.statKey as string) || 'presentToday';
const stat = sampleStats[statKey] || sampleStats.presentToday;
return <StatsWidget {...commonProps} stat={stat} />;
}
case 'calendar':
return <CalendarWidget {...commonProps} events={[]} />;
case 'activity':
return <ActivityWidget {...commonProps} />;
// Integration Widgets
case 'orders':
return <OrdersWidget {...commonProps} />;
case 'chat':
return <ChatWidget {...commonProps} />;
case 'todoistTasks':
return <TasksWidget {...commonProps} />;
case 'tickets':
return <TicketsWidget {...commonProps} />;
case 'files':
return <FilesWidget {...commonProps} />;
case 'documents':
return <DocumentsWidget {...commonProps} />;
case 'gembadocs':
return <GembaDocsWidget {...commonProps} />;
default:
return (
<div className="flex h-32 items-center justify-center rounded-lg border bg-muted">
<span className="text-sm text-muted-foreground">
Unknown widget: {item.type}
</span>
</div>
);
}
},
[handleRemoveWidget, locale, sampleStats]
);
return (
<TooltipProvider delayDuration={0}>
<div className="space-y-6">
{/* Dashboard Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">{t('overview')}</p>
</div>
{/* Dashboard Actions */}
<div className="flex items-center gap-2">
{isEditing ? (
<>
<AddWidgetDialog
onAddWidget={handleAddWidget}
existingWidgets={widgets.map((w) => w.type)}
userRoles={userRoles}
/>
<Button variant="outline" size="sm" onClick={resetToDefault} className="gap-2">
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{t('reset')}</span>
</Button>
<Button size="sm" onClick={toggleEditMode} className="gap-2">
<Check className="h-4 w-4" />
<span className="hidden sm:inline">{t('done')}</span>
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={toggleEditMode} className="gap-2">
<Edit3 className="h-4 w-4" />
<span className="hidden sm:inline">{t('customize')}</span>
</Button>
)}
</div>
</motion.div>
{/* Edit Mode Banner */}
{isEditing && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="rounded-lg border border-primary/20 bg-primary/5 p-4"
>
<p className="text-sm text-primary">
<strong>{t('editModeActive')}</strong> {t('editModeDescription')}
</p>
</motion.div>
)}
{/* Widget Grid */}
{widgets.length === 0 ? (
<WidgetGridEmpty onAddWidget={() => toggleEditMode()} />
) : (
<WidgetGrid
widgets={widgets}
onWidgetsChange={handleWidgetsChange}
isEditing={isEditing}
renderWidget={renderWidget}
columns={4}
className={cn(
// Responsive grid adjustments
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
)}
/>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,24 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { DashboardContent } from './dashboard-content';
interface DashboardPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('dashboard');
return {
title: t('title'),
};
}
/**
* Dashboard page - Server Component
* Acts as the entry point for the dashboard with customizable widgets
*/
export default function DashboardPage({ params: { locale } }: DashboardPageProps) {
return <DashboardContent locale={locale} />;
}

View File

@@ -0,0 +1,176 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSession } from 'next-auth/react';
import Link from 'next/link';
import {
Palmtree,
Calendar,
Plus,
ClipboardCheck,
AlertCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { PageTransition } from '@/components/layout/page-transition';
import {
VacationBalance,
AbsenceCard,
AbsenceRequestForm,
} from '@/components/hr/absences';
import { useMyAbsenceRequests, useCancelAbsence } from '@/hooks/hr/use-absences';
import { useToast } from '@/hooks/use-toast';
interface AbsencesContentProps {
locale: string;
}
/**
* Absences overview page content - Client Component
* Shows vacation balance and personal absence requests
*/
export function AbsencesContent({ locale }: AbsencesContentProps) {
const t = useTranslations('hr.absences');
const tCommon = useTranslations('common');
const { data: session } = useSession();
const { toast } = useToast();
const [requestDialog, setRequestDialog] = useState(false);
const employeeId = session?.user?.id || '1';
const userRoles = session?.user?.roles || [];
const canApprove =
userRoles.includes('manager') ||
userRoles.includes('department_head') ||
userRoles.includes('admin');
const { data: requests, isLoading, error } = useMyAbsenceRequests();
const cancelAbsence = useCancelAbsence();
const handleCancel = async (id: string) => {
try {
await cancelAbsence.mutateAsync(id);
toast({
title: t('cancelled'),
description: t('cancelledDesc'),
});
} catch {
toast({
title: tCommon('error'),
description: t('errorCancel'),
variant: 'destructive',
});
}
};
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">{t('description')}</p>
</div>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href={`/${locale}/hr/absences/calendar`} className="gap-2">
<Calendar className="h-4 w-4" />
{t('teamCalendar')}
</Link>
</Button>
{canApprove && (
<Button asChild variant="outline">
<Link href={`/${locale}/hr/absences/requests`} className="gap-2">
<ClipboardCheck className="h-4 w-4" />
{t('approvals')}
</Link>
</Button>
)}
<Button onClick={() => setRequestDialog(true)} className="gap-2">
<Plus className="h-4 w-4" />
{t('newRequest')}
</Button>
</div>
</div>
{/* Main content */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Left column: Vacation Balance */}
<div className="lg:col-span-1">
<VacationBalance locale={locale} employeeId={employeeId} />
</div>
{/* Right column: My Requests */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palmtree className="h-5 w-5" />
{t('myRequests')}
</CardTitle>
<CardDescription>{t('myRequestsDesc')}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-8">
<AlertCircle className="h-10 w-10 text-destructive" />
<p className="mt-4 text-muted-foreground">{t('errorLoadingRequests')}</p>
</div>
) : !requests || requests.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Palmtree className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-4 text-muted-foreground">{t('noRequests')}</p>
<Button
variant="outline"
className="mt-4 gap-2"
onClick={() => setRequestDialog(true)}
>
<Plus className="h-4 w-4" />
{t('createFirstRequest')}
</Button>
</div>
) : (
<ScrollArea className="h-[500px] pr-4">
<div className="space-y-3">
{requests.map((request) => (
<AbsenceCard
key={request.id}
absence={request}
locale={locale}
onCancel={handleCancel}
/>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
</div>
</div>
{/* Request dialog */}
<AbsenceRequestForm
open={requestDialog}
onOpenChange={setRequestDialog}
locale={locale}
/>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { ArrowLeft, Plus, Filter } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageTransition } from '@/components/layout/page-transition';
import { AbsenceCalendar, AbsenceRequestForm } from '@/components/hr/absences';
interface AbsenceCalendarPageContentProps {
locale: string;
}
// Mock departments - in production, this would come from the API
const mockDepartments = [
{ id: 'all', name: 'Alle Abteilungen' },
{ id: '1', name: 'IT & Entwicklung' },
{ id: '2', name: 'Personal' },
{ id: '3', name: 'Vertrieb' },
{ id: '4', name: 'Lager & Logistik' },
];
/**
* Absence calendar page content - Client Component
* Shows team absences with department filter
*/
export function AbsenceCalendarPageContent({ locale }: AbsenceCalendarPageContentProps) {
const t = useTranslations('hr.absences');
const [requestDialog, setRequestDialog] = useState(false);
const [selectedDepartment, setSelectedDepartment] = useState<string>('all');
const departmentId = selectedDepartment === 'all' ? undefined : selectedDepartment;
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href={`/${locale}/hr/absences`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold tracking-tight">{t('teamCalendar')}</h1>
<p className="text-sm text-muted-foreground">{t('teamCalendarDesc')}</p>
</div>
<div className="flex items-center gap-2">
<Select value={selectedDepartment} onValueChange={setSelectedDepartment}>
<SelectTrigger className="w-[200px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder={t('selectDepartment')} />
</SelectTrigger>
<SelectContent>
{mockDepartments.map((dept) => (
<SelectItem key={dept.id} value={dept.id}>
{dept.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setRequestDialog(true)} className="gap-2">
<Plus className="h-4 w-4" />
{t('newRequest')}
</Button>
</div>
</div>
{/* Calendar */}
<AbsenceCalendar locale={locale} departmentId={departmentId} />
{/* Request dialog */}
<AbsenceRequestForm
open={requestDialog}
onOpenChange={setRequestDialog}
locale={locale}
/>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,24 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { AbsenceCalendarPageContent } from './absence-calendar-content';
interface AbsenceCalendarPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('hr.absences');
return {
title: `${t('teamCalendar')} - HR`,
};
}
/**
* Absence calendar page - Server Component
* Shows team absences in a calendar view
*/
export default function AbsenceCalendarPage({ params: { locale } }: AbsenceCalendarPageProps) {
return <AbsenceCalendarPageContent locale={locale} />;
}

View File

@@ -0,0 +1,24 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { AbsencesContent } from './absences-content';
interface AbsencesPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('navigation');
return {
title: `${t('absences')} - HR`,
};
}
/**
* Absences overview page - Server Component
* Shows vacation balance and absence requests
*/
export default function AbsencesPage({ params: { locale } }: AbsencesPageProps) {
return <AbsencesContent locale={locale} />;
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { ArrowLeft, Filter, Calendar } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PageTransition } from '@/components/layout/page-transition';
import { AbsenceApprovalList, AbsenceCalendar } from '@/components/hr/absences';
interface AbsenceRequestsContentProps {
locale: string;
}
// Mock departments - in production, this would come from the API
const mockDepartments = [
{ id: 'all', name: 'Alle Abteilungen' },
{ id: '1', name: 'IT & Entwicklung' },
{ id: '2', name: 'Personal' },
{ id: '3', name: 'Vertrieb' },
{ id: '4', name: 'Lager & Logistik' },
];
/**
* Absence requests approval page content - Client Component
* Shows pending requests for managers to approve/reject
*/
export function AbsenceRequestsContent({ locale }: AbsenceRequestsContentProps) {
const t = useTranslations('hr.absences');
const [selectedDepartment, setSelectedDepartment] = useState<string>('all');
const departmentId = selectedDepartment === 'all' ? undefined : selectedDepartment;
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href={`/${locale}/hr/absences`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold tracking-tight">{t('pendingApprovals')}</h1>
<p className="text-sm text-muted-foreground">{t('pendingApprovalsManagerDesc')}</p>
</div>
<Select value={selectedDepartment} onValueChange={setSelectedDepartment}>
<SelectTrigger className="w-[200px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder={t('selectDepartment')} />
</SelectTrigger>
<SelectContent>
{mockDepartments.map((dept) => (
<SelectItem key={dept.id} value={dept.id}>
{dept.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Tabs */}
<Tabs defaultValue="pending" className="w-full">
<TabsList>
<TabsTrigger value="pending">{t('pending')}</TabsTrigger>
<TabsTrigger value="calendar" className="gap-2">
<Calendar className="h-4 w-4" />
{t('overview')}
</TabsTrigger>
</TabsList>
<TabsContent value="pending" className="mt-6">
<AbsenceApprovalList locale={locale} departmentId={departmentId} />
</TabsContent>
<TabsContent value="calendar" className="mt-6">
<AbsenceCalendar locale={locale} departmentId={departmentId} />
</TabsContent>
</Tabs>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,24 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { AbsenceRequestsContent } from './absence-requests-content';
interface AbsenceRequestsPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('hr.absences');
return {
title: `${t('pendingApprovals')} - HR`,
};
}
/**
* Absence requests page for managers - Server Component
* Shows pending requests for approval
*/
export default function AbsenceRequestsPage({ params: { locale } }: AbsenceRequestsPageProps) {
return <AbsenceRequestsContent locale={locale} />;
}

View File

@@ -0,0 +1,495 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import {
ArrowLeft,
Edit,
Mail,
Phone,
Building2,
Calendar,
Clock,
Briefcase,
MapPin,
AlertCircle,
User,
} from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { PageTransition } from '@/components/layout/page-transition';
import { cn } from '@/lib/utils';
import {
useEmployee,
EMPLOYMENT_STATUS_COLORS,
} from '@/hooks/hr/use-employees';
interface EmployeeDetailContentProps {
locale: string;
employeeId: string;
}
/**
* Employee detail page content
* Shows employee information in tabs: Overview, Time Account, Absences
*/
export function EmployeeDetailContent({
locale,
employeeId,
}: EmployeeDetailContentProps) {
const t = useTranslations('hr');
const tContract = useTranslations('hr.contractType');
const tStatus = useTranslations('hr.employeeStatus');
const tCommon = useTranslations('common');
const [activeTab, setActiveTab] = useState('overview');
const { data: employee, isLoading, error } = useEmployee(employeeId);
// Loading state
if (isLoading) {
return (
<PageTransition>
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<Skeleton className="h-8 w-48" />
</div>
<div className="grid gap-6 md:grid-cols-3">
<Skeleton className="h-[300px]" />
<Skeleton className="h-[300px] md:col-span-2" />
</div>
</div>
</PageTransition>
);
}
// Error state
if (error || !employee) {
return (
<PageTransition>
<div className="flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('employeeNotFound')}</h2>
<p className="text-muted-foreground mb-4">
{t('employeeNotFoundDesc')}
</p>
<Link href={`/${locale}/hr/employees`}>
<Button>
<ArrowLeft className="h-4 w-4 mr-2" />
{t('backToList')}
</Button>
</Link>
</div>
</PageTransition>
);
}
const initials = `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`;
const statusColors = EMPLOYMENT_STATUS_COLORS[employee.status];
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Link href={`/${locale}/hr/employees`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">
{employee.firstName} {employee.lastName}
</h1>
<p className="text-muted-foreground">{employee.employeeNumber}</p>
</div>
</div>
<Link href={`/${locale}/hr/employees/${employee.id}?edit=true`}>
<Button className="gap-2">
<Edit className="h-4 w-4" />
{tCommon('edit')}
</Button>
</Link>
</div>
{/* Content */}
<div className="grid gap-6 md:grid-cols-3">
{/* Sidebar - Employee Card */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center text-center">
<Avatar className="h-24 w-24 mb-4">
<AvatarImage
src={employee.avatarUrl}
alt={`${employee.firstName} ${employee.lastName}`}
/>
<AvatarFallback className="bg-primary/10 text-primary text-2xl font-bold">
{initials}
</AvatarFallback>
</Avatar>
<h2 className="text-xl font-semibold">
{employee.firstName} {employee.lastName}
</h2>
<p className="text-muted-foreground">{employee.position}</p>
<Badge
variant="outline"
className={cn(
'mt-2 font-medium border',
statusColors.bg,
statusColors.text,
statusColors.border
)}
>
{tStatus(employee.status)}
</Badge>
</div>
<Separator className="my-6" />
<div className="space-y-4">
<div className="flex items-center gap-3">
<Mail className="h-4 w-4 text-muted-foreground" />
<a
href={`mailto:${employee.email}`}
className="text-sm hover:underline"
>
{employee.email}
</a>
</div>
{employee.phone && (
<div className="flex items-center gap-3">
<Phone className="h-4 w-4 text-muted-foreground" />
<a
href={`tel:${employee.phone}`}
className="text-sm hover:underline"
>
{employee.phone}
</a>
</div>
)}
<div className="flex items-center gap-3">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{employee.department?.name}</span>
</div>
{employee.manager && (
<div className="flex items-center gap-3">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{t('manager')}: {employee.manager.firstName}{' '}
{employee.manager.lastName}
</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Main Content - Tabs */}
<div className="md:col-span-2">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start">
<TabsTrigger value="overview">{t('tabs.overview')}</TabsTrigger>
<TabsTrigger value="timeAccount">
{t('tabs.timeAccount')}
</TabsTrigger>
<TabsTrigger value="absences">{t('tabs.absences')}</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-6 mt-6">
{/* Employment Details */}
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t('employmentDetails')}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">
{t('contractType.label')}
</p>
<p className="font-medium">
{tContract(employee.contractType)}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
{t('weeklyHours')}
</p>
<p className="font-medium">{employee.weeklyHours} h</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
{t('hireDate')}
</p>
<p className="font-medium">
{format(new Date(employee.hireDate), 'PPP', {
locale: locale === 'de' ? de : undefined,
})}
</p>
</div>
{employee.terminationDate && (
<div>
<p className="text-sm text-muted-foreground">
{t('terminationDate')}
</p>
<p className="font-medium text-destructive">
{format(new Date(employee.terminationDate), 'PPP', {
locale: locale === 'de' ? de : undefined,
})}
</p>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">
{t('roles')}
</p>
<div className="flex flex-wrap gap-1 mt-1">
{employee.roles.map((role) => (
<Badge key={role} variant="secondary">
{role}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
{/* Address */}
{employee.address && (
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('address')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-3">
<MapPin className="h-4 w-4 text-muted-foreground mt-1" />
<div>
<p>{employee.address.street}</p>
<p>
{employee.address.zipCode} {employee.address.city}
</p>
<p>{employee.address.country}</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Emergency Contact */}
{employee.emergencyContact && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t('emergencyContact')}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">
{t('emergencyName')}
</p>
<p className="font-medium">
{employee.emergencyContact.name}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
{t('emergencyPhone')}
</p>
<p className="font-medium">
{employee.emergencyContact.phone}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
{t('emergencyRelationship')}
</p>
<p className="font-medium">
{employee.emergencyContact.relationship}
</p>
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* Time Account Tab */}
<TabsContent value="timeAccount" className="space-y-6 mt-6">
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('overtimeBalance')}
</CardTitle>
</CardHeader>
<CardContent>
<div
className={cn(
'text-2xl font-bold',
employee.overtimeBalance > 0
? 'text-green-600'
: employee.overtimeBalance < 0
? 'text-red-600'
: ''
)}
>
{employee.overtimeBalance > 0 ? '+' : ''}
{employee.overtimeBalance.toFixed(1)} h
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('weeklyTarget')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{employee.weeklyHours} h
</div>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Clock className="h-12 w-12 mb-4 opacity-50" />
<p>{t('timeTracking.noData')}</p>
<p className="text-sm">{t('timeTracking.noDataDesc')}</p>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Absences Tab */}
<TabsContent value="absences" className="space-y-6 mt-6">
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('vacationDaysTotal')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{employee.vacationDaysTotal}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('vacationDaysUsed')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{employee.vacationDaysUsed}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('vacationDaysRemaining')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{employee.vacationDaysTotal -
employee.vacationDaysUsed -
employee.vacationDaysPending}
</div>
{employee.vacationDaysPending > 0 && (
<p className="text-xs text-yellow-600">
+{employee.vacationDaysPending} {t('pending')}
</p>
)}
</CardContent>
</Card>
</div>
{/* Vacation Progress Bar */}
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t('vacationOverview')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>
{employee.vacationDaysUsed +
employee.vacationDaysPending}{' '}
/ {employee.vacationDaysTotal} {t('daysUsed')}
</span>
<span>
{Math.round(
((employee.vacationDaysUsed +
employee.vacationDaysPending) /
employee.vacationDaysTotal) *
100
)}
%
</span>
</div>
<div className="h-3 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{
width: `${Math.min(
100,
((employee.vacationDaysUsed +
employee.vacationDaysPending) /
employee.vacationDaysTotal) *
100
)}%`,
}}
/>
</div>
</div>
</CardContent>
</Card>
{/* Absence List Placeholder */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Calendar className="h-12 w-12 mb-4 opacity-50" />
<p>{t('absences.noData')}</p>
<p className="text-sm">{t('absences.noDataDesc')}</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,26 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { EmployeeDetailContent } from './employee-detail-content';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'hr' });
return {
title: t('employees.details'),
description: t('employees.detailsDescription'),
};
}
export default async function EmployeeDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { locale, id } = await params;
return <EmployeeDetailContent locale={locale} employeeId={id} />;
}

View File

@@ -0,0 +1,52 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { UserPlus, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PageTransition } from '@/components/layout/page-transition';
import { EmployeeList } from '@/components/hr/employees';
interface EmployeesContentProps {
locale: string;
}
/**
* Employees list page content
* Displays employee table with search and filters
*/
export function EmployeesContent({ locale }: EmployeesContentProps) {
const t = useTranslations('hr');
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Link href={`/${locale}/hr`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t('employees.title')}
</h1>
<p className="text-muted-foreground">{t('employees.subtitle')}</p>
</div>
</div>
<Link href={`/${locale}/hr/employees/new`}>
<Button className="gap-2">
<UserPlus className="h-4 w-4" />
{t('newEmployee')}
</Button>
</Link>
</div>
{/* Employee List */}
<EmployeeList locale={locale} />
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PageTransition } from '@/components/layout/page-transition';
import { EmployeeForm } from '@/components/hr/employees';
interface NewEmployeeContentProps {
locale: string;
}
/**
* New employee page content
* Displays employee creation form
*/
export function NewEmployeeContent({ locale }: NewEmployeeContentProps) {
const t = useTranslations('hr');
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href={`/${locale}/hr/employees`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t('employees.new')}
</h1>
<p className="text-muted-foreground">{t('employees.newSubtitle')}</p>
</div>
</div>
{/* Form */}
<EmployeeForm locale={locale} mode="create" />
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,26 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { NewEmployeeContent } from './new-employee-content';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'hr' });
return {
title: t('employees.new'),
description: t('employees.newDescription'),
};
}
export default async function NewEmployeePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return <NewEmployeeContent locale={locale} />;
}

View File

@@ -0,0 +1,26 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { EmployeesContent } from './employees-content';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'hr' });
return {
title: t('employees.title'),
description: t('employees.description'),
};
}
export default async function EmployeesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return <EmployeesContent locale={locale} />;
}

View File

@@ -0,0 +1,257 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import {
Users,
Clock,
CalendarDays,
Network,
UserPlus,
TrendingUp,
TrendingDown,
Minus,
Building2,
} from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { PageTransition } from '@/components/layout/page-transition';
import { Skeleton } from '@/components/ui/skeleton';
import { useEmployeeStats } from '@/hooks/hr/use-employees';
import { cn } from '@/lib/utils';
interface HROverviewContentProps {
locale: string;
}
/**
* HR module configuration
*/
const hrModules = [
{
id: 'employees',
icon: Users,
href: '/hr/employees',
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
},
{
id: 'timeTracking',
icon: Clock,
href: '/hr/time-tracking',
color: 'text-green-500',
bgColor: 'bg-green-500/10',
},
{
id: 'absences',
icon: CalendarDays,
href: '/hr/absences',
color: 'text-orange-500',
bgColor: 'bg-orange-500/10',
},
{
id: 'orgChart',
icon: Network,
href: '/hr/org-chart',
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
},
];
/**
* HR Overview page content
* Shows HR modules and statistics
*/
export function HROverviewContent({ locale }: HROverviewContentProps) {
const t = useTranslations('hr');
const tNav = useTranslations('navigation');
const { data: stats, isLoading: statsLoading } = useEmployeeStats();
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">{t('description')}</p>
</div>
<Link href={`/${locale}/hr/employees/new`}>
<Button className="gap-2">
<UserPlus className="h-4 w-4" />
{t('newEmployee')}
</Button>
</Link>
</div>
{/* Quick Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('stats.totalEmployees')}
</CardTitle>
</CardHeader>
<CardContent>
{statsLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold">{stats?.total || 0}</div>
<div className="flex items-center text-xs text-green-600">
<TrendingUp className="h-3 w-3 mr-1" />
+{stats?.newThisMonth || 0} {t('stats.thisMonth')}
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('stats.activeEmployees')}
</CardTitle>
</CardHeader>
<CardContent>
{statsLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold">{stats?.active || 0}</div>
<div className="text-xs text-muted-foreground">
{stats?.total
? Math.round((stats.active / stats.total) * 100)
: 0}
% {t('stats.ofTotal')}
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('stats.onLeave')}
</CardTitle>
</CardHeader>
<CardContent>
{statsLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold">{stats?.onLeave || 0}</div>
<div className="text-xs text-muted-foreground">
{t('stats.currentlyAbsent')}
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('stats.departments')}
</CardTitle>
</CardHeader>
<CardContent>
{statsLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<>
<div className="text-2xl font-bold">
{stats?.departmentBreakdown?.length || 0}
</div>
<div className="text-xs text-muted-foreground">
{t('stats.activeDepartments')}
</div>
</>
)}
</CardContent>
</Card>
</div>
{/* HR Modules */}
<div>
<h2 className="text-xl font-semibold mb-4">{t('modules')}</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{hrModules.map((module) => {
const Icon = module.icon;
return (
<Link key={module.id} href={`/${locale}${module.href}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardHeader>
<div
className={cn(
'w-12 h-12 rounded-lg flex items-center justify-center mb-4',
module.bgColor
)}
>
<Icon className={cn('h-6 w-6', module.color)} />
</div>
<CardTitle className="text-lg">
{tNav(module.id)}
</CardTitle>
<CardDescription>
{t(`modules.${module.id}.description`)}
</CardDescription>
</CardHeader>
</Card>
</Link>
);
})}
</div>
</div>
{/* Department Breakdown */}
<Card>
<CardHeader>
<CardTitle>{t('departmentBreakdown')}</CardTitle>
<CardDescription>{t('departmentBreakdownDesc')}</CardDescription>
</CardHeader>
<CardContent>
{statsLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-5 w-12" />
</div>
))}
</div>
) : stats?.departmentBreakdown?.length ? (
<div className="space-y-3">
{stats.departmentBreakdown.map((dept) => (
<div
key={dept.departmentId}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{dept.departmentName}</span>
</div>
<Badge variant="secondary">{dept.count}</Badge>
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-center py-4">
{t('noDepartments')}
</p>
)}
</CardContent>
</Card>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageTransition } from '@/components/layout/page-transition';
import { OrgChart } from '@/components/hr/employees';
interface OrgChartContentProps {
locale: string;
}
/**
* Organization chart page content
* Displays hierarchical tree view of employees
*/
export function OrgChartContent({ locale }: OrgChartContentProps) {
const t = useTranslations('hr');
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href={`/${locale}/hr`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t('orgChart.title')}
</h1>
<p className="text-muted-foreground">{t('orgChart.subtitle')}</p>
</div>
</div>
{/* Org Chart */}
<Card>
<CardContent className="pt-6">
<OrgChart locale={locale} />
</CardContent>
</Card>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,26 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { OrgChartContent } from './org-chart-content';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'hr' });
return {
title: t('orgChart.title'),
description: t('orgChart.description'),
};
}
export default async function OrgChartPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return <OrgChartContent locale={locale} />;
}

View File

@@ -0,0 +1,26 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { HROverviewContent } from './hr-overview-content';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'hr' });
return {
title: t('title'),
description: t('description'),
};
}
export default async function HRPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return <HROverviewContent locale={locale} />;
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { format, subMonths, addMonths } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { ArrowLeft, ChevronLeft, ChevronRight, User, Calendar } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { PageTransition } from '@/components/layout/page-transition';
import { TimeSummary, TimeEntryList, TimeEntryForm } from '@/components/hr/time-tracking';
import { useTimeSummary } from '@/hooks/hr/use-time-tracking';
import type { TimeEntry } from '@/types/hr';
interface EmployeeTimeAccountContentProps {
locale: string;
employeeId: string;
}
/**
* Employee time account detail view
* Shows monthly summary and all entries for a specific employee
*/
export function EmployeeTimeAccountContent({
locale,
employeeId,
}: EmployeeTimeAccountContentProps) {
const t = useTranslations('hr.timeTracking');
const dateLocale = locale === 'de' ? de : enUS;
const [currentDate, setCurrentDate] = useState(new Date());
const [correctionDialog, setCorrectionDialog] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<TimeEntry | null>(null);
const year = currentDate.getFullYear();
const month = currentDate.getMonth() + 1;
const { data: summary, isLoading } = useTimeSummary(employeeId, year, month);
const goToPreviousMonth = () => setCurrentDate(subMonths(currentDate, 1));
const goToNextMonth = () => setCurrentDate(addMonths(currentDate, 1));
const handleEditEntry = (entry: TimeEntry) => {
setSelectedEntry(entry);
setCorrectionDialog(true);
};
// Mock employee data - in production, this would come from the API
const employee = summary?.entries[0]?.employee || {
firstName: 'Mitarbeiter',
lastName: '',
avatarUrl: undefined,
};
return (
<PageTransition>
<div className="space-y-6">
{/* Header with back button */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href={`/${locale}/hr/time-tracking`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1">
{isLoading ? (
<Skeleton className="h-8 w-48" />
) : (
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={employee.avatarUrl} />
<AvatarFallback>
{employee.firstName[0]}
{employee.lastName[0] || ''}
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-2xl font-bold tracking-tight">
{employee.firstName} {employee.lastName}
</h1>
<p className="text-sm text-muted-foreground">{t('timeAccount')}</p>
</div>
</div>
)}
</div>
</div>
{/* Month navigation */}
<Card>
<CardContent className="flex items-center justify-between py-4">
<Button variant="outline" size="icon" onClick={goToPreviousMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-muted-foreground" />
<span className="text-lg font-medium">
{format(currentDate, 'MMMM yyyy', { locale: dateLocale })}
</span>
</div>
<Button variant="outline" size="icon" onClick={goToNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</CardContent>
</Card>
{/* Content grid */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Summary */}
<TimeSummary locale={locale} employeeId={employeeId} year={year} month={month} />
{/* Entries */}
<TimeEntryList
locale={locale}
employeeId={employeeId}
onEditEntry={handleEditEntry}
/>
</div>
{/* Correction dialog */}
<TimeEntryForm
open={correctionDialog}
onOpenChange={setCorrectionDialog}
entry={selectedEntry}
/>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,26 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { EmployeeTimeAccountContent } from './employee-time-account-content';
interface EmployeeTimeAccountPageProps {
params: { locale: string; employeeId: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('hr.timeTracking');
return {
title: `${t('timeAccount')} - HR`,
};
}
/**
* Employee time account page - Server Component
* Shows detailed time tracking for a specific employee
*/
export default function EmployeeTimeAccountPage({
params: { locale, employeeId },
}: EmployeeTimeAccountPageProps) {
return <EmployeeTimeAccountContent locale={locale} employeeId={employeeId} />;
}

View File

@@ -0,0 +1,24 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { TimeTrackingContent } from './time-tracking-content';
interface TimeTrackingPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('navigation');
return {
title: `${t('timeTracking')} - HR`,
};
}
/**
* Time Tracking overview page - Server Component
* Shows time clock and recent entries
*/
export default function TimeTrackingPage({ params: { locale } }: TimeTrackingPageProps) {
return <TimeTrackingContent locale={locale} />;
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSession } from 'next-auth/react';
import { Clock, Calendar, FileText } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PageTransition } from '@/components/layout/page-transition';
import {
TimeClock,
TimeEntryList,
TimeEntryForm,
TimeSummary,
} from '@/components/hr/time-tracking';
import type { TimeEntry } from '@/types/hr';
interface TimeTrackingContentProps {
locale: string;
}
/**
* Time tracking page content - Client Component
* Provides time clock, entry list, and monthly summary
*/
export function TimeTrackingContent({ locale }: TimeTrackingContentProps) {
const t = useTranslations('hr.timeTracking');
const { data: session } = useSession();
const [correctionDialog, setCorrectionDialog] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<TimeEntry | null>(null);
const employeeId = session?.user?.id || '1';
const handleEditEntry = (entry: TimeEntry) => {
setSelectedEntry(entry);
setCorrectionDialog(true);
};
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">{t('description')}</p>
</div>
{/* Main content */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Left column: Time Clock */}
<div className="lg:col-span-1">
<TimeClock locale={locale} />
</div>
{/* Right column: Tabs for entries and summary */}
<div className="lg:col-span-2">
<Tabs defaultValue="entries" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="entries" className="gap-2">
<FileText className="h-4 w-4" />
{t('entries')}
</TabsTrigger>
<TabsTrigger value="summary" className="gap-2">
<Calendar className="h-4 w-4" />
{t('summary')}
</TabsTrigger>
</TabsList>
<TabsContent value="entries">
<TimeEntryList
locale={locale}
employeeId={employeeId}
onEditEntry={handleEditEntry}
/>
</TabsContent>
<TabsContent value="summary">
<TimeSummary locale={locale} employeeId={employeeId} />
</TabsContent>
</Tabs>
</div>
</div>
{/* Correction dialog */}
<TimeEntryForm
open={correctionDialog}
onOpenChange={setCorrectionDialog}
entry={selectedEntry}
/>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,283 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import { FileText, Search, Download, Tag, FolderOpen, Archive, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useDocuments, useClassifications, formatDocumentSize } from '@/hooks/integrations';
interface EcoDmsContentProps {
locale: string;
}
/**
* ecoDMS integration content - displays documents with search
*/
export function EcoDmsContent({ locale }: EcoDmsContentProps) {
const t = useTranslations('widgets.documents');
const tInt = useTranslations('integrations');
const [searchQuery, setSearchQuery] = useState('');
const [selectedClassification, setSelectedClassification] = useState<string | null>(null);
const { data: documents, isLoading: documentsLoading } = useDocuments({
search: searchQuery || undefined,
classification: selectedClassification || undefined,
});
const { data: classifications, isLoading: classificationsLoading } = useClassifications();
const dateLocale = locale === 'de' ? de : enUS;
const hasFilters = searchQuery || selectedClassification;
const handleClearFilters = () => {
setSearchQuery('');
setSelectedClassification(null);
};
return (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Dokumente</CardTitle>
<FileText className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{documentsLoading ? <Skeleton className="h-8 w-12" /> : documents?.length ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Klassifikationen</CardTitle>
<FolderOpen className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{classificationsLoading ? <Skeleton className="h-8 w-12" /> : classifications?.length ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Archiviert</CardTitle>
<Archive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{classificationsLoading ? (
<Skeleton className="h-8 w-12" />
) : (
classifications?.reduce((sum, c) => sum + c.documentCount, 0) ?? 0
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Heute hinzugefuegt</CardTitle>
<FileText className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-500">
{documentsLoading ? (
<Skeleton className="h-8 w-12" />
) : (
documents?.filter((d) => {
const today = new Date();
const created = new Date(d.createdAt);
return created.toDateString() === today.toDateString();
}).length ?? 0
)}
</div>
</CardContent>
</Card>
</div>
{/* Classifications */}
<Card>
<CardHeader>
<CardTitle>Klassifikationen</CardTitle>
<CardDescription>Dokumentkategorien im Archiv</CardDescription>
</CardHeader>
<CardContent>
{classificationsLoading ? (
<div className="flex flex-wrap gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-24" />
))}
</div>
) : classifications && classifications.length > 0 ? (
<div className="flex flex-wrap gap-2">
{classifications.map((classification) => (
<Badge
key={classification.id}
variant={selectedClassification === classification.name ? 'default' : 'outline'}
className="cursor-pointer text-sm"
onClick={() =>
setSelectedClassification(
selectedClassification === classification.name ? null : classification.name
)
}
>
<Tag className="mr-1 h-3 w-3" />
{classification.name}
<span className="ml-1 rounded-full bg-muted px-1.5 text-xs">
{classification.documentCount}
</span>
</Badge>
))}
</div>
) : (
<p className="text-muted-foreground">{tInt('noClassificationsFound')}</p>
)}
</CardContent>
</Card>
{/* Documents */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Dokumente</CardTitle>
<CardDescription>Dokumente aus dem ecoDMS Archiv</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
className="pl-8"
/>
</div>
{hasFilters && (
<Button variant="ghost" size="icon" onClick={handleClearFilters}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px]">
{documentsLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : documents && documents.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Titel</TableHead>
<TableHead>Klassifikation</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Groesse</TableHead>
<TableHead>Erstellt</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.map((doc, index) => (
<motion.tr
key={doc.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="border-b transition-colors hover:bg-muted/50"
>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10 text-red-500">
<FileText className="h-5 w-5" />
</div>
<div>
<p className="font-medium">{doc.title}</p>
<p className="text-xs text-muted-foreground">{doc.fileName}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{doc.classification}</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{doc.tags.slice(0, 2).map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs"
>
{tag}
</span>
))}
{doc.tags.length > 2 && (
<span className="text-xs text-muted-foreground">
+{doc.tags.length - 2}
</span>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{formatDocumentSize(doc.fileSize)}
</TableCell>
<TableCell>
<div className="text-sm">
<p>{doc.createdBy}</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(doc.createdAt, {
addSuffix: true,
locale: dateLocale,
})}
</p>
</div>
</TableCell>
<TableCell>
{doc.downloadUrl && (
<Button variant="ghost" size="icon" asChild>
<a href={doc.downloadUrl} download>
<Download className="h-4 w-4" />
</a>
</Button>
)}
</TableCell>
</motion.tr>
))}
</TableBody>
</Table>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<FileText className="mr-2 h-5 w-5" />
{hasFilters ? t('noResults') : t('noDocuments')}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { useTranslations } from 'next-intl';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import { Headphones, MessageCircle, AlertTriangle, Clock, User, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useTickets, useTicketCounts } from '@/hooks/integrations';
import type { TicketPriority, TicketStatus } from '@/types/integrations';
interface FreeScoutContentProps {
locale: string;
}
/** Priority badge variants */
const priorityVariants: Record<TicketPriority, 'default' | 'secondary' | 'destructive' | 'warning'> = {
urgent: 'destructive',
high: 'warning',
medium: 'secondary',
low: 'default',
};
/** Status badge variants */
const statusVariants: Record<TicketStatus, 'default' | 'secondary' | 'success' | 'outline'> = {
open: 'default',
pending: 'secondary',
resolved: 'success',
closed: 'outline',
};
/**
* FreeScout integration content - displays tickets
*/
export function FreeScoutContent({ locale }: FreeScoutContentProps) {
const t = useTranslations('widgets.tickets');
const { data: tickets, isLoading: ticketsLoading } = useTickets({ limit: 15 });
const { data: counts, isLoading: countsLoading } = useTicketCounts();
const dateLocale = locale === 'de' ? de : enUS;
return (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('status.open')}</CardTitle>
<Headphones className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{countsLoading ? <Skeleton className="h-8 w-12" /> : counts?.open ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('status.pending')}</CardTitle>
<Clock className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{countsLoading ? <Skeleton className="h-8 w-12" /> : counts?.pending ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('priority.urgent')}</CardTitle>
<AlertTriangle className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className={cn('text-2xl font-bold', (counts?.byPriority?.urgent ?? 0) > 0 && 'text-destructive')}>
{countsLoading ? <Skeleton className="h-8 w-12" /> : counts?.byPriority?.urgent ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('status.resolved')}</CardTitle>
<CheckCircle className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-500">
{countsLoading ? <Skeleton className="h-8 w-12" /> : counts?.resolved ?? 0}
</div>
</CardContent>
</Card>
</div>
{/* Tickets Table */}
<Card>
<CardHeader>
<CardTitle>Support-Tickets</CardTitle>
<CardDescription>Aktuelle Tickets aus FreeScout</CardDescription>
</CardHeader>
<CardContent>
{ticketsLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : tickets && tickets.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">#</TableHead>
<TableHead>Betreff</TableHead>
<TableHead>Kunde</TableHead>
<TableHead>Status</TableHead>
<TableHead>Prioritaet</TableHead>
<TableHead>Zugewiesen</TableHead>
<TableHead className="text-right">Antworten</TableHead>
<TableHead>Aktualisiert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets.map((ticket, index) => (
<motion.tr
key={ticket.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="border-b transition-colors hover:bg-muted/50"
>
<TableCell className="font-mono text-sm">{ticket.number}</TableCell>
<TableCell>
<p className="max-w-[200px] truncate font-medium">{ticket.subject}</p>
</TableCell>
<TableCell>
<div>
<p className="truncate">{ticket.customerName}</p>
<p className="truncate text-xs text-muted-foreground">{ticket.customerEmail}</p>
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariants[ticket.status]}>
{t(`status.${ticket.status}`)}
</Badge>
</TableCell>
<TableCell>
<Badge variant={priorityVariants[ticket.priority]}>
{t(`priority.${ticket.priority}`)}
</Badge>
</TableCell>
<TableCell>
{ticket.assigneeName ? (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarFallback className="text-xs">
{ticket.assigneeName
.split(' ')
.map((n) => n[0])
.join('')}
</AvatarFallback>
</Avatar>
<span className="text-sm">{ticket.assigneeName}</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
{ticket.replyCount}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{ticket.lastReplyAt
? formatDistanceToNow(ticket.lastReplyAt, {
addSuffix: true,
locale: dateLocale,
})
: '-'}
</TableCell>
</motion.tr>
))}
</TableBody>
</Table>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Headphones className="mr-2 h-5 w-5" />
{t('noTickets')}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,305 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import {
ClipboardCheck,
AlertTriangle,
Search,
Shield,
TrendingUp,
TrendingDown,
Minus,
Calendar,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
useAudits,
useOpenFindings,
useFindingCounts,
useComplianceScore,
getDaysUntilAudit,
} from '@/hooks/integrations';
interface GembaDocsContentProps {
locale: string;
}
const severityColors: Record<string, { bg: string; text: string }> = {
critical: { bg: 'bg-red-100', text: 'text-red-700' },
high: { bg: 'bg-orange-100', text: 'text-orange-700' },
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
low: { bg: 'bg-blue-100', text: 'text-blue-700' },
};
const statusColors: Record<string, { bg: string; text: string }> = {
scheduled: { bg: 'bg-blue-100', text: 'text-blue-700' },
in_progress: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
completed: { bg: 'bg-green-100', text: 'text-green-700' },
cancelled: { bg: 'bg-gray-100', text: 'text-gray-700' },
open: { bg: 'bg-red-100', text: 'text-red-700' },
resolved: { bg: 'bg-green-100', text: 'text-green-700' },
verified: { bg: 'bg-emerald-100', text: 'text-emerald-700' },
};
/**
* GembaDocs integration content - displays audits, findings, and compliance scores
*/
export function GembaDocsContent({ locale }: GembaDocsContentProps) {
const t = useTranslations('integrations');
const tTime = useTranslations('time');
const [searchQuery, setSearchQuery] = useState('');
const dateLocale = locale === 'de' ? de : enUS;
const { data: audits, isLoading: auditsLoading } = useAudits();
const { data: openFindings, isLoading: findingsLoading } = useOpenFindings();
const { data: findingCounts, isLoading: countsLoading } = useFindingCounts();
const { data: compliance, isLoading: complianceLoading } = useComplianceScore();
const TrendIcon =
compliance?.trend === 'up'
? TrendingUp
: compliance?.trend === 'down'
? TrendingDown
: Minus;
const trendColor =
compliance?.trend === 'up'
? 'text-green-600'
: compliance?.trend === 'down'
? 'text-red-600'
: 'text-muted-foreground';
const filteredAudits = audits?.filter(
(a) =>
!searchQuery ||
a.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
a.department.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t('complianceScore')}
</CardTitle>
<Shield className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
{complianceLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<div className="flex items-center gap-2">
<div className="text-2xl font-bold">{compliance?.overall ?? 0}%</div>
<TrendIcon className={cn('h-4 w-4', trendColor)} />
</div>
)}
{!complianceLoading && compliance && (
<Progress value={compliance.overall} className="mt-2" />
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t('audits')}
</CardTitle>
<ClipboardCheck className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{auditsLoading ? <Skeleton className="h-8 w-12" /> : audits?.length ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t('openFindings')}
</CardTitle>
<AlertTriangle className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className={cn('text-2xl font-bold', (findingCounts?.total ?? 0) > 0 && 'text-orange-500')}>
{countsLoading ? <Skeleton className="h-8 w-12" /> : findingCounts?.total ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t('critical')}
</CardTitle>
<AlertTriangle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className={cn('text-2xl font-bold', (findingCounts?.critical ?? 0) > 0 && 'text-red-500')}>
{countsLoading ? <Skeleton className="h-8 w-12" /> : findingCounts?.critical ?? 0}
</div>
</CardContent>
</Card>
</div>
{/* Finding Severity Breakdown */}
{!countsLoading && findingCounts && findingCounts.total > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('findingsBySeverity')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-3">
{(['critical', 'high', 'medium', 'low'] as const).map((severity) => {
const count = findingCounts[severity];
const colors = severityColors[severity];
return (
<Badge key={severity} className={cn(colors.bg, colors.text, 'text-sm')}>
{severity}: {count}
</Badge>
);
})}
</div>
</CardContent>
</Card>
)}
{/* Audits Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>{t('audits')}</CardTitle>
<CardDescription>
{t('allAuditsStatus')}
</CardDescription>
</div>
<div className="relative w-64">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('searchAudits')}
className="pl-8"
/>
</div>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px]">
{auditsLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : filteredAudits && filteredAudits.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('title')}</TableHead>
<TableHead>{t('type')}</TableHead>
<TableHead>Status</TableHead>
<TableHead>{t('department')}</TableHead>
<TableHead>{t('findings')}</TableHead>
<TableHead>{t('scheduled')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAudits.map((audit, index) => {
const daysUntil = getDaysUntilAudit(audit.scheduledDate);
const colors = statusColors[audit.status] ?? statusColors.scheduled;
return (
<motion.tr
key={audit.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="border-b transition-colors hover:bg-muted/50"
>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ClipboardCheck className="h-5 w-5" />
</div>
<div>
<p className="font-medium">{audit.title}</p>
<p className="text-xs text-muted-foreground">{audit.auditor}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{audit.type}</Badge>
</TableCell>
<TableCell>
<Badge className={cn(colors.bg, colors.text)}>
{audit.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{audit.department}</TableCell>
<TableCell>
<span className={cn(audit.openFindingsCount > 0 && 'text-orange-600 font-medium')}>
{audit.openFindingsCount}/{audit.findingsCount}
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm">
<Calendar className="h-3 w-3" />
{daysUntil !== null && daysUntil >= 0 ? (
<span className={cn(daysUntil <= 7 && 'text-orange-600')}>
{daysUntil === 0
? t('today')
: `${daysUntil}d`}
</span>
) : (
<span className="text-muted-foreground">
{formatDistanceToNow(audit.scheduledDate, {
addSuffix: true,
locale: dateLocale,
})}
</span>
)}
</div>
</TableCell>
</motion.tr>
);
})}
</TableBody>
</Table>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<ClipboardCheck className="mr-2 h-5 w-5" />
{searchQuery
? t('noAuditsFound')
: t('noAuditsAvailable')}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,248 @@
'use client';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import {
ArrowLeft,
Building2,
MessageSquare,
CheckSquare,
Headphones,
Cloud,
FileText,
ClipboardCheck,
RefreshCw,
Settings,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { IntegrationStatusBadge, SyncStatus, ConnectionTestButton } from '@/components/integrations';
import { useIntegrationStatus, useTriggerSync } from '@/hooks/integrations';
import type { IntegrationType } from '@/types/integrations';
// Integration-specific content imports
import { PlentyOneContent } from './plentyone-content';
import { ZulipContent } from './zulip-content';
import { TodoistContent } from './todoist-content';
import { FreeScoutContent } from './freescout-content';
import { NextcloudContent } from './nextcloud-content';
import { EcoDmsContent } from './ecodms-content';
import { GembaDocsContent } from './gembadocs-content';
interface IntegrationDetailContentProps {
locale: string;
integrationType: IntegrationType;
}
/** Integration metadata */
const integrationMeta: Record<
IntegrationType,
{ icon: LucideIcon; nameKey: string; descKey: string }
> = {
plentyone: { icon: Building2, nameKey: 'plentyOne', descKey: 'plentyOneDesc' },
zulip: { icon: MessageSquare, nameKey: 'zulip', descKey: 'zulipDesc' },
todoist: { icon: CheckSquare, nameKey: 'todoist', descKey: 'todoistDesc' },
freescout: { icon: Headphones, nameKey: 'freeScout', descKey: 'freeScoutDesc' },
nextcloud: { icon: Cloud, nameKey: 'nextcloud', descKey: 'nextcloudDesc' },
ecodms: { icon: FileText, nameKey: 'ecoDms', descKey: 'ecoDmsDesc' },
gembadocs: { icon: ClipboardCheck, nameKey: 'gembaDocs', descKey: 'gembaDocsDesc' },
};
/** Integration-specific content components */
const integrationContentMap: Record<IntegrationType, React.ComponentType<{ locale: string }>> = {
plentyone: PlentyOneContent,
zulip: ZulipContent,
todoist: TodoistContent,
freescout: FreeScoutContent,
nextcloud: NextcloudContent,
ecodms: EcoDmsContent,
gembadocs: GembaDocsContent,
};
/**
* Integration detail page content
*/
export function IntegrationDetailContent({
locale,
integrationType,
}: IntegrationDetailContentProps) {
const t = useTranslations('integrations');
const { data: config, isLoading } = useIntegrationStatus(integrationType);
const triggerSync = useTriggerSync();
const dateLocale = locale === 'de' ? de : enUS;
const meta = integrationMeta[integrationType];
const Icon = meta?.icon ?? Building2;
const ContentComponent = integrationContentMap[integrationType];
const handleSync = () => {
triggerSync.mutate(integrationType);
};
if (isLoading) {
return (
<div className="container mx-auto space-y-6 py-6">
<Skeleton className="h-8 w-48" />
<Card>
<CardHeader>
<Skeleton className="h-12 w-12 rounded-lg" />
<Skeleton className="mt-2 h-6 w-32" />
<Skeleton className="mt-1 h-4 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
</div>
);
}
if (!config) {
return (
<div className="container mx-auto py-6">
<p className="text-muted-foreground">Integration nicht gefunden.</p>
</div>
);
}
return (
<div className="container mx-auto space-y-6 py-6">
{/* Back link */}
<Button variant="ghost" size="sm" asChild>
<Link href={`/${locale}/integrations`}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('overview')}
</Link>
</Button>
{/* Header Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-lg',
config.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
<Icon className="h-6 w-6" />
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl">{t(meta.nameKey as never)}</CardTitle>
<IntegrationStatusBadge status={config.status} />
</div>
<CardDescription>{t(meta.descKey as never)}</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<ConnectionTestButton integrationType={integrationType} />
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={triggerSync.isPending || config.status !== 'connected'}
>
<RefreshCw
className={cn('mr-2 h-4 w-4', triggerSync.isPending && 'animate-spin')}
/>
{t('syncNow')}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<SyncStatus
lastSync={config.lastSync}
locale={locale}
error={config.lastError}
/>
</CardContent>
</Card>
</motion.div>
{/* Tabs */}
<Tabs defaultValue="data" className="space-y-4">
<TabsList>
<TabsTrigger value="data">{t('data')}</TabsTrigger>
<TabsTrigger value="settings">
<Settings className="mr-2 h-4 w-4" />
{t('settingsTab')}
</TabsTrigger>
<TabsTrigger value="logs">{t('logs')}</TabsTrigger>
</TabsList>
<TabsContent value="data" className="space-y-4">
{ContentComponent && <ContentComponent locale={locale} />}
</TabsContent>
<TabsContent value="settings">
<Card>
<CardHeader>
<CardTitle>{t('credentials')}</CardTitle>
<CardDescription>
{t('configureCredentials')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('settingsManagedViaAdmin')}
</p>
<Button variant="outline" asChild>
<Link href={`/${locale}/admin/integrations/${integrationType}`}>
<Settings className="mr-2 h-4 w-4" />
{t('configure')}
</Link>
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="logs">
<Card>
<CardHeader>
<CardTitle>{t('syncLogs')}</CardTitle>
<CardDescription>
{t('recentSyncActivity')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between border-b py-2">
<span className="text-muted-foreground">{t('syncSuccessful')}</span>
<span>
{config.lastSync
? formatDistanceToNow(config.lastSync, { addSuffix: true, locale: dateLocale })
: '-'}
</span>
</div>
{config.lastError && (
<div className="rounded-lg bg-destructive/10 p-3 text-destructive">
<p className="font-medium">{t('lastError')}</p>
<p>{config.lastError}</p>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,200 @@
'use client';
import { useTranslations } from 'next-intl';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import {
Cloud,
FileText,
FileImage,
FileSpreadsheet,
FileVideo,
FileArchive,
Folder,
Download,
HardDrive,
Clock,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useFiles, useRecentFiles, formatFileSize } from '@/hooks/integrations';
import type { NextcloudFile } from '@/types/integrations';
interface NextcloudContentProps {
locale: string;
}
/**
* Get file icon based on mime type
*/
function getFileIcon(file: NextcloudFile) {
if (file.type === 'folder') return Folder;
const mimeType = file.mimeType || '';
if (mimeType.startsWith('image/')) return FileImage;
if (mimeType.startsWith('video/')) return FileVideo;
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return FileSpreadsheet;
if (mimeType.includes('zip') || mimeType.includes('compressed')) return FileArchive;
return FileText;
}
/**
* Get file icon color based on type
*/
function getFileColor(file: NextcloudFile): string {
if (file.type === 'folder') return 'text-yellow-500 bg-yellow-500/10';
const mimeType = file.mimeType || '';
if (mimeType.startsWith('image/')) return 'text-green-500 bg-green-500/10';
if (mimeType.startsWith('video/')) return 'text-purple-500 bg-purple-500/10';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'text-emerald-500 bg-emerald-500/10';
if (mimeType.includes('pdf')) return 'text-red-500 bg-red-500/10';
if (mimeType.includes('zip') || mimeType.includes('compressed')) return 'text-orange-500 bg-orange-500/10';
return 'text-blue-500 bg-blue-500/10';
}
/**
* Nextcloud integration content - displays files
*/
export function NextcloudContent({ locale }: NextcloudContentProps) {
const t = useTranslations('widgets.files');
const { data: files, isLoading: filesLoading } = useFiles();
const { data: recentFiles, isLoading: recentLoading } = useRecentFiles(10);
const dateLocale = locale === 'de' ? de : enUS;
const totalSize = files?.reduce((sum, f) => sum + f.size, 0) ?? 0;
const folderCount = files?.filter((f) => f.type === 'folder').length ?? 0;
const fileCount = files?.filter((f) => f.type === 'file').length ?? 0;
return (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Dateien</CardTitle>
<FileText className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{filesLoading ? <Skeleton className="h-8 w-12" /> : fileCount}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Ordner</CardTitle>
<Folder className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{filesLoading ? <Skeleton className="h-8 w-12" /> : folderCount}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Speicher</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{filesLoading ? <Skeleton className="h-8 w-12" /> : formatFileSize(totalSize)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Zuletzt geaendert</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{recentLoading ? <Skeleton className="h-8 w-12" /> : recentFiles?.length ?? 0}
</div>
</CardContent>
</Card>
</div>
{/* Recent Files */}
<Card>
<CardHeader>
<CardTitle>Zuletzt geaenderte Dateien</CardTitle>
<CardDescription>Die neuesten Änderungen in Nextcloud</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px]">
{recentLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : recentFiles && recentFiles.length > 0 ? (
<div className="space-y-2">
{recentFiles.map((file, index) => {
const Icon = getFileIcon(file);
const colorClass = getFileColor(file);
return (
<motion.div
key={file.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="flex items-center gap-4 rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-lg',
colorClass
)}
>
<Icon className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{file.name}</p>
<p className="truncate text-sm text-muted-foreground">{file.path}</p>
<div className="mt-1 flex items-center gap-4 text-xs text-muted-foreground">
<span>{formatFileSize(file.size)}</span>
<span>
{formatDistanceToNow(file.modifiedAt, {
addSuffix: true,
locale: dateLocale,
})}
</span>
</div>
</div>
{file.downloadUrl && (
<Button variant="ghost" size="icon" asChild>
<a href={file.downloadUrl} download>
<Download className="h-4 w-4" />
</a>
</Button>
)}
</motion.div>
);
})}
</div>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Cloud className="mr-2 h-5 w-5" />
{t('noFiles')}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import { IntegrationDetailContent } from './integration-detail-content';
import type { IntegrationType } from '@/types/integrations';
interface IntegrationDetailPageProps {
params: {
locale: string;
type: string;
};
}
const integrationTitles: Record<string, string> = {
plentyone: 'PlentyONE',
zulip: 'ZULIP',
todoist: 'Todoist',
freescout: 'FreeScout',
nextcloud: 'Nextcloud',
ecodms: 'ecoDMS',
};
export async function generateMetadata({
params,
}: IntegrationDetailPageProps): Promise<Metadata> {
const title = integrationTitles[params.type] || 'Integration';
return {
title: `${title} | Integrationen | tOS`,
description: `${title} Integration verwalten`,
};
}
/**
* Dynamic integration detail page
*/
export default function IntegrationDetailPage({ params }: IntegrationDetailPageProps) {
return (
<IntegrationDetailContent
locale={params.locale}
integrationType={params.type as IntegrationType}
/>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { useTranslations } from 'next-intl';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import { Package, ShoppingCart, TrendingUp, TrendingDown, Truck, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useOrders, useOrderCounts } from '@/hooks/integrations';
import type { OrderStatus } from '@/types/integrations';
interface PlentyOneContentProps {
locale: string;
}
/** Order status badge variants */
const statusVariants: Record<OrderStatus, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
new: 'default',
processing: 'warning',
shipped: 'success',
delivered: 'secondary',
cancelled: 'destructive',
returned: 'destructive',
};
/**
* PlentyONE integration content - displays orders and statistics
*/
export function PlentyOneContent({ locale }: PlentyOneContentProps) {
const t = useTranslations('widgets.orders');
const { data: orders, isLoading: ordersLoading } = useOrders({ limit: 10 });
const { data: counts, isLoading: countsLoading } = useOrderCounts();
const dateLocale = locale === 'de' ? de : enUS;
return (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('status.new')}</CardTitle>
<ShoppingCart className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{countsLoading ? <Skeleton className="h-8 w-12" /> : counts?.new ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('status.processing')}</CardTitle>
<Package className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{countsLoading ? <Skeleton className="h-8 w-12" /> : counts?.processing ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('status.shipped')}</CardTitle>
<Truck className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{countsLoading ? <Skeleton className="h-8 w-12" /> : counts?.shipped ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Gesamt</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{countsLoading ? <Skeleton className="h-8 w-12" /> : counts?.total ?? 0}
</div>
</CardContent>
</Card>
</div>
{/* Orders Table */}
<Card>
<CardHeader>
<CardTitle>Letzte Bestellungen</CardTitle>
<CardDescription>Die 10 neuesten Bestellungen aus PlentyONE</CardDescription>
</CardHeader>
<CardContent>
{ordersLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : orders && orders.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Bestellnummer</TableHead>
<TableHead>Kunde</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Betrag</TableHead>
<TableHead>Datum</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((order, index) => (
<motion.tr
key={order.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="border-b transition-colors hover:bg-muted/50"
>
<TableCell className="font-medium">{order.orderNumber}</TableCell>
<TableCell>
<div>
<p>{order.customerName}</p>
<p className="text-xs text-muted-foreground">{order.customerEmail}</p>
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariants[order.status]}>
{t(`status.${order.status}`)}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{order.totalAmount.toLocaleString(locale, {
style: 'currency',
currency: order.currency,
})}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDistanceToNow(order.createdAt, {
addSuffix: true,
locale: dateLocale,
})}
</TableCell>
</motion.tr>
))}
</TableBody>
</Table>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Package className="mr-2 h-5 w-5" />
{t('noOrders')}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { CheckSquare, Flag, Calendar, FolderOpen, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useTasks, useProjects, useCompleteTask } from '@/hooks/integrations';
import type { TodoistPriority } from '@/types/integrations';
interface TodoistContentProps {
locale: string;
}
/** Priority colors */
const priorityColors: Record<TodoistPriority, string> = {
1: 'text-red-500',
2: 'text-orange-500',
3: 'text-blue-500',
4: 'text-muted-foreground',
};
const PRIORITY_LABEL_KEYS: Record<TodoistPriority, string> = {
1: 'priority1',
2: 'priority2',
3: 'priority3',
4: 'noPriority',
};
/**
* Todoist integration content - displays tasks and projects
*/
export function TodoistContent({ locale }: TodoistContentProps) {
const t = useTranslations('widgets.todoistTasks');
const tInt = useTranslations('integrations');
const { data: tasks, isLoading: tasksLoading } = useTasks();
const { data: projects, isLoading: projectsLoading } = useProjects();
const completeTask = useCompleteTask();
const pendingTasks = tasks?.filter((task) => !task.isCompleted) ?? [];
const todayTasks = pendingTasks.filter((task) => {
if (!task.dueDate) return false;
const today = new Date();
const due = new Date(task.dueDate);
return due.toDateString() === today.toDateString();
});
const handleComplete = (taskId: string) => {
completeTask.mutate(taskId);
};
return (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Heute faellig</CardTitle>
<Calendar className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{tasksLoading ? <Skeleton className="h-8 w-12" /> : todayTasks.length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Gesamt offen</CardTitle>
<CheckSquare className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{tasksLoading ? <Skeleton className="h-8 w-12" /> : pendingTasks.length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Hohe Prioritaet</CardTitle>
<Flag className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-500">
{tasksLoading ? (
<Skeleton className="h-8 w-12" />
) : (
pendingTasks.filter((t) => t.priority === 1).length
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projekte</CardTitle>
<FolderOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{projectsLoading ? <Skeleton className="h-8 w-12" /> : projects?.length ?? 0}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Projects */}
<Card>
<CardHeader>
<CardTitle>Projekte</CardTitle>
<CardDescription>Ihre Todoist Projekte</CardDescription>
</CardHeader>
<CardContent>
{projectsLoading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : projects && projects.length > 0 ? (
<div className="space-y-2">
{projects.map((project) => {
const taskCount = pendingTasks.filter((t) => t.projectId === project.id).length;
return (
<div
key={project.id}
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: project.color }}
/>
<span className="font-medium">{project.name}</span>
{project.isInbox && (
<Badge variant="secondary" className="text-xs">
Inbox
</Badge>
)}
</div>
<Badge variant="outline">{taskCount}</Badge>
</div>
);
})}
</div>
) : (
<p className="text-center text-muted-foreground">{tInt('noProjectsFound')}</p>
)}
</CardContent>
</Card>
{/* Tasks */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Aufgaben</CardTitle>
<CardDescription>Alle offenen Aufgaben</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px]">
{tasksLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : pendingTasks.length > 0 ? (
<div className="space-y-2">
{pendingTasks.map((task, index) => (
<motion.div
key={task.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className={cn(
'flex items-start gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/50',
task.isCompleted && 'opacity-50'
)}
>
<Checkbox
checked={task.isCompleted}
onCheckedChange={() => handleComplete(task.id)}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<p
className={cn(
'font-medium',
task.isCompleted && 'line-through text-muted-foreground'
)}
>
{task.content}
</p>
{task.description && (
<p className="text-sm text-muted-foreground">{task.description}</p>
)}
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{task.projectName && (
<span className="flex items-center gap-1">
<FolderOpen className="h-3 w-3" />
{task.projectName}
</span>
)}
{task.dueString && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{task.dueString}
</span>
)}
{task.labels.length > 0 && (
<div className="flex gap-1">
{task.labels.map((label) => (
<Badge key={label} variant="secondary" className="text-xs">
{label}
</Badge>
))}
</div>
)}
</div>
</div>
<Flag className={cn('h-4 w-4 shrink-0', priorityColors[task.priority])} />
</motion.div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<CheckCircle className="mb-2 h-10 w-10" />
<p>{t('noTasks')}</p>
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { useTranslations } from 'next-intl';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import { MessageSquare, Hash, Users, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useMessages, useStreams, useUnreadCounts } from '@/hooks/integrations';
interface ZulipContentProps {
locale: string;
}
/**
* ZULIP integration content - displays messages and streams
*/
export function ZulipContent({ locale }: ZulipContentProps) {
const t = useTranslations('widgets.chat');
const tInt = useTranslations('integrations');
const { data: messages, isLoading: messagesLoading } = useMessages();
const { data: streams, isLoading: streamsLoading } = useStreams();
const { data: unreadCounts } = useUnreadCounts();
const dateLocale = locale === 'de' ? de : enUS;
const totalUnread = unreadCounts?.reduce((sum, c) => sum + c.count, 0) ?? 0;
return (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Streams</CardTitle>
<Hash className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{streamsLoading ? <Skeleton className="h-8 w-12" /> : streams?.length ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Ungelesene</CardTitle>
<Circle className="h-4 w-4 fill-primary text-primary" />
</CardHeader>
<CardContent>
<div className={cn('text-2xl font-bold', totalUnread > 0 && 'text-primary')}>
{totalUnread}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Nachrichten heute</CardTitle>
<MessageSquare className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{messagesLoading ? <Skeleton className="h-8 w-12" /> : messages?.length ?? 0}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Streams */}
<Card>
<CardHeader>
<CardTitle>Streams</CardTitle>
<CardDescription>Ihre abonnierten ZULIP Streams</CardDescription>
</CardHeader>
<CardContent>
{streamsLoading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : streams && streams.length > 0 ? (
<div className="space-y-2">
{streams.map((stream) => {
const unread = unreadCounts?.find((c) => c.streamId === stream.id);
return (
<div
key={stream.id}
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
<Hash className="h-4 w-4 text-primary" />
</div>
<div>
<p className="font-medium">{stream.name}</p>
{stream.description && (
<p className="text-xs text-muted-foreground">{stream.description}</p>
)}
</div>
</div>
{unread && unread.count > 0 && (
<Badge variant="default">{unread.count}</Badge>
)}
</div>
);
})}
</div>
) : (
<p className="text-center text-muted-foreground">{tInt('noStreamsFound')}</p>
)}
</CardContent>
</Card>
{/* Recent Messages */}
<Card>
<CardHeader>
<CardTitle>Letzte Nachrichten</CardTitle>
<CardDescription>Aktuelle Nachrichten aus Ihren Streams</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[300px]">
{messagesLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : messages && messages.length > 0 ? (
<div className="space-y-4">
{messages.map((message, index) => (
<motion.div
key={message.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className={cn(
'rounded-lg border p-3',
!message.isRead && 'border-primary/50 bg-primary/5'
)}
>
<div className="flex items-start gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback>
{message.senderName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{message.senderName}</span>
{!message.isRead && (
<Circle className="h-2 w-2 fill-primary text-primary" />
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Hash className="h-3 w-3" />
{message.streamName} - {message.topic}
</div>
<p className="mt-1 text-sm">{message.content}</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatDistanceToNow(message.timestamp, {
addSuffix: true,
locale: dateLocale,
})}
</p>
</div>
</div>
</motion.div>
))}
</div>
) : (
<p className="text-center text-muted-foreground">{t('noMessages')}</p>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import {
Plug,
CheckCircle2,
AlertCircle,
Building2,
MessageSquare,
CheckSquare,
Headphones,
Cloud,
FileText,
ClipboardCheck,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { IntegrationCard } from '@/components/integrations';
import { useAllIntegrationStatuses } from '@/hooks/integrations';
import type { IntegrationType } from '@/types/integrations';
interface IntegrationsContentProps {
locale: string;
}
/** Integration metadata for display */
const integrationMeta: Record<
IntegrationType,
{ icon: typeof Building2; descKey: string }
> = {
plentyone: { icon: Building2, descKey: 'plentyOneDesc' },
zulip: { icon: MessageSquare, descKey: 'zulipDesc' },
todoist: { icon: CheckSquare, descKey: 'todoistDesc' },
freescout: { icon: Headphones, descKey: 'freeScoutDesc' },
nextcloud: { icon: Cloud, descKey: 'nextcloudDesc' },
ecodms: { icon: FileText, descKey: 'ecoDmsDesc' },
gembadocs: { icon: ClipboardCheck, descKey: 'gembaDocsDesc' },
};
/**
* Integrations overview content component
*/
export function IntegrationsContent({ locale }: IntegrationsContentProps) {
const t = useTranslations('integrations');
const { data: integrations, isLoading } = useAllIntegrationStatuses();
const connectedCount = integrations?.filter((i) => i.status === 'connected').length ?? 0;
const errorCount = integrations?.filter((i) => i.status === 'error').length ?? 0;
return (
<div className="container mx-auto space-y-8 py-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="mt-2 text-muted-foreground">{t('subtitle')}</p>
</div>
{/* Summary Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('allIntegrations')}</CardTitle>
<Plug className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{isLoading ? <Skeleton className="h-8 w-12" /> : integrations?.length ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('connected')}</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-500">
{isLoading ? <Skeleton className="h-8 w-12" /> : connectedCount}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('error')}</CardTitle>
<AlertCircle className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className={cn('text-2xl font-bold', errorCount > 0 && 'text-destructive')}>
{isLoading ? <Skeleton className="h-8 w-12" /> : errorCount}
</div>
</CardContent>
</Card>
</div>
{/* Integration Cards */}
<div>
<h2 className="mb-4 text-xl font-semibold">{t('overview')}</h2>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
</div>
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
<div className="mt-4 flex gap-2">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-24" />
</div>
</CardContent>
</Card>
))}
</div>
) : (
<motion.div
initial="hidden"
animate="visible"
variants={{
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
}}
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"
>
{integrations?.map((config, index) => (
<motion.div
key={config.id}
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
}}
>
<IntegrationCard config={config} locale={locale} />
</motion.div>
))}
</motion.div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { ReactNode } from 'react';
interface IntegrationsLayoutProps {
children: ReactNode;
}
/**
* Layout for integrations pages
* Provides consistent structure for all integration-related routes
*/
export default function IntegrationsLayout({ children }: IntegrationsLayoutProps) {
return <>{children}</>;
}

View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
import { IntegrationsContent } from './integrations-content';
export const metadata: Metadata = {
title: 'Integrationen | tOS',
description: 'Verwalten Sie Ihre externen Dienste und Integrationen',
};
interface IntegrationsPageProps {
params: {
locale: string;
};
}
/**
* Integrations overview page
* Displays status of all connected external services
*/
export default function IntegrationsPage({ params }: IntegrationsPageProps) {
return <IntegrationsContent locale={params.locale} />;
}

View File

@@ -0,0 +1,49 @@
'use client';
import { ReactNode } from 'react';
import { Sidebar, MobileSidebar, Header, PageTransition } from '@/components/layout';
import { useSidebarStore } from '@/stores/sidebar-store';
import { cn } from '@/lib/utils';
interface AuthLayoutProps {
children: ReactNode;
params: { locale: string };
}
/**
* Authenticated layout with sidebar and header
* Used for all protected routes
*/
export default function AuthLayout({ children, params: { locale } }: AuthLayoutProps) {
const { isExpanded } = useSidebarStore();
return (
<div className="min-h-screen bg-background">
{/* Desktop Sidebar - hidden on mobile */}
<div className="hidden lg:block">
<Sidebar locale={locale} />
</div>
{/* Mobile Sidebar Sheet */}
<MobileSidebar locale={locale} />
{/* Main Content Area */}
<div
className={cn(
'flex min-h-screen flex-col transition-[margin-left] duration-200 ease-in-out',
// Mobile: no margin (sidebar is overlay)
'ml-0',
// Desktop: margin based on sidebar state
isExpanded ? 'lg:ml-[240px]' : 'lg:ml-[64px]'
)}
>
<Header locale={locale} />
<main className="flex-1 p-4 md:p-6 lg:p-8">
<PageTransition>{children}</PageTransition>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,220 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { ArrowLeft, History, Settings, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
import { MeetingBoard } from '@/components/lean/morning-meeting';
import {
useTodaysMeeting,
useStartMeeting,
useEndMeeting,
useUpdateTopic,
useAddAction,
useUpdateAction,
useCompleteAction,
} from '@/hooks/lean/use-meetings';
import type { CreateActionDto, UpdateActionDto, UpdateTopicDto } from '@/types/lean';
interface DepartmentMeetingContentProps {
locale: string;
departmentId: string;
}
/**
* Department meeting content component
*/
export function DepartmentMeetingContent({
locale,
departmentId,
}: DepartmentMeetingContentProps) {
const router = useRouter();
const { toast } = useToast();
const t = useTranslations('lean.morningMeeting.toast');
const tNav = useTranslations('lean.morningMeeting');
// Fetch today's meeting (create if not exists)
const {
data: meeting,
isLoading,
error,
refetch,
} = useTodaysMeeting(departmentId, true);
// Mutations
const startMeeting = useStartMeeting();
const endMeeting = useEndMeeting();
const updateTopic = useUpdateTopic();
const addAction = useAddAction();
const updateAction = useUpdateAction();
const completeAction = useCompleteAction();
const isMutating =
startMeeting.isPending ||
endMeeting.isPending ||
updateTopic.isPending ||
addAction.isPending ||
updateAction.isPending ||
completeAction.isPending;
const handleStartMeeting = async () => {
if (!meeting) return;
try {
await startMeeting.mutateAsync(meeting.id);
toast({
title: t('meetingStarted'),
description: t('meetingStartedDesc'),
});
} catch (err) {
toast({
title: t('error'),
description: t('errorStartMeeting'),
variant: 'destructive',
});
}
};
const handleEndMeeting = async () => {
if (!meeting) return;
try {
await endMeeting.mutateAsync(meeting.id);
toast({
title: t('meetingEnded'),
description: t('meetingEndedDesc'),
});
} catch (err) {
toast({
title: t('error'),
description: t('errorEndMeeting'),
variant: 'destructive',
});
}
};
const handleUpdateTopic = async (id: string, dto: UpdateTopicDto) => {
try {
await updateTopic.mutateAsync({ id, dto });
toast({
title: t('kpiUpdated'),
description: t('kpiUpdatedDesc'),
});
} catch (err) {
toast({
title: t('error'),
description: t('errorUpdateKpi'),
variant: 'destructive',
});
}
};
const handleAddAction = async (dto: CreateActionDto) => {
if (!meeting) return;
try {
await addAction.mutateAsync({ meetingId: meeting.id, dto });
toast({
title: t('actionCreated'),
description: t('actionCreatedDesc'),
});
} catch (err) {
toast({
title: t('error'),
description: t('errorCreateAction'),
variant: 'destructive',
});
}
};
const handleUpdateAction = async (id: string, dto: UpdateActionDto) => {
try {
await updateAction.mutateAsync({ id, dto });
toast({
title: t('actionUpdated'),
description: t('actionUpdatedDesc'),
});
} catch (err) {
toast({
title: t('error'),
description: t('errorUpdateAction'),
variant: 'destructive',
});
}
};
const handleCompleteAction = async (id: string) => {
try {
await completeAction.mutateAsync(id);
toast({
title: t('actionCompleted'),
description: t('actionCompletedDesc'),
});
} catch (err) {
toast({
title: t('error'),
description: t('errorCompleteAction'),
variant: 'destructive',
});
}
};
const handleRefresh = () => {
refetch();
};
return (
<div className="container mx-auto py-6 space-y-6">
{/* Navigation */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/${locale}/lean/morning-meeting`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
{tNav('backToOverview')}
</Button>
</Link>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
{tNav('refresh')}
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/${locale}/lean/morning-meeting/${departmentId}/history`}>
<History className="h-4 w-4 mr-2" />
{tNav('history')}
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/${locale}/lean/morning-meeting/${departmentId}/settings`}>
<Settings className="h-4 w-4 mr-2" />
{tNav('settings')}
</Link>
</Button>
</div>
</div>
{/* Meeting Board */}
<MeetingBoard
meeting={meeting}
isLoading={isLoading}
error={error || null}
editable={true}
onStartMeeting={handleStartMeeting}
onEndMeeting={handleEndMeeting}
onUpdateTopic={handleUpdateTopic}
onAddAction={handleAddAction}
onUpdateAction={handleUpdateAction}
onCompleteAction={handleCompleteAction}
onRefresh={handleRefresh}
isMutating={isMutating}
locale={locale}
targetDuration={15}
/>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { Metadata } from 'next';
import { DepartmentMeetingContent } from './department-meeting-content';
export const metadata: Metadata = {
title: 'Morning Meeting Board | tOS',
description: 'Morning Meeting SQCDM Board fuer die Abteilung',
};
interface DepartmentMeetingPageProps {
params: {
locale: string;
departmentId: string;
};
}
export default function DepartmentMeetingPage({
params,
}: DepartmentMeetingPageProps) {
return (
<DepartmentMeetingContent
locale={params.locale}
departmentId={params.departmentId}
/>
);
}

View File

@@ -0,0 +1,505 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import {
Calendar,
Building2,
ChevronLeft,
ChevronRight,
Clock,
CheckCircle,
AlertCircle,
Plus,
Search,
Filter,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useMeetings, useOpenActions } from '@/hooks/lean/use-meetings';
import type { MorningMeeting, MeetingStatus } from '@/types/lean';
interface MorningMeetingOverviewContentProps {
locale: string;
}
// Mock departments - replace with actual data fetching
const MOCK_DEPARTMENTS = [
{ id: 'dept-1', name: 'Produktion', code: 'PROD' },
{ id: 'dept-2', name: 'Logistik', code: 'LOG' },
{ id: 'dept-3', name: 'Qualitaet', code: 'QM' },
{ id: 'dept-4', name: 'Instandhaltung', code: 'IH' },
];
/**
* Meeting status indicator
*/
function StatusIndicator({ status }: { status: MeetingStatus }) {
switch (status) {
case 'COMPLETED':
return <CheckCircle className="h-3 w-3 text-green-600" />;
case 'IN_PROGRESS':
return <Clock className="h-3 w-3 text-blue-600 animate-pulse" />;
case 'CANCELLED':
return <AlertCircle className="h-3 w-3 text-red-600" />;
default:
return <Clock className="h-3 w-3 text-gray-400" />;
}
}
/**
* Calendar view component
*/
function MeetingCalendar({
meetings,
currentMonth,
onMonthChange,
onDateClick,
locale,
}: {
meetings: MorningMeeting[];
currentMonth: Date;
onMonthChange: (date: Date) => void;
onDateClick: (date: Date) => void;
locale: string;
}) {
const dateLocale = locale === 'de' ? de : enUS;
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
// Get the day of week for the first day (0 = Sunday, 1 = Monday, etc.)
// Adjust for Monday start
const startDayOfWeek = (monthStart.getDay() + 6) % 7;
const paddingDays = Array(startDayOfWeek).fill(null);
const getMeetingsForDay = (date: Date) =>
meetings.filter((m) => isSameDay(new Date(m.date), date));
const weekDays = locale === 'de'
? ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
{format(currentMonth, 'MMMM yyyy', { locale: dateLocale })}
</CardTitle>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() =>
onMonthChange(
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1)
)
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() =>
onMonthChange(
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1)
)
}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{/* Week day headers */}
<div className="grid grid-cols-7 gap-1 mb-2">
{weekDays.map((day) => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2"
>
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{/* Padding days */}
{paddingDays.map((_, i) => (
<div key={`pad-${i}`} className="aspect-square" />
))}
{/* Actual days */}
{days.map((day) => {
const dayMeetings = getMeetingsForDay(day);
const isToday = isSameDay(day, new Date());
const hasMeetings = dayMeetings.length > 0;
return (
<button
key={day.toISOString()}
onClick={() => onDateClick(day)}
className={cn(
'aspect-square p-1 rounded-lg text-sm transition-colors relative',
'hover:bg-muted',
isToday && 'bg-primary/10 font-bold',
hasMeetings && 'bg-blue-50'
)}
>
<span className={cn(isToday && 'text-primary')}>
{format(day, 'd')}
</span>
{hasMeetings && (
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-0.5">
{dayMeetings.slice(0, 3).map((m) => (
<StatusIndicator key={m.id} status={m.status} />
))}
{dayMeetings.length > 3 && (
<span className="text-[8px] text-muted-foreground">
+{dayMeetings.length - 3}
</span>
)}
</div>
)}
</button>
);
})}
</div>
</CardContent>
</Card>
);
}
/**
* Status badge for department card
*/
function DepartmentCardStatusBadge({ status }: { status: MeetingStatus }) {
const t = useTranslations('lean.morningMeeting.status');
const statusKeyMap: Record<MeetingStatus, 'scheduled' | 'inProgress' | 'completed' | 'cancelled'> = {
SCHEDULED: 'scheduled',
IN_PROGRESS: 'inProgress',
COMPLETED: 'completed',
CANCELLED: 'cancelled',
};
return (
<Badge variant="outline" className="text-xs">
<StatusIndicator status={status} />
<span className="ml-1">{t(statusKeyMap[status])}</span>
</Badge>
);
}
/**
* Department quick access card
*/
function DepartmentCard({
department,
lastMeeting,
onClick,
}: {
department: (typeof MOCK_DEPARTMENTS)[0];
lastMeeting?: MorningMeeting;
onClick: () => void;
}) {
return (
<Card
className="cursor-pointer transition-shadow hover:shadow-md"
onClick={onClick}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Building2 className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-medium">{department.name}</h3>
<p className="text-xs text-muted-foreground">{department.code}</p>
</div>
</div>
{lastMeeting && (
<DepartmentCardStatusBadge status={lastMeeting.status} />
)}
</div>
</CardContent>
</Card>
);
}
/**
* Recent meetings list
*/
function RecentMeetingsList({
meetings,
isLoading,
onMeetingClick,
locale,
}: {
meetings: MorningMeeting[];
isLoading: boolean;
onMeetingClick: (meeting: MorningMeeting) => void;
locale: string;
}) {
const dateLocale = locale === 'de' ? de : enUS;
const tMeeting = useTranslations('lean.morningMeeting');
if (isLoading) {
return (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
);
}
if (meetings.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{tMeeting('noMeetingsFound')}</p>
</div>
);
}
return (
<div className="space-y-2">
{meetings.map((meeting) => (
<button
key={meeting.id}
onClick={() => onMeetingClick(meeting)}
className="w-full p-3 rounded-lg border bg-card hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusIndicator status={meeting.status} />
<div>
<p className="font-medium text-sm">{meeting.department.name}</p>
<p className="text-xs text-muted-foreground">
{format(new Date(meeting.date), 'EEEE, d. MMM', {
locale: dateLocale,
})}
</p>
</div>
</div>
{meeting.duration && (
<span className="text-xs text-muted-foreground">
{meeting.duration} Min.
</span>
)}
</div>
</button>
))}
</div>
);
}
/**
* Main overview content component
*/
export function MorningMeetingOverviewContent({
locale,
}: MorningMeetingOverviewContentProps) {
const router = useRouter();
const t = useTranslations('lean.morningMeeting');
const tStatus = useTranslations('lean.morningMeeting.status');
const tCommon = useTranslations('common');
const [currentMonth, setCurrentMonth] = useState(new Date());
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<MeetingStatus | 'ALL'>('ALL');
// Fetch meetings for the current month
const { data: meetingsData, isLoading: meetingsLoading } = useMeetings({
dateFrom: format(startOfMonth(currentMonth), 'yyyy-MM-dd'),
dateTo: format(endOfMonth(currentMonth), 'yyyy-MM-dd'),
limit: 100,
sortBy: 'date',
sortOrder: 'desc',
status: statusFilter === 'ALL' ? undefined : statusFilter,
});
// Fetch open actions
const { data: openActions, isLoading: actionsLoading } = useOpenActions();
const meetings = meetingsData?.data || [];
const handleDepartmentClick = (departmentId: string) => {
router.push(`/${locale}/lean/morning-meeting/${departmentId}`);
};
const handleMeetingClick = (meeting: MorningMeeting) => {
router.push(`/${locale}/lean/morning-meeting/${meeting.department.id}`);
};
const handleDateClick = (date: Date) => {
// Find meetings for this date
const dayMeetings = meetings.filter((m) =>
isSameDay(new Date(m.date), date)
);
if (dayMeetings.length === 1) {
handleMeetingClick(dayMeetings[0]);
}
// If multiple meetings, could show a modal or filter the list
};
return (
<div className="container mx-auto py-6 space-y-6">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground">
{t('overview')}
</p>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={tCommon('search') + '...'}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 w-[200px]"
/>
</div>
<Select
value={statusFilter}
onValueChange={(v) => setStatusFilter(v as MeetingStatus | 'ALL')}
>
<SelectTrigger className="w-[150px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">{t('allStatuses')}</SelectItem>
<SelectItem value="SCHEDULED">{tStatus('scheduled')}</SelectItem>
<SelectItem value="IN_PROGRESS">{tStatus('inProgress')}</SelectItem>
<SelectItem value="COMPLETED">{tStatus('completed')}</SelectItem>
<SelectItem value="CANCELLED">{tStatus('cancelled')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Quick access - Departments */}
<div>
<h2 className="text-lg font-semibold mb-3">{t('departmentsTitle')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{MOCK_DEPARTMENTS.map((dept) => {
const lastMeeting = meetings.find(
(m) => m.department.id === dept.id
);
return (
<DepartmentCard
key={dept.id}
department={dept}
lastMeeting={lastMeeting}
onClick={() => handleDepartmentClick(dept.id)}
/>
);
})}
</div>
</div>
{/* Main content grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Calendar */}
<div className="lg:col-span-2">
<MeetingCalendar
meetings={meetings}
currentMonth={currentMonth}
onMonthChange={setCurrentMonth}
onDateClick={handleDateClick}
locale={locale}
/>
</div>
{/* Recent meetings */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('currentMeetingsTitle')}</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[350px]">
<RecentMeetingsList
meetings={meetings.slice(0, 10)}
isLoading={meetingsLoading}
onMeetingClick={handleMeetingClick}
locale={locale}
/>
</ScrollArea>
</CardContent>
</Card>
</div>
{/* Open actions summary */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{t('openActionsTitle')}</CardTitle>
<Badge variant="secondary">
{actionsLoading ? '...' : t('openCount', { count: openActions?.length || 0 })}
</Badge>
</div>
</CardHeader>
<CardContent>
{actionsLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : openActions && openActions.length > 0 ? (
<ScrollArea className="h-[200px]">
<div className="space-y-2">
{openActions.slice(0, 10).map((action) => (
<div
key={action.id}
className="flex items-center justify-between p-2 rounded-lg border"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{action.title}</p>
<p className="text-xs text-muted-foreground">
{(action as { meeting?: { department?: { name: string } } }).meeting?.department?.name}
</p>
</div>
{action.assignee && (
<span className="text-xs text-muted-foreground ml-2">
{action.assignee.firstName} {action.assignee.lastName}
</span>
)}
</div>
))}
</div>
</ScrollArea>
) : (
<div className="text-center py-8 text-muted-foreground">
<CheckCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('noOpenActions')}</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next';
import { MorningMeetingOverviewContent } from './morning-meeting-overview-content';
export const metadata: Metadata = {
title: 'Morning Meeting | tOS',
description: 'Morning Meeting Uebersicht und Kalender',
};
interface MorningMeetingPageProps {
params: { locale: string };
}
export default function MorningMeetingPage({ params }: MorningMeetingPageProps) {
return <MorningMeetingOverviewContent locale={params.locale} />;
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { PageTransition } from '@/components/layout/page-transition';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import {
ClipboardList,
Users,
Calendar,
TrendingUp,
CheckCircle2,
Target
} from 'lucide-react';
const leanModules = [
{
id: 's3-planning',
icon: ClipboardList,
href: '/lean/s3-planning',
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
},
{
id: 'morning-meeting',
icon: Calendar,
href: '/lean/morning-meeting',
color: 'text-green-500',
bgColor: 'bg-green-500/10',
},
{
id: 'skill-matrix',
icon: Users,
href: '/lean/skill-matrix',
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
},
];
export default function LeanPage() {
const t = useTranslations('lean');
const params = useParams();
const locale = params.locale as string;
return (
<PageTransition>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">{t('description')}</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{leanModules.map((module) => {
const Icon = module.icon;
return (
<Link key={module.id} href={`/${locale}${module.href}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardHeader>
<div className={`w-12 h-12 rounded-lg ${module.bgColor} flex items-center justify-center mb-4`}>
<Icon className={`h-6 w-6 ${module.color}`} />
</div>
<CardTitle>{t(`modules.${module.id}.title`)}</CardTitle>
<CardDescription>{t(`modules.${module.id}.description`)}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Target className="h-4 w-4" />
<span>{t(`modules.${module.id}.status`)}</span>
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
{/* Quick Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('stats.s3Completion')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">78%</div>
<div className="flex items-center text-xs text-green-600">
<TrendingUp className="h-3 w-3 mr-1" />
+5% vs. last month
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('stats.meetingsThisWeek')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">12</div>
<div className="text-xs text-muted-foreground">5 departments</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('stats.openActions')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">23</div>
<div className="text-xs text-yellow-600">8 overdue</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('stats.skillCoverage')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">85%</div>
<div className="flex items-center text-xs text-green-600">
<CheckCircle2 className="h-3 w-3 mr-1" />
Target: 80%
</div>
</CardContent>
</Card>
</div>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,27 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { S3DepartmentView } from '@/components/lean/s3';
interface DepartmentPlanPageProps {
params: { locale: string; departmentId: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('lean');
return {
title: `${t('s3Planning')} - Abteilung`,
description: '3S-Plan fuer eine spezifische Abteilung mit Wochenansicht',
};
}
/**
* Department S3 Plan Page - Server Component
* Displays the 3S plan for a specific department with weekly status grid
*/
export default function DepartmentPlanPage({
params: { locale, departmentId },
}: DepartmentPlanPageProps) {
return <S3DepartmentView departmentId={departmentId} locale={locale} />;
}

View File

@@ -0,0 +1,25 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { S3PlanOverview } from '@/components/lean/s3';
interface S3PlanningPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('lean');
return {
title: t('s3Planning'),
description: 'Uebersicht aller 3S-Plaene nach Abteilung',
};
}
/**
* S3 Planning Overview Page - Server Component
* Displays all department 3S plans with filtering and statistics
*/
export default function S3PlanningPage({ params: { locale } }: S3PlanningPageProps) {
return <S3PlanOverview locale={locale} />;
}

View File

@@ -0,0 +1,399 @@
'use client';
import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { motion } from 'framer-motion';
import {
ArrowLeft,
Download,
Upload,
RefreshCw,
Settings,
BarChart3,
Users,
AlertTriangle,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageTransition } from '@/components/layout/page-transition';
import { useToast } from '@/hooks/use-toast';
import {
SkillMatrixGrid,
SkillGapChart,
SkillGapSummary,
} from '@/components/lean/skill-matrix';
import {
useSkillMatrix,
useSkillGapAnalysis,
useBulkUpsertSkillEntries,
type SkillMatrix,
type SkillGapAnalysis,
} from '@/hooks/lean/use-skill-matrix';
import type { SkillLevelValue } from '@/hooks/lean/use-skills';
interface DepartmentSkillMatrixContentProps {
locale: string;
departmentId: string;
}
// Mock data for development
// TODO: Remove when API is connected
const mockMatrix: SkillMatrix = {
department: {
id: '1',
name: 'IT & Entwicklung',
code: 'IT',
},
skills: [
{ id: 's1', name: 'TypeScript', description: 'TypeScript programming', category: 'Frontend', isGlobal: false },
{ id: 's2', name: 'React', description: 'React framework', category: 'Frontend', isGlobal: false },
{ id: 's3', name: 'Next.js', description: 'Next.js framework', category: 'Frontend', isGlobal: false },
{ id: 's4', name: 'Node.js', description: 'Node.js runtime', category: 'Backend', isGlobal: false },
{ id: 's5', name: 'PostgreSQL', description: 'PostgreSQL database', category: 'Backend', isGlobal: false },
{ id: 's6', name: 'Docker', description: 'Containerization', category: 'DevOps', isGlobal: true },
{ id: 's7', name: 'Git', description: 'Version control', category: 'Tools', isGlobal: true },
{ id: 's8', name: 'Scrum', description: 'Agile methodology', category: 'Process', isGlobal: true },
],
matrix: [
{
employee: {
id: 'e1',
employeeNumber: 'EMP001',
position: 'Senior Developer',
user: { id: 'u1', firstName: 'Max', lastName: 'Mustermann', email: 'max@example.com' },
},
skills: {
s1: { level: 4 as SkillLevelValue, assessedAt: '2024-01-15', assessedBy: { id: 'a1', firstName: 'Admin', lastName: 'User' }, notes: null },
s2: { level: 4 as SkillLevelValue, assessedAt: '2024-01-15', assessedBy: { id: 'a1', firstName: 'Admin', lastName: 'User' }, notes: null },
s3: { level: 3 as SkillLevelValue, assessedAt: '2024-01-15', assessedBy: { id: 'a1', firstName: 'Admin', lastName: 'User' }, notes: null },
s4: { level: 3 as SkillLevelValue, assessedAt: '2024-01-15', assessedBy: null, notes: null },
s5: { level: 2 as SkillLevelValue, assessedAt: '2024-01-10', assessedBy: null, notes: null },
s6: { level: 3 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s7: { level: 4 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s8: { level: 2 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
},
},
{
employee: {
id: 'e2',
employeeNumber: 'EMP002',
position: 'Junior Developer',
user: { id: 'u2', firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' },
},
skills: {
s1: { level: 2 as SkillLevelValue, assessedAt: '2024-01-20', assessedBy: null, notes: null },
s2: { level: 2 as SkillLevelValue, assessedAt: '2024-01-20', assessedBy: null, notes: null },
s3: { level: 1 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: 'In training' },
s4: { level: 1 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s5: { level: 1 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s6: { level: 1 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s7: { level: 2 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s8: { level: 2 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
},
},
{
employee: {
id: 'e3',
employeeNumber: 'EMP003',
position: 'Backend Developer',
user: { id: 'u3', firstName: 'Peter', lastName: 'Mueller', email: 'peter@example.com' },
},
skills: {
s1: { level: 3 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s2: { level: 2 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s3: { level: 1 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s4: { level: 4 as SkillLevelValue, assessedAt: '2024-01-05', assessedBy: null, notes: 'Expert' },
s5: { level: 4 as SkillLevelValue, assessedAt: '2024-01-05', assessedBy: null, notes: null },
s6: { level: 3 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s7: { level: 3 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s8: { level: 2 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
},
},
{
employee: {
id: 'e4',
employeeNumber: 'EMP004',
position: 'DevOps Engineer',
user: { id: 'u4', firstName: 'Lisa', lastName: 'Weber', email: 'lisa@example.com' },
},
skills: {
s1: { level: 2 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s2: { level: 1 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s3: { level: 0 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s4: { level: 3 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s5: { level: 3 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s6: { level: 4 as SkillLevelValue, assessedAt: '2024-01-08', assessedBy: null, notes: 'Trainer' },
s7: { level: 4 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
s8: { level: 3 as SkillLevelValue, assessedAt: null, assessedBy: null, notes: null },
},
},
],
};
const mockGapAnalysis: SkillGapAnalysis[] = [
{ skillId: 's3', skillName: 'Next.js', category: 'Frontend', targetLevel: 2, averageLevel: 1.25, gap: 0.75, employeesBelow: 2, employeesAtOrAbove: 2, totalEmployees: 4 },
{ skillId: 's4', skillName: 'Node.js', category: 'Backend', targetLevel: 2, averageLevel: 2.75, gap: -0.75, employeesBelow: 1, employeesAtOrAbove: 3, totalEmployees: 4 },
{ skillId: 's1', skillName: 'TypeScript', category: 'Frontend', targetLevel: 2, averageLevel: 2.75, gap: -0.75, employeesBelow: 0, employeesAtOrAbove: 4, totalEmployees: 4 },
{ skillId: 's5', skillName: 'PostgreSQL', category: 'Backend', targetLevel: 2, averageLevel: 2.5, gap: -0.5, employeesBelow: 1, employeesAtOrAbove: 3, totalEmployees: 4 },
{ skillId: 's2', skillName: 'React', category: 'Frontend', targetLevel: 2, averageLevel: 2.25, gap: -0.25, employeesBelow: 1, employeesAtOrAbove: 3, totalEmployees: 4 },
{ skillId: 's8', skillName: 'Scrum', category: 'Process', targetLevel: 2, averageLevel: 2.25, gap: -0.25, employeesBelow: 0, employeesAtOrAbove: 4, totalEmployees: 4 },
{ skillId: 's6', skillName: 'Docker', category: 'DevOps', targetLevel: 2, averageLevel: 2.75, gap: -0.75, employeesBelow: 1, employeesAtOrAbove: 3, totalEmployees: 4 },
{ skillId: 's7', skillName: 'Git', category: 'Tools', targetLevel: 2, averageLevel: 3.25, gap: -1.25, employeesBelow: 0, employeesAtOrAbove: 4, totalEmployees: 4 },
];
/**
* Department skill matrix content
* Shows the matrix grid and gap analysis for a specific department
*/
export function DepartmentSkillMatrixContent({
locale,
departmentId,
}: DepartmentSkillMatrixContentProps) {
const t = useTranslations('lean');
const tCommon = useTranslations('common');
const { toast } = useToast();
const [targetLevel, setTargetLevel] = useState<number>(2);
const [pendingChanges, setPendingChanges] = useState<
Map<string, { employeeId: string; skillId: string; level: SkillLevelValue }>
>(new Map());
// API hooks - using mock data for now
// const { data: matrix, isLoading: matrixLoading } = useSkillMatrix(departmentId);
// const { data: gapAnalysis, isLoading: gapLoading } = useSkillGapAnalysis(departmentId, targetLevel);
const bulkUpsert = useBulkUpsertSkillEntries();
// Use mock data for development
const matrix = mockMatrix;
const matrixLoading = false;
const gapAnalysis = mockGapAnalysis;
const gapLoading = false;
// Handle level change with optimistic update
const handleLevelChange = useCallback(
(employeeId: string, skillId: string, newLevel: SkillLevelValue) => {
const key = `${employeeId}-${skillId}`;
setPendingChanges((prev) => {
const next = new Map(prev);
next.set(key, { employeeId, skillId, level: newLevel });
return next;
});
// TODO: Debounce and batch API calls
toast({
title: t('skillMatrix.levelUpdated'),
description: t('skillMatrix.levelUpdatedDescription'),
});
},
[t, toast]
);
// Save all pending changes
const handleSaveChanges = useCallback(async () => {
if (pendingChanges.size === 0) return;
const entries = Array.from(pendingChanges.values());
try {
await bulkUpsert.mutateAsync({ entries });
setPendingChanges(new Map());
toast({
title: tCommon('success'),
description: t('skillMatrix.changesSaved'),
});
} catch {
toast({
title: tCommon('error'),
description: t('skillMatrix.saveError'),
variant: 'destructive',
});
}
}, [pendingChanges, bulkUpsert, t, tCommon, toast]);
if (matrixLoading) {
return <LoadingSkeleton />;
}
if (!matrix) {
return (
<PageTransition>
<div className="flex flex-col items-center justify-center py-12">
<AlertTriangle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-muted-foreground">{t('skillMatrix.notFound')}</p>
<Link href={`/${locale}/lean/skill-matrix`}>
<Button variant="outline" className="mt-4 gap-2">
<ArrowLeft className="h-4 w-4" />
{tCommon('back')}
</Button>
</Link>
</div>
</PageTransition>
);
}
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Link href={`/${locale}/lean/skill-matrix`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">{matrix.department.name}</h1>
<p className="text-muted-foreground">
{t('skillMatrix.subtitle', {
employees: matrix.matrix.length,
skills: matrix.skills.length,
})}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{pendingChanges.size > 0 && (
<Badge variant="secondary">
{pendingChanges.size} {t('skillMatrix.unsavedChanges')}
</Badge>
)}
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleSaveChanges}
disabled={pendingChanges.size === 0 || bulkUpsert.isPending}
>
{bulkUpsert.isPending ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{tCommon('save')}
</Button>
<Button variant="outline" size="sm" className="gap-2">
<Upload className="h-4 w-4" />
{tCommon('export')}
</Button>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
{/* Tabs for Matrix and Analysis */}
<Tabs defaultValue="matrix" className="space-y-4">
<TabsList>
<TabsTrigger value="matrix" className="gap-2">
<Users className="h-4 w-4" />
{t('skillMatrix.matrixView')}
</TabsTrigger>
<TabsTrigger value="analysis" className="gap-2">
<BarChart3 className="h-4 w-4" />
{t('skillMatrix.gapAnalysis')}
</TabsTrigger>
</TabsList>
<TabsContent value="matrix" className="space-y-4">
<SkillMatrixGrid
matrix={matrix}
isLoading={matrixLoading}
editable={true}
onLevelChange={handleLevelChange}
locale={locale}
/>
</TabsContent>
<TabsContent value="analysis" className="space-y-4">
{/* Target Level Selector */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{t('skillMatrix.gapAnalysisSettings')}</CardTitle>
<CardDescription>{t('skillMatrix.gapAnalysisSettingsDescription')}</CardDescription>
</div>
<Select
value={String(targetLevel)}
onValueChange={(v) => setTargetLevel(Number(v))}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t('skillMatrix.levels.1')}</SelectItem>
<SelectItem value="2">{t('skillMatrix.levels.2')}</SelectItem>
<SelectItem value="3">{t('skillMatrix.levels.3')}</SelectItem>
<SelectItem value="4">{t('skillMatrix.levels.4')}</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
</Card>
{/* Gap Summary */}
<SkillGapSummary data={gapAnalysis || []} />
{/* Gap Chart */}
<SkillGapChart
data={gapAnalysis || []}
targetLevel={targetLevel}
isLoading={gapLoading}
/>
</TabsContent>
</Tabs>
</div>
</PageTransition>
);
}
/**
* Loading skeleton
*/
function LoadingSkeleton() {
return (
<PageTransition>
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-32 mt-2" />
</div>
</div>
<Skeleton className="h-10 w-64" />
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-10 w-40" />
<div className="flex gap-2">
{[1, 2, 3, 4, 5, 6].map((j) => (
<Skeleton key={j} className="h-10 w-10 rounded-full" />
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</PageTransition>
);
}

View File

@@ -0,0 +1,28 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { DepartmentSkillMatrixContent } from './department-skill-matrix-content';
interface DepartmentSkillMatrixPageProps {
params: { locale: string; departmentId: string };
}
export async function generateMetadata({
params,
}: DepartmentSkillMatrixPageProps): Promise<Metadata> {
const t = await getTranslations('lean');
return {
title: `${t('skillMatrix')} - LEAN`,
};
}
/**
* Department Skill Matrix page - Server Component
* Shows the skill matrix grid for a specific department
*/
export default function DepartmentSkillMatrixPage({
params: { locale, departmentId },
}: DepartmentSkillMatrixPageProps) {
return <DepartmentSkillMatrixContent locale={locale} departmentId={departmentId} />;
}

View File

@@ -0,0 +1,24 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { SkillMatrixOverviewContent } from './skill-matrix-overview-content';
interface SkillMatrixPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('lean');
return {
title: `${t('skillMatrix')} - LEAN`,
};
}
/**
* Skill Matrix overview page - Server Component
* Shows list of departments with skill matrix access
*/
export default function SkillMatrixPage({ params: { locale } }: SkillMatrixPageProps) {
return <SkillMatrixOverviewContent locale={locale} />;
}

View File

@@ -0,0 +1,322 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { motion } from 'framer-motion';
import {
Users,
Building2,
ChevronRight,
Search,
TrendingUp,
TrendingDown,
Minus,
Plus,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { PageTransition } from '@/components/layout/page-transition';
import { SkillLevelLegend } from '@/components/lean/skill-matrix';
interface SkillMatrixOverviewContentProps {
locale: string;
}
// Mock departments with skill matrix stats
// TODO: Replace with API call
const mockDepartments = [
{
id: '1',
name: 'IT & Entwicklung',
code: 'IT',
employeeCount: 24,
skillCount: 18,
averageLevel: 2.8,
coverage: 92,
trend: 'up' as const,
},
{
id: '2',
name: 'Personal',
code: 'HR',
employeeCount: 8,
skillCount: 12,
averageLevel: 2.5,
coverage: 88,
trend: 'stable' as const,
},
{
id: '3',
name: 'Vertrieb',
code: 'SALES',
employeeCount: 32,
skillCount: 15,
averageLevel: 2.2,
coverage: 75,
trend: 'down' as const,
},
{
id: '4',
name: 'Lager & Logistik',
code: 'LOG',
employeeCount: 45,
skillCount: 20,
averageLevel: 2.4,
coverage: 80,
trend: 'up' as const,
},
{
id: '5',
name: 'Finanzen',
code: 'FIN',
employeeCount: 6,
skillCount: 10,
averageLevel: 3.1,
coverage: 95,
trend: 'stable' as const,
},
];
/**
* Skill Matrix overview page content
* Shows list of departments with skill matrix summary
*/
export function SkillMatrixOverviewContent({ locale }: SkillMatrixOverviewContentProps) {
const t = useTranslations('lean');
const tCommon = useTranslations('common');
const [searchQuery, setSearchQuery] = useState('');
const [isLoading] = useState(false);
// Filter departments by search
const filteredDepartments = mockDepartments.filter((dept) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
dept.name.toLowerCase().includes(query) || dept.code.toLowerCase().includes(query)
);
});
// Calculate overall stats
const totalEmployees = mockDepartments.reduce((sum, d) => sum + d.employeeCount, 0);
const avgCoverage = Math.round(
mockDepartments.reduce((sum, d) => sum + d.coverage, 0) / mockDepartments.length
);
const avgLevel = (
mockDepartments.reduce((sum, d) => sum + d.averageLevel, 0) / mockDepartments.length
).toFixed(1);
return (
<PageTransition>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('skillMatrix')}</h1>
<p className="text-muted-foreground">{t('skillMatrix.description')}</p>
</div>
<Button className="gap-2">
<Plus className="h-4 w-4" />
{t('skillMatrix.addSkill')}
</Button>
</div>
{/* Overall Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('skillMatrix.totalEmployees')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalEmployees}</div>
<p className="text-xs text-muted-foreground">
{t('skillMatrix.inDepartments', { count: mockDepartments.length })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('skillMatrix.avgCoverage')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{avgCoverage}%</div>
<div className="flex items-center text-xs text-green-600">
<TrendingUp className="h-3 w-3 mr-1" />
+3% {t('skillMatrix.vsLastMonth')}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('skillMatrix.avgLevel')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{avgLevel}</div>
<p className="text-xs text-muted-foreground">{t('skillMatrix.targetLevel')}: 2.5</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('skillMatrix.trainers')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">12</div>
<p className="text-xs text-muted-foreground">Level 4 ({t('skillMatrix.levels.4')})</p>
</CardContent>
</Card>
</div>
{/* Search and Legend */}
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`${t('skillMatrix.searchDepartment')}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</div>
</CardHeader>
<CardContent className="pb-3">
<SkillLevelLegend />
</CardContent>
</Card>
{/* Department Cards */}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48 mt-2" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))}
</div>
) : filteredDepartments.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Building2 className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-muted-foreground">{t('skillMatrix.noDepartmentsFound')}</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredDepartments.map((dept) => (
<DepartmentCard key={dept.id} department={dept} locale={locale} t={t} />
))}
</div>
)}
</div>
</PageTransition>
);
}
/**
* Department card with skill matrix summary
*/
interface DepartmentCardProps {
department: (typeof mockDepartments)[0];
locale: string;
t: ReturnType<typeof import('next-intl').useTranslations>;
}
function DepartmentCard({ department, locale, t }: DepartmentCardProps) {
const TrendIcon =
department.trend === 'up' ? TrendingUp : department.trend === 'down' ? TrendingDown : Minus;
const trendColor =
department.trend === 'up'
? 'text-green-600'
: department.trend === 'down'
? 'text-red-600'
: 'text-muted-foreground';
const coverageColor =
department.coverage >= 90
? 'text-green-600 bg-green-100 dark:bg-green-900/30'
: department.coverage >= 75
? 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30'
: 'text-red-600 bg-red-100 dark:bg-red-900/30';
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Link href={`/${locale}/lean/skill-matrix/${department.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Building2 className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{department.name}</CardTitle>
<CardDescription>{department.code}</CardDescription>
</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<span>{department.employeeCount}</span>
</div>
<p className="text-xs text-muted-foreground">{t('skillMatrix.employees')}</p>
</div>
<div>
<Badge variant="secondary" className={coverageColor}>
{department.coverage}%
</Badge>
<p className="text-xs text-muted-foreground mt-1">{t('skillMatrix.coverage')}</p>
</div>
<div>
<div className="text-sm font-medium">{department.averageLevel.toFixed(1)}</div>
<p className="text-xs text-muted-foreground">{t('skillMatrix.avgLevel')}</p>
</div>
<div>
<div className={`flex items-center gap-1 text-sm ${trendColor}`}>
<TrendIcon className="h-4 w-4" />
<span>{t(`skillMatrix.trend.${department.trend}`)}</span>
</div>
<p className="text-xs text-muted-foreground">{t('skillMatrix.trend')}</p>
</div>
</div>
<div className="mt-3 pt-3 border-t">
<p className="text-xs text-muted-foreground">
{department.skillCount} {t('skills')}
</p>
</div>
</CardContent>
</Card>
</Link>
</motion.div>
);
}

View File

@@ -0,0 +1,271 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { ArrowLeft, Bell, Mail, MessageSquare, Calendar, Clock, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
interface NotificationsSettingsContentProps {
locale: string;
}
interface NotificationSetting {
id: string;
label: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
email: boolean;
push: boolean;
inApp: boolean;
}
const defaultSettings: NotificationSetting[] = [
{
id: 'tasks',
label: 'Aufgaben',
description: 'Benachrichtigungen bei neuen oder faelligen Aufgaben',
icon: Clock,
email: true,
push: true,
inApp: true,
},
{
id: 'absences',
label: 'Abwesenheiten',
description: 'Urlaubsantraege und Genehmigungen',
icon: Calendar,
email: true,
push: false,
inApp: true,
},
{
id: 'meetings',
label: 'Termine',
description: 'Erinnerungen und Einladungen',
icon: Users,
email: true,
push: true,
inApp: true,
},
{
id: 'messages',
label: 'Nachrichten',
description: 'Direktnachrichten und Erwaechnungen',
icon: MessageSquare,
email: false,
push: true,
inApp: true,
},
];
/**
* Simple toggle switch component
*/
function Toggle({
enabled,
onChange,
disabled = false,
}: {
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={disabled}
onClick={() => onChange(!enabled)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
enabled ? 'bg-primary' : 'bg-input'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform',
enabled ? 'translate-x-5' : 'translate-x-0'
)}
/>
</button>
);
}
/**
* Notifications settings content - Manage notification preferences
*/
export function NotificationsSettingsContent({ locale }: NotificationsSettingsContentProps) {
const t = useTranslations('settings');
const [settings, setSettings] = useState(defaultSettings);
const [masterEmail, setMasterEmail] = useState(true);
const [masterPush, setMasterPush] = useState(true);
const updateSetting = (id: string, field: 'email' | 'push' | 'inApp', value: boolean) => {
setSettings((prev) =>
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
);
};
return (
<div className="space-y-6">
{/* Header with back button */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-4"
>
<Link href={`/${locale}/settings`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('notificationsTitle')}</h1>
<p className="text-muted-foreground">{t('notificationsDescription')}</p>
</div>
</motion.div>
{/* Master Toggles */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Globale Einstellungen
</CardTitle>
<CardDescription>Hauptschalter fuer Benachrichtigungen</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Mail className="h-5 w-5 text-muted-foreground" />
<div>
<Label>E-Mail-Benachrichtigungen</Label>
<p className="text-sm text-muted-foreground">
Benachrichtigungen per E-Mail erhalten
</p>
</div>
</div>
<Toggle enabled={masterEmail} onChange={setMasterEmail} />
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bell className="h-5 w-5 text-muted-foreground" />
<div>
<Label>Push-Benachrichtigungen</Label>
<p className="text-sm text-muted-foreground">
Browser-Benachrichtigungen erhalten
</p>
</div>
</div>
<Toggle enabled={masterPush} onChange={setMasterPush} />
</div>
</CardContent>
</Card>
</motion.div>
{/* Detailed Settings */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<Card>
<CardHeader>
<CardTitle>Detaillierte Einstellungen</CardTitle>
<CardDescription>
Passen Sie Benachrichtigungen fuer einzelne Kategorien an
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{settings.map((setting, index) => {
const Icon = setting.icon;
return (
<motion.div
key={setting.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
>
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 space-y-3">
<div>
<Label>{setting.label}</Label>
<p className="text-sm text-muted-foreground">{setting.description}</p>
</div>
<div className="flex flex-wrap gap-6">
<div className="flex items-center gap-2">
<Toggle
enabled={setting.email}
onChange={(v) => updateSetting(setting.id, 'email', v)}
disabled={!masterEmail}
/>
<span className="text-sm">E-Mail</span>
</div>
<div className="flex items-center gap-2">
<Toggle
enabled={setting.push}
onChange={(v) => updateSetting(setting.id, 'push', v)}
disabled={!masterPush}
/>
<span className="text-sm">Push</span>
</div>
<div className="flex items-center gap-2">
<Toggle
enabled={setting.inApp}
onChange={(v) => updateSetting(setting.id, 'inApp', v)}
/>
<span className="text-sm">In-App</span>
</div>
</div>
</div>
</div>
{index < settings.length - 1 && <Separator className="mt-6" />}
</motion.div>
);
})}
</div>
</CardContent>
</Card>
</motion.div>
{/* Save Button */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
className="flex justify-end"
>
<Button>Einstellungen speichern</Button>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { NotificationsSettingsContent } from './notifications-settings-content';
interface NotificationsPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('settings');
return {
title: `${t('notificationsTitle')} - ${t('title')}`,
};
}
/**
* Notifications settings page - Server Component
*/
export default function NotificationsSettingsPage({ params: { locale } }: NotificationsPageProps) {
return <NotificationsSettingsContent locale={locale} />;
}

View File

@@ -0,0 +1,24 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { SettingsContent } from './settings-content';
interface SettingsPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('settings');
return {
title: t('title'),
};
}
/**
* Settings page - Server Component
* Entry point for user settings
*/
export default function SettingsPage({ params: { locale } }: SettingsPageProps) {
return <SettingsContent locale={locale} />;
}

View File

@@ -0,0 +1,23 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PreferencesContent } from './preferences-content';
interface PreferencesPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('settings');
return {
title: `${t('preferencesTitle')} - ${t('title')}`,
};
}
/**
* Preferences settings page - Server Component
*/
export default function PreferencesPage({ params: { locale } }: PreferencesPageProps) {
return <PreferencesContent locale={locale} />;
}

View File

@@ -0,0 +1,207 @@
'use client';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useRouter, usePathname } from 'next/navigation';
import { useTheme } from 'next-themes';
import { motion } from 'framer-motion';
import { ArrowLeft, Sun, Moon, Monitor, Languages, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
interface PreferencesContentProps {
locale: string;
}
const themes = [
{ value: 'light', icon: Sun, labelKey: 'lightMode' },
{ value: 'dark', icon: Moon, labelKey: 'darkMode' },
{ value: 'system', icon: Monitor, labelKey: 'systemDefault' },
] as const;
const languages = [
{ value: 'de', label: 'Deutsch', flag: 'DE' },
{ value: 'en', label: 'English', flag: 'EN' },
] as const;
/**
* Preferences settings content - Theme and language settings
*/
export function PreferencesContent({ locale }: PreferencesContentProps) {
const t = useTranslations('settings');
const { theme, setTheme } = useTheme();
const router = useRouter();
const pathname = usePathname();
const handleLanguageChange = (newLocale: string) => {
if (newLocale === locale) return;
// Replace the locale in the current path
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
router.push(newPath);
};
return (
<div className="space-y-6">
{/* Header with back button */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-4"
>
<Link href={`/${locale}/settings`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('preferencesTitle')}</h1>
<p className="text-muted-foreground">{t('preferencesDescription')}</p>
</div>
</motion.div>
{/* Theme Selection */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<Card>
<CardHeader>
<CardTitle>{t('theme')}</CardTitle>
<CardDescription>Waehlen Sie Ihr bevorzugtes Farbschema</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
{themes.map((themeOption) => {
const Icon = themeOption.icon;
const isSelected = theme === themeOption.value;
return (
<button
key={themeOption.value}
onClick={() => setTheme(themeOption.value)}
className={cn(
'relative flex flex-col items-center gap-3 rounded-lg border-2 p-4 transition-colors',
isSelected
? 'border-primary bg-primary/5'
: 'border-muted hover:border-muted-foreground/50'
)}
>
{isSelected && (
<div className="absolute right-2 top-2">
<Check className="h-4 w-4 text-primary" />
</div>
)}
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-full',
isSelected ? 'bg-primary/20' : 'bg-muted'
)}
>
<Icon className={cn('h-6 w-6', isSelected ? 'text-primary' : 'text-muted-foreground')} />
</div>
<span className={cn('text-sm font-medium', isSelected && 'text-primary')}>
{t(themeOption.labelKey)}
</span>
</button>
);
})}
</div>
</CardContent>
</Card>
</motion.div>
{/* Language Selection */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Languages className="h-5 w-5" />
{t('language')}
</CardTitle>
<CardDescription>Waehlen Sie Ihre bevorzugte Sprache</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{languages.map((lang) => {
const isSelected = locale === lang.value;
return (
<button
key={lang.value}
onClick={() => handleLanguageChange(lang.value)}
className={cn(
'relative flex items-center gap-3 rounded-lg border-2 p-4 transition-colors',
isSelected
? 'border-primary bg-primary/5'
: 'border-muted hover:border-muted-foreground/50'
)}
>
{isSelected && (
<div className="absolute right-2 top-2">
<Check className="h-4 w-4 text-primary" />
</div>
)}
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg font-bold text-sm',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-muted'
)}
>
{lang.flag}
</div>
<span className={cn('font-medium', isSelected && 'text-primary')}>
{lang.label}
</span>
</button>
);
})}
</div>
</CardContent>
</Card>
</motion.div>
{/* Dashboard Preferences */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>Dashboard</CardTitle>
<CardDescription>Einstellungen fuer Ihr Dashboard</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Widgets zuruecksetzen</Label>
<p className="text-sm text-muted-foreground">
Setzen Sie Ihr Dashboard auf die Standardkonfiguration zurueck
</p>
</div>
<Button
variant="outline"
onClick={() => {
localStorage.removeItem('tos-dashboard-layout');
window.location.reload();
}}
>
Zuruecksetzen
</Button>
</div>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { ProfileSettingsContent } from './profile-settings-content';
interface ProfilePageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('settings');
return {
title: `${t('profileTitle')} - ${t('title')}`,
};
}
/**
* Profile settings page - Server Component
*/
export default function ProfileSettingsPage({ params: { locale } }: ProfilePageProps) {
return <ProfileSettingsContent locale={locale} />;
}

View File

@@ -0,0 +1,205 @@
'use client';
import { useSession } from 'next-auth/react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { ArrowLeft, Camera, Mail, User, Building2, Calendar } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
interface ProfileSettingsContentProps {
locale: string;
}
/**
* Profile settings content - Edit user profile information
*/
export function ProfileSettingsContent({ locale }: ProfileSettingsContentProps) {
const { data: session } = useSession();
const t = useTranslations('settings');
const tCommon = useTranslations('common');
const user = session?.user;
const userInitials = user?.name
?.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2) || 'U';
return (
<div className="space-y-6">
{/* Header with back button */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-4"
>
<Link href={`/${locale}/settings`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('profileTitle')}</h1>
<p className="text-muted-foreground">{t('profileDescription')}</p>
</div>
</motion.div>
{/* Profile Picture Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<Card>
<CardHeader>
<CardTitle>{t('profilePicture')}</CardTitle>
<CardDescription>{t('profilePictureSyncedFromKeycloak')}</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-6">
<div className="relative">
<Avatar className="h-24 w-24">
<AvatarImage src={user?.image || undefined} />
<AvatarFallback className="text-2xl">{userInitials}</AvatarFallback>
</Avatar>
<Button
variant="secondary"
size="icon"
className="absolute bottom-0 right-0 h-8 w-8 rounded-full"
disabled
>
<Camera className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
{t('profilePictureManagedInKeycloak')}
</p>
<Button variant="outline" size="sm" disabled>
{t('changeImageViaKeycloak')}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
{/* Personal Information Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<Card>
<CardHeader>
<CardTitle>{t('personalInformation')}</CardTitle>
<CardDescription>
{t('personalInformationDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name" className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
{t('name')}
</Label>
<Input
id="name"
value={user?.name || ''}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
{t('email')}
</Label>
<Input
id="email"
type="email"
value={user?.email || ''}
disabled
className="bg-muted"
/>
</div>
</div>
<Separator />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="department" className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
{t('departmentLabel')}
</Label>
<Input
id="department"
value={t('notAssigned')}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="joined" className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
{t('memberSince')}
</Label>
<Input
id="joined"
value="01.01.2024"
disabled
className="bg-muted"
/>
</div>
</div>
<div className="rounded-lg border border-dashed p-4">
<p className="text-sm text-muted-foreground">
{t('contactAdminToChange')}
</p>
</div>
</CardContent>
</Card>
</motion.div>
{/* Roles Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('rolesAndPermissions')}</CardTitle>
<CardDescription>{t('assignedRoles')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{user?.roles?.map((role) => (
<span
key={role}
className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary"
>
{role}
</span>
)) || (
<span className="text-sm text-muted-foreground">{tCommon('noRolesAssigned')}</span>
)}
</div>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { SecuritySettingsContent } from './security-settings-content';
interface SecurityPageProps {
params: { locale: string };
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('settings');
return {
title: `${t('securityTitle')} - ${t('title')}`,
};
}
/**
* Security settings page - Server Component
*/
export default function SecuritySettingsPage({ params: { locale } }: SecurityPageProps) {
return <SecuritySettingsContent locale={locale} />;
}

View File

@@ -0,0 +1,183 @@
'use client';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { ArrowLeft, Key, Shield, Smartphone, ExternalLink, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
interface SecuritySettingsContentProps {
locale: string;
}
/**
* Security settings content - Password and 2FA settings
*/
export function SecuritySettingsContent({ locale }: SecuritySettingsContentProps) {
const t = useTranslations('settings');
// Keycloak account management URL (would be configured via environment variable)
const keycloakAccountUrl = process.env.NEXT_PUBLIC_KEYCLOAK_ACCOUNT_URL || '#';
return (
<div className="space-y-6">
{/* Header with back button */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-4"
>
<Link href={`/${locale}/settings`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('securityTitle')}</h1>
<p className="text-muted-foreground">{t('securityDescription')}</p>
</div>
</motion.div>
{/* Info Banner */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<Card className="border-yellow-500/50 bg-yellow-500/5">
<CardContent className="flex items-start gap-4 pt-6">
<AlertTriangle className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">Sicherheitseinstellungen werden in Keycloak verwaltet</p>
<p className="text-sm text-muted-foreground">
Passwortaenderungen und Zwei-Faktor-Authentifizierung werden zentral ueber Keycloak
verwaltet. Klicken Sie auf die Buttons unten, um zur Keycloak-Kontoverwaltung
weitergeleitet zu werden.
</p>
</div>
</CardContent>
</Card>
</motion.div>
{/* Password Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Key className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle>{t('changePassword')}</CardTitle>
<CardDescription>Aendern Sie Ihr Kontopasswort</CardDescription>
</div>
</div>
<Badge variant="secondary">Via Keycloak</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Ihr Passwort wird zentral in Keycloak verwaltet. Ein starkes Passwort sollte
mindestens 12 Zeichen lang sein und Gross- und Kleinbuchstaben, Zahlen sowie
Sonderzeichen enthalten.
</p>
<Button asChild>
<a href={keycloakAccountUrl} target="_blank" rel="noopener noreferrer">
Passwort in Keycloak aendern
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</motion.div>
{/* Two-Factor Authentication Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Smartphone className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle>{t('twoFactor')}</CardTitle>
<CardDescription>Zusaetzliche Sicherheit fuer Ihr Konto</CardDescription>
</div>
</div>
<Badge variant="outline">Empfohlen</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Die Zwei-Faktor-Authentifizierung bietet eine zusaetzliche Sicherheitsebene fuer Ihr
Konto. Selbst wenn jemand Ihr Passwort kennt, benoetigt er zusaetzlich Zugriff auf
Ihr Authentifizierungsgeraet.
</p>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">Unterstuetzte Methoden:</p>
<ul className="text-sm text-muted-foreground list-disc list-inside">
<li>Authenticator App (Google Authenticator, Authy, etc.)</li>
<li>WebAuthn / FIDO2 Security Keys</li>
</ul>
</div>
</div>
</div>
<Button asChild variant="outline">
<a href={keycloakAccountUrl} target="_blank" rel="noopener noreferrer">
2FA in Keycloak konfigurieren
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</motion.div>
{/* Active Sessions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<Card>
<CardHeader>
<CardTitle>Aktive Sitzungen</CardTitle>
<CardDescription>Verwalten Sie Ihre aktiven Anmeldesitzungen</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Sie koennen Ihre aktiven Sitzungen in der Keycloak-Kontoverwaltung einsehen und bei
Bedarf einzelne Sitzungen beenden.
</p>
<Button asChild variant="outline">
<a href={keycloakAccountUrl} target="_blank" rel="noopener noreferrer">
Sitzungen verwalten
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { motion } from 'framer-motion';
import {
User,
Palette,
Bell,
Shield,
ChevronRight,
type LucideIcon,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
interface SettingsLink {
href: string;
icon: LucideIcon;
titleKey: string;
descriptionKey: string;
}
const settingsLinks: SettingsLink[] = [
{
href: '/settings/profile',
icon: User,
titleKey: 'profileTitle',
descriptionKey: 'profileDescription',
},
{
href: '/settings/preferences',
icon: Palette,
titleKey: 'preferencesTitle',
descriptionKey: 'preferencesDescription',
},
{
href: '/settings/notifications',
icon: Bell,
titleKey: 'notificationsTitle',
descriptionKey: 'notificationsDescription',
},
{
href: '/settings/security',
icon: Shield,
titleKey: 'securityTitle',
descriptionKey: 'securityDescription',
},
];
interface SettingsContentProps {
locale: string;
}
/**
* Settings overview page - Shows all settings categories
*/
export function SettingsContent({ locale }: SettingsContentProps) {
const t = useTranslations('settings');
return (
<div className="space-y-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">{t('subtitle')}</p>
</motion.div>
{/* Settings Grid */}
<div className="grid gap-4 md:grid-cols-2">
{settingsLinks.map((link, index) => {
const Icon = link.icon;
return (
<motion.div
key={link.href}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
>
<Link href={`/${locale}${link.href}`}>
<Card className="group cursor-pointer transition-colors hover:border-primary/50 hover:bg-muted/50">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">{t(link.titleKey)}</CardTitle>
<CardDescription>{t(link.descriptionKey)}</CardDescription>
</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
</CardHeader>
</Card>
</Link>
</motion.div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
interface ErrorPageProps {
error: Error & { digest?: string };
reset: () => void;
}
/**
* Error boundary page
* Displays when an unhandled error occurs
*/
export default function ErrorPage({ error, reset }: ErrorPageProps) {
const t = useTranslations('errors');
useEffect(() => {
// Log the error to an error reporting service
console.error('Application error:', error);
}, [error]);
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h1 className="text-6xl font-bold text-destructive">!</h1>
<h2 className="mt-4 text-2xl font-semibold">{t('serverError')}</h2>
<p className="mt-2 text-muted-foreground">{t('serverErrorDescription')}</p>
<div className="mt-8 flex gap-4 justify-center">
<Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
{t('tryAgain')}
</Button>
<Button asChild>
<Link href="/dashboard">
<Home className="mr-2 h-4 w-4" />
{t('goHome')}
</Link>
</Button>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales, type Locale } from '@/i18n';
import { Providers } from '@/components/providers';
import { Toaster } from '@/components/ui/toaster';
import '@/styles/globals.css';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
});
export const metadata: Metadata = {
title: {
default: 'tOS - Enterprise Operating System',
template: '%s | tOS',
},
description: 'Enterprise Web Dashboard for managing business operations',
icons: {
icon: '/favicon.ico',
},
};
interface RootLayoutProps {
children: React.ReactNode;
params: { locale: string };
}
/**
* Root layout for the application
* Handles locale validation, font loading, and provider setup
*/
export default async function RootLayout({ children, params: { locale } }: RootLayoutProps) {
// Validate that the incoming `locale` parameter is valid
if (!locales.includes(locale as Locale)) {
notFound();
}
// Load messages for the current locale
const messages = await getMessages();
return (
<html lang={locale} suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<NextIntlClientProvider messages={messages}>
<Providers>
{children}
<Toaster />
</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}
/**
* Generate static params for all supported locales
*/
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}

View File

@@ -0,0 +1,13 @@
import { Loader2 } from 'lucide-react';
/**
* Loading state component
* Displayed while page content is loading
*/
export default function Loading() {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { signIn, useSession } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { Loader2, KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
interface LoginPageProps {
params: { locale: string };
}
/**
* Login page component
* Handles authentication via Keycloak
*/
export default function LoginPage({ params: { locale } }: LoginPageProps) {
const { status } = useSession();
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations('auth');
const [isLoading, setIsLoading] = useState(false);
const callbackUrl = searchParams.get('callbackUrl') || `/${locale}/dashboard`;
const error = searchParams.get('error');
// Redirect if already authenticated
useEffect(() => {
if (status === 'authenticated') {
router.push(callbackUrl);
}
}, [status, router, callbackUrl]);
const handleLogin = async () => {
setIsLoading(true);
try {
await signIn('keycloak', { callbackUrl });
} catch {
setIsLoading(false);
}
};
// Show loading state while checking session
if (status === 'loading') {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
>
<Card className="w-full max-w-md">
<CardHeader className="space-y-4 text-center">
{/* Logo */}
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<span className="text-3xl font-bold">t</span>
<span className="text-3xl font-light">OS</span>
</div>
<div>
<CardTitle className="text-2xl">{t('loginTitle')}</CardTitle>
<CardDescription className="mt-2">{t('loginSubtitle')}</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Error message */}
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive"
>
{error === 'SessionRequired' ? t('sessionExpired') : t('unauthorized')}
</motion.div>
)}
{/* Login button */}
<Button
onClick={handleLogin}
disabled={isLoading}
className="w-full"
size="lg"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('loggingIn')}
</>
) : (
<>
<KeyRound className="mr-2 h-4 w-4" />
{t('loginButton')}
</>
)}
</Button>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { Home } from 'lucide-react';
import { Button } from '@/components/ui/button';
/**
* 404 Not Found page
*/
export default function NotFound() {
const t = useTranslations('errors');
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h1 className="text-9xl font-bold text-primary">404</h1>
<h2 className="mt-4 text-2xl font-semibold">{t('notFound')}</h2>
<p className="mt-2 text-muted-foreground">{t('notFoundDescription')}</p>
<Button asChild className="mt-8">
<Link href="/dashboard">
<Home className="mr-2 h-4 w-4" />
{t('goHome')}
</Link>
</Button>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { redirect } from 'next/navigation';
interface HomePageProps {
params: { locale: string };
}
/**
* Home page - redirects to dashboard
*/
export default function HomePage({ params: { locale } }: HomePageProps) {
redirect(`/${locale}/dashboard`);
}

View File

@@ -0,0 +1,10 @@
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
/**
* NextAuth API route handler
* Handles all authentication requests (signin, signout, callback, etc.)
*/
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,145 @@
'use client';
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { ChartContainer, ChartEmptyState } from './chart-container';
interface BarChartDataPoint {
name: string;
[key: string]: string | number;
}
interface BarChartSeries {
dataKey: string;
name: string;
color: string;
stackId?: string;
}
interface BarChartProps {
/** Chart title */
title: string;
/** Optional description */
description?: string;
/** Data points to display */
data: BarChartDataPoint[];
/** Series configuration */
series: BarChartSeries[];
/** Whether data is loading */
isLoading?: boolean;
/** Chart height */
height?: number;
/** Whether to show grid lines */
showGrid?: boolean;
/** Whether to show legend */
showLegend?: boolean;
/** Whether bars should be stacked */
stacked?: boolean;
/** Layout direction */
layout?: 'vertical' | 'horizontal';
/** Additional CSS classes */
className?: string;
}
/**
* Bar Chart component using Recharts
* Supports single or multiple series, stacked bars, and horizontal/vertical layout
*/
export function BarChart({
title,
description,
data,
series,
isLoading = false,
height = 300,
showGrid = true,
showLegend = true,
stacked = false,
layout = 'horizontal',
className,
}: BarChartProps) {
if (!isLoading && data.length === 0) {
return (
<ChartContainer title={title} description={description} height={height} className={className}>
<ChartEmptyState />
</ChartContainer>
);
}
const isVertical = layout === 'vertical';
return (
<ChartContainer
title={title}
description={description}
isLoading={isLoading}
height={height}
className={className}
>
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart
data={data}
layout={layout}
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
>
{showGrid && (
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" opacity={0.5} />
)}
{isVertical ? (
<>
<XAxis type="number" className="text-xs" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
<YAxis
dataKey="name"
type="category"
className="text-xs"
tick={{ fill: 'hsl(var(--muted-foreground))' }}
width={100}
/>
</>
) : (
<>
<XAxis
dataKey="name"
className="text-xs"
tick={{ fill: 'hsl(var(--muted-foreground))' }}
/>
<YAxis className="text-xs" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
</>
)}
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
color: 'hsl(var(--popover-foreground))',
}}
/>
{showLegend && <Legend wrapperStyle={{ paddingTop: '20px' }} />}
{series.map((s) => (
<Bar
key={s.dataKey}
dataKey={s.dataKey}
name={s.name}
fill={s.color}
stackId={stacked ? 'stack' : s.stackId}
radius={[4, 4, 0, 0]}
/>
))}
</RechartsBarChart>
</ResponsiveContainer>
</ChartContainer>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
interface ChartContainerProps {
/** Chart title */
title: string;
/** Optional description */
description?: string;
/** Chart content */
children: ReactNode;
/** Whether data is loading */
isLoading?: boolean;
/** Chart height */
height?: number;
/** Additional CSS classes */
className?: string;
/** Header action element (e.g., dropdown for time range) */
headerAction?: ReactNode;
}
/**
* Responsive container for chart components
* Provides consistent styling and loading states
*/
export function ChartContainer({
title,
description,
children,
isLoading = false,
height = 300,
className,
headerAction,
}: ChartContainerProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle className="text-base font-medium">{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</div>
{headerAction}
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center" style={{ height }}>
<div className="space-y-3 w-full">
<Skeleton className="h-[60%] w-full" style={{ height: height * 0.6 }} />
<div className="flex justify-center gap-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
</div>
</div>
</div>
) : (
<div style={{ height }} className="w-full">
{children}
</div>
)}
</CardContent>
</Card>
</motion.div>
);
}
/**
* Chart legend component
*/
interface ChartLegendProps {
items: Array<{
name: string;
color: string;
value?: string | number;
}>;
className?: string;
}
export function ChartLegend({ items, className }: ChartLegendProps) {
return (
<div className={cn('flex flex-wrap justify-center gap-4', className)}>
{items.map((item) => (
<div key={item.name} className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className="text-sm text-muted-foreground">
{item.name}
{item.value !== undefined && (
<span className="ml-1 font-medium text-foreground">{item.value}</span>
)}
</span>
</div>
))}
</div>
);
}
/**
* Empty state for charts with no data
*/
export function ChartEmptyState({ message }: { message?: string }) {
const tCommon = useTranslations('common');
const displayMessage = message ?? tCommon('noData');
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<svg
className="mx-auto h-12 w-12 text-muted-foreground/50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<p className="mt-2 text-sm text-muted-foreground">{displayMessage}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
// Charts components barrel export
export { ChartContainer, ChartLegend, ChartEmptyState } from './chart-container';
export { BarChart } from './bar-chart';
export { LineChart } from './line-chart';
export { PieChart } from './pie-chart';

View File

@@ -0,0 +1,127 @@
'use client';
import {
LineChart as RechartsLineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { ChartContainer, ChartEmptyState } from './chart-container';
interface LineChartDataPoint {
name: string;
[key: string]: string | number;
}
interface LineChartSeries {
dataKey: string;
name: string;
color: string;
strokeWidth?: number;
dot?: boolean;
dashed?: boolean;
}
interface LineChartProps {
/** Chart title */
title: string;
/** Optional description */
description?: string;
/** Data points to display */
data: LineChartDataPoint[];
/** Series configuration */
series: LineChartSeries[];
/** Whether data is loading */
isLoading?: boolean;
/** Chart height */
height?: number;
/** Whether to show grid lines */
showGrid?: boolean;
/** Whether to show legend */
showLegend?: boolean;
/** Whether to use curved lines */
curved?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* Line Chart component using Recharts
* Supports single or multiple series with various styling options
*/
export function LineChart({
title,
description,
data,
series,
isLoading = false,
height = 300,
showGrid = true,
showLegend = true,
curved = true,
className,
}: LineChartProps) {
if (!isLoading && data.length === 0) {
return (
<ChartContainer title={title} description={description} height={height} className={className}>
<ChartEmptyState />
</ChartContainer>
);
}
return (
<ChartContainer
title={title}
description={description}
isLoading={isLoading}
height={height}
className={className}
>
<ResponsiveContainer width="100%" height="100%">
<RechartsLineChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
{showGrid && (
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" opacity={0.5} />
)}
<XAxis
dataKey="name"
className="text-xs"
tick={{ fill: 'hsl(var(--muted-foreground))' }}
/>
<YAxis className="text-xs" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
color: 'hsl(var(--popover-foreground))',
}}
/>
{showLegend && <Legend wrapperStyle={{ paddingTop: '20px' }} />}
{series.map((s) => (
<Line
key={s.dataKey}
type={curved ? 'monotone' : 'linear'}
dataKey={s.dataKey}
name={s.name}
stroke={s.color}
strokeWidth={s.strokeWidth || 2}
strokeDasharray={s.dashed ? '5 5' : undefined}
dot={s.dot !== false}
activeDot={{ r: 6, strokeWidth: 2 }}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
</ChartContainer>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { useState } from 'react';
import {
PieChart as RechartsPieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
Sector,
} from 'recharts';
import { ChartContainer, ChartEmptyState } from './chart-container';
interface PieChartDataPoint {
name: string;
value: number;
color: string;
}
interface PieChartProps {
/** Chart title */
title: string;
/** Optional description */
description?: string;
/** Data points to display */
data: PieChartDataPoint[];
/** Whether data is loading */
isLoading?: boolean;
/** Chart height */
height?: number;
/** Whether to show legend */
showLegend?: boolean;
/** Whether to show as donut chart */
donut?: boolean;
/** Inner radius for donut chart (percentage) */
innerRadius?: number;
/** Whether to show labels */
showLabels?: boolean;
/** Whether to animate on hover */
activeOnHover?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* Render active sector for hover effect
*/
const renderActiveShape = (props: any) => {
const {
cx,
cy,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
} = props;
return (
<g>
<text x={cx} y={cy - 10} dy={8} textAnchor="middle" fill="hsl(var(--foreground))" className="text-sm font-medium">
{payload.name}
</text>
<text x={cx} y={cy + 10} dy={8} textAnchor="middle" fill="hsl(var(--muted-foreground))" className="text-xs">
{value} ({(percent * 100).toFixed(0)}%)
</text>
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius + 10}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx}
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={outerRadius + 12}
outerRadius={outerRadius + 14}
fill={fill}
/>
</g>
);
};
/**
* Pie/Donut Chart component using Recharts
* Supports pie and donut variants with hover effects
*/
export function PieChart({
title,
description,
data,
isLoading = false,
height = 300,
showLegend = true,
donut = false,
innerRadius = 60,
showLabels = false,
activeOnHover = true,
className,
}: PieChartProps) {
const [activeIndex, setActiveIndex] = useState<number | undefined>(undefined);
if (!isLoading && data.length === 0) {
return (
<ChartContainer title={title} description={description} height={height} className={className}>
<ChartEmptyState />
</ChartContainer>
);
}
const onPieEnter = (_: any, index: number) => {
if (activeOnHover) {
setActiveIndex(index);
}
};
const onPieLeave = () => {
if (activeOnHover) {
setActiveIndex(undefined);
}
};
const total = data.reduce((sum, item) => sum + item.value, 0);
return (
<ChartContainer
title={title}
description={description}
isLoading={isLoading}
height={height}
className={className}
>
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={donut ? `${innerRadius}%` : 0}
outerRadius="80%"
paddingAngle={data.length > 1 ? 2 : 0}
dataKey="value"
onMouseEnter={onPieEnter}
onMouseLeave={onPieLeave}
activeIndex={activeIndex}
activeShape={activeOnHover ? renderActiveShape : undefined}
label={
showLabels && !activeOnHover
? ({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`
: false
}
labelLine={showLabels && !activeOnHover}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
color: 'hsl(var(--popover-foreground))',
}}
formatter={(value: number, name: string) => [
`${value} (${((value / total) * 100).toFixed(1)}%)`,
name,
]}
/>
{showLegend && (
<Legend
layout="vertical"
align="right"
verticalAlign="middle"
wrapperStyle={{ paddingLeft: '20px' }}
formatter={(value, entry: any) => (
<span className="text-sm text-foreground">{value}</span>
)}
/>
)}
</RechartsPieChart>
</ResponsiveContainer>
</ChartContainer>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { Plus, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
WIDGET_REGISTRY,
WIDGET_CATEGORIES,
getWidgetsByCategory,
type WidgetMeta,
type WidgetCategory,
} from './widget-registry';
interface AddWidgetDialogProps {
onAddWidget: (widgetType: string) => void;
existingWidgets?: string[];
userRoles?: string[];
trigger?: React.ReactNode;
}
/**
* Add Widget Dialog - Allows users to add new widgets to their dashboard
*/
export function AddWidgetDialog({
onAddWidget,
existingWidgets = [],
userRoles = [],
trigger,
}: AddWidgetDialogProps) {
const t = useTranslations('widgets');
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<WidgetCategory | 'all'>('all');
// Filter widgets based on search and category
const getFilteredWidgets = (): WidgetMeta[] => {
let widgets = Object.values(WIDGET_REGISTRY);
// Filter by category
if (selectedCategory !== 'all') {
widgets = getWidgetsByCategory(selectedCategory);
}
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
widgets = widgets.filter(
(widget) =>
t(widget.nameKey).toLowerCase().includes(query) ||
t(widget.descriptionKey).toLowerCase().includes(query)
);
}
// Filter by user roles (if widget has required roles)
widgets = widgets.filter(
(widget) =>
widget.requiredRoles.length === 0 ||
widget.requiredRoles.some((role) => userRoles.includes(role))
);
return widgets;
};
const filteredWidgets = getFilteredWidgets();
const handleAddWidget = (widgetType: string) => {
onAddWidget(widgetType);
setOpen(false);
setSearchQuery('');
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" size="sm" className="gap-2">
<Plus className="h-4 w-4" />
{t('addWidget')}
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('addWidgetTitle')}</DialogTitle>
<DialogDescription>{t('addWidgetDescription')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t('searchWidgets')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Category Tabs */}
<Tabs
value={selectedCategory}
onValueChange={(value) => setSelectedCategory(value as WidgetCategory | 'all')}
>
<TabsList className="w-full justify-start">
<TabsTrigger value="all">{t('categories.all')}</TabsTrigger>
{WIDGET_CATEGORIES.map((category) => (
<TabsTrigger key={category.key} value={category.key}>
{t(category.labelKey)}
</TabsTrigger>
))}
</TabsList>
{/* Widget Grid */}
<TabsContent value={selectedCategory} className="mt-4">
<ScrollArea className="h-[400px] pr-4">
{filteredWidgets.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
{t('noWidgetsFound')}
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{filteredWidgets.map((widget, index) => {
const Icon = widget.icon;
const isAlreadyAdded = existingWidgets.includes(widget.type);
return (
<motion.button
key={widget.type}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
onClick={() => handleAddWidget(widget.type)}
disabled={isAlreadyAdded}
className={cn(
'flex items-start gap-3 rounded-lg border p-4 text-left transition-colors',
'hover:bg-accent hover:border-accent-foreground/20',
'focus:outline-none focus:ring-2 focus:ring-ring',
isAlreadyAdded && 'cursor-not-allowed opacity-50'
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 space-y-1">
<p className="font-medium leading-none">{t(widget.nameKey)}</p>
<p className="text-sm text-muted-foreground line-clamp-2">
{t(widget.descriptionKey)}
</p>
{isAlreadyAdded && (
<p className="text-xs text-primary">{t('alreadyAdded')}</p>
)}
</div>
</motion.button>
);
})}
</div>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,19 @@
// Dashboard Components barrel export
export { WidgetContainer, type WidgetContainerProps } from './widget-container';
export { WidgetGrid, WidgetGridEmpty, type WidgetItem, type WidgetGridProps } from './widget-grid';
export {
WIDGET_REGISTRY,
WIDGET_SIZE_MAP,
WIDGET_CATEGORIES,
getWidgetMeta,
getWidgetsByCategory,
getAvailableWidgets,
hasWidgetAccess,
type WidgetMeta,
type WidgetSize,
type WidgetCategory,
} from './widget-registry';
export { AddWidgetDialog } from './add-widget-dialog';
// Widget components
export * from './widgets';

View File

@@ -0,0 +1,164 @@
'use client';
import { ReactNode, forwardRef } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { motion } from 'framer-motion';
import { GripVertical, X, Settings } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export interface WidgetContainerProps {
/** Unique widget ID */
id: string;
/** Widget title */
title: string;
/** Optional icon */
icon?: ReactNode;
/** Widget content */
children: ReactNode;
/** Whether the widget is being edited (showing controls) */
isEditing?: boolean;
/** Callback when remove button is clicked */
onRemove?: () => void;
/** Callback when settings button is clicked */
onSettings?: () => void;
/** Additional CSS classes */
className?: string;
/** Whether to show the header */
showHeader?: boolean;
/** Grid column span */
colSpan?: number;
/** Grid row span */
rowSpan?: number;
}
/**
* Widget Container - Wrapper component for dashboard widgets
* Provides drag handle, remove/settings buttons, and consistent styling
*/
export const WidgetContainer = forwardRef<HTMLDivElement, WidgetContainerProps>(
function WidgetContainer(
{
id,
title,
icon,
children,
isEditing = false,
onRemove,
onSettings,
className,
showHeader = true,
colSpan = 1,
rowSpan = 1,
},
ref
) {
const t = useTranslations('widgets');
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled: !isEditing });
const style = {
transform: CSS.Transform.toString(transform),
transition,
gridColumn: `span ${colSpan}`,
gridRow: `span ${rowSpan}`,
};
return (
<motion.div
ref={setNodeRef}
style={style}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className={cn(isDragging && 'z-50')}
>
<Card
ref={ref}
className={cn(
'relative h-full transition-shadow duration-200',
isDragging && 'shadow-lg ring-2 ring-primary/20',
isEditing && 'ring-1 ring-dashed ring-muted-foreground/30',
className
)}
>
{showHeader && (
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
{/* Drag Handle - only visible in edit mode */}
{isEditing && (
<button
{...attributes}
{...listeners}
className="cursor-grab touch-none rounded p-1 hover:bg-muted active:cursor-grabbing"
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
)}
{/* Title with optional icon */}
<CardTitle className="flex items-center gap-2 text-sm font-medium">
{icon}
{title}
</CardTitle>
</div>
{/* Action buttons - only visible in edit mode */}
{isEditing && (
<div className="flex items-center gap-1">
{onSettings && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onSettings}
>
<Settings className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('settings')}</TooltipContent>
</Tooltip>
)}
{onRemove && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={onRemove}
>
<X className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('remove')}</TooltipContent>
</Tooltip>
)}
</div>
)}
</CardHeader>
)}
<CardContent className={cn(!showHeader && 'pt-6')}>{children}</CardContent>
</Card>
</motion.div>
);
}
);

View File

@@ -0,0 +1,207 @@
'use client';
import { ReactNode, useState, useCallback } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { motion, AnimatePresence } from 'framer-motion';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
export interface WidgetItem {
id: string;
type: string;
colSpan?: number;
rowSpan?: number;
settings?: Record<string, unknown>;
}
export interface WidgetGridProps {
/** Array of widget items to render */
widgets: WidgetItem[];
/** Callback when widget order changes */
onWidgetsChange: (widgets: WidgetItem[]) => void;
/** Whether the grid is in edit mode */
isEditing?: boolean;
/** Function to render a widget by its item */
renderWidget: (item: WidgetItem, isEditing: boolean) => ReactNode;
/** Additional CSS classes */
className?: string;
/** Number of grid columns */
columns?: number;
}
/**
* Widget Grid - Drag and drop grid layout for dashboard widgets
* Uses dnd-kit for drag and drop functionality
*/
export function WidgetGrid({
widgets,
onWidgetsChange,
isEditing = false,
renderWidget,
className,
columns = 4,
}: WidgetGridProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Prevent accidental drags
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = widgets.findIndex((w) => w.id === active.id);
const newIndex = widgets.findIndex((w) => w.id === over.id);
const newWidgets = arrayMove(widgets, oldIndex, newIndex);
onWidgetsChange(newWidgets);
}
setActiveId(null);
},
[widgets, onWidgetsChange]
);
const handleDragCancel = useCallback(() => {
setActiveId(null);
}, []);
const activeWidget = activeId ? widgets.find((w) => w.id === activeId) : null;
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={widgets.map((w) => w.id)} strategy={rectSortingStrategy}>
<motion.div
className={cn(
'grid gap-4',
// Responsive grid columns
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
className
)}
style={{
// Allow custom column count on larger screens
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
layout
>
<AnimatePresence mode="popLayout">
{widgets.map((item) => (
<motion.div
key={item.id}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
style={{
gridColumn: item.colSpan ? `span ${item.colSpan}` : undefined,
gridRow: item.rowSpan ? `span ${item.rowSpan}` : undefined,
}}
>
{renderWidget(item, isEditing)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</SortableContext>
{/* Drag overlay for visual feedback */}
<DragOverlay adjustScale>
{activeWidget ? (
<div className="opacity-80">{renderWidget(activeWidget, false)}</div>
) : null}
</DragOverlay>
</DndContext>
);
}
/**
* Empty state component for when no widgets are added
*/
export function WidgetGridEmpty({ onAddWidget }: { onAddWidget?: () => void }) {
const t = useTranslations('widgets');
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex min-h-[300px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-8 text-center"
>
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
<svg
className="h-10 w-10 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
/>
</svg>
</div>
<h3 className="mt-4 text-lg font-semibold">{t('noWidgets')}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{t('noWidgetsDescription')}
</p>
{onAddWidget && (
<button
onClick={onAddWidget}
className="mt-4 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{t('addWidgetButton')}
</button>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,279 @@
/**
* Widget Registry - Central registry for all available widget types
*
* Each widget type has a unique key and metadata for rendering and configuration
*/
import {
Clock,
User,
Zap,
BarChart3,
Calendar,
Activity,
Bell,
CheckSquare,
ShoppingCart,
MessageSquare,
Headphones,
Cloud,
FileText,
ClipboardCheck,
} from 'lucide-react';
import type { ComponentType } from 'react';
/** Widget size presets */
export type WidgetSize = 'small' | 'medium' | 'large' | 'wide' | 'tall';
/** Widget category for grouping in the add widget dialog */
export type WidgetCategory = 'general' | 'productivity' | 'analytics' | 'communication' | 'integrations';
/** Widget metadata definition */
export interface WidgetMeta {
/** Unique identifier for the widget type */
type: string;
/** Display name (translation key) */
nameKey: string;
/** Description (translation key) */
descriptionKey: string;
/** Icon component */
icon: ComponentType<{ className?: string }>;
/** Category for grouping */
category: WidgetCategory;
/** Default size */
defaultSize: WidgetSize;
/** Minimum grid columns the widget needs */
minCols: number;
/** Minimum grid rows the widget needs */
minRows: number;
/** Whether the widget can be resized */
resizable: boolean;
/** Required roles to use this widget (empty = all roles) */
requiredRoles: string[];
}
/** Predefined widget sizes in grid units */
export const WIDGET_SIZE_MAP: Record<WidgetSize, { cols: number; rows: number }> = {
small: { cols: 1, rows: 1 },
medium: { cols: 1, rows: 2 },
large: { cols: 2, rows: 2 },
wide: { cols: 2, rows: 1 },
tall: { cols: 1, rows: 3 },
};
/** Registry of all available widgets */
export const WIDGET_REGISTRY: Record<string, WidgetMeta> = {
clock: {
type: 'clock',
nameKey: 'widgets.clock.name',
descriptionKey: 'widgets.clock.description',
icon: Clock,
category: 'general',
defaultSize: 'small',
minCols: 1,
minRows: 1,
resizable: true,
requiredRoles: [],
},
welcome: {
type: 'welcome',
nameKey: 'widgets.welcome.name',
descriptionKey: 'widgets.welcome.description',
icon: User,
category: 'general',
defaultSize: 'wide',
minCols: 2,
minRows: 1,
resizable: false,
requiredRoles: [],
},
quickActions: {
type: 'quickActions',
nameKey: 'widgets.quickActions.name',
descriptionKey: 'widgets.quickActions.description',
icon: Zap,
category: 'productivity',
defaultSize: 'medium',
minCols: 1,
minRows: 2,
resizable: true,
requiredRoles: [],
},
stats: {
type: 'stats',
nameKey: 'widgets.stats.name',
descriptionKey: 'widgets.stats.description',
icon: BarChart3,
category: 'analytics',
defaultSize: 'small',
minCols: 1,
minRows: 1,
resizable: true,
requiredRoles: [],
},
calendar: {
type: 'calendar',
nameKey: 'widgets.calendar.name',
descriptionKey: 'widgets.calendar.description',
icon: Calendar,
category: 'productivity',
defaultSize: 'medium',
minCols: 1,
minRows: 2,
resizable: true,
requiredRoles: [],
},
activity: {
type: 'activity',
nameKey: 'widgets.activity.name',
descriptionKey: 'widgets.activity.description',
icon: Activity,
category: 'general',
defaultSize: 'large',
minCols: 2,
minRows: 2,
resizable: true,
requiredRoles: [],
},
notifications: {
type: 'notifications',
nameKey: 'widgets.notifications.name',
descriptionKey: 'widgets.notifications.description',
icon: Bell,
category: 'communication',
defaultSize: 'medium',
minCols: 1,
minRows: 2,
resizable: true,
requiredRoles: [],
},
tasks: {
type: 'tasks',
nameKey: 'widgets.tasks.name',
descriptionKey: 'widgets.tasks.description',
icon: CheckSquare,
category: 'productivity',
defaultSize: 'medium',
minCols: 1,
minRows: 2,
resizable: true,
requiredRoles: [],
},
// Integration Widgets
orders: {
type: 'orders',
nameKey: 'widgets.orders.name',
descriptionKey: 'widgets.orders.description',
icon: ShoppingCart,
category: 'integrations',
defaultSize: 'medium',
minCols: 1,
minRows: 2,
resizable: true,
requiredRoles: ['manager', 'admin'],
},
chat: {
type: 'chat',
nameKey: 'widgets.chat.name',
descriptionKey: 'widgets.chat.description',
icon: MessageSquare,
category: 'integrations',
defaultSize: 'medium',
minCols: 1,
minRows: 2,
resizable: true,
requiredRoles: [],
},
todoistTasks: {
type: 'todoistTasks',
nameKey: 'widgets.todoistTasks.name',
descriptionKey: 'widgets.todoistTasks.description',
icon: CheckSquare,
category: 'integrations',
defaultSize: 'medium',
minCols: 1,
minRows: 2,
resizable: true,
requiredRoles: [],
},
tickets: {
type: 'tickets',
nameKey: 'widgets.tickets.name',
descriptionKey: 'widgets.tickets.description',
icon: Headphones,
category: 'integrations',
defaultSize: 'medium',
minCols: 1,
minRows: 2,
resizable: true,
requiredRoles: ['manager', 'admin'],
},
files: {
type: 'files',
nameKey: 'widgets.files.name',
descriptionKey: 'widgets.files.description',
icon: Cloud,
category: 'integrations',
defaultSize: 'medium',
minCols: 1,
minRows: 2,
resizable: true,
requiredRoles: [],
},
documents: {
type: 'documents',
nameKey: 'widgets.documents.name',
descriptionKey: 'widgets.documents.description',
icon: FileText,
category: 'integrations',
defaultSize: 'large',
minCols: 2,
minRows: 2,
resizable: true,
requiredRoles: [],
},
gembadocs: {
type: 'gembadocs',
nameKey: 'widgets.gembadocs.name',
descriptionKey: 'widgets.gembadocs.description',
icon: ClipboardCheck,
category: 'integrations',
defaultSize: 'large',
minCols: 2,
minRows: 2,
resizable: true,
requiredRoles: ['manager', 'admin'],
},
} as const;
/** Get all widgets by category */
export function getWidgetsByCategory(category: WidgetCategory): WidgetMeta[] {
return Object.values(WIDGET_REGISTRY).filter((widget) => widget.category === category);
}
/** Get a widget by type */
export function getWidgetMeta(type: string): WidgetMeta | undefined {
return WIDGET_REGISTRY[type];
}
/** Check if user has access to a widget based on roles */
export function hasWidgetAccess(widgetType: string, userRoles: string[]): boolean {
const widget = WIDGET_REGISTRY[widgetType];
if (!widget) return false;
if (widget.requiredRoles.length === 0) return true;
return widget.requiredRoles.some((role) => userRoles.includes(role));
}
/** Get available widgets for a user based on their roles */
export function getAvailableWidgets(userRoles: string[]): WidgetMeta[] {
return Object.values(WIDGET_REGISTRY).filter((widget) => hasWidgetAccess(widget.type, userRoles));
}
/** Widget categories with translations */
export const WIDGET_CATEGORIES: { key: WidgetCategory; labelKey: string }[] = [
{ key: 'general', labelKey: 'widgets.categories.general' },
{ key: 'productivity', labelKey: 'widgets.categories.productivity' },
{ key: 'analytics', labelKey: 'widgets.categories.analytics' },
{ key: 'communication', labelKey: 'widgets.categories.communication' },
{ key: 'integrations', labelKey: 'widgets.categories.integrations' },
];

View File

@@ -0,0 +1,213 @@
'use client';
import { useTranslations } from 'next-intl';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import { Activity, User, FileText, CheckCircle, AlertCircle, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { WidgetContainer } from '../widget-container';
export type ActivityType = 'user' | 'document' | 'task' | 'alert' | 'system';
export interface ActivityItem {
id: string;
type: ActivityType;
title: string;
description?: string;
timestamp: Date;
user?: {
name: string;
image?: string;
};
icon?: LucideIcon;
iconColor?: string;
}
interface ActivityWidgetProps {
id: string;
activities?: ActivityItem[];
isLoading?: boolean;
isEditing?: boolean;
onRemove?: () => void;
locale?: string;
maxItems?: number;
className?: string;
}
/**
* Get icon for activity type
*/
function getActivityIcon(type: ActivityType): LucideIcon {
switch (type) {
case 'user':
return User;
case 'document':
return FileText;
case 'task':
return CheckCircle;
case 'alert':
return AlertCircle;
default:
return Activity;
}
}
/**
* Get color for activity type
*/
function getActivityColor(type: ActivityType): string {
switch (type) {
case 'user':
return 'text-blue-500';
case 'document':
return 'text-purple-500';
case 'task':
return 'text-green-500';
case 'alert':
return 'text-red-500';
default:
return 'text-muted-foreground';
}
}
// Sample activities for demonstration
const SAMPLE_ACTIVITIES: ActivityItem[] = [
{
id: '1',
type: 'task',
title: '3S-Audit abgeschlossen',
description: 'Abteilung Lager - Status: Bestanden',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
user: { name: 'Anna Mueller' },
},
{
id: '2',
type: 'user',
title: 'Neuer Mitarbeiter',
description: 'Max Mustermann wurde dem Team hinzugefuegt',
timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000), // 5 hours ago
user: { name: 'HR System' },
},
{
id: '3',
type: 'document',
title: 'Urlaubsantrag genehmigt',
description: 'Ihr Antrag fuer 15.-19. Januar wurde genehmigt',
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000), // Yesterday
user: { name: 'Maria Schmidt' },
},
{
id: '4',
type: 'alert',
title: 'Systemwartung geplant',
description: 'Am Samstag von 02:00 - 04:00 Uhr',
timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000), // 2 days ago
},
];
/**
* Activity Widget - Displays recent activities/events
*/
export function ActivityWidget({
id,
activities = SAMPLE_ACTIVITIES,
isLoading = false,
isEditing = false,
onRemove,
locale = 'de',
maxItems = 5,
className,
}: ActivityWidgetProps) {
const t = useTranslations('widgets.activity');
const dateLocale = locale === 'de' ? de : enUS;
const displayedActivities = activities.slice(0, maxItems);
return (
<WidgetContainer
id={id}
title={t('name')}
icon={<Activity className="h-4 w-4 text-primary" />}
isEditing={isEditing}
onRemove={onRemove}
className={className}
colSpan={2}
>
<ScrollArea className="h-[280px] pr-4">
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : displayedActivities.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t('noActivity')}
</div>
) : (
<div className="space-y-4">
{displayedActivities.map((activity, index) => {
const Icon = activity.icon || getActivityIcon(activity.type);
const iconColor = activity.iconColor || getActivityColor(activity.type);
return (
<motion.div
key={activity.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="flex gap-4"
>
{/* Icon or Avatar */}
<div className="flex-shrink-0">
{activity.user ? (
<Avatar className="h-10 w-10">
<AvatarImage src={activity.user.image} />
<AvatarFallback className="text-xs">
{activity.user.name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()}
</AvatarFallback>
</Avatar>
) : (
<div className={cn('flex h-10 w-10 items-center justify-center rounded-full bg-muted', iconColor)}>
<Icon className="h-5 w-5" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">{activity.title}</p>
{activity.description && (
<p className="text-sm text-muted-foreground">{activity.description}</p>
)}
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(activity.timestamp, {
addSuffix: true,
locale: dateLocale,
})}
</p>
</div>
</motion.div>
);
})}
</div>
)}
</ScrollArea>
</WidgetContainer>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay, addMonths, subMonths } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { WidgetContainer } from '../widget-container';
interface CalendarEvent {
id: string;
title: string;
date: Date;
color?: string;
}
interface CalendarWidgetProps {
id: string;
isEditing?: boolean;
onRemove?: () => void;
events?: CalendarEvent[];
locale?: string;
className?: string;
}
/**
* Calendar Widget - Mini calendar with event indicators
*/
export function CalendarWidget({
id,
isEditing = false,
onRemove,
events = [],
locale = 'de',
className,
}: CalendarWidgetProps) {
const t = useTranslations('widgets.calendar');
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const dateLocale = locale === 'de' ? de : enUS;
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
// Get day names for header
const dayNames = locale === 'de'
? ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
// Calculate padding days for alignment (week starts on Monday for German locale)
const firstDayOffset = locale === 'de'
? (monthStart.getDay() + 6) % 7
: monthStart.getDay();
const paddingDays = Array.from({ length: firstDayOffset }, (_, i) => i);
// Check if a day has events
const getDayEvents = (date: Date) => events.filter((event) => isSameDay(event.date, date));
const handlePrevMonth = () => setCurrentDate(subMonths(currentDate, 1));
const handleNextMonth = () => setCurrentDate(addMonths(currentDate, 1));
return (
<WidgetContainer
id={id}
title={t('name')}
icon={<CalendarIcon className="h-4 w-4 text-primary" />}
isEditing={isEditing}
onRemove={onRemove}
className={className}
>
<div className="space-y-3">
{/* Month Navigation */}
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handlePrevMonth}
disabled={isEditing}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<AnimatePresence mode="wait">
<motion.span
key={format(currentDate, 'yyyy-MM')}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="text-sm font-medium"
>
{format(currentDate, 'MMMM yyyy', { locale: dateLocale })}
</motion.span>
</AnimatePresence>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleNextMonth}
disabled={isEditing}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* Day Names Header */}
<div className="grid grid-cols-7 gap-1 text-center">
{dayNames.map((day) => (
<div key={day} className="text-xs font-medium text-muted-foreground py-1">
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-1">
{/* Padding for first week alignment */}
{paddingDays.map((i) => (
<div key={`padding-${i}`} className="aspect-square" />
))}
{/* Actual days */}
{days.map((day) => {
const dayEvents = getDayEvents(day);
const hasEvents = dayEvents.length > 0;
const isSelected = selectedDate && isSameDay(day, selectedDate);
return (
<button
key={day.toISOString()}
onClick={() => setSelectedDate(day)}
disabled={isEditing}
className={cn(
'relative aspect-square rounded-md text-xs transition-colors',
'hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring',
isToday(day) && 'bg-primary text-primary-foreground hover:bg-primary/90',
isSelected && !isToday(day) && 'bg-accent',
!isSameMonth(day, currentDate) && 'text-muted-foreground/50'
)}
>
{format(day, 'd')}
{/* Event indicator dot */}
{hasEvents && (
<span
className={cn(
'absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full',
isToday(day) ? 'bg-primary-foreground' : 'bg-primary'
)}
/>
)}
</button>
);
})}
</div>
{/* Selected date events */}
{selectedDate && getDayEvents(selectedDate).length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="border-t pt-2"
>
<p className="mb-1 text-xs font-medium">
{format(selectedDate, 'd. MMMM', { locale: dateLocale })}
</p>
<div className="space-y-1">
{getDayEvents(selectedDate).slice(0, 3).map((event) => (
<div
key={event.id}
className="flex items-center gap-2 text-xs"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
/>
<span className="truncate">{event.title}</span>
</div>
))}
</div>
</motion.div>
)}
</div>
</WidgetContainer>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { format } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { WidgetContainer } from '../widget-container';
interface ClockWidgetProps {
id: string;
isEditing?: boolean;
onRemove?: () => void;
locale?: string;
className?: string;
}
/**
* Clock Widget - Displays current time and date
* Updates every second for real-time display
*/
export function ClockWidget({
id,
isEditing = false,
onRemove,
locale = 'de',
className,
}: ClockWidgetProps) {
const t = useTranslations('widgets.clock');
const [now, setNow] = useState<Date | null>(null);
const [mounted, setMounted] = useState(false);
// Initialize time only on client
useEffect(() => {
setMounted(true);
setNow(new Date());
}, []);
// Update time every second
useEffect(() => {
if (!mounted) return;
const interval = setInterval(() => {
setNow(new Date());
}, 1000);
return () => clearInterval(interval);
}, [mounted]);
const dateLocale = locale === 'de' ? de : enUS;
// Show placeholder during server render and initial client mount
if (!mounted || !now) {
return (
<WidgetContainer
id={id}
title={t('name')}
icon={<Clock className="h-4 w-4 text-primary" />}
isEditing={isEditing}
onRemove={onRemove}
className={className}
>
<div className="flex flex-col items-center justify-center py-2">
<div
className={cn(
'font-mono text-4xl font-bold tracking-tight tabular-nums',
'bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent'
)}
>
--:--:--
</div>
<p className="mt-2 text-sm text-muted-foreground capitalize">Loading...</p>
</div>
</WidgetContainer>
);
}
const timeString = format(now, 'HH:mm:ss');
const dateString = format(now, 'EEEE, d. MMMM yyyy', { locale: dateLocale });
return (
<WidgetContainer
id={id}
title={t('name')}
icon={<Clock className="h-4 w-4 text-primary" />}
isEditing={isEditing}
onRemove={onRemove}
className={className}
>
<div className="flex flex-col items-center justify-center py-2">
<div
className={cn(
'font-mono text-4xl font-bold tracking-tight tabular-nums',
'bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent'
)}
>
{timeString}
</div>
<p className="mt-2 text-sm text-muted-foreground capitalize">{dateString}</p>
</div>
</WidgetContainer>
);
}

View File

@@ -0,0 +1,18 @@
// Dashboard Widgets barrel export
export { ClockWidget } from './clock-widget';
export { WelcomeWidget } from './welcome-widget';
export { QuickActionsWidget } from './quick-actions-widget';
export { StatsWidget, StatsGridWidget, type StatConfig, type TrendDirection } from './stats-widget';
export { CalendarWidget } from './calendar-widget';
export { ActivityWidget, type ActivityItem, type ActivityType } from './activity-widget';
// Integration Widgets
export {
OrdersWidget,
ChatWidget,
TasksWidget,
TicketsWidget,
FilesWidget,
DocumentsWidget,
GembaDocsWidget,
} from './integrations';

View File

@@ -0,0 +1,211 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { MessageSquare, Send, Hash, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { WidgetContainer } from '../../widget-container';
import { useMessages, useUnreadCounts, useSendMessage } from '@/hooks/integrations';
interface ChatWidgetProps {
id: string;
isEditing?: boolean;
onRemove?: () => void;
locale?: string;
maxItems?: number;
className?: string;
}
/**
* Chat Widget - Displays recent ZULIP messages with quick reply
*/
export function ChatWidget({
id,
isEditing = false,
onRemove,
locale = 'de',
maxItems = 5,
className,
}: ChatWidgetProps) {
const t = useTranslations('widgets.chat');
const { data: messages, isLoading: messagesLoading } = useMessages();
const { data: unreadCounts } = useUnreadCounts();
const sendMessage = useSendMessage();
const [replyTo, setReplyTo] = useState<number | null>(null);
const [replyText, setReplyText] = useState('');
const dateLocale = locale === 'de' ? de : enUS;
const displayedMessages = messages?.slice(0, maxItems) ?? [];
const totalUnread = unreadCounts?.reduce((sum, c) => sum + c.count, 0) ?? 0;
const handleReply = async () => {
if (!replyText.trim() || !replyTo) return;
const message = messages?.find((m) => m.id === replyTo);
if (!message) return;
await sendMessage.mutateAsync({
streamId: message.streamId,
topic: message.topic,
content: replyText,
});
setReplyText('');
setReplyTo(null);
};
return (
<WidgetContainer
id={id}
title={t('name')}
icon={
<div className="relative">
<MessageSquare className="h-4 w-4 text-primary" />
{totalUnread > 0 && (
<span className="absolute -right-1 -top-1 flex h-3 w-3 items-center justify-center rounded-full bg-destructive text-[8px] text-white">
{totalUnread > 9 ? '9+' : totalUnread}
</span>
)}
</div>
}
isEditing={isEditing}
onRemove={onRemove}
className={className}
>
<ScrollArea className="h-[200px] pr-2">
{messagesLoading ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : displayedMessages.length > 0 ? (
<div className="space-y-3">
{displayedMessages.map((message, index) => (
<motion.div
key={message.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className={cn(
'rounded-lg border p-2 transition-colors',
!message.isRead && 'border-primary/50 bg-primary/5',
replyTo === message.id && 'ring-2 ring-primary'
)}
>
<div className="flex items-start gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{message.senderName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{message.senderName}
</span>
{!message.isRead && (
<Circle className="h-2 w-2 fill-primary text-primary" />
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Hash className="h-3 w-3" />
<span>{message.streamName}</span>
<span>-</span>
<span className="truncate">{message.topic}</span>
</div>
<p className="mt-1 line-clamp-2 text-sm">{message.content}</p>
<div className="mt-1 flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(message.timestamp, {
addSuffix: true,
locale: dateLocale,
})}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() =>
setReplyTo(replyTo === message.id ? null : message.id)
}
>
{t('reply')}
</Button>
</div>
</div>
</div>
</motion.div>
))}
</div>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t('noMessages')}
</div>
)}
</ScrollArea>
{/* Quick Reply */}
{replyTo && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-2 flex gap-2"
>
<Input
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={t('replyPlaceholder')}
className="h-8 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleReply();
}
}}
/>
<Button
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleReply}
disabled={!replyText.trim() || sendMessage.isPending}
>
<Send className="h-4 w-4" />
</Button>
</motion.div>
)}
{/* Footer link */}
<div className="mt-3 border-t pt-3">
<Button variant="ghost" size="sm" className="w-full" asChild>
<Link href={`/${locale}/integrations/zulip`}>
<MessageSquare className="mr-2 h-4 w-4" />
{t('viewAll')}
</Link>
</Button>
</div>
</WidgetContainer>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { FileText, Search, Download, Tag, ExternalLink, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { WidgetContainer } from '../../widget-container';
import { useDocuments, useClassifications, formatDocumentSize } from '@/hooks/integrations';
interface DocumentsWidgetProps {
id: string;
isEditing?: boolean;
onRemove?: () => void;
locale?: string;
maxItems?: number;
className?: string;
}
/**
* Documents Widget - Displays recent ecoDMS documents with search
*/
export function DocumentsWidget({
id,
isEditing = false,
onRemove,
locale = 'de',
maxItems = 5,
className,
}: DocumentsWidgetProps) {
const t = useTranslations('widgets.documents');
const [searchQuery, setSearchQuery] = useState('');
const [selectedClassification, setSelectedClassification] = useState<string | null>(null);
const { data: documents, isLoading } = useDocuments({
search: searchQuery || undefined,
classification: selectedClassification || undefined,
limit: maxItems,
});
const { data: classifications } = useClassifications();
const dateLocale = locale === 'de' ? de : enUS;
const handleClearFilters = () => {
setSearchQuery('');
setSelectedClassification(null);
};
const hasFilters = searchQuery || selectedClassification;
return (
<WidgetContainer
id={id}
title={t('name')}
icon={<FileText className="h-4 w-4 text-primary" />}
isEditing={isEditing}
onRemove={onRemove}
className={className}
colSpan={2}
>
{/* Search and Filter */}
<div className="mb-3 space-y-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
className="h-8 pl-8 text-sm"
/>
{hasFilters && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2"
onClick={handleClearFilters}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{/* Classification Tags */}
{classifications && classifications.length > 0 && (
<div className="flex flex-wrap gap-1">
{classifications.slice(0, 5).map((classification) => (
<Badge
key={classification.id}
variant={selectedClassification === classification.name ? 'default' : 'outline'}
className="cursor-pointer text-xs"
onClick={() =>
setSelectedClassification(
selectedClassification === classification.name ? null : classification.name
)
}
>
<Tag className="mr-1 h-3 w-3" />
{classification.name}
</Badge>
))}
</div>
)}
</div>
<ScrollArea className="h-[180px] pr-2">
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : documents && documents.length > 0 ? (
<div className="space-y-2">
{documents.map((doc, index) => (
<motion.div
key={doc.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="flex items-start gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/50"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10 text-red-500">
<FileText className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{doc.title}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="text-xs">
{doc.classification}
</Badge>
<span>{formatDocumentSize(doc.fileSize)}</span>
</div>
<div className="mt-1 flex flex-wrap gap-1">
{doc.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs"
>
{tag}
</span>
))}
{doc.tags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{doc.tags.length - 3}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{doc.createdBy} -{' '}
{formatDistanceToNow(doc.createdAt, {
addSuffix: true,
locale: dateLocale,
})}
</p>
</div>
{doc.downloadUrl && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
asChild
>
<a href={doc.downloadUrl} download>
<Download className="h-4 w-4" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>{t('download')}</TooltipContent>
</Tooltip>
)}
</motion.div>
))}
</div>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{searchQuery || selectedClassification ? t('noResults') : t('noDocuments')}
</div>
)}
</ScrollArea>
{/* Footer link */}
<div className="mt-3 border-t pt-3">
<Button variant="ghost" size="sm" className="w-full" asChild>
<Link href={`/${locale}/integrations/ecodms`}>
<ExternalLink className="mr-2 h-4 w-4" />
{t('openEcoDms')}
</Link>
</Button>
</div>
</WidgetContainer>
);
}

View File

@@ -0,0 +1,181 @@
'use client';
import { useTranslations } from 'next-intl';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { motion } from 'framer-motion';
import Link from 'next/link';
import {
Cloud,
FileText,
FileImage,
FileSpreadsheet,
FileVideo,
FileArchive,
Folder,
Download,
ExternalLink,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { WidgetContainer } from '../../widget-container';
import { useRecentFiles, formatFileSize } from '@/hooks/integrations';
import type { NextcloudFile } from '@/types/integrations';
interface FilesWidgetProps {
id: string;
isEditing?: boolean;
onRemove?: () => void;
locale?: string;
maxItems?: number;
className?: string;
}
/**
* Get file icon based on mime type
*/
function getFileIcon(file: NextcloudFile) {
if (file.type === 'folder') return Folder;
const mimeType = file.mimeType || '';
if (mimeType.startsWith('image/')) return FileImage;
if (mimeType.startsWith('video/')) return FileVideo;
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return FileSpreadsheet;
if (mimeType.includes('zip') || mimeType.includes('compressed')) return FileArchive;
return FileText;
}
/**
* Get file icon color based on type
*/
function getFileColor(file: NextcloudFile): string {
if (file.type === 'folder') return 'text-yellow-500';
const mimeType = file.mimeType || '';
if (mimeType.startsWith('image/')) return 'text-green-500';
if (mimeType.startsWith('video/')) return 'text-purple-500';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'text-emerald-500';
if (mimeType.includes('pdf')) return 'text-red-500';
if (mimeType.includes('zip') || mimeType.includes('compressed')) return 'text-orange-500';
return 'text-blue-500';
}
/**
* Files Widget - Displays recent Nextcloud files
*/
export function FilesWidget({
id,
isEditing = false,
onRemove,
locale = 'de',
maxItems = 5,
className,
}: FilesWidgetProps) {
const t = useTranslations('widgets.files');
const { data: files, isLoading } = useRecentFiles(maxItems);
const dateLocale = locale === 'de' ? de : enUS;
return (
<WidgetContainer
id={id}
title={t('name')}
icon={<Cloud className="h-4 w-4 text-primary" />}
isEditing={isEditing}
onRemove={onRemove}
className={className}
>
<ScrollArea className="h-[220px] pr-2">
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : files && files.length > 0 ? (
<div className="space-y-2">
{files.map((file, index) => {
const Icon = getFileIcon(file);
const iconColor = getFileColor(file);
return (
<motion.div
key={file.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="flex items-center gap-3 rounded-lg border p-2 transition-colors hover:bg-muted/50"
>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg bg-muted',
iconColor
)}
>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{file.name}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatFileSize(file.size)}</span>
<span>-</span>
<span>
{formatDistanceToNow(file.modifiedAt, {
addSuffix: true,
locale: dateLocale,
})}
</span>
</div>
<p className="truncate text-xs text-muted-foreground">{file.path}</p>
</div>
{file.downloadUrl && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
asChild
>
<a href={file.downloadUrl} download>
<Download className="h-4 w-4" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>{t('download')}</TooltipContent>
</Tooltip>
)}
</motion.div>
);
})}
</div>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t('noFiles')}
</div>
)}
</ScrollArea>
{/* Footer link */}
<div className="mt-3 border-t pt-3">
<Button variant="ghost" size="sm" className="w-full" asChild>
<Link href={`/${locale}/integrations/nextcloud`}>
<ExternalLink className="mr-2 h-4 w-4" />
{t('openNextcloud')}
</Link>
</Button>
</div>
</WidgetContainer>
);
}

View File

@@ -0,0 +1,256 @@
'use client';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import Link from 'next/link';
import {
ClipboardCheck,
AlertTriangle,
Calendar,
TrendingUp,
TrendingDown,
Minus,
ExternalLink,
AlertCircle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { WidgetContainer } from '../../widget-container';
import {
useUpcomingAudits,
useFindingCounts,
useComplianceScore,
getDaysUntilAudit,
} from '@/hooks/integrations';
import type { AuditType, FindingSeverity } from '@/types/integrations';
interface GembaDocsWidgetProps {
id: string;
isEditing?: boolean;
onRemove?: () => void;
locale?: string;
className?: string;
}
/** Audit type badge configuration */
const auditTypeConfig: Record<AuditType, { variant: 'default' | 'secondary' | 'outline' }> = {
internal: { variant: 'secondary' },
external: { variant: 'default' },
certification: { variant: 'outline' },
};
/** Severity badge configuration */
const severityConfig: Record<
FindingSeverity,
{ variant: 'default' | 'secondary' | 'destructive' | 'warning'; className: string }
> = {
critical: { variant: 'destructive', className: 'bg-red-500 hover:bg-red-600' },
high: { variant: 'warning', className: 'bg-orange-500 hover:bg-orange-600' },
medium: { variant: 'secondary', className: 'bg-yellow-500 hover:bg-yellow-600 text-black' },
low: { variant: 'default', className: 'bg-blue-500 hover:bg-blue-600' },
};
/**
* GembaDocs Widget - Displays upcoming audits, findings, and compliance score
*/
export function GembaDocsWidget({
id,
isEditing = false,
onRemove,
locale = 'de',
className,
}: GembaDocsWidgetProps) {
const t = useTranslations('widgets.gembadocs');
const { data: audits, isLoading: auditsLoading } = useUpcomingAudits(3);
const { data: findingCounts, isLoading: findingsLoading } = useFindingCounts();
const { data: complianceScore, isLoading: complianceLoading } = useComplianceScore();
const isLoading = auditsLoading || findingsLoading || complianceLoading;
/**
* Format days until audit
*/
const formatDaysLeft = (scheduledDate: Date): string => {
const days = getDaysUntilAudit(scheduledDate);
if (days === 0) return t('today');
if (days === 1) return t('tomorrow');
return t('daysLeft', { days });
};
/**
* Get trend icon component
*/
const TrendIcon = complianceScore?.trend === 'up'
? TrendingUp
: complianceScore?.trend === 'down'
? TrendingDown
: Minus;
const trendColor = complianceScore?.trend === 'up'
? 'text-green-500'
: complianceScore?.trend === 'down'
? 'text-red-500'
: 'text-muted-foreground';
return (
<WidgetContainer
id={id}
title={t('name')}
icon={<ClipboardCheck className="h-4 w-4 text-primary" />}
isEditing={isEditing}
onRemove={onRemove}
className={className}
colSpan={2}
>
{isLoading ? (
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-20 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-12 w-full" />
</div>
</div>
) : (
<div className="space-y-4">
{/* Compliance Score */}
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('complianceScore')}</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold">{complianceScore?.overall ?? 0}%</span>
<TrendIcon className={cn('h-4 w-4', trendColor)} />
</div>
</div>
{/* Circular progress indicator */}
<div className="relative h-12 w-12">
<svg className="h-12 w-12 -rotate-90" viewBox="0 0 36 36">
<path
className="stroke-muted"
fill="none"
strokeWidth="3"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
className="stroke-primary"
fill="none"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={`${complianceScore?.overall ?? 0}, 100`}
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
</svg>
</div>
</div>
{/* Open Findings Summary */}
<div>
<div className="mb-2 flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{t('openFindings')}</span>
{findingCounts && findingCounts.total > 0 && (
<Badge variant="secondary" className="ml-auto">
{findingCounts.total}
</Badge>
)}
</div>
{findingCounts && findingCounts.total > 0 ? (
<div className="flex flex-wrap gap-2">
{findingCounts.critical > 0 && (
<Badge className={severityConfig.critical.className}>
<AlertCircle className="mr-1 h-3 w-3" />
{findingCounts.critical} {t('severity.critical')}
</Badge>
)}
{findingCounts.high > 0 && (
<Badge className={severityConfig.high.className}>
{findingCounts.high} {t('severity.high')}
</Badge>
)}
{findingCounts.medium > 0 && (
<Badge className={severityConfig.medium.className}>
{findingCounts.medium} {t('severity.medium')}
</Badge>
)}
{findingCounts.low > 0 && (
<Badge className={severityConfig.low.className}>
{findingCounts.low} {t('severity.low')}
</Badge>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">
{t('noAudits')}
</p>
)}
</div>
<Separator />
{/* Upcoming Audits */}
<div>
<div className="mb-2 flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{t('upcomingAudits')}</span>
</div>
{audits && audits.length > 0 ? (
<div className="space-y-2">
{audits.map((audit, index) => (
<motion.div
key={audit.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="flex items-center justify-between rounded-lg border p-2 text-sm"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{audit.title}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge
variant={auditTypeConfig[audit.type].variant}
className="text-xs"
>
{t(`auditType.${audit.type}`)}
</Badge>
<span>{audit.department}</span>
</div>
</div>
<Badge
variant="outline"
className={cn(
'shrink-0 ml-2',
getDaysUntilAudit(audit.scheduledDate) <= 3 && 'border-orange-500 text-orange-500'
)}
>
{formatDaysLeft(audit.scheduledDate)}
</Badge>
</motion.div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">{t('noAudits')}</p>
)}
</div>
{/* Footer link */}
<div className="border-t pt-3">
<Button variant="ghost" size="sm" className="w-full" asChild>
<Link href={`/${locale}/integrations/gembadocs`}>
<ExternalLink className="mr-2 h-4 w-4" />
{t('openGembaDocs')}
</Link>
</Button>
</div>
</div>
)}
</WidgetContainer>
);
}

Some files were not shown because too many files have changed in this diff Show More