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);
+ });
+}