From ca91de2284f5b75ebb9f63721345a72e4a96e3cb Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sat, 8 Aug 2026 22:36:09 +0200 Subject: [PATCH] KI-AGENT: Share-Upload an FEDEO-Design und Listenfilter angleichen --- mobile/app/(tabs)/projects.tsx | 50 ++------- mobile/app/more/customers.tsx | 19 +--- mobile/app/more/plants.tsx | 29 ++--- mobile/app/share-upload.tsx | 135 +++++++++++++++--------- mobile/src/lib/resource-list-filters.ts | 63 +++++++++++ 5 files changed, 166 insertions(+), 130 deletions(-) create mode 100644 mobile/src/lib/resource-list-filters.ts diff --git a/mobile/app/(tabs)/projects.tsx b/mobile/app/(tabs)/projects.tsx index 2c2312b..edd6b5d 100644 --- a/mobile/app/(tabs)/projects.tsx +++ b/mobile/app/(tabs)/projects.tsx @@ -15,6 +15,7 @@ import { import { router } from 'expo-router'; import { createProject, Customer, fetchCustomers, fetchPlants, fetchProjects, Plant, Project } from '@/src/lib/api'; +import { filterProjects, getActiveProjectPhase, isProjectCompleted } from '@/src/lib/resource-list-filters'; import { useAuth } from '@/src/providers/auth-provider'; const PRIMARY = '#69c350'; @@ -24,20 +25,6 @@ function getProjectLine(project: Project): string { return project.name; } -function getActivePhaseLabel(project: Project): string { - const explicit = String(project.active_phase || '').trim(); - if (explicit) return explicit; - - const phases = Array.isArray(project.phases) ? project.phases : []; - const active = phases.find((phase: any) => phase?.active); - return String(active?.label || '').trim(); -} - -function isProjectCompletedByPhase(project: Project): boolean { - const phase = getActivePhaseLabel(project).toLowerCase(); - return phase === 'abgeschlossen'; -} - export default function ProjectsScreen() { const { token } = useAuth(); @@ -64,31 +51,10 @@ export default function ProjectsScreen() { const [customerSearch, setCustomerSearch] = useState(''); const [plantSearch, setPlantSearch] = useState(''); - const filteredProjects = useMemo(() => { - const terms = search - .trim() - .toLowerCase() - .split(/\s+/) - .filter(Boolean); - - return projects.filter((project) => { - if (!showArchived && isProjectCompletedByPhase(project)) return false; - if (terms.length === 0) return true; - - const haystack = [ - project.name, - project.projectNumber, - project.notes, - project.customerRef, - project.active_phase, - getActivePhaseLabel(project), - ] - .map((value) => String(value || '').toLowerCase()) - .join(' '); - - return terms.every((term) => haystack.includes(term)); - }); - }, [projects, search, showArchived]); + const filteredProjects = useMemo( + () => filterProjects(projects, search, showArchived), + [projects, search, showArchived] + ); const selectedCustomerLabel = useMemo(() => { if (!selectedCustomerId) return 'Kunde auswählen (optional)'; @@ -241,10 +207,10 @@ export default function ProjectsScreen() { router.push(`/project/${project.id}`)}> {getProjectLine(project)} - {isProjectCompletedByPhase(project) ? Abgeschlossen : null} + {isProjectCompleted(project) ? Abgeschlossen : null} - {getActivePhaseLabel(project) ? ( - Phase: {getActivePhaseLabel(project)} + {getActiveProjectPhase(project) ? ( + Phase: {getActiveProjectPhase(project)} ) : null} {project.notes ? {String(project.notes)} : null} diff --git a/mobile/app/more/customers.tsx b/mobile/app/more/customers.tsx index 704b328..b6a2893 100644 --- a/mobile/app/more/customers.tsx +++ b/mobile/app/more/customers.tsx @@ -15,6 +15,7 @@ import { import { useRouter } from 'expo-router'; import { createCustomer, Customer, fetchCustomers } from '@/src/lib/api'; +import { filterCustomers } from '@/src/lib/resource-list-filters'; import { useAuth } from '@/src/providers/auth-provider'; const PRIMARY = '#69c350'; @@ -37,20 +38,10 @@ export default function CustomersScreen() { const [numberInput, setNumberInput] = useState(''); const [notesInput, setNotesInput] = useState(''); - const filtered = useMemo(() => { - const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); - - return customers.filter((customer) => { - if (!showArchived && customer.archived) return false; - if (terms.length === 0) return true; - - const haystack = [customer.name, customer.customerNumber, customer.notes] - .map((value) => String(value || '').toLowerCase()) - .join(' '); - - return terms.every((term) => haystack.includes(term)); - }); - }, [customers, search, showArchived]); + const filtered = useMemo( + () => filterCustomers(customers, search, showArchived), + [customers, search, showArchived] + ); const load = useCallback(async (showSpinner = true) => { if (!token) return; diff --git a/mobile/app/more/plants.tsx b/mobile/app/more/plants.tsx index 3fcdd75..5e86d49 100644 --- a/mobile/app/more/plants.tsx +++ b/mobile/app/more/plants.tsx @@ -15,16 +15,11 @@ import { import { useRouter } from 'expo-router'; import { createPlant, Customer, fetchCustomers, fetchPlants, Plant } from '@/src/lib/api'; +import { filterPlants, getPlantCustomerName } from '@/src/lib/resource-list-filters'; import { useAuth } from '@/src/providers/auth-provider'; const PRIMARY = '#69c350'; -function getCustomerName(raw: Plant['customer']): string | null { - if (!raw) return null; - if (typeof raw === 'object') return raw.name ? String(raw.name) : null; - return String(raw); -} - export default function PlantsScreen() { const { token } = useAuth(); const router = useRouter(); @@ -47,20 +42,10 @@ export default function PlantsScreen() { const [pickerMode, setPickerMode] = useState<'customer' | null>(null); const [customerSearch, setCustomerSearch] = useState(''); - const filtered = useMemo(() => { - const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); - - return plants.filter((plant) => { - if (!showArchived && plant.archived) return false; - if (terms.length === 0) return true; - - const haystack = [plant.name, plant.description, getCustomerName(plant.customer)] - .map((value) => String(value || '').toLowerCase()) - .join(' '); - - return terms.every((term) => haystack.includes(term)); - }); - }, [plants, search, showArchived]); + const filtered = useMemo( + () => filterPlants(plants, search, showArchived), + [plants, search, showArchived] + ); const filteredCustomerOptions = useMemo(() => { const terms = customerSearch.trim().toLowerCase().split(/\s+/).filter(Boolean); @@ -189,8 +174,8 @@ export default function PlantsScreen() { {plant.name} {plant.archived ? Abgeschlossen : null} - {getCustomerName(plant.customer) ? ( - Kunde: {getCustomerName(plant.customer)} + {getPlantCustomerName(plant.customer) ? ( + Kunde: {getPlantCustomerName(plant.customer)} ) : null} {plant.description ? {String(plant.description)} : null} diff --git a/mobile/app/share-upload.tsx b/mobile/app/share-upload.tsx index 16d74ed..92c3d5c 100644 --- a/mobile/app/share-upload.tsx +++ b/mobile/app/share-upload.tsx @@ -26,6 +26,13 @@ import { uploadProjectFile, } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; +import { + filterCustomers, + filterPlants, + filterProjects, + getActiveProjectPhase, + getPlantCustomerName, +} from '@/src/lib/resource-list-filters'; type TargetType = 'project' | 'customer' | 'plant'; type UploadTarget = Project | Customer | Plant; @@ -36,6 +43,8 @@ const TARGET_TYPES: { key: TargetType; label: string; plural: string; icon: keyo { key: 'plant', label: 'Objekt', plural: 'Objekte', icon: 'business-outline' }, ]; +const PRIMARY = '#69c350'; + export default function ShareUploadScreen() { const router = useRouter(); const { token } = useAuth(); @@ -60,10 +69,10 @@ export default function ShareUploadScreen() { setSearch(''); const request = targetType === 'project' - ? fetchProjects(token) + ? fetchProjects(token, true) : targetType === 'customer' - ? fetchCustomers(token) - : fetchPlants(token); + ? fetchCustomers(token, true) + : fetchPlants(token, true); void request .then((rows) => { @@ -82,17 +91,11 @@ export default function ShareUploadScreen() { }, [targetType, token]); const filteredTargets = useMemo(() => { - const term = search.trim().toLocaleLowerCase('de'); - if (!term) return targets; - return targets.filter((target) => { - const number = 'projectNumber' in target - ? target.projectNumber - : 'customerNumber' in target - ? target.customerNumber - : null; - return `${target.name} ${number || ''}`.toLocaleLowerCase('de').includes(term); - }); - }, [search, targets]); + if (targetType === 'project') return filterProjects(targets as Project[], search); + if (targetType === 'customer') return filterCustomers(targets as Customer[], search); + if (targetType === 'plant') return filterPlants(targets as Plant[], search); + return []; + }, [search, targets, targetType]); const close = () => { resetShareIntent(); @@ -144,7 +147,9 @@ export default function ShareUploadScreen() { return ( - + + + Keine Dateien gefunden Teile eine Datei oder ein Foto erneut mit FEDEO. @@ -159,7 +164,9 @@ export default function ShareUploadScreen() { - + + + {files.length} {files.length === 1 ? 'Datei' : 'Dateien'} ausgewählt {files.map((file) => file.fileName).join(', ')} @@ -173,7 +180,7 @@ export default function ShareUploadScreen() { {TARGET_TYPES.map((type) => ( setTargetType(type.key)}> - + {type.label} @@ -183,21 +190,24 @@ export default function ShareUploadScreen() { ) : ( <> setTargetType(null)} disabled={isUploading}> - + Andere Zielart wählen {selectedType?.label} auswählen - + + + + {isLoading ? ( - + ) : loadError ? ( {loadError} ) : filteredTargets.length === 0 ? ( @@ -210,13 +220,29 @@ export default function ShareUploadScreen() { : 'customerNumber' in target ? target.customerNumber : null; + const detail = targetType === 'project' + ? getActiveProjectPhase(target as Project) + : targetType === 'plant' + ? getPlantCustomerName((target as Plant).customer) + : null; return ( - void uploadTo(target)} disabled={isUploading}> + [styles.targetRow, pressed ? styles.targetRowPressed : null]} + onPress={() => void uploadTo(target)} + disabled={isUploading}> {target.name} - {number ? {String(number)} : null} + {number ? Nr.: {String(number)} : null} + {detail ? ( + + {targetType === 'project' ? `Phase: ${detail}` : `Kunde: ${detail}`} + + ) : null} + + + - ); })} @@ -227,7 +253,7 @@ export default function ShareUploadScreen() { {isUploading ? ( - + Upload läuft: {uploadedCount} von {files.length} ) : null} @@ -241,35 +267,40 @@ export default function ShareUploadScreen() { } const styles = StyleSheet.create({ - safeArea: { flex: 1, backgroundColor: '#f9fafb' }, - content: { padding: 20, paddingBottom: 36, gap: 16 }, + safeArea: { flex: 1, backgroundColor: '#ffffff' }, + content: { paddingBottom: 28, gap: 14 }, flex: { flex: 1 }, - fileSummary: { flexDirection: 'row', gap: 12, alignItems: 'center', padding: 16, borderRadius: 14, backgroundColor: '#eff6ff' }, - summaryTitle: { fontSize: 16, fontWeight: '700', color: '#1e3a8a' }, - summaryText: { marginTop: 3, fontSize: 13, color: '#3b4f78' }, - heading: { marginTop: 8, fontSize: 23, fontWeight: '800', color: '#111827' }, - description: { marginTop: -10, fontSize: 15, color: '#6b7280' }, - typeGrid: { gap: 12 }, - typeCard: { flexDirection: 'row', alignItems: 'center', gap: 14, padding: 16, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 14, backgroundColor: '#fff' }, - iconCircle: { width: 46, height: 46, borderRadius: 23, alignItems: 'center', justifyContent: 'center', backgroundColor: '#eff6ff' }, - typeLabel: { flex: 1, fontSize: 17, fontWeight: '700', color: '#111827' }, - backLink: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', marginTop: 2 }, - backLinkText: { fontSize: 14, fontWeight: '600', color: '#2563eb' }, - searchInput: { height: 48, paddingHorizontal: 14, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 12, backgroundColor: '#fff', fontSize: 16, color: '#111827' }, + fileSummary: { flexDirection: 'row', gap: 12, alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: '#d8edcf', backgroundColor: '#eff9ea' }, + summaryIcon: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: '#ffffff' }, + summaryTitle: { fontSize: 15, fontWeight: '700', color: '#2f5f24' }, + summaryText: { marginTop: 2, fontSize: 12, color: '#4f6f47' }, + heading: { marginTop: 4, paddingHorizontal: 16, fontSize: 20, fontWeight: '700', color: '#111827' }, + description: { marginTop: -8, paddingHorizontal: 16, fontSize: 14, color: '#6b7280' }, + typeGrid: { borderTopWidth: 1, borderTopColor: '#e5e7eb' }, + typeCard: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: '#e5e7eb', backgroundColor: '#ffffff' }, + iconCircle: { width: 42, height: 42, borderRadius: 21, alignItems: 'center', justifyContent: 'center', backgroundColor: '#eff9ea' }, + typeLabel: { flex: 1, fontSize: 15, fontWeight: '600', color: '#111827' }, + backLink: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', marginHorizontal: 12, marginTop: 2, padding: 4 }, + backLinkText: { fontSize: 14, fontWeight: '600', color: '#3d7a30' }, + searchBox: { minHeight: 44, marginHorizontal: 16, flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 12, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10, backgroundColor: '#ffffff' }, + searchInput: { flex: 1, paddingVertical: 10, fontSize: 15, color: '#111827' }, loader: { marginVertical: 32 }, - errorText: { padding: 16, borderRadius: 12, backgroundColor: '#fef2f2', color: '#b91c1c' }, + errorText: { marginHorizontal: 16, padding: 12, borderRadius: 10, backgroundColor: '#fef2f2', color: '#b91c1c' }, emptyListText: { paddingVertical: 28, textAlign: 'center', color: '#6b7280' }, - targetList: { overflow: 'hidden', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 14, backgroundColor: '#fff' }, - targetRow: { minHeight: 64, flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#d1d5db' }, - targetName: { fontSize: 16, fontWeight: '600', color: '#111827' }, + targetList: { borderTopWidth: 1, borderTopColor: '#e5e7eb', backgroundColor: '#ffffff' }, + targetRow: { minHeight: 64, flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#e5e7eb', backgroundColor: '#ffffff' }, + targetRowPressed: { backgroundColor: '#f3f4f6' }, + targetName: { fontSize: 15, fontWeight: '600', color: '#111827' }, targetNumber: { marginTop: 3, fontSize: 13, color: '#6b7280' }, - uploadOverlay: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, padding: 14, borderRadius: 12, backgroundColor: '#eff6ff' }, - uploadText: { fontWeight: '600', color: '#1e3a8a' }, + uploadIcon: { width: 38, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', backgroundColor: '#eff9ea' }, + uploadOverlay: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, marginHorizontal: 16, padding: 14, borderRadius: 10, backgroundColor: '#eff9ea' }, + uploadText: { fontWeight: '600', color: '#2f5f24' }, cancelButton: { alignItems: 'center', padding: 14 }, cancelButtonText: { fontSize: 16, fontWeight: '600', color: '#b91c1c' }, emptyState: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 28 }, + emptyIcon: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center', backgroundColor: '#eff9ea' }, emptyTitle: { marginTop: 14, fontSize: 20, fontWeight: '700', color: '#111827' }, emptyText: { marginTop: 6, textAlign: 'center', color: '#6b7280' }, - primaryButton: { marginTop: 22, paddingHorizontal: 18, paddingVertical: 13, borderRadius: 11, backgroundColor: '#2563eb' }, + primaryButton: { marginTop: 22, paddingHorizontal: 18, paddingVertical: 13, borderRadius: 10, backgroundColor: PRIMARY }, primaryButtonText: { fontSize: 15, fontWeight: '700', color: '#fff' }, }); diff --git a/mobile/src/lib/resource-list-filters.ts b/mobile/src/lib/resource-list-filters.ts new file mode 100644 index 0000000..33fa773 --- /dev/null +++ b/mobile/src/lib/resource-list-filters.ts @@ -0,0 +1,63 @@ +import { Customer, Plant, Project } from './api'; + +function searchTerms(search: string): string[] { + return search.trim().toLocaleLowerCase('de').split(/\s+/).filter(Boolean); +} + +function matchesTerms(values: unknown[], terms: string[]): boolean { + if (terms.length === 0) return true; + const haystack = values + .map((value) => String(value || '').toLocaleLowerCase('de')) + .join(' '); + return terms.every((term) => haystack.includes(term)); +} + +export function getActiveProjectPhase(project: Project): string { + const explicit = String(project.active_phase || '').trim(); + if (explicit) return explicit; + + const phases = Array.isArray(project.phases) ? project.phases : []; + const active = phases.find((phase: any) => phase?.active); + return String(active?.label || '').trim(); +} + +export function isProjectCompleted(project: Project): boolean { + return getActiveProjectPhase(project).toLocaleLowerCase('de') === 'abgeschlossen'; +} + +export function filterProjects(projects: Project[], search: string, showCompleted = false): Project[] { + const terms = searchTerms(search); + return projects.filter((project) => { + if (!showCompleted && isProjectCompleted(project)) return false; + return matchesTerms([ + project.name, + project.projectNumber, + project.notes, + project.customerRef, + project.active_phase, + getActiveProjectPhase(project), + ], terms); + }); +} + +export function filterCustomers(customers: Customer[], search: string, showArchived = false): Customer[] { + const terms = searchTerms(search); + return customers.filter((customer) => { + if (!showArchived && customer.archived) return false; + return matchesTerms([customer.name, customer.customerNumber, customer.notes], terms); + }); +} + +export function getPlantCustomerName(raw: Plant['customer']): string | null { + if (!raw) return null; + if (typeof raw === 'object') return raw.name ? String(raw.name) : null; + return String(raw); +} + +export function filterPlants(plants: Plant[], search: string, showArchived = false): Plant[] { + const terms = searchTerms(search); + return plants.filter((plant) => { + if (!showArchived && plant.archived) return false; + return matchesTerms([plant.name, plant.description, getPlantCustomerName(plant.customer)], terms); + }); +}