feat: Implement mobile application and lead processing utilities.

This commit is contained in:
2026-02-19 14:21:51 +01:00
parent fca42db4d2
commit c53a71a5f9
120 changed files with 24080 additions and 851 deletions

View File

@@ -0,0 +1,105 @@
import { TouchableOpacity, Text, View, StyleSheet, Platform } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import * as WebBrowser from 'expo-web-browser'
interface Attachment {
id: string
name: string
storagePath: string
sizeBytes: number | null
mimeType?: string | null
}
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
function getFileIcon(mimeType?: string | null): keyof typeof Ionicons.glyphMap {
if (!mimeType) return 'document-outline'
if (mimeType.includes('pdf')) return 'document-text-outline'
if (mimeType.startsWith('image/')) return 'image-outline'
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'grid-outline'
if (mimeType.includes('word') || mimeType.includes('document')) return 'document-outline'
return 'attach-outline'
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function AttachmentRow({ attachment }: { attachment: Attachment }) {
const url = `${API_URL}/uploads/${attachment.storagePath}`
const icon = getFileIcon(attachment.mimeType)
return (
<TouchableOpacity
onPress={() => WebBrowser.openBrowserAsync(url)}
style={styles.row}
activeOpacity={0.75}
>
<View style={styles.iconBox}>
<Ionicons name={icon} size={20} color="#003B7E" />
</View>
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>
{attachment.name}
</Text>
{attachment.sizeBytes != null && (
<Text style={styles.meta}>{formatSize(attachment.sizeBytes)}</Text>
)}
</View>
<View style={styles.openChip}>
<Ionicons name="download-outline" size={13} color="#003B7E" />
<Text style={styles.openText}>Öffnen</Text>
</View>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 13,
},
iconBox: {
width: 42,
height: 42,
borderRadius: 12,
backgroundColor: '#EFF6FF',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
info: {
flex: 1,
},
name: {
fontSize: 13,
fontWeight: '600',
color: '#0F172A',
lineHeight: 18,
},
meta: {
fontSize: 11,
color: '#94A3B8',
marginTop: 2,
},
openChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: '#EFF6FF',
paddingHorizontal: 11,
paddingVertical: 6,
borderRadius: 99,
},
openText: {
fontSize: 12,
color: '#003B7E',
fontWeight: '600',
},
})

View File

@@ -0,0 +1,133 @@
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { Badge } from '@/components/ui/Badge'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import { useNewsReadStore } from '@/store/news.store'
interface NewsCardProps {
news: {
id: string
title: string
kategorie: string
publishedAt: Date | null
isRead: boolean
author: { name: string } | null
}
onPress: () => void
}
export function NewsCard({ news, onPress }: NewsCardProps) {
const localReadIds = useNewsReadStore((s) => s.readIds)
const isRead = news.isRead || localReadIds.has(news.id)
return (
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.84}>
{!isRead && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>Neu</Text>
</View>
)}
<View style={styles.content}>
<View style={styles.topRow}>
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
<Text style={styles.dateText}>
{news.publishedAt
? format(new Date(news.publishedAt), 'dd. MMM', { locale: de })
: 'Entwurf'}
</Text>
</View>
<Text style={isRead ? styles.titleRead : styles.titleUnread} numberOfLines={2}>
{news.title}
</Text>
<View style={styles.metaRow}>
<View style={styles.dot} />
<Text style={styles.authorText}>{news.author?.name ?? 'Innung'}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: '#E2E8F0',
borderRadius: 16,
flexDirection: 'row',
alignItems: 'center',
padding: 14,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 10,
elevation: 2,
position: 'relative',
},
newBadge: {
position: 'absolute',
right: 30,
top: 8,
borderRadius: 999,
backgroundColor: '#EF4444',
paddingHorizontal: 8,
paddingVertical: 2,
zIndex: 2,
},
newBadgeText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '700',
},
content: {
flex: 1,
marginRight: 10,
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
dateText: {
fontSize: 11,
color: '#94A3B8',
fontWeight: '600',
},
titleUnread: {
fontSize: 15,
fontWeight: '700',
color: '#0F172A',
lineHeight: 21,
marginBottom: 8,
},
titleRead: {
fontSize: 14,
fontWeight: '500',
color: '#475569',
lineHeight: 20,
marginBottom: 8,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
dot: {
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: '#CBD5E1',
},
authorText: {
fontSize: 11,
color: '#64748B',
},
})