diff --git a/backend/src/routes/history.ts b/backend/src/routes/history.ts index 0e74798..829f247 100644 --- a/backend/src/routes/history.ts +++ b/backend/src/routes/history.ts @@ -127,7 +127,10 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) { const data = await server.db .select() .from(historyitems) - .where(eq(column, parseId(id))) + .where(and( + eq(historyitems.tenant, req.user?.tenant_id), + eq(column, parseId(id)) + )) .orderBy(asc(historyitems.createdAt)); const userIds = Array.from( diff --git a/mobile/app/more/customer/[id].tsx b/mobile/app/more/customer/[id].tsx index 1349a8f..5d0a1fe 100644 --- a/mobile/app/more/customer/[id].tsx +++ b/mobile/app/more/customer/[id].tsx @@ -17,6 +17,7 @@ import * as DocumentPicker from 'expo-document-picker'; import * as WebBrowser from 'expo-web-browser'; import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera'; +import { HistorySection } from '@/components/history-section'; import { createCustomerInventoryItem, CreatedDocument, @@ -117,10 +118,18 @@ export default function CustomerDetailScreen() { return [ { label: 'Name', value: String(customer.name || '-') }, { label: 'Kundennummer', value: String(customer.customerNumber || '-') }, - { label: 'Typ', value: String(customer.type || '-') }, + { label: 'Kundentyp', value: customer.isCompany ? 'Firma' : 'Privat' }, + { label: 'Namenszusatz', value: String(customer.nameAddition || '-') }, { label: 'E-Mail', value: String(infoData.email || customer.email || '-') }, + { label: 'Rechnungs-E-Mail', value: String(infoData.invoiceEmail || '-') }, { label: 'Telefon', value: String(infoData.tel || customer.phone || '-') }, { label: 'Mobilnummer', value: String(infoData.mobileTel || '-') }, + { label: 'Straße', value: String(infoData.street || '-') }, + { label: 'Adresszusatz', value: String(infoData.special || '-') }, + { label: 'PLZ / Stadt', value: [infoData.zip, infoData.city].filter(Boolean).join(' ') || '-' }, + { label: 'Land', value: String(infoData.country || '-') }, + { label: 'Webseite', value: String(infoData.web || '-') }, + { label: 'USt-Id', value: String(infoData.ustid || '-') }, { label: 'Erstellt', value: formatDateTime(customer.createdAt || customer.created_at) }, { label: 'Aktualisiert', value: formatDateTime(customer.updatedAt || customer.updated_at) }, ]; @@ -373,6 +382,8 @@ export default function CustomerDetailScreen() { ) : null} + + Kundeninventar ({inventoryItems.length}) diff --git a/mobile/app/more/customers.tsx b/mobile/app/more/customers.tsx index b6a2893..90b34ff 100644 --- a/mobile/app/more/customers.tsx +++ b/mobile/app/more/customers.tsx @@ -34,8 +34,25 @@ export default function CustomersScreen() { const [createOpen, setCreateOpen] = useState(false); const [saving, setSaving] = useState(false); const [createError, setCreateError] = useState(null); + const [isCompanyInput, setIsCompanyInput] = useState(false); const [nameInput, setNameInput] = useState(''); + const [nameAdditionInput, setNameAdditionInput] = useState(''); + const [salutationInput, setSalutationInput] = useState(''); + const [titleInput, setTitleInput] = useState(''); + const [firstnameInput, setFirstnameInput] = useState(''); + const [lastnameInput, setLastnameInput] = useState(''); const [numberInput, setNumberInput] = useState(''); + const [streetInput, setStreetInput] = useState(''); + const [addressAdditionInput, setAddressAdditionInput] = useState(''); + const [zipInput, setZipInput] = useState(''); + const [cityInput, setCityInput] = useState(''); + const [countryInput, setCountryInput] = useState('Deutschland'); + const [phoneInput, setPhoneInput] = useState(''); + const [mobileInput, setMobileInput] = useState(''); + const [emailInput, setEmailInput] = useState(''); + const [invoiceEmailInput, setInvoiceEmailInput] = useState(''); + const [websiteInput, setWebsiteInput] = useState(''); + const [vatIdInput, setVatIdInput] = useState(''); const [notesInput, setNotesInput] = useState(''); const filtered = useMemo( @@ -72,17 +89,36 @@ export default function CustomersScreen() { function closeCreateModal() { setCreateOpen(false); setCreateError(null); + setIsCompanyInput(false); setNameInput(''); + setNameAdditionInput(''); + setSalutationInput(''); + setTitleInput(''); + setFirstnameInput(''); + setLastnameInput(''); setNumberInput(''); + setStreetInput(''); + setAddressAdditionInput(''); + setZipInput(''); + setCityInput(''); + setCountryInput('Deutschland'); + setPhoneInput(''); + setMobileInput(''); + setEmailInput(''); + setInvoiceEmailInput(''); + setWebsiteInput(''); + setVatIdInput(''); setNotesInput(''); } async function onCreateCustomer() { if (!token) return; - const name = nameInput.trim(); + const name = isCompanyInput + ? nameInput.trim() + : [salutationInput, titleInput, firstnameInput, lastnameInput].map((value) => value.trim()).filter(Boolean).join(' '); if (!name) { - setCreateError('Bitte einen Namen eingeben.'); + setCreateError(isCompanyInput ? 'Bitte einen Firmennamen eingeben.' : 'Bitte mindestens Vor- oder Nachname eingeben.'); return; } @@ -93,6 +129,25 @@ export default function CustomersScreen() { await createCustomer(token, { name, customerNumber: numberInput.trim() || null, + isCompany: isCompanyInput, + nameAddition: nameAdditionInput.trim() || null, + salutation: salutationInput.trim() || null, + title: titleInput.trim() || null, + firstname: firstnameInput.trim() || null, + lastname: lastnameInput.trim() || null, + infoData: { + street: streetInput.trim() || null, + special: addressAdditionInput.trim() || null, + zip: zipInput.trim() || null, + city: cityInput.trim() || null, + country: countryInput.trim() || null, + tel: phoneInput.trim() || null, + mobileTel: mobileInput.trim() || null, + email: emailInput.trim() || null, + invoiceEmail: invoiceEmailInput.trim() || null, + web: websiteInput.trim() || null, + ustid: vatIdInput.trim() || null, + }, notes: notesInput.trim() || null, }); closeCreateModal(); @@ -168,30 +223,187 @@ export default function CustomersScreen() { Neuer Kunde - - - + + Kundentyp + + setIsCompanyInput(false)}> + Privat + + setIsCompanyInput(true)}> + Firma + + - {createError ? {createError} : null} + Allgemeines + {isCompanyInput ? ( + <> + + + + ) : ( + <> + + + + + + + + )} + + + Adresse + + + + + + + + + Kontaktdaten + + + + + + + + + {createError ? {createError} : null} + @@ -313,6 +525,7 @@ const styles = StyleSheet.create({ borderRadius: 14, padding: 16, gap: 10, + maxHeight: '92%', }, modalTitle: { color: '#111827', @@ -323,6 +536,63 @@ const styles = StyleSheet.create({ minHeight: 92, textAlignVertical: 'top', }, + modalScroll: { + flexShrink: 1, + }, + modalForm: { + gap: 10, + paddingBottom: 4, + }, + fieldLabel: { + color: '#374151', + fontSize: 13, + fontWeight: '600', + }, + sectionLabel: { + color: '#111827', + fontSize: 14, + fontWeight: '700', + marginTop: 6, + }, + segmentedControl: { + flexDirection: 'row', + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + padding: 3, + gap: 3, + }, + segment: { + flex: 1, + minHeight: 36, + borderRadius: 7, + alignItems: 'center', + justifyContent: 'center', + }, + segmentActive: { + backgroundColor: '#eff9ea', + }, + segmentText: { + color: '#6b7280', + fontWeight: '600', + }, + segmentTextActive: { + color: '#3d7a30', + }, + inputRow: { + flexDirection: 'row', + gap: 8, + }, + flexInput: { + flex: 1, + }, + zipInput: { + width: 105, + }, + formError: { + color: '#dc2626', + fontSize: 13, + }, modalActions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/mobile/app/more/plant/[id].tsx b/mobile/app/more/plant/[id].tsx index d47a37c..243fb2f 100644 --- a/mobile/app/more/plant/[id].tsx +++ b/mobile/app/more/plant/[id].tsx @@ -5,6 +5,7 @@ import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; import * as WebBrowser from 'expo-web-browser'; +import { HistorySection } from '@/components/history-section'; import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; @@ -233,6 +234,8 @@ export default function PlantDetailScreen() { + + Dokumente ({files.length}) diff --git a/mobile/app/project/[id].tsx b/mobile/app/project/[id].tsx index 77fddc3..d5e5e4b 100644 --- a/mobile/app/project/[id].tsx +++ b/mobile/app/project/[id].tsx @@ -17,6 +17,7 @@ import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; import * as WebBrowser from 'expo-web-browser'; +import { HistorySection } from '@/components/history-section'; import { createProjectTask, fetchProjectById, @@ -366,6 +367,8 @@ export default function ProjectDetailScreen() { ) : null} + + Aufgaben ({tasks.length}) diff --git a/mobile/components/history-section.tsx b/mobile/components/history-section.tsx new file mode 100644 index 0000000..c3a735e --- /dev/null +++ b/mobile/components/history-section.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; + +import { + createResourceHistoryItem, + fetchResourceHistory, + HistoryItem, +} from '@/src/lib/api'; +import { useAuth } from '@/src/providers/auth-provider'; + +const PRIMARY = '#69c350'; + +type HistorySectionProps = { + resource: string; + resourceId: number | string; +}; + +function formatDateTime(value: unknown): string { + if (!value) return '-'; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +function getAuthor(item: HistoryItem): string { + if (!item.created_by && !item.createdBy) return 'FEDEO Bot'; + return String( + item.created_by_profile?.full_name || + item.created_by_profile?.fullName || + item.created_by_profile?.email || + 'Benutzer' + ); +} + +export function HistorySection({ resource, resourceId }: HistorySectionProps) { + const { token } = useAuth(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [formError, setFormError] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [text, setText] = useState(''); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + if (!token || !resource || !resourceId) return; + setLoading(true); + setLoadError(null); + try { + const rows = await fetchResourceHistory(token, resource, resourceId); + setItems((rows || []).slice().reverse()); + } catch (err) { + setLoadError(err instanceof Error ? err.message : 'Das Logbuch konnte nicht geladen werden.'); + } finally { + setLoading(false); + } + }, [resource, resourceId, token]); + + useEffect(() => { + void load(); + }, [load]); + + function closeModal() { + if (saving) return; + setModalOpen(false); + setText(''); + setFormError(null); + } + + async function addItem() { + if (!token || saving) return; + const value = text.trim(); + if (!value) { + setFormError('Bitte einen Text für den Logbucheintrag eingeben.'); + return; + } + + setSaving(true); + setFormError(null); + try { + await createResourceHistoryItem(token, resource, resourceId, value); + setModalOpen(false); + setText(''); + await load(); + } catch (err) { + setFormError(err instanceof Error ? err.message : 'Der Logbucheintrag konnte nicht gespeichert werden.'); + } finally { + setSaving(false); + } + } + + return ( + + + Logbuch ({items.length}) + setModalOpen(true)}> + + Eintrag + + + + {loading ? : null} + {!loading && loadError ? {loadError} : null} + {!loading && !loadError && items.length === 0 ? ( + Noch keine Logbucheinträge vorhanden. + ) : null} + + {!loading && !loadError + ? items.map((item) => ( + + {getAuthor(item)} + {item.text} + {formatDateTime(item.created_at || item.createdAt)} + + )) + : null} + + + + + + Logbucheintrag hinzufügen + + {formError ? {formError} : null} + + + Abbrechen + + + {saving ? 'Speichere...' : 'Speichern'} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#ffffff', + borderRadius: 12, + borderWidth: 1, + borderColor: '#e5e7eb', + padding: 12, + gap: 8, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 10, + }, + title: { color: '#111827', fontSize: 16, fontWeight: '700' }, + addButton: { + minHeight: 36, + borderRadius: 8, + backgroundColor: PRIMARY, + paddingHorizontal: 12, + alignItems: 'center', + justifyContent: 'center', + }, + addButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' }, + item: { + borderTopWidth: 1, + borderTopColor: '#e5e7eb', + paddingTop: 9, + gap: 2, + }, + itemAuthor: { color: '#111827', fontSize: 13, fontWeight: '700' }, + itemText: { color: '#374151', fontSize: 14, lineHeight: 20 }, + itemDate: { color: '#6b7280', fontSize: 12 }, + empty: { color: '#6b7280', fontSize: 13, paddingVertical: 4 }, + error: { color: '#dc2626', fontSize: 13 }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(17, 24, 39, 0.45)', + justifyContent: 'center', + padding: 20, + }, + modalKeyboardWrap: { width: '100%' }, + modalCard: { backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 12 }, + modalTitle: { color: '#111827', fontSize: 18, fontWeight: '700' }, + input: { + minHeight: 120, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + color: '#111827', + fontSize: 15, + textAlignVertical: 'top', + }, + modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8 }, + secondaryButton: { + minHeight: 40, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryButtonText: { color: '#374151', fontWeight: '600' }, + primaryButton: { + minHeight: 40, + borderRadius: 10, + backgroundColor: PRIMARY, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonText: { color: '#ffffff', fontWeight: '700' }, + disabled: { opacity: 0.6 }, +}); diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index ecd20a0..c312109 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -76,8 +76,32 @@ export type Customer = { archived?: boolean; infoData?: { email?: string | null; + invoiceEmail?: string | null; tel?: string | null; mobileTel?: string | null; + street?: string | null; + special?: string | null; + zip?: string | null; + city?: string | null; + country?: string | null; + web?: string | null; + ustid?: string | null; + [key: string]: unknown; + } | null; + [key: string]: unknown; +}; + +export type HistoryItem = { + id: number; + text: string; + created_at?: string | null; + createdAt?: string | null; + created_by?: string | null; + createdBy?: string | null; + created_by_profile?: { + full_name?: string | null; + fullName?: string | null; + email?: string | null; [key: string]: unknown; } | null; [key: string]: unknown; @@ -874,6 +898,13 @@ export async function createCustomer( name: string; customerNumber?: string | null; notes?: string | null; + isCompany?: boolean; + salutation?: string | null; + title?: string | null; + firstname?: string | null; + lastname?: string | null; + nameAddition?: string | null; + infoData?: Customer['infoData']; } ): Promise { return apiRequest('/api/resource/customers', { @@ -887,6 +918,33 @@ export async function fetchCustomerById(token: string, customerId: number): Prom return apiRequest(`/api/resource/customers/${customerId}`, { token }); } +export async function fetchResourceHistory( + token: string, + resource: string, + resourceId: number | string +): Promise { + return apiRequest( + `/api/resource/${encodeURIComponent(resource)}/${encodeURIComponent(String(resourceId))}/history`, + { token } + ); +} + +export async function createResourceHistoryItem( + token: string, + resource: string, + resourceId: number | string, + text: string +): Promise { + return apiRequest( + `/api/resource/${encodeURIComponent(resource)}/${encodeURIComponent(String(resourceId))}/history`, + { + method: 'POST', + token, + body: { text }, + } + ); +} + function resolveCustomerIdFromCustomerInventoryItem(item: CustomerInventoryItem): number | null { const rawCustomer = item.customer; if (!rawCustomer) return null;