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;