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:
14
apps/web/.env.example
Normal file
14
apps/web/.env.example
Normal 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
14
apps/web/.eslintrc.json
Normal 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
48
apps/web/.gitignore
vendored
Normal 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
20
apps/web/components.json
Normal 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
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
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
30
apps/web/next.config.mjs
Normal 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
74
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
apps/web/public/favicon.ico
Normal file
1
apps/web/public/favicon.ico
Normal file
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/app/[locale]/(auth)/admin/departments/page.tsx
Normal file
23
apps/web/src/app/[locale]/(auth)/admin/departments/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/app/[locale]/(auth)/admin/integrations/page.tsx
Normal file
20
apps/web/src/app/[locale]/(auth)/admin/integrations/page.tsx
Normal 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} />;
|
||||
}
|
||||
101
apps/web/src/app/[locale]/(auth)/admin/layout.tsx
Normal file
101
apps/web/src/app/[locale]/(auth)/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/app/[locale]/(auth)/admin/page.tsx
Normal file
19
apps/web/src/app/[locale]/(auth)/admin/page.tsx
Normal 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 />;
|
||||
}
|
||||
23
apps/web/src/app/[locale]/(auth)/admin/users/page.tsx
Normal file
23
apps/web/src/app/[locale]/(auth)/admin/users/page.tsx
Normal 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} />;
|
||||
}
|
||||
284
apps/web/src/app/[locale]/(auth)/admin/users/users-content.tsx
Normal file
284
apps/web/src/app/[locale]/(auth)/admin/users/users-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
270
apps/web/src/app/[locale]/(auth)/dashboard/dashboard-content.tsx
Normal file
270
apps/web/src/app/[locale]/(auth)/dashboard/dashboard-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/app/[locale]/(auth)/dashboard/page.tsx
Normal file
24
apps/web/src/app/[locale]/(auth)/dashboard/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
24
apps/web/src/app/[locale]/(auth)/hr/absences/page.tsx
Normal file
24
apps/web/src/app/[locale]/(auth)/hr/absences/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/app/[locale]/(auth)/hr/employees/[id]/page.tsx
Normal file
26
apps/web/src/app/[locale]/(auth)/hr/employees/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/app/[locale]/(auth)/hr/employees/new/page.tsx
Normal file
26
apps/web/src/app/[locale]/(auth)/hr/employees/new/page.tsx
Normal 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} />;
|
||||
}
|
||||
26
apps/web/src/app/[locale]/(auth)/hr/employees/page.tsx
Normal file
26
apps/web/src/app/[locale]/(auth)/hr/employees/page.tsx
Normal 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} />;
|
||||
}
|
||||
257
apps/web/src/app/[locale]/(auth)/hr/hr-overview-content.tsx
Normal file
257
apps/web/src/app/[locale]/(auth)/hr/hr-overview-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/app/[locale]/(auth)/hr/org-chart/page.tsx
Normal file
26
apps/web/src/app/[locale]/(auth)/hr/org-chart/page.tsx
Normal 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} />;
|
||||
}
|
||||
26
apps/web/src/app/[locale]/(auth)/hr/page.tsx
Normal file
26
apps/web/src/app/[locale]/(auth)/hr/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
24
apps/web/src/app/[locale]/(auth)/hr/time-tracking/page.tsx
Normal file
24
apps/web/src/app/[locale]/(auth)/hr/time-tracking/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
13
apps/web/src/app/[locale]/(auth)/integrations/layout.tsx
Normal file
13
apps/web/src/app/[locale]/(auth)/integrations/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
21
apps/web/src/app/[locale]/(auth)/integrations/page.tsx
Normal file
21
apps/web/src/app/[locale]/(auth)/integrations/page.tsx
Normal 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} />;
|
||||
}
|
||||
49
apps/web/src/app/[locale]/(auth)/layout.tsx
Normal file
49
apps/web/src/app/[locale]/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
138
apps/web/src/app/[locale]/(auth)/lean/page.tsx
Normal file
138
apps/web/src/app/[locale]/(auth)/lean/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
25
apps/web/src/app/[locale]/(auth)/lean/s3-planning/page.tsx
Normal file
25
apps/web/src/app/[locale]/(auth)/lean/s3-planning/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
24
apps/web/src/app/[locale]/(auth)/lean/skill-matrix/page.tsx
Normal file
24
apps/web/src/app/[locale]/(auth)/lean/skill-matrix/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
24
apps/web/src/app/[locale]/(auth)/settings/page.tsx
Normal file
24
apps/web/src/app/[locale]/(auth)/settings/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/app/[locale]/(auth)/settings/profile/page.tsx
Normal file
23
apps/web/src/app/[locale]/(auth)/settings/profile/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/app/[locale]/(auth)/settings/security/page.tsx
Normal file
23
apps/web/src/app/[locale]/(auth)/settings/security/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
109
apps/web/src/app/[locale]/(auth)/settings/settings-content.tsx
Normal file
109
apps/web/src/app/[locale]/(auth)/settings/settings-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/app/[locale]/error.tsx
Normal file
55
apps/web/src/app/[locale]/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/app/[locale]/layout.tsx
Normal file
65
apps/web/src/app/[locale]/layout.tsx
Normal 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 }));
|
||||
}
|
||||
13
apps/web/src/app/[locale]/loading.tsx
Normal file
13
apps/web/src/app/[locale]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
apps/web/src/app/[locale]/login/page.tsx
Normal file
111
apps/web/src/app/[locale]/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/app/[locale]/not-found.tsx
Normal file
36
apps/web/src/app/[locale]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/web/src/app/[locale]/page.tsx
Normal file
12
apps/web/src/app/[locale]/page.tsx
Normal 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`);
|
||||
}
|
||||
10
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
10
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
145
apps/web/src/components/charts/bar-chart.tsx
Normal file
145
apps/web/src/components/charts/bar-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
apps/web/src/components/charts/chart-container.tsx
Normal file
137
apps/web/src/components/charts/chart-container.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/components/charts/index.ts
Normal file
5
apps/web/src/components/charts/index.ts
Normal 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';
|
||||
127
apps/web/src/components/charts/line-chart.tsx
Normal file
127
apps/web/src/components/charts/line-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
apps/web/src/components/charts/pie-chart.tsx
Normal file
197
apps/web/src/components/charts/pie-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
apps/web/src/components/dashboard/add-widget-dialog.tsx
Normal file
183
apps/web/src/components/dashboard/add-widget-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/components/dashboard/index.ts
Normal file
19
apps/web/src/components/dashboard/index.ts
Normal 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';
|
||||
164
apps/web/src/components/dashboard/widget-container.tsx
Normal file
164
apps/web/src/components/dashboard/widget-container.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
207
apps/web/src/components/dashboard/widget-grid.tsx
Normal file
207
apps/web/src/components/dashboard/widget-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
279
apps/web/src/components/dashboard/widget-registry.ts
Normal file
279
apps/web/src/components/dashboard/widget-registry.ts
Normal 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' },
|
||||
];
|
||||
213
apps/web/src/components/dashboard/widgets/activity-widget.tsx
Normal file
213
apps/web/src/components/dashboard/widgets/activity-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
apps/web/src/components/dashboard/widgets/calendar-widget.tsx
Normal file
194
apps/web/src/components/dashboard/widgets/calendar-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/components/dashboard/widgets/clock-widget.tsx
Normal file
105
apps/web/src/components/dashboard/widgets/clock-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/components/dashboard/widgets/index.ts
Normal file
18
apps/web/src/components/dashboard/widgets/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user