diff --git a/PROGRESS.md b/PROGRESS.md index 4d10861..551cccb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -11,7 +11,7 @@ |---|---|---|---| | 0.1 | ✅ done | 2026-07-23 | codebase-vs-requirements.md neu geschrieben, security-review-phase2.md Resolution Summary, architecture.md Implementation Status, MASTER-PLAN.md + PROGRESS.md erstellt | | 0.2 | ✅ done | 2026-07-23 | lucide-react installieren + Icons migrieren | -| 0.3 | ⬜ pending | | date-fns installieren + Datum-Formatierung | +| 0.3 | ✅ done | 2026-07-23 | date-fns installieren + Datum-Formatierung | | 0.4 | ⬜ pending | | hooks.ts aufteilen | | 0.5 | ⬜ pending | | Store-Verzeichnis konsolidieren | | 0.6 | ⬜ pending | | Frontend-Bestandsanalyse als Dokument speichern | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2cddf92..8e64c68 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@tiptap/starter-kit": "^3.28.0", "axios": "^1.7.7", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "i18next": "^23.14.0", "i18next-browser-languagedetector": "^8.0.0", "lucide-react": "^1.25.0", @@ -2747,6 +2748,15 @@ "node": ">=18" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0a6bdf6..25e6921 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@tiptap/starter-kit": "^3.28.0", "axios": "^1.7.7", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "i18next": "^23.14.0", "i18next-browser-languagedetector": "^8.0.0", "lucide-react": "^1.25.0", diff --git a/frontend/src/api/mail.ts b/frontend/src/api/mail.ts index 88ec7cf..d86b5ba 100644 --- a/frontend/src/api/mail.ts +++ b/frontend/src/api/mail.ts @@ -438,13 +438,14 @@ export function decodeMimeHeader(value: string | undefined | null): string { * Variables: {{user.name}}, {{user.first_name}}, {{user.last_name}}, * {{user.email}}, {{user.role}}, {{tenant.name}}, {{date}} */ +import { formatDateShort } from '@/utils/date'; + export function replaceSignatureVariables( html: string, user: { name?: string; email?: string; role?: string; first_name?: string; last_name?: string }, tenant?: { name?: string }, ): string { - const now = new Date(); - const dateStr = now.toLocaleDateString([], { year: 'numeric', month: '2-digit', day: '2-digit' }); + const dateStr = formatDateShort(new Date()); const firstName = user.first_name || (user.name ? user.name.split(' ')[0] : ''); const lastName = user.last_name || (user.name ? user.name.split(' ').slice(1).join(' ') : ''); diff --git a/frontend/src/components/calendar/AppointmentModal.tsx b/frontend/src/components/calendar/AppointmentModal.tsx index 6c3d1da..9d1c5c3 100644 --- a/frontend/src/components/calendar/AppointmentModal.tsx +++ b/frontend/src/components/calendar/AppointmentModal.tsx @@ -21,6 +21,7 @@ import { type EntryPriority, type EntrySubtype, } from '@/api/calendar'; +import { formatDateTimeInput } from '@/utils/date'; export interface AppointmentModalProps { open: boolean; @@ -41,9 +42,7 @@ export interface AppointmentModalProps { function toDateTimeLocalValue(d: Date | null | undefined): string { if (!d) return ''; - // datetime-local needs YYYY-MM-DDTHH:mm in local time - const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + return formatDateTimeInput(d); } function fromDateTimeLocalValue(v: string): Date | null { diff --git a/frontend/src/components/calendar/CalendarDetail.tsx b/frontend/src/components/calendar/CalendarDetail.tsx index 38cd040..06db519 100644 --- a/frontend/src/components/calendar/CalendarDetail.tsx +++ b/frontend/src/components/calendar/CalendarDetail.tsx @@ -21,21 +21,11 @@ import { import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Calendar as CalendarIcon, Pencil, Trash2, X } from 'lucide-react'; +import { formatDateTime } from '@/utils/date'; function formatDate(dateStr: string | null | undefined): string { if (!dateStr) return '-'; - try { - const d = new Date(dateStr); - return d.toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return dateStr; - } + return formatDateTime(dateStr) || '-'; } function priorityClass(p: string): string { diff --git a/frontend/src/components/calendar/KanbanBoard.tsx b/frontend/src/components/calendar/KanbanBoard.tsx index 17764a2..f162ed1 100644 --- a/frontend/src/components/calendar/KanbanBoard.tsx +++ b/frontend/src/components/calendar/KanbanBoard.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; import type { CalendarEntry } from '@/api/calendar'; +import { formatDateShort } from '@/utils/date'; export type KanbanStatus = 'open' | 'in_progress' | 'done' | 'cancelled'; @@ -38,8 +39,7 @@ function priorityBadgeClass(p: string): string { function fmtDue(entry: CalendarEntry): string | null { if (!entry.due_date) return null; - const d = new Date(entry.due_date); - return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + return formatDateShort(entry.due_date) || null; } export function KanbanBoard({ board, loading, onMove, onSelect }: KanbanBoardProps) { diff --git a/frontend/src/components/calendar/TaskDetailPanel.tsx b/frontend/src/components/calendar/TaskDetailPanel.tsx index 3240379..bf7c754 100644 --- a/frontend/src/components/calendar/TaskDetailPanel.tsx +++ b/frontend/src/components/calendar/TaskDetailPanel.tsx @@ -15,6 +15,7 @@ import { type CalendarEntry, type Subtask, } from '@/api/calendar'; +import { formatDateShort } from '@/utils/date'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -129,7 +130,7 @@ export function TaskDetailPanel({ entry, onClose, onChanged }: TaskDetailPanelPr {entry.due_date && ( - {new Date(entry.due_date).toLocaleDateString('de-DE')} + {formatDateShort(entry.due_date)} )} diff --git a/frontend/src/components/dms/FileDetails.tsx b/frontend/src/components/dms/FileDetails.tsx index dfe3d34..f3d588a 100644 --- a/frontend/src/components/dms/FileDetails.tsx +++ b/frontend/src/components/dms/FileDetails.tsx @@ -9,15 +9,11 @@ import { useTranslation } from 'react-i18next'; import type { DmsFile } from '@/api/dms'; import { getFileIcon, formatFileSize } from './FileExplorer'; import { Download, Eye, FileText, Share2, Trash2, X } from 'lucide-react'; +import { formatDateTime } from '@/utils/date'; function formatDate(dateStr: string | null | undefined): string { if (!dateStr) return '-'; - try { - const d = new Date(dateStr); - return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); - } catch { - return dateStr; - } + return formatDateTime(dateStr) || '-'; } function getFileSize(file: DmsFile): number { diff --git a/frontend/src/components/dms/FileExplorer.tsx b/frontend/src/components/dms/FileExplorer.tsx index d384bcf..891b8fb 100644 --- a/frontend/src/components/dms/FileExplorer.tsx +++ b/frontend/src/components/dms/FileExplorer.tsx @@ -38,6 +38,8 @@ export function formatFileSize(bytes: number): string { if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } +import { formatDateShort } from '@/utils/date'; + function getFileSize(file: DmsFile): number { return file.size_bytes ?? file.size ?? 0; @@ -45,12 +47,7 @@ function getFileSize(file: DmsFile): number { function formatDate(dateStr: string | null | undefined): string { if (!dateStr) return ''; - try { - const d = new Date(dateStr); - return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); - } catch { - return dateStr; - } + return formatDateShort(dateStr) || ''; } export type ViewMode = 'list' | 'table' | 'icons-sm' | 'icons-md' | 'icons-lg'; diff --git a/frontend/src/components/dms/FilePreviewModal.tsx b/frontend/src/components/dms/FilePreviewModal.tsx index 806323d..066e021 100644 --- a/frontend/src/components/dms/FilePreviewModal.tsx +++ b/frontend/src/components/dms/FilePreviewModal.tsx @@ -8,6 +8,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; +import { formatDateShort } from '@/utils/date'; import { Badge } from '@/components/ui/Badge'; import { getFilePreviewUrl, type DmsFile } from '@/api/dms'; import { FileText } from 'lucide-react'; @@ -56,7 +57,7 @@ export function FilePreviewModal({ open, file, onClose }: FilePreviewModalProps) {formatFileSize(file.size_bytes ?? file.size ?? 0)} {file.created_at && ( - {t('dms.fileModified')}: {new Date(file.created_at).toLocaleDateString()} + {t('dms.fileModified')}: {formatDateShort(file.created_at)} )} diff --git a/frontend/src/components/dms/ShareDialog.tsx b/frontend/src/components/dms/ShareDialog.tsx index 51b8fbb..580b85c 100644 --- a/frontend/src/components/dms/ShareDialog.tsx +++ b/frontend/src/components/dms/ShareDialog.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; +import { formatDateShort } from '@/utils/date'; import { Select } from '@/components/ui/Select'; import { Badge } from '@/components/ui/Badge'; import { EmptyState } from '@/components/ui/EmptyState'; @@ -259,7 +260,7 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps) )} {link.expires_at && ( - {t('permissions.expiresAt')}: {new Date(link.expires_at).toLocaleDateString()} + {t('permissions.expiresAt')}: {formatDateShort(link.expires_at)} )} diff --git a/frontend/src/components/layout/MessageSidebar.tsx b/frontend/src/components/layout/MessageSidebar.tsx index 0f1730f..a50f726 100644 --- a/frontend/src/components/layout/MessageSidebar.tsx +++ b/frontend/src/components/layout/MessageSidebar.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { formatTime } from '@/utils/date'; import { ResizablePanel } from '@/components/ui/ResizablePanel'; import { useUIStore } from '@/store/uiStore'; import { useCommStore } from '@/store/commStore'; @@ -241,7 +242,7 @@ function MessageFeed({ )} {msg.created_at && ( - {new Date(msg.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} + {formatTime(msg.created_at)} )} diff --git a/frontend/src/components/mail/MailDetail.tsx b/frontend/src/components/mail/MailDetail.tsx index ead3878..b4685ed 100644 --- a/frontend/src/components/mail/MailDetail.tsx +++ b/frontend/src/components/mail/MailDetail.tsx @@ -11,6 +11,7 @@ import { decodeMimeHeader } from '@/api/mail'; import { Button } from '@/components/ui/Button'; import { EmptyState } from '@/components/ui/EmptyState'; import { FileText, Loader2 } from 'lucide-react'; +import { formatDateTime } from '@/utils/date'; export interface MailDetailProps { mail: Mail | null; loading: boolean; @@ -26,14 +27,7 @@ export interface MailDetailProps { } function formatFullDate(dateStr: string): string { - const date = new Date(dateStr); - return date.toLocaleString([], { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); + return formatDateTime(dateStr) || dateStr; } function formatBytes(bytes: number): string { diff --git a/frontend/src/components/mail/MailList.tsx b/frontend/src/components/mail/MailList.tsx index 0a4a04b..7eacfc3 100644 --- a/frontend/src/components/mail/MailList.tsx +++ b/frontend/src/components/mail/MailList.tsx @@ -11,6 +11,7 @@ import { decodeMimeHeader } from '@/api/mail'; import { EmptyState } from '@/components/ui/EmptyState'; import { Pagination } from '@/components/ui/Pagination'; import { ChevronUp, Loader2, Paperclip, Star } from 'lucide-react'; +import { formatSmartDate } from '@/utils/date'; export interface MailListProps { mails: Mail[]; @@ -30,12 +31,7 @@ export interface MailListProps { } function formatDate(dateStr: string): string { - const date = new Date(dateStr); - const now = new Date(); - if (date.toDateString() === now.toDateString()) { - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } - return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return formatSmartDate(dateStr) || dateStr; } export function MailList({ diff --git a/frontend/src/pages/AuditLog.tsx b/frontend/src/pages/AuditLog.tsx index c62eb4c..1d5c58d 100644 --- a/frontend/src/pages/AuditLog.tsx +++ b/frontend/src/pages/AuditLog.tsx @@ -8,6 +8,7 @@ import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { EmptyState } from '@/components/ui/EmptyState'; import { Badge } from '@/components/ui/Badge'; +import { formatDateTime } from '@/utils/date'; export function AuditLogPage() { const { t } = useTranslation(); @@ -41,8 +42,7 @@ export function AuditLogPage() { cell: (info) => { const val = info.getValue(); if (!val) return '—'; - const date = new Date(val); - return date.toLocaleString('de-DE'); + return formatDateTime(val) || '—'; }, }, { diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index a0d1b22..c65caa2 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -18,6 +18,7 @@ import { MonthView } from '@/components/calendar/MonthView'; import { WeekView } from '@/components/calendar/WeekView'; import { DayView } from '@/components/calendar/DayView'; import { RangeView } from '@/components/calendar/RangeView'; +import { formatDateInput } from '@/utils/date'; import { CalendarDetail } from '@/components/calendar/CalendarDetail'; import { AppointmentModal } from '@/components/calendar/AppointmentModal'; import { IcsControls } from '@/components/calendar/IcsControls'; @@ -488,7 +489,7 @@ export function CalendarPage() { setRangeStart(e.target.value ? new Date(e.target.value) : null)} className="text-sm rounded-md border border-secondary-300 px-2 py-1" data-testid="calendar-range-start" @@ -496,7 +497,7 @@ export function CalendarPage() { setRangeEnd(e.target.value ? new Date(e.target.value) : null)} className="text-sm rounded-md border border-secondary-300 px-2 py-1" data-testid="calendar-range-end" diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3d65479..144fcb3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { StatCard } from '@/components/shared/StatCard'; import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed'; import { useUnifiedContacts, useAuditLog } from '@/api/hooks'; +import { formatDateTime } from '@/utils/date'; export function DashboardPage() { const { t } = useTranslation(); @@ -36,7 +37,7 @@ export function DashboardPage() { id: `${entry.timestamp}-${entry.user}-${entry.action}`, user: entry.user || 'System', action: entry.action || '', - time: entry.timestamp ? new Date(entry.timestamp).toLocaleString('de-DE') : '', + time: entry.timestamp ? (formatDateTime(entry.timestamp) || '') : '', avatarUrl: null, })); diff --git a/frontend/src/pages/DmsTrash.tsx b/frontend/src/pages/DmsTrash.tsx index a9da9c5..543f86a 100644 --- a/frontend/src/pages/DmsTrash.tsx +++ b/frontend/src/pages/DmsTrash.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; +import { formatDateShort } from '@/utils/date'; import { Table, TableColumn } from '@/components/ui/Table'; import { EmptyState } from '@/components/ui/EmptyState'; import { Skeleton } from '@/components/ui/Skeleton'; @@ -79,7 +80,7 @@ export function DmsTrashPage() { sortable: true, accessor: (file) => file.deleted_at || '', render: (file) => file.deleted_at - ? new Date(file.deleted_at).toLocaleDateString() + ? (formatDateShort(file.deleted_at) || '—') : '—', }, { diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts new file mode 100644 index 0000000..76b5269 --- /dev/null +++ b/frontend/src/utils/date.ts @@ -0,0 +1,86 @@ +import { format, formatDistanceToNow, parseISO, isValid, isToday } from 'date-fns'; +import { de } from 'date-fns/locale'; + +/** + * Parse a date string or Date object into a valid Date, or null if invalid/empty. + */ +function toDate(value: string | Date | null | undefined): Date | null { + if (value === null || value === undefined || value === '') return null; + if (value instanceof Date) return isValid(value) ? value : null; + const parsed = parseISO(value); + return isValid(parsed) ? parsed : null; +} + +/** + * Format a date with a custom date-fns pattern and German locale. + * Returns empty string for null/undefined/invalid dates. + */ +export function formatDate(date: string | Date | null | undefined, pattern: string): string { + const d = toDate(date); + if (!d) return ''; + return format(d, pattern, { locale: de }); +} + +/** + * Standard date + time format: dd.MM.yyyy HH:mm + */ +export function formatDateTime(date: string | Date | null | undefined): string { + return formatDate(date, 'dd.MM.yyyy HH:mm'); +} + +/** + * Short date format: dd.MM.yyyy + */ +export function formatDateShort(date: string | Date | null | undefined): string { + return formatDate(date, 'dd.MM.yyyy'); +} + +/** + * Time only format: HH:mm + */ +export function formatTime(date: string | Date | null | undefined): string { + return formatDate(date, 'HH:mm'); +} + +/** + * Relative time format: "vor 5 Minuten", "in 2 Stunden" + */ +export function formatRelative(date: string | Date | null | undefined): string { + const d = toDate(date); + if (!d) return ''; + return formatDistanceToNow(d, { locale: de, addSuffix: true }); +} + +/** + * Month + day format: "15. Juli" + */ +export function formatMonthDay(date: string | Date | null | undefined): string { + return formatDate(date, 'd. MMMM'); +} + +/** + * Smart date: shows time (HH:mm) if today, otherwise short date (d. MMM). + * Used in mail list and similar compact views. + */ +export function formatSmartDate(date: string | Date | null | undefined): string { + const d = toDate(date); + if (!d) return ''; + if (isToday(d)) { + return format(d, 'HH:mm', { locale: de }); + } + return format(d, 'd. MMM', { locale: de }); +} + +/** + * Date input format for : yyyy-MM-dd + */ +export function formatDateInput(date: string | Date | null | undefined): string { + return formatDate(date, 'yyyy-MM-dd'); +} + +/** + * DateTime input format for : yyyy-MM-ddTHH:mm + */ +export function formatDateTimeInput(date: string | Date | null | undefined): string { + return formatDate(date, "yyyy-MM-dd'T'HH:mm"); +}