Compare commits

..

2 Commits

Author SHA1 Message Date
409db82368 Mobile Dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m50s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
2026-02-21 21:21:39 +01:00
30d761f899 fix memberrlation 2026-02-21 21:21:27 +01:00
22 changed files with 2828 additions and 693 deletions

View File

@@ -0,0 +1,33 @@
ALTER TABLE "customers" ADD COLUMN IF NOT EXISTS "memberrelation" bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'customers_memberrelation_memberrelations_id_fk'
) THEN
ALTER TABLE "customers"
ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk"
FOREIGN KEY ("memberrelation")
REFERENCES "public"."memberrelations"("id")
ON DELETE no action
ON UPDATE no action;
END IF;
END $$;
UPDATE "customers"
SET "memberrelation" = ("infoData"->>'memberrelation')::bigint
WHERE
"memberrelation" IS NULL
AND "type" = 'Mitglied'
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation'
AND ("infoData"->>'memberrelation') ~ '^[0-9]+$';
UPDATE "customers"
SET "infoData" = COALESCE("infoData", '{}'::jsonb) - 'memberrelation'
WHERE
"type" = 'Mitglied'
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation';

View File

@@ -0,0 +1,108 @@
CREATE TABLE "contracttypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "contracttypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"paymentType" text,
"recurring" boolean DEFAULT false NOT NULL,
"billingInterval" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerinventoryitems" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerinventoryitems_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"description" text,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"customerspace" bigint,
"customerInventoryId" text NOT NULL,
"serialNumber" text,
"quantity" bigint DEFAULT 0 NOT NULL,
"manufacturer" text,
"manufacturerNumber" text,
"purchaseDate" date,
"purchasePrice" double precision DEFAULT 0,
"currentValue" double precision,
"product" bigint,
"vendor" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerspaces" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerspaces_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"spaceNumber" text NOT NULL,
"parentSpace" bigint,
"infoData" jsonb DEFAULT '{"zip":"","city":"","streetNumber":""}'::jsonb NOT NULL,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "entitybankaccounts" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"iban_encrypted" jsonb NOT NULL,
"bic_encrypted" jsonb NOT NULL,
"bank_name_encrypted" jsonb NOT NULL,
"description" text,
"updated_at" timestamp with time zone,
"updated_by" uuid,
"archived" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memberrelations" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "memberrelations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"type" text NOT NULL,
"billingInterval" text NOT NULL,
"billingAmount" double precision DEFAULT 0 NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000}}'::jsonb;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_product_products_id_fk" FOREIGN KEY ("product") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_vendor_vendors_id_fk" FOREIGN KEY ("vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_parentSpace_customerspaces_id_fk" FOREIGN KEY ("parentSpace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerinventoryitem_customerinventoryitems_id_fk" FOREIGN KEY ("customerinventoryitem") REFERENCES "public"."customerinventoryitems"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -113,6 +113,20 @@
"when": 1773000700000,
"tag": "0015_wise_memberrelation_history",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1773000800000,
"tag": "0016_fix_memberrelation_column_usage",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1771704862789,
"tag": "0017_slow_the_hood",
"breakpoints": true
}
]
}
}

View File

@@ -10,6 +10,7 @@ import {
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { memberrelations } from "./memberrelations"
export const customers = pgTable(
"customers",
@@ -63,6 +64,7 @@ export const customers = pgTable(
customPaymentType: text("custom_payment_type"), // ENUM payment_types separat?
customTaxType: text("customTaxType"),
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
}
)

View File

@@ -10,7 +10,8 @@
"build": "tsc",
"start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts"
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts"
},
"repository": {
"type": "git",

View File

@@ -11,18 +11,22 @@ const relations = ref([])
const normalizeId = (value) => {
if (value === null || value === undefined || value === "") return null
if (typeof value === "object") return normalizeId(value.id)
const parsed = Number(value)
return Number.isNaN(parsed) ? String(value) : parsed
}
const relationLabel = computed(() => {
const id = normalizeId(props.row?.infoData?.memberrelation)
const relation = props.row?.memberrelation
if (relation && typeof relation === "object" && relation.type) return relation.type
const id = normalizeId(relation)
if (!id) return ""
return relations.value.find((i) => normalizeId(i.id) === id)?.type || ""
})
const relationId = computed(() => {
return normalizeId(props.row?.infoData?.memberrelation)
return normalizeId(props.row?.memberrelation)
})
const loadRelations = async () => {

View File

@@ -535,7 +535,7 @@ export const useDataStore = defineStore('data', () => {
disabledInTable: true
},
{
key: "infoData.memberrelation",
key: "memberrelation",
label: "Mitgliedsverhältnis",
component: memberrelation,
inputType: "select",

View File

@@ -1,17 +1,32 @@
{
"expo": {
"name": "mobile",
"slug": "mobile",
"name": "FEDEO",
"slug": "fedeo-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobile",
"scheme": "fedeo",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "de.fedeo.mobile",
"buildNumber": "1",
"infoPlist": {
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
"NSPhotoLibraryAddUsageDescription": "Die App benötigt Zugriff, um Fotos für Uploads zu speichern und zu verwenden.",
"NSBluetoothAlwaysUsageDescription": "Bluetooth wird benötigt, um den Nimbot M2 Etikettendrucker zu verbinden.",
"NSBluetoothPeripheralUsageDescription": "Bluetooth wird benötigt, um mit dem Nimbot M2 zu kommunizieren."
}
},
"android": {
"package": "de.fedeo.mobile",
"permissions": [
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH_CONNECT",
"android.permission.ACCESS_FINE_LOCATION"
],
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
@@ -38,7 +53,8 @@
"backgroundColor": "#000000"
}
}
]
],
"react-native-ble-plx"
],
"experiments": {
"typedRoutes": true,

View File

@@ -31,16 +31,23 @@ export default function TabLayout() {
}}>
<Tabs.Screen
name="index"
options={{
title: 'Dashboard',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="tasks"
options={{
title: 'Aufgaben',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="checklist" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
name="projects"
options={{
title: 'Konto',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="person.crop.circle.fill" color={color} />,
title: 'Projekte',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="folder.fill" color={color} />,
}}
/>
<Tabs.Screen
@@ -50,6 +57,13 @@ export default function TabLayout() {
tabBarIcon: ({ color }) => <IconSymbol size={24} name="clock.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Mehr',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="ellipsis.circle.fill" color={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -1,187 +1,139 @@
import { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
export default function AccountScreen() {
const { token, user, tenants, activeTenantId, activeTenant, switchTenant, logout } = useAuth();
const storageInfo = useTokenStorageInfo();
const [switchingTenantId, setSwitchingTenantId] = useState<number | null>(null);
const [switchError, setSwitchError] = useState<string | null>(null);
const userId = String(user?.id || 'unbekannt');
async function onSwitchTenant(tenantId: number) {
setSwitchingTenantId(tenantId);
setSwitchError(null);
try {
await switchTenant(tenantId);
} catch (err) {
setSwitchError(err instanceof Error ? err.message : 'Tenant konnte nicht gewechselt werden.');
} finally {
setSwitchingTenantId(null);
}
}
const ITEMS = [
{
key: 'account',
title: 'Konto',
subtitle: 'Session, Tenant-Wechsel, Logout',
href: '/more/account',
},
{
key: 'settings',
title: 'Einstellungen',
subtitle: 'Server-Instanz verwalten',
href: '/more/settings',
},
{
key: 'wiki',
title: 'Wiki',
subtitle: 'Wissen und Dokumentation',
href: '/more/wiki',
},
{
key: 'customers',
title: 'Kunden',
subtitle: '',
href: '/more/customers',
},
{
key: 'plants',
title: 'Objekte',
subtitle: '',
href: '/more/plants',
},
{
key: 'inventory',
title: 'Kundeninventar',
subtitle: 'Inventar und Scanner',
href: '/more/inventory',
},
{
key: 'nimbot',
title: 'Nimbot M2',
subtitle: 'Drucker verbinden',
href: '/more/nimbot',
},
];
export default function MoreScreen() {
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Konto</Text>
<Text style={styles.subtitle}>Session-Infos und Tenant-Wechsel.</Text>
<View style={styles.screen}>
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Funktionen</Text>
<Text style={styles.sectionSubtitle}>Weitere Bereiche und Einstellungen.</Text>
</View>
<View style={styles.card}>
<Text style={styles.label}>Token vorhanden</Text>
<Text style={styles.value}>{token ? 'ja' : 'nein'}</Text>
<Text style={styles.label}>User ID</Text>
<Text style={styles.value}>{userId}</Text>
<Text style={styles.label}>Aktiver Tenant</Text>
<Text style={styles.value}>{activeTenant ? `${activeTenant.name} (#${activeTenantId})` : 'nicht gesetzt'}</Text>
<Text style={styles.label}>Storage Modus</Text>
<Text style={styles.value}>{storageInfo.mode}</Text>
</View>
<View style={styles.card}>
<Text style={styles.sectionTitle}>Tenant wechseln</Text>
{switchError ? <Text style={styles.error}>{switchError}</Text> : null}
{tenants.map((tenant) => {
const tenantId = Number(tenant.id);
const isActive = tenantId === activeTenantId;
const isSwitching = switchingTenantId === tenantId;
return (
<Pressable
key={String(tenant.id)}
style={[
styles.tenantButton,
isActive ? styles.tenantButtonActive : null,
isSwitching ? styles.tenantButtonDisabled : null,
]}
onPress={() => onSwitchTenant(tenantId)}
disabled={isActive || switchingTenantId !== null}>
<View style={styles.tenantInfo}>
<Text style={styles.tenantName} numberOfLines={2} ellipsizeMode="tail">
{tenant.name}
</Text>
<Text style={styles.tenantMeta}>ID: {tenantId}</Text>
</View>
<View style={styles.tenantActionWrap}>
<Text style={styles.tenantAction}>{isActive ? 'Aktiv' : isSwitching ? 'Wechsel...' : 'Wechseln'}</Text>
</View>
</Pressable>
);
})}
</View>
<Pressable style={styles.logoutButton} onPress={logout}>
<Text style={styles.logoutText}>Logout</Text>
</Pressable>
</ScrollView>
{ITEMS.map((item, index) => (
<Pressable key={item.key} style={styles.row} onPress={() => router.push(item.href as any)}>
<View style={styles.rowMain}>
<Text style={styles.rowTitle}>{item.title}</Text>
<Text style={styles.rowSubtitle}>{item.subtitle}</Text>
</View>
<Text style={styles.rowArrow}></Text>
{index < ITEMS.length - 1 ? <View style={styles.rowDivider} /> : null}
</Pressable>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
backgroundColor: '#f9fafb',
padding: 16,
gap: 12,
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#111827',
},
subtitle: {
color: '#6b7280',
},
card: {
screen: {
flex: 1,
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
gap: 8,
},
list: {
flex: 1,
backgroundColor: '#ffffff',
},
listContent: {
paddingBottom: 24,
},
section: {
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 2,
fontSize: 17,
fontWeight: '700',
},
label: {
fontSize: 12,
color: '#6b7280',
textTransform: 'uppercase',
},
value: {
fontSize: 16,
color: '#111827',
marginBottom: 2,
},
error: {
color: '#dc2626',
marginBottom: 4,
},
tenantButton: {
borderRadius: 10,
padding: 12,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
tenantButtonActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
tenantButtonDisabled: {
opacity: 0.6,
},
tenantInfo: {
flex: 1,
minWidth: 0,
paddingRight: 10,
},
tenantName: {
fontSize: 15,
fontWeight: '600',
color: '#111827',
flexShrink: 1,
},
tenantMeta: {
sectionSubtitle: {
color: '#6b7280',
fontSize: 13,
marginTop: 3,
},
tenantActionWrap: {
minWidth: 84,
alignItems: 'flex-end',
},
tenantAction: {
color: PRIMARY,
fontWeight: '600',
textAlign: 'right',
},
logoutButton: {
marginTop: 4,
backgroundColor: '#dc2626',
minHeight: 44,
borderRadius: 10,
row: {
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: '#ffffff',
position: 'relative',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
justifyContent: 'space-between',
gap: 10,
},
logoutText: {
color: '#ffffff',
rowMain: {
flex: 1,
minWidth: 0,
},
rowTitle: {
color: '#111827',
fontSize: 15,
fontWeight: '600',
fontSize: 16,
},
rowSubtitle: {
color: '#6b7280',
fontSize: 13,
marginTop: 2,
},
rowArrow: {
color: '#9ca3af',
fontSize: 24,
lineHeight: 24,
},
rowDivider: {
position: 'absolute',
left: 16,
right: 16,
bottom: 0,
height: 1,
backgroundColor: '#e5e7eb',
},
});

View File

@@ -1,416 +1,258 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { createTask, fetchTasks, fetchTenantProfiles, Task, TaskStatus, updateTask } from '@/src/lib/api';
import { fetchStaffTimeSpans, fetchTasks, Task } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const STATUSES: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
const PRIMARY = '#69c350';
function normalizeStatus(status: unknown): TaskStatus {
if (status === 'In Bearbeitung' || status === 'Abgeschlossen') return status;
type DashboardData = {
tasks: Task[];
openTasks: number;
inProgressTasks: number;
activeTimeStart: string | null;
pendingSubmissions: number;
todayMinutes: number;
};
function normalizeTaskStatus(value: unknown): 'Offen' | 'In Bearbeitung' | 'Abgeschlossen' {
if (value === 'In Bearbeitung') return 'In Bearbeitung';
if (value === 'Abgeschlossen') return 'Abgeschlossen';
return 'Offen';
}
function getTaskAssigneeId(task: Task): string | null {
return (task.userId || task.user_id || task.profile || null) as string | null;
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}h ${String(m).padStart(2, '0')}m`;
}
export default function TasksScreen() {
const { token, user, activeTenantId } = useAuth();
function formatDateTime(value: string | null): string {
if (!value) return '-';
return new Date(value).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export default function DashboardScreen() {
const { token, user } = useAuth();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [profiles, setProfiles] = useState<{ id: string; label: string }[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<'Alle' | TaskStatus>('Alle');
const [showCompleted, setShowCompleted] = useState(false);
const [showSearchPanel, setShowSearchPanel] = useState(false);
const [showFilterPanel, setShowFilterPanel] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [newTaskName, setNewTaskName] = useState('');
const [newTaskDescription, setNewTaskDescription] = useState('');
const [data, setData] = useState<DashboardData>({
tasks: [],
openTasks: 0,
inProgressTasks: 0,
activeTimeStart: null,
pendingSubmissions: 0,
todayMinutes: 0,
});
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
const filteredTasks = useMemo(() => {
const needle = search.trim().toLowerCase();
return tasks
.filter((task) => {
const status = normalizeStatus(task.categorie);
if (!showCompleted && status === 'Abgeschlossen') return false;
const statusMatch = statusFilter === 'Alle' || status === statusFilter;
const textMatch =
!needle ||
[task.name, task.description, task.categorie].some((value) =>
String(value || '').toLowerCase().includes(needle)
);
return statusMatch && textMatch;
})
.sort((a, b) => Number(a.id) - Number(b.id));
}, [search, showCompleted, statusFilter, tasks]);
function getAssigneeLabel(task: Task): string {
const assigneeId = getTaskAssigneeId(task);
if (!assigneeId) return '-';
return profiles.find((profile) => profile.id === assigneeId)?.label || assigneeId;
}
const loadTasks = useCallback(
const loadDashboard = useCallback(
async (showSpinner = true) => {
if (!token) return;
if (!token || !currentUserId) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [taskRows, profileRows] = await Promise.all([fetchTasks(token), fetchTenantProfiles(token)]);
setTasks(taskRows || []);
setProfiles(
(profileRows || [])
.map((profile) => {
const id = profile.user_id || (profile.id ? String(profile.id) : null);
const label = profile.full_name || profile.fullName || profile.email || id;
return id ? { id: String(id), label: String(label || id) } : null;
})
.filter((value): value is { id: string; label: string } => Boolean(value))
);
const [taskRows, spans] = await Promise.all([
fetchTasks(token),
fetchStaffTimeSpans(token, currentUserId),
]);
const tasks = taskRows || [];
const openTasks = tasks.filter((task) => normalizeTaskStatus(task.categorie) === 'Offen').length;
const inProgressTasks = tasks.filter(
(task) => normalizeTaskStatus(task.categorie) === 'In Bearbeitung'
).length;
const activeTime = spans.find((span) => !span.stopped_at) || null;
const pendingSubmissions = spans.filter(
(span) => (span.state === 'draft' || span.state === 'factual') && !!span.stopped_at
).length;
const today = new Date();
const todayIso = today.toISOString().slice(0, 10);
const todayMinutes = spans
.filter((span) => span.started_at?.slice(0, 10) === todayIso)
.reduce((sum, span) => sum + (span.duration_minutes || 0), 0);
setData({
tasks,
openTasks,
inProgressTasks,
activeTimeStart: activeTime?.started_at || null,
pendingSubmissions,
todayMinutes,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Aufgaben konnten nicht geladen werden.');
setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
},
[token]
[currentUserId, token]
);
useEffect(() => {
if (!token || !activeTenantId) return;
void loadTasks(true);
}, [token, activeTenantId, loadTasks]);
if (!token || !currentUserId) return;
void loadDashboard(true);
}, [currentUserId, loadDashboard, token]);
async function onRefresh() {
if (!token) return;
setRefreshing(true);
await loadTasks(false);
}
function closeCreateModal() {
setCreateModalOpen(false);
setCreateError(null);
setNewTaskName('');
setNewTaskDescription('');
}
async function onCreateTask() {
if (!token) return;
const name = newTaskName.trim();
if (!name) {
setCreateError('Bitte einen Aufgabennamen eingeben.');
return;
}
setSaving(true);
setCreateError(null);
try {
await createTask(token, {
name,
description: newTaskDescription.trim() || null,
categorie: 'Offen',
userId: currentUserId,
});
closeCreateModal();
await loadTasks(false);
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Aufgabe konnte nicht erstellt werden.');
} finally {
setSaving(false);
}
}
async function setTaskStatus(task: Task, status: TaskStatus) {
if (!token || !task?.id) return;
if (normalizeStatus(task.categorie) === status) return;
setUpdatingTaskId(Number(task.id));
setError(null);
try {
await updateTask(token, Number(task.id), { categorie: status });
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, categorie: status } : item)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Status konnte nicht gesetzt werden.');
} finally {
setUpdatingTaskId(null);
}
await loadDashboard(false);
}
return (
<View style={styles.screen}>
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.topActions}>
<Pressable
style={[styles.topActionButton, showSearchPanel ? styles.topActionButtonActive : null]}
onPress={() => setShowSearchPanel((prev) => !prev)}>
<Text style={[styles.topActionText, showSearchPanel ? styles.topActionTextActive : null]}>Suche</Text>
</Pressable>
<Pressable
style={[styles.topActionButton, showFilterPanel ? styles.topActionButtonActive : null]}
onPress={() => setShowFilterPanel((prev) => !prev)}>
<Text style={[styles.topActionText, showFilterPanel ? styles.topActionTextActive : null]}>Filter</Text>
</Pressable>
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Dashboard wird geladen...</Text>
</View>
) : null}
{showSearchPanel ? (
<View style={styles.panel}>
<TextInput
placeholder="Suche"
placeholderTextColor="#9ca3af"
style={styles.input}
value={search}
onChangeText={setSearch}
/>
{!loading ? (
<>
<View style={styles.row}>
<View style={[styles.metricCard, styles.metricCardPrimary]}>
<Text style={styles.metricLabelPrimary}>Aktive Zeit</Text>
<Text style={styles.metricValuePrimary}>
{data.activeTimeStart ? `Seit ${formatDateTime(data.activeTimeStart)}` : 'Nicht aktiv'}
</Text>
</View>
</View>
) : null}
{showFilterPanel ? (
<View style={styles.panel}>
<View style={styles.filterRow}>
{(['Alle', 'Offen', 'In Bearbeitung'] as const).map((status) => (
<Pressable
key={status}
style={[styles.filterChip, statusFilter === status ? styles.filterChipActive : null]}
onPress={() => setStatusFilter(status)}>
<Text
style={[
styles.filterChipText,
statusFilter === status ? styles.filterChipTextActive : null,
]}>
{status}
</Text>
</Pressable>
))}
<Pressable
style={[styles.filterChip, showCompleted ? styles.filterChipActive : null]}
onPress={() => setShowCompleted((prev) => !prev)}>
<Text style={[styles.filterChipText, showCompleted ? styles.filterChipTextActive : null]}>
Abgeschlossene anzeigen
</Text>
<View style={styles.row}>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>Offene Aufgaben</Text>
<Text style={styles.metricValue}>{data.openTasks}</Text>
</View>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>In Bearbeitung</Text>
<Text style={styles.metricValue}>{data.inProgressTasks}</Text>
</View>
</View>
<View style={styles.row}>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>Heute erfasst</Text>
<Text style={styles.metricValue}>{formatMinutes(data.todayMinutes)}</Text>
</View>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>Zum Einreichen</Text>
<Text style={styles.metricValue}>{data.pendingSubmissions}</Text>
</View>
</View>
<View style={styles.quickActionsCard}>
<Text style={styles.quickActionsTitle}>Schnellzugriff</Text>
<View style={styles.quickActionsRow}>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/tasks')}>
<Text style={styles.quickActionText}>Aufgaben</Text>
</Pressable>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/projects')}>
<Text style={styles.quickActionText}>Projekten</Text>
</Pressable>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/time')}>
<Text style={styles.quickActionText}>Zeiten</Text>
</Pressable>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/more/inventory?action=scan')}>
<Text style={styles.quickActionText}>Inventar Scan</Text>
</Pressable>
</View>
</View>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Aufgaben werden geladen...</Text>
</View>
) : null}
{!loading && filteredTasks.length === 0 ? (
<Text style={styles.empty}>Keine Aufgaben gefunden.</Text>
) : null}
{!loading &&
filteredTasks.map((task) => {
const status = normalizeStatus(task.categorie);
const isUpdating = updatingTaskId === Number(task.id);
return (
<View key={String(task.id)} style={styles.taskCard}>
<View style={styles.taskHeader}>
<Text style={styles.taskTitle} numberOfLines={2}>
{task.name}
</Text>
<Text style={styles.statusBadge}>{status}</Text>
</View>
{task.description ? (
<Text style={styles.taskDescription} numberOfLines={3}>
{task.description}
</Text>
) : null}
<Text style={styles.taskMeta}>Zuweisung: {getAssigneeLabel(task)}</Text>
<View style={styles.actionRow}>
{STATUSES.map((nextStatus) => (
<Pressable
key={nextStatus}
style={[
styles.actionButton,
nextStatus === status ? styles.actionButtonActive : null,
isUpdating ? styles.buttonDisabled : null,
]}
onPress={() => setTaskStatus(task, nextStatus)}
disabled={isUpdating || nextStatus === status}>
<Text style={styles.actionButtonText}>{nextStatus}</Text>
</Pressable>
))}
</View>
</View>
);
})}
</ScrollView>
<Pressable style={styles.fab} onPress={() => setCreateModalOpen(true)}>
<Text style={styles.fabText}>+</Text>
</Pressable>
<Modal visible={createModalOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neue Aufgabe</Text>
<TextInput
placeholder="Titel"
placeholderTextColor="#9ca3af"
style={styles.input}
value={newTaskName}
onChangeText={setNewTaskName}
/>
<TextInput
placeholder="Beschreibung (optional)"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.inputMultiline]}
multiline
value={newTaskDescription}
onChangeText={setNewTaskDescription}
/>
{createError ? <Text style={styles.error}>{createError}</Text> : null}
<View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
onPress={onCreateTask}
disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</View>
</>
) : null}
</ScrollView>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#f9fafb',
},
container: {
padding: 16,
gap: 12,
paddingBottom: 96,
backgroundColor: '#f9fafb',
},
topActions: {
row: {
flexDirection: 'row',
gap: 8,
gap: 10,
},
topActionButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 8,
metricCard: {
flex: 1,
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 12,
gap: 6,
},
topActionButtonActive: {
metricCardPrimary: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
topActionText: {
color: '#374151',
fontWeight: '600',
metricLabel: {
color: '#6b7280',
fontSize: 12,
textTransform: 'uppercase',
},
topActionTextActive: {
metricValue: {
color: '#111827',
fontSize: 22,
fontWeight: '700',
},
metricLabelPrimary: {
color: '#3d7a30',
fontSize: 12,
textTransform: 'uppercase',
},
panel: {
metricValuePrimary: {
color: '#2f5f24',
fontSize: 18,
fontWeight: '700',
},
quickActionsCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 12,
gap: 10,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
quickActionsTitle: {
color: '#111827',
backgroundColor: '#ffffff',
fontSize: 15,
fontWeight: '600',
},
inputMultiline: {
minHeight: 72,
textAlignVertical: 'top',
},
filterRow: {
quickActionsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
filterChip: {
borderRadius: 999,
borderWidth: 1,
borderColor: '#d1d5db',
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#ffffff',
quickActionButton: {
minWidth: 120,
minHeight: 40,
borderRadius: 10,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
filterChipActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
filterChipText: {
color: '#374151',
fontSize: 13,
fontWeight: '500',
},
filterChipTextActive: {
color: '#3d7a30',
quickActionText: {
color: '#ffffff',
fontWeight: '600',
},
error: {
color: '#dc2626',
@@ -424,144 +266,4 @@ const styles = StyleSheet.create({
loadingText: {
color: '#6b7280',
},
empty: {
color: '#6b7280',
textAlign: 'center',
paddingVertical: 16,
},
taskCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
},
taskHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 8,
},
taskTitle: {
flex: 1,
color: '#111827',
fontSize: 16,
fontWeight: '600',
},
statusBadge: {
color: '#3d7a30',
backgroundColor: '#eff9ea',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 4,
fontSize: 12,
overflow: 'hidden',
},
taskDescription: {
color: '#374151',
fontSize: 14,
},
taskMeta: {
color: '#6b7280',
fontSize: 12,
},
actionRow: {
flexDirection: 'row',
gap: 8,
flexWrap: 'wrap',
},
actionButton: {
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
paddingHorizontal: 10,
paddingVertical: 7,
},
actionButtonActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
actionButtonText: {
color: '#1f2937',
fontSize: 12,
fontWeight: '500',
},
fab: {
position: 'absolute',
right: 18,
bottom: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
elevation: 4,
shadowColor: '#111827',
shadowOpacity: 0.18,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
},
fabText: {
color: '#ffffff',
fontSize: 30,
lineHeight: 30,
fontWeight: '500',
marginTop: -1,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
justifyContent: 'center',
padding: 16,
},
modalKeyboardWrap: {
width: '100%',
},
modalCard: {
backgroundColor: '#ffffff',
borderRadius: 14,
padding: 14,
gap: 10,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
marginTop: 2,
},
secondaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#e5e7eb',
},
secondaryButtonText: {
color: '#111827',
fontWeight: '600',
},
primaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: '#ffffff',
fontWeight: '600',
},
buttonDisabled: {
opacity: 0.6,
},
});

View File

@@ -0,0 +1,533 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { router } from 'expo-router';
import { createProject, Customer, fetchCustomers, fetchPlants, fetchProjects, Plant, Project } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
function getProjectLine(project: Project): string {
if (project.projectNumber) return `${project.name} · ${project.projectNumber}`;
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();
const [projects, setProjects] = useState<Project[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [plants, setPlants] = useState<Plant[]>([]);
const [search, setSearch] = useState('');
const [showArchived, setShowArchived] = useState(false);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [nameInput, setNameInput] = useState('');
const [projectNumberInput, setProjectNumberInput] = useState('');
const [notesInput, setNotesInput] = useState('');
const [selectedCustomerId, setSelectedCustomerId] = useState<number | null>(null);
const [selectedPlantId, setSelectedPlantId] = useState<number | null>(null);
const [pickerMode, setPickerMode] = useState<'customer' | 'plant' | null>(null);
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 selectedCustomerLabel = useMemo(() => {
if (!selectedCustomerId) return 'Kunde auswählen (optional)';
const customer = customers.find((item) => Number(item.id) === selectedCustomerId);
return customer ? `${customer.name}${customer.customerNumber ? ` · ${customer.customerNumber}` : ''}` : `ID ${selectedCustomerId}`;
}, [customers, selectedCustomerId]);
const selectedPlantLabel = useMemo(() => {
if (!selectedPlantId) return 'Objekt auswählen (optional)';
const plant = plants.find((item) => Number(item.id) === selectedPlantId);
return plant ? plant.name : `ID ${selectedPlantId}`;
}, [plants, selectedPlantId]);
const filteredCustomerOptions = useMemo(() => {
const terms = customerSearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
return customers
.filter((customer) => !customer.archived)
.filter((customer) => {
if (terms.length === 0) return true;
const haystack = [customer.name, customer.customerNumber]
.map((value) => String(value || '').toLowerCase())
.join(' ');
return terms.every((term) => haystack.includes(term));
});
}, [customerSearch, customers]);
const filteredPlantOptions = useMemo(() => {
const terms = plantSearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
return plants
.filter((plant) => !plant.archived)
.filter((plant) => {
if (terms.length === 0) return true;
const haystack = [plant.name, plant.description]
.map((value) => String(value || '').toLowerCase())
.join(' ');
return terms.every((term) => haystack.includes(term));
});
}, [plantSearch, plants]);
const loadProjects = useCallback(async (showSpinner = true) => {
if (!token) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [projectRows, customerRows, plantRows] = await Promise.all([
fetchProjects(token, true),
fetchCustomers(token, true),
fetchPlants(token, true),
]);
setProjects(projectRows);
setCustomers(customerRows);
setPlants(plantRows);
} catch (err) {
setError(err instanceof Error ? err.message : 'Projekte konnten nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [token]);
useEffect(() => {
if (!token) return;
void loadProjects(true);
}, [loadProjects, token]);
async function onRefresh() {
setRefreshing(true);
await loadProjects(false);
}
function closeCreateModal() {
setCreateOpen(false);
setCreateError(null);
setNameInput('');
setProjectNumberInput('');
setNotesInput('');
setSelectedCustomerId(null);
setSelectedPlantId(null);
setCustomerSearch('');
setPlantSearch('');
setPickerMode(null);
}
async function onCreateProject() {
if (!token) return;
const name = nameInput.trim();
if (!name) {
setCreateError('Bitte einen Projektnamen eingeben.');
return;
}
setSaving(true);
setCreateError(null);
try {
await createProject(token, {
name,
projectNumber: projectNumberInput.trim() || null,
customer: selectedCustomerId,
plant: selectedPlantId,
notes: notesInput.trim() || null,
});
closeCreateModal();
await loadProjects(false);
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Projekt konnte nicht erstellt werden.');
} finally {
setSaving(false);
}
}
return (
<View style={styles.screen}>
<View style={styles.searchWrap}>
<TextInput
placeholder="Projekte suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={search}
onChangeText={setSearch}
/>
<Pressable
style={[styles.toggleButton, showArchived ? styles.toggleButtonActive : null]}
onPress={() => setShowArchived((prev) => !prev)}>
<Text style={[styles.toggleButtonText, showArchived ? styles.toggleButtonTextActive : null]}>
Abgeschlossene anzeigen
</Text>
</Pressable>
</View>
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Projekte werden geladen...</Text>
</View>
) : null}
{!loading && filteredProjects.length === 0 ? <Text style={styles.empty}>Keine Projekte gefunden.</Text> : null}
{!loading &&
filteredProjects.map((project) => (
<Pressable key={String(project.id)} style={styles.row} onPress={() => router.push(`/project/${project.id}`)}>
<View style={styles.rowHeader}>
<Text style={styles.rowTitle} numberOfLines={1}>{getProjectLine(project)}</Text>
{isProjectCompletedByPhase(project) ? <Text style={styles.archivedBadge}>Abgeschlossen</Text> : null}
</View>
{getActivePhaseLabel(project) ? (
<Text style={styles.phaseText} numberOfLines={1}>Phase: {getActivePhaseLabel(project)}</Text>
) : null}
{project.notes ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(project.notes)}</Text> : null}
</Pressable>
))}
</ScrollView>
<Pressable style={styles.fab} onPress={() => setCreateOpen(true)}>
<Text style={styles.fabText}>+</Text>
</Pressable>
<Modal visible={createOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neues Projekt</Text>
<TextInput
placeholder="Projektname"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={nameInput}
onChangeText={setNameInput}
/>
<TextInput
placeholder="Projekt-Nr. (optional)"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={projectNumberInput}
onChangeText={setProjectNumberInput}
/>
<Pressable style={styles.selectButton} onPress={() => setPickerMode('customer')}>
<Text style={styles.selectButtonText} numberOfLines={1}>{selectedCustomerLabel}</Text>
</Pressable>
<Pressable style={styles.selectButton} onPress={() => setPickerMode('plant')}>
<Text style={styles.selectButtonText} numberOfLines={1}>{selectedPlantLabel}</Text>
</Pressable>
<TextInput
placeholder="Notizen (optional)"
placeholderTextColor="#9ca3af"
style={[styles.searchInput, styles.multilineInput]}
value={notesInput}
onChangeText={setNotesInput}
multiline
/>
{pickerMode === 'customer' ? (
<View style={styles.inlinePickerBox}>
<Text style={styles.inlinePickerTitle}>Kunde auswählen</Text>
<TextInput
placeholder="Kunden suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={customerSearch}
onChangeText={setCustomerSearch}
/>
<ScrollView style={styles.pickerList}>
<Pressable
style={styles.pickerRow}
onPress={() => {
setSelectedCustomerId(null);
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle}>Keine Auswahl</Text>
</Pressable>
{filteredCustomerOptions.map((customer) => (
<Pressable
key={String(customer.id)}
style={styles.pickerRow}
onPress={() => {
setSelectedCustomerId(Number(customer.id));
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle} numberOfLines={1}>{customer.name}</Text>
<Text style={styles.pickerRowMeta} numberOfLines={1}>
{customer.customerNumber ? `Nr. ${customer.customerNumber}` : `ID ${customer.id}`}
</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
{pickerMode === 'plant' ? (
<View style={styles.inlinePickerBox}>
<Text style={styles.inlinePickerTitle}>Objekt auswählen</Text>
<TextInput
placeholder="Objekte suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={plantSearch}
onChangeText={setPlantSearch}
/>
<ScrollView style={styles.pickerList}>
<Pressable
style={styles.pickerRow}
onPress={() => {
setSelectedPlantId(null);
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle}>Keine Auswahl</Text>
</Pressable>
{filteredPlantOptions.map((plant) => (
<Pressable
key={String(plant.id)}
style={styles.pickerRow}
onPress={() => {
setSelectedPlantId(Number(plant.id));
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle} numberOfLines={1}>{plant.name}</Text>
<Text style={styles.pickerRowMeta} numberOfLines={1}>ID {plant.id}</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
{createError ? <Text style={styles.error}>{createError}</Text> : null}
<View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
onPress={onCreateProject}
disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#ffffff' },
searchWrap: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
backgroundColor: '#ffffff',
gap: 8,
},
searchInput: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#111827',
backgroundColor: '#ffffff',
},
list: { flex: 1, backgroundColor: '#ffffff' },
listContent: { paddingBottom: 96 },
row: {
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
backgroundColor: '#ffffff',
},
rowHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
},
rowTitle: { flex: 1, color: '#111827', fontSize: 15, fontWeight: '600' },
archivedBadge: {
color: '#3d7a30',
fontSize: 11,
fontWeight: '600',
backgroundColor: '#eff9ea',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 3,
overflow: 'hidden',
},
rowSubtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 },
phaseText: { color: '#6b7280', fontSize: 12, marginTop: 2 },
toggleButton: {
alignSelf: 'flex-start',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#ffffff',
},
toggleButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
toggleButtonText: { color: '#374151', fontSize: 12, fontWeight: '600' },
toggleButtonTextActive: { color: '#3d7a30' },
loadingBox: { paddingVertical: 20, alignItems: 'center', gap: 8 },
loadingText: { color: '#6b7280' },
empty: { color: '#6b7280', textAlign: 'center', paddingVertical: 20 },
error: { color: '#dc2626', fontSize: 13, paddingHorizontal: 16, paddingTop: 10 },
fab: {
position: 'absolute',
right: 18,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#111827',
shadowOpacity: 0.25,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 5,
},
fabText: { color: '#ffffff', fontSize: 30, lineHeight: 30, marginTop: -2 },
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: 10 },
modalTitle: { color: '#111827', fontSize: 18, fontWeight: '700' },
multilineInput: { minHeight: 92, textAlignVertical: 'top' },
selectButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#ffffff',
},
selectButtonText: { color: '#111827', fontSize: 15 },
inlinePickerBox: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
padding: 10,
gap: 8,
backgroundColor: '#fafafa',
},
inlinePickerTitle: { color: '#111827', fontSize: 14, fontWeight: '700' },
pickerList: { maxHeight: 220 },
pickerRow: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 8,
backgroundColor: '#ffffff',
},
pickerRowTitle: { color: '#111827', fontSize: 14, fontWeight: '600' },
pickerRowMeta: { color: '#6b7280', fontSize: 12, marginTop: 2 },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8, marginTop: 4 },
secondaryButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
minHeight: 40,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
},
secondaryButtonText: { color: '#374151', fontWeight: '600' },
primaryButton: {
borderRadius: 10,
minHeight: 40,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: PRIMARY,
},
primaryButtonText: { color: '#ffffff', fontWeight: '700' },
buttonDisabled: { opacity: 0.6 },
});

582
mobile/app/(tabs)/tasks.tsx Normal file
View File

@@ -0,0 +1,582 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { createTask, fetchTasks, fetchTenantProfiles, Task, TaskStatus, updateTask } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const STATUSES: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
const PRIMARY = '#69c350';
function normalizeStatus(status: unknown): TaskStatus {
if (status === 'In Bearbeitung' || status === 'Abgeschlossen') return status;
return 'Offen';
}
function getTaskAssigneeId(task: Task): string | null {
return (task.userId || task.user_id || task.profile || null) as string | null;
}
export default function TasksScreen() {
const { token, user, activeTenantId } = useAuth();
const params = useLocalSearchParams<{ action?: string | string[] }>();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [profiles, setProfiles] = useState<{ id: string; label: string }[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<'Alle' | TaskStatus>('Alle');
const [showCompleted, setShowCompleted] = useState(false);
const [showSearchPanel, setShowSearchPanel] = useState(false);
const [showFilterPanel, setShowFilterPanel] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [newTaskName, setNewTaskName] = useState('');
const [newTaskDescription, setNewTaskDescription] = useState('');
const handledActionRef = useRef<string | null>(null);
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
const incomingAction = useMemo(() => {
const raw = Array.isArray(params.action) ? params.action[0] : params.action;
return String(raw || '').toLowerCase();
}, [params.action]);
const filteredTasks = useMemo(() => {
const needle = search.trim().toLowerCase();
return tasks
.filter((task) => {
const status = normalizeStatus(task.categorie);
if (!showCompleted && status === 'Abgeschlossen') return false;
const statusMatch = statusFilter === 'Alle' || status === statusFilter;
const textMatch =
!needle ||
[task.name, task.description, task.categorie].some((value) =>
String(value || '').toLowerCase().includes(needle)
);
return statusMatch && textMatch;
})
.sort((a, b) => Number(a.id) - Number(b.id));
}, [search, showCompleted, statusFilter, tasks]);
function getAssigneeLabel(task: Task): string {
const assigneeId = getTaskAssigneeId(task);
if (!assigneeId) return '-';
return profiles.find((profile) => profile.id === assigneeId)?.label || assigneeId;
}
const loadTasks = useCallback(
async (showSpinner = true) => {
if (!token) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [taskRows, profileRows] = await Promise.all([fetchTasks(token), fetchTenantProfiles(token)]);
setTasks(taskRows || []);
setProfiles(
(profileRows || [])
.map((profile) => {
const id = profile.user_id || (profile.id ? String(profile.id) : null);
const label = profile.full_name || profile.fullName || profile.email || id;
return id ? { id: String(id), label: String(label || id) } : null;
})
.filter((value): value is { id: string; label: string } => Boolean(value))
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Aufgaben konnten nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
},
[token]
);
useEffect(() => {
if (!token || !activeTenantId) return;
void loadTasks(true);
}, [token, activeTenantId, loadTasks]);
useEffect(() => {
if (incomingAction !== 'create') return;
if (handledActionRef.current === incomingAction) return;
handledActionRef.current = incomingAction;
setCreateModalOpen(true);
}, [incomingAction]);
async function onRefresh() {
if (!token) return;
setRefreshing(true);
await loadTasks(false);
}
function closeCreateModal() {
setCreateModalOpen(false);
setCreateError(null);
setNewTaskName('');
setNewTaskDescription('');
handledActionRef.current = null;
}
async function onCreateTask() {
if (!token) return;
const name = newTaskName.trim();
if (!name) {
setCreateError('Bitte einen Aufgabennamen eingeben.');
return;
}
setSaving(true);
setCreateError(null);
try {
await createTask(token, {
name,
description: newTaskDescription.trim() || null,
categorie: 'Offen',
userId: currentUserId,
});
closeCreateModal();
await loadTasks(false);
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Aufgabe konnte nicht erstellt werden.');
} finally {
setSaving(false);
}
}
async function setTaskStatus(task: Task, status: TaskStatus) {
if (!token || !task?.id) return;
if (normalizeStatus(task.categorie) === status) return;
setUpdatingTaskId(Number(task.id));
setError(null);
try {
await updateTask(token, Number(task.id), { categorie: status });
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, categorie: status } : item)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Status konnte nicht gesetzt werden.');
} finally {
setUpdatingTaskId(null);
}
}
return (
<View style={styles.screen}>
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.topActions}>
<Pressable
style={[styles.topActionButton, showSearchPanel ? styles.topActionButtonActive : null]}
onPress={() => setShowSearchPanel((prev) => !prev)}>
<Text style={[styles.topActionText, showSearchPanel ? styles.topActionTextActive : null]}>Suche</Text>
</Pressable>
<Pressable
style={[styles.topActionButton, showFilterPanel ? styles.topActionButtonActive : null]}
onPress={() => setShowFilterPanel((prev) => !prev)}>
<Text style={[styles.topActionText, showFilterPanel ? styles.topActionTextActive : null]}>Filter</Text>
</Pressable>
</View>
{showSearchPanel ? (
<View style={styles.panel}>
<TextInput
placeholder="Suche"
placeholderTextColor="#9ca3af"
style={styles.input}
value={search}
onChangeText={setSearch}
/>
</View>
) : null}
{showFilterPanel ? (
<View style={styles.panel}>
<View style={styles.filterRow}>
{(['Alle', 'Offen', 'In Bearbeitung'] as const).map((status) => (
<Pressable
key={status}
style={[styles.filterChip, statusFilter === status ? styles.filterChipActive : null]}
onPress={() => setStatusFilter(status)}>
<Text
style={[
styles.filterChipText,
statusFilter === status ? styles.filterChipTextActive : null,
]}>
{status}
</Text>
</Pressable>
))}
<Pressable
style={[styles.filterChip, showCompleted ? styles.filterChipActive : null]}
onPress={() => setShowCompleted((prev) => !prev)}>
<Text style={[styles.filterChipText, showCompleted ? styles.filterChipTextActive : null]}>
Abgeschlossene anzeigen
</Text>
</Pressable>
</View>
</View>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Aufgaben werden geladen...</Text>
</View>
) : null}
{!loading && filteredTasks.length === 0 ? (
<Text style={styles.empty}>Keine Aufgaben gefunden.</Text>
) : null}
{!loading &&
filteredTasks.map((task) => {
const status = normalizeStatus(task.categorie);
const isUpdating = updatingTaskId === Number(task.id);
return (
<View key={String(task.id)} style={styles.taskCard}>
<View style={styles.taskHeader}>
<Text style={styles.taskTitle} numberOfLines={2}>
{task.name}
</Text>
<Text style={styles.statusBadge}>{status}</Text>
</View>
{task.description ? (
<Text style={styles.taskDescription} numberOfLines={3}>
{task.description}
</Text>
) : null}
<Text style={styles.taskMeta}>Zuweisung: {getAssigneeLabel(task)}</Text>
<View style={styles.actionRow}>
{STATUSES.map((nextStatus) => (
<Pressable
key={nextStatus}
style={[
styles.actionButton,
nextStatus === status ? styles.actionButtonActive : null,
isUpdating ? styles.buttonDisabled : null,
]}
onPress={() => setTaskStatus(task, nextStatus)}
disabled={isUpdating || nextStatus === status}>
<Text style={styles.actionButtonText}>{nextStatus}</Text>
</Pressable>
))}
</View>
</View>
);
})}
</ScrollView>
<Pressable style={styles.fab} onPress={() => setCreateModalOpen(true)}>
<Text style={styles.fabText}>+</Text>
</Pressable>
<Modal visible={createModalOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neue Aufgabe</Text>
<TextInput
placeholder="Titel"
placeholderTextColor="#9ca3af"
style={styles.input}
value={newTaskName}
onChangeText={setNewTaskName}
/>
<TextInput
placeholder="Beschreibung (optional)"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.inputMultiline]}
multiline
value={newTaskDescription}
onChangeText={setNewTaskDescription}
/>
{createError ? <Text style={styles.error}>{createError}</Text> : null}
<View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
onPress={onCreateTask}
disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#f9fafb',
},
container: {
padding: 16,
gap: 12,
paddingBottom: 96,
},
topActions: {
flexDirection: 'row',
gap: 8,
},
topActionButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#ffffff',
},
topActionButtonActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
topActionText: {
color: '#374151',
fontWeight: '600',
},
topActionTextActive: {
color: '#3d7a30',
},
panel: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#111827',
backgroundColor: '#ffffff',
},
inputMultiline: {
minHeight: 72,
textAlignVertical: 'top',
},
filterRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
filterChip: {
borderRadius: 999,
borderWidth: 1,
borderColor: '#d1d5db',
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#ffffff',
},
filterChipActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
filterChipText: {
color: '#374151',
fontSize: 13,
fontWeight: '500',
},
filterChipTextActive: {
color: '#3d7a30',
},
error: {
color: '#dc2626',
fontSize: 13,
},
loadingBox: {
padding: 16,
alignItems: 'center',
gap: 8,
},
loadingText: {
color: '#6b7280',
},
empty: {
color: '#6b7280',
textAlign: 'center',
paddingVertical: 16,
},
taskCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
},
taskHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 8,
},
taskTitle: {
flex: 1,
color: '#111827',
fontSize: 16,
fontWeight: '600',
},
statusBadge: {
color: '#3d7a30',
backgroundColor: '#eff9ea',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 4,
fontSize: 12,
overflow: 'hidden',
},
taskDescription: {
color: '#374151',
fontSize: 14,
},
taskMeta: {
color: '#6b7280',
fontSize: 12,
},
actionRow: {
flexDirection: 'row',
gap: 8,
flexWrap: 'wrap',
},
actionButton: {
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
paddingHorizontal: 10,
paddingVertical: 7,
},
actionButtonActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
actionButtonText: {
color: '#1f2937',
fontSize: 12,
fontWeight: '500',
},
fab: {
position: 'absolute',
right: 18,
bottom: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
elevation: 4,
shadowColor: '#111827',
shadowOpacity: 0.18,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
},
fabText: {
color: '#ffffff',
fontSize: 30,
lineHeight: 30,
fontWeight: '500',
marginTop: -1,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
justifyContent: 'center',
padding: 16,
},
modalKeyboardWrap: {
width: '100%',
},
modalCard: {
backgroundColor: '#ffffff',
borderRadius: 14,
padding: 14,
gap: 10,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
marginTop: 2,
},
secondaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#e5e7eb',
},
secondaryButtonText: {
color: '#111827',
fontWeight: '600',
},
primaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: '#ffffff',
fontWeight: '600',
},
buttonDisabled: {
opacity: 0.6,
},
});

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import {
createStaffTimeEvent,
@@ -47,6 +48,7 @@ function getTypeLabel(type: string): string {
export default function TimeTrackingScreen() {
const { token, user } = useAuth();
const params = useLocalSearchParams<{ action?: string | string[] }>();
const [entries, setEntries] = useState<StaffTimeSpan[]>([]);
const [loading, setLoading] = useState(true);
@@ -55,8 +57,13 @@ export default function TimeTrackingScreen() {
const [error, setError] = useState<string | null>(null);
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
const handledActionRef = useRef<string | null>(null);
const active = useMemo(() => entries.find((entry) => !entry.stopped_at) || null, [entries]);
const incomingAction = useMemo(() => {
const raw = Array.isArray(params.action) ? params.action[0] : params.action;
return String(raw || '').toLowerCase();
}, [params.action]);
const load = useCallback(
async (showSpinner = true) => {
@@ -87,7 +94,7 @@ export default function TimeTrackingScreen() {
await load(false);
}
async function onStart() {
const onStart = useCallback(async () => {
if (!token || !currentUserId) return;
setActionLoading(true);
setError(null);
@@ -105,9 +112,9 @@ export default function TimeTrackingScreen() {
} finally {
setActionLoading(false);
}
}
}, [currentUserId, token, load]);
async function onStop() {
const onStop = useCallback(async () => {
if (!token || !currentUserId || !active) return;
setActionLoading(true);
setError(null);
@@ -124,7 +131,7 @@ export default function TimeTrackingScreen() {
} finally {
setActionLoading(false);
}
}
}, [active, currentUserId, token, load]);
async function onSubmit(entry: StaffTimeSpan) {
if (!token || !entry.eventIds?.length) return;
@@ -141,6 +148,57 @@ export default function TimeTrackingScreen() {
}
}
const onSubmitAll = useCallback(async () => {
if (!token) return;
const submitCandidates = entries.filter(
(entry) => (entry.state === 'draft' || entry.state === 'factual') && !!entry.stopped_at && !!entry.eventIds?.length
);
if (submitCandidates.length === 0) return;
setActionLoading(true);
setError(null);
try {
for (const entry of submitCandidates) {
await submitStaffTime(token, entry.eventIds);
}
await load(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Einreichen fehlgeschlagen.');
} finally {
setActionLoading(false);
}
}, [entries, load, token]);
useEffect(() => {
if (!token || !currentUserId) return;
if (!incomingAction) return;
if (handledActionRef.current === incomingAction) return;
if (incomingAction === 'start' && !active) {
handledActionRef.current = incomingAction;
void onStart().finally(() => router.replace('/(tabs)/time'));
return;
}
if (incomingAction === 'stop' && active) {
handledActionRef.current = incomingAction;
void onStop().finally(() => router.replace('/(tabs)/time'));
return;
}
if (incomingAction === 'submit') {
handledActionRef.current = incomingAction;
void onSubmitAll().finally(() => router.replace('/(tabs)/time'));
return;
}
handledActionRef.current = incomingAction;
void router.replace('/(tabs)/time');
}, [active, currentUserId, incomingAction, onStart, onStop, onSubmitAll, token]);
return (
<ScrollView
contentContainerStyle={styles.container}
@@ -148,7 +206,7 @@ export default function TimeTrackingScreen() {
<View style={styles.statusCard}>
<Text style={styles.statusLabel}>Aktive Zeit</Text>
<Text style={styles.statusValue}>
{active ? `Laeuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
{active ? `Läuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
</Text>
<View style={styles.statusActions}>
@@ -179,7 +237,7 @@ export default function TimeTrackingScreen() {
</View>
) : null}
{!loading && entries.length === 0 ? <Text style={styles.empty}>Keine Zeiteintraege vorhanden.</Text> : null}
{!loading && entries.length === 0 ? <Text style={styles.empty}>Keine Zeiteinträge vorhanden.</Text> : null}
{!loading &&
entries.map((entry) => {
@@ -194,7 +252,7 @@ export default function TimeTrackingScreen() {
<Text style={styles.entryTime}>Start: {formatDateTime(entry.started_at)}</Text>
<Text style={styles.entryTime}>
Ende: {entry.stopped_at ? formatDateTime(entry.stopped_at) : 'laeuft...'}
Ende: {entry.stopped_at ? formatDateTime(entry.stopped_at) : 'läuft...'}
</Text>
<Text style={styles.entryTime}>Dauer: {formatDuration(entry.duration_minutes)}</Text>

View File

@@ -10,8 +10,108 @@ export default function RootLayout() {
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ title: 'Login', headerBackVisible: false }} />
<Stack.Screen name="tenant-select" options={{ title: 'Tenant auswaehlen', headerBackVisible: false }} />
<Stack.Screen name="tenant-select" options={{ title: 'Tenant auswählen', headerBackVisible: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="project/[id]"
options={{
title: 'Projekt',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/account"
options={{
title: 'Konto',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/settings"
options={{
title: 'Einstellungen',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/wiki"
options={{
title: 'Wiki',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/customers"
options={{
title: 'Kunden',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/customer/[id]"
options={{
title: 'Kunde',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/plants"
options={{
title: 'Objekte',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/plant/[id]"
options={{
title: 'Objekt',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/inventory"
options={{
title: 'Kundeninventar',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/nimbot"
options={{
title: 'Nimbot M2',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
</Stack>
<StatusBar style="dark" />
</AuthProvider>

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import { Redirect, router } from 'expo-router';
import { useCallback, useState } from 'react';
import { Redirect, router, useFocusEffect } from 'expo-router';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
StyleSheet,
@@ -11,22 +12,100 @@ import {
View,
} from 'react-native';
import { API_BASE_URL } from '@/src/config/env';
import {
getApiBaseUrlSync,
hydrateApiBaseUrl,
isServerSetupDone,
markServerSetupDone,
setApiBaseUrl as persistApiBaseUrl,
} from '@/src/lib/server-config';
import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
function isValidServerUrl(value: string): boolean {
return /^https?:\/\/.+/i.test(value.trim());
}
export default function LoginScreen() {
const { token, requiresTenantSelection, login } = useAuth();
const storageInfo = useTokenStorageInfo();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync());
const [serverInput, setServerInput] = useState(getApiBaseUrlSync());
const [showServerModal, setShowServerModal] = useState(false);
const [isServerSetupRequired, setIsServerSetupRequired] = useState(false);
const [isServerSaving, setIsServerSaving] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useFocusEffect(
useCallback(() => {
let active = true;
async function refreshApiBase() {
const current = await hydrateApiBaseUrl();
const setupDone = await isServerSetupDone();
if (!active) return;
setApiBaseUrl(current);
setServerInput(current);
setIsServerSetupRequired(!setupDone);
setShowServerModal(!setupDone);
}
void refreshApiBase();
return () => {
active = false;
};
}, [])
);
if (token) {
return <Redirect href={requiresTenantSelection ? '/tenant-select' : '/(tabs)'} />;
}
async function applyDefaultServer() {
setIsServerSaving(true);
setServerError(null);
try {
await markServerSetupDone();
setIsServerSetupRequired(false);
setShowServerModal(false);
setApiBaseUrl(getApiBaseUrlSync());
} catch (err) {
setServerError(err instanceof Error ? err.message : 'Server-Einstellung konnte nicht gespeichert werden.');
} finally {
setIsServerSaving(false);
}
}
async function saveCustomServer() {
setServerError(null);
const value = serverInput.trim();
if (!isValidServerUrl(value)) {
setServerError('Bitte eine gültige URL mit http:// oder https:// eingeben.');
return;
}
setIsServerSaving(true);
try {
const normalized = await persistApiBaseUrl(value);
setApiBaseUrl(normalized);
setServerInput(normalized);
setIsServerSetupRequired(false);
setShowServerModal(false);
} catch (err) {
setServerError(err instanceof Error ? err.message : 'Server-Einstellung konnte nicht gespeichert werden.');
} finally {
setIsServerSaving(false);
}
}
async function onSubmit() {
setIsSubmitting(true);
setError(null);
@@ -76,15 +155,57 @@ export default function LoginScreen() {
<Pressable
style={[styles.button, isSubmitting ? styles.buttonDisabled : null]}
onPress={onSubmit}
disabled={isSubmitting || !email || !password}>
disabled={isSubmitting || isServerSetupRequired || !email || !password}>
{isSubmitting ? <ActivityIndicator color="#ffffff" /> : <Text style={styles.buttonText}>Anmelden</Text>}
</Pressable>
<Pressable style={styles.serverLink} onPress={() => setShowServerModal(true)}>
<Text style={styles.serverLinkText}>Eigenen Server festlegen</Text>
</Pressable>
<View style={styles.metaBox}>
<Text style={styles.metaText}>API: {API_BASE_URL}</Text>
<Text style={styles.metaText}>API: {apiBaseUrl}</Text>
<Text style={styles.metaText}>Token Storage: {storageInfo.mode}</Text>
</View>
</View>
<Modal visible={showServerModal} transparent animationType="fade" onRequestClose={() => {}}>
<View style={styles.modalBackdrop}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Server-Instanz festlegen</Text>
<Text style={styles.modalText}>
Vor dem ersten Login bitte Server wählen. Standard verwenden oder eigene Instanz hinterlegen.
</Text>
<TextInput
value={serverInput}
onChangeText={setServerInput}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="https://dein-server.tld"
placeholderTextColor="#9ca3af"
style={styles.input}
/>
{serverError ? <Text style={styles.error}>{serverError}</Text> : null}
<Pressable
style={[styles.modalPrimaryButton, isServerSaving ? styles.buttonDisabled : null]}
onPress={saveCustomServer}
disabled={isServerSaving}>
<Text style={styles.modalPrimaryText}>{isServerSaving ? 'Speichern...' : 'Eigene Instanz speichern'}</Text>
</Pressable>
<Pressable
style={[styles.modalSecondaryButton, isServerSaving ? styles.buttonDisabled : null]}
onPress={applyDefaultServer}
disabled={isServerSaving}>
<Text style={styles.modalSecondaryText}>Standardserver verwenden</Text>
</Pressable>
</View>
</View>
</Modal>
</KeyboardAvoidingView>
);
}
@@ -132,7 +253,7 @@ const styles = StyleSheet.create({
},
button: {
marginTop: 6,
backgroundColor: '#16a34a',
backgroundColor: PRIMARY,
borderRadius: 10,
minHeight: 44,
alignItems: 'center',
@@ -157,8 +278,66 @@ const styles = StyleSheet.create({
borderTopColor: '#e5e7eb',
gap: 4,
},
serverLink: {
marginTop: 2,
alignSelf: 'flex-start',
paddingVertical: 4,
},
serverLinkText: {
color: PRIMARY,
fontSize: 13,
fontWeight: '600',
},
metaText: {
fontSize: 12,
color: '#6b7280',
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(17, 24, 39, 0.45)',
justifyContent: 'center',
padding: 20,
},
modalCard: {
backgroundColor: '#ffffff',
borderRadius: 14,
padding: 16,
gap: 10,
},
modalTitle: {
color: '#111827',
fontSize: 18,
fontWeight: '700',
},
modalText: {
color: '#6b7280',
fontSize: 13,
lineHeight: 18,
},
modalPrimaryButton: {
minHeight: 42,
borderRadius: 10,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
modalPrimaryText: {
color: '#ffffff',
fontWeight: '700',
fontSize: 14,
},
modalSecondaryButton: {
minHeight: 42,
borderRadius: 10,
borderWidth: 1,
borderColor: '#d1d5db',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff',
},
modalSecondaryText: {
color: '#374151',
fontWeight: '600',
fontSize: 14,
},
});

View File

@@ -1,15 +1,31 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Redirect, router } from 'expo-router';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useAuth } from '@/src/providers/auth-provider';
import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
export default function TenantSelectScreen() {
const { token, tenants, activeTenantId, requiresTenantSelection, switchTenant } = useAuth();
const { token, tenants, activeTenantId, requiresTenantSelection, switchTenant, user, logout } = useAuth();
const storageInfo = useTokenStorageInfo();
const [switchingTenantId, setSwitchingTenantId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
const filteredTenants = useMemo(() => {
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return tenants;
return tenants.filter((tenant) => {
const haystack = `${String(tenant.name || '').toLowerCase()} ${String(tenant.id || '').toLowerCase()} ${
String(tenant.short || '').toLowerCase()
}`;
return terms.every((term) => haystack.includes(term));
});
}, [search, tenants]);
if (!token) {
return <Redirect href="/login" />;
}
@@ -32,14 +48,34 @@ export default function TenantSelectScreen() {
}
}
async function onLogout() {
setSwitchingTenantId(null);
await logout();
router.replace('/login');
}
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Tenant auswaehlen</Text>
<Text style={styles.subtitle}>Bitte waehle den Tenant fuer deine Session.</Text>
<View style={styles.headerCard}>
<Text style={styles.title}>Tenant auswählen</Text>
<Text style={styles.subtitle}>Wähle den Mandanten für diese Session.</Text>
<Text style={styles.meta}>User: {String(user?.email || user?.id || 'unbekannt')}</Text>
<Text style={styles.meta}>Storage: {storageInfo.mode}</Text>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
{tenants.map((tenant) => {
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Tenant suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
/>
{filteredTenants.length === 0 ? <Text style={styles.empty}>Keine passenden Tenants gefunden.</Text> : null}
{filteredTenants.map((tenant) => {
const tenantId = Number(tenant.id);
const isBusy = switchingTenantId === tenantId;
@@ -49,14 +85,20 @@ export default function TenantSelectScreen() {
style={[styles.tenantButton, isBusy ? styles.tenantButtonDisabled : null]}
onPress={() => onSelectTenant(tenantId)}
disabled={switchingTenantId !== null}>
<View>
<Text style={styles.tenantName}>{tenant.name}</Text>
<View style={styles.tenantInfo}>
<Text style={styles.tenantName} numberOfLines={1}>
{tenant.name}
</Text>
<Text style={styles.tenantMeta}>ID: {tenantId}</Text>
</View>
{isBusy ? <ActivityIndicator /> : <Text style={styles.tenantAction}>Auswaehlen</Text>}
{isBusy ? <ActivityIndicator color={PRIMARY} /> : <Text style={styles.tenantAction}>Auswählen</Text>}
</Pressable>
);
})}
<Pressable style={styles.logoutButton} onPress={onLogout} disabled={switchingTenantId !== null}>
<Text style={styles.logoutText}>Anderen Nutzer anmelden</Text>
</Pressable>
</ScrollView>
);
}
@@ -65,21 +107,43 @@ const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 16,
gap: 10,
gap: 12,
backgroundColor: '#f9fafb',
},
headerCard: {
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#ffffff',
padding: 14,
gap: 4,
},
title: {
fontSize: 24,
fontSize: 22,
fontWeight: '700',
color: '#111827',
},
subtitle: {
color: '#6b7280',
marginBottom: 8,
marginBottom: 2,
},
meta: {
color: '#6b7280',
fontSize: 12,
},
error: {
color: '#dc2626',
marginBottom: 8,
fontSize: 13,
},
searchInput: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#111827',
backgroundColor: '#ffffff',
},
tenantButton: {
borderRadius: 12,
@@ -94,6 +158,11 @@ const styles = StyleSheet.create({
tenantButtonDisabled: {
opacity: 0.6,
},
tenantInfo: {
flex: 1,
minWidth: 0,
paddingRight: 10,
},
tenantName: {
fontSize: 16,
fontWeight: '600',
@@ -104,7 +173,27 @@ const styles = StyleSheet.create({
marginTop: 4,
},
tenantAction: {
color: '#2563eb',
color: PRIMARY,
fontWeight: '600',
},
empty: {
color: '#6b7280',
fontSize: 13,
textAlign: 'center',
paddingVertical: 8,
},
logoutButton: {
marginTop: 4,
minHeight: 42,
borderRadius: 10,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
alignItems: 'center',
justifyContent: 'center',
},
logoutText: {
color: '#374151',
fontWeight: '600',
},
});

View File

@@ -13,10 +13,13 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-document-picker": "^14.0.8",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.8",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "^15.0.8",
@@ -28,11 +31,13 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-ble-plx": "^3.5.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
@@ -6066,6 +6071,26 @@
"react-native": "*"
}
},
"node_modules/expo-camera": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
"integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
@@ -6080,6 +6105,15 @@
"react-native": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
@@ -6130,6 +6164,27 @@
}
}
},
"node_modules/expo-image-loader": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
"integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
"integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-keep-awake": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
@@ -10274,6 +10329,19 @@
}
}
},
"node_modules/react-native-ble-plx": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/react-native-ble-plx/-/react-native-ble-plx-3.5.1.tgz",
"integrity": "sha512-SxksmrUt9jG6DOarrrdkb5c/HBLSfZOKauo/9VQSSi3WJA4bmF78GkrtXrgSoGNk0m1ksacFTjB5DuL39xZq/g==",
"license": "MIT",
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-gesture-handler": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
@@ -10384,6 +10452,20 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/react-native-webview": {
"version": "13.16.0",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz",
"integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0",
"invariant": "2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-worklets": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",

View File

@@ -3,12 +3,15 @@
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"start": "expo start --dev-client --host lan",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios",
"ios:device": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios --device",
"web": "expo start --web",
"lint": "expo lint"
"lint": "expo lint",
"build:ios:dev": "eas build --profile development --platform ios",
"build:ios:preview": "eas build --profile preview --platform ios"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
@@ -16,10 +19,13 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-document-picker": "^14.0.8",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.8",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "^15.0.8",
@@ -31,11 +37,13 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-ble-plx": "^3.5.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {

View File

@@ -1,2 +1,2 @@
export const API_BASE_URL =
export const DEFAULT_API_BASE_URL =
process.env.EXPO_PUBLIC_API_BASE?.replace(/\/$/, '') || 'http://localhost:3100';

View File

@@ -1,4 +1,4 @@
import { API_BASE_URL } from '@/src/config/env';
import { getApiBaseUrlSync } from '@/src/lib/server-config';
export type Tenant = {
id: number;
@@ -17,6 +17,9 @@ export type Task = {
userId?: string | null;
user_id?: string | null;
profile?: string | null;
project?: number | { id?: number; name?: string } | null;
customer?: number | { id?: number; name?: string } | null;
plant?: number | { id?: number; name?: string } | null;
archived?: boolean;
[key: string]: unknown;
};
@@ -42,6 +45,73 @@ export type StaffTimeSpan = {
description: string;
};
export type Project = {
id: number;
name: string;
notes?: string | null;
projectNumber?: string | null;
archived?: boolean;
[key: string]: unknown;
};
export type ProjectFile = {
id: string;
name?: string | null;
path?: string | null;
project?: number | { id?: number; name?: string };
customer?: number | { id?: number; name?: string };
plant?: number | { id?: number; name?: string };
createddocument?: number | { id?: number; documentNumber?: string };
mimeType?: string | null;
url?: string;
archived?: boolean;
[key: string]: unknown;
};
export type Customer = {
id: number;
name: string;
customerNumber?: string | null;
notes?: string | null;
archived?: boolean;
[key: string]: unknown;
};
export type Plant = {
id: number;
name: string;
description?: string | null;
customer?: number | { id?: number; name?: string };
archived?: boolean;
[key: string]: unknown;
};
export type CustomerInventoryItem = {
id: number;
name: string;
customer?: number | { id?: number; name?: string } | null;
customerInventoryId?: string | null;
serialNumber?: string | null;
description?: string | null;
manufacturer?: string | null;
manufacturerNumber?: string | null;
quantity?: number | null;
archived?: boolean;
[key: string]: unknown;
};
export type CreatedDocument = {
id: number;
documentNumber?: string | null;
title?: string | null;
type?: string | null;
state?: string | null;
documentDate?: string | null;
customer?: number | { id?: number; name?: string };
archived?: boolean;
[key: string]: unknown;
};
export type MeResponse = {
user: {
id: string;
@@ -55,6 +125,52 @@ export type MeResponse = {
permissions: string[];
};
export type EncodedLabelRow = {
dataType: 'pixels' | 'void' | 'check';
rowNumber: number;
repeat: number;
rowData?: Uint8Array | number[] | Record<string, number>;
blackPixelsCount: number;
};
export type EncodedLabelImage = {
cols: number;
rows: number;
rowsData: EncodedLabelRow[];
};
export type PrintLabelResponse = {
encoded: EncodedLabelImage;
base64?: string;
};
export type WikiTreeItem = {
id: string;
parentId?: string | null;
title: string;
isFolder?: boolean;
isVirtual?: boolean;
sortOrder?: number;
entityType?: string | null;
entityId?: number | null;
entityUuid?: string | null;
updatedAt?: string;
[key: string]: unknown;
};
export type WikiPage = {
id: string;
title: string;
content?: unknown;
parentId?: string | null;
isFolder?: boolean;
entityType?: string | null;
entityId?: number | null;
entityUuid?: string | null;
updatedAt?: string;
[key: string]: unknown;
};
type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
token?: string | null;
@@ -67,7 +183,7 @@ function buildUrl(path: string): string {
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${API_BASE_URL}${normalizedPath}`;
return `${getApiBaseUrlSync()}${normalizedPath}`;
}
async function parseJson(response: Response): Promise<unknown> {
@@ -109,6 +225,23 @@ export async function checkBackendHealth(): Promise<{ status: string; [key: stri
return apiRequest<{ status: string; [key: string]: unknown }>('/health');
}
export async function renderPrintLabel(
token: string,
context: Record<string, unknown>,
width = 584,
height = 354
): Promise<PrintLabelResponse> {
return apiRequest<PrintLabelResponse>('/api/print/label', {
method: 'POST',
token,
body: {
context,
width,
height,
},
});
}
export async function loginWithEmailPassword(email: string, password: string): Promise<string> {
const payload = await apiRequest<{ token?: string }>('/auth/login', {
method: 'POST',
@@ -154,6 +287,9 @@ export async function createTask(
description?: string | null;
categorie?: TaskStatus;
userId?: string | null;
project?: number | null;
customer?: number | null;
plant?: number | null;
}
): Promise<Task> {
return apiRequest<Task>('/api/resource/tasks', {
@@ -258,3 +394,523 @@ export async function rejectStaffTime(
body: { eventIds, employeeUserId, reason },
});
}
export async function fetchProjects(token: string, includeArchived = false): Promise<Project[]> {
const projects = await apiRequest<Project[]>('/api/resource/projects', { token });
if (includeArchived) return projects || [];
return (projects || []).filter((project) => !project.archived);
}
export async function createProject(
token: string,
payload: {
name: string;
projectNumber?: string | null;
customer?: number | null;
plant?: number | null;
notes?: string | null;
}
): Promise<Project> {
return apiRequest<Project>('/api/resource/projects', {
method: 'POST',
token,
body: payload,
});
}
export async function fetchCustomers(token: string, includeArchived = false): Promise<Customer[]> {
const customers = await apiRequest<Customer[]>('/api/resource/customers', { token });
if (includeArchived) return customers || [];
return (customers || []).filter((customer) => !customer.archived);
}
export async function createCustomer(
token: string,
payload: {
name: string;
customerNumber?: string | null;
notes?: string | null;
}
): Promise<Customer> {
return apiRequest<Customer>('/api/resource/customers', {
method: 'POST',
token,
body: payload,
});
}
export async function fetchCustomerById(token: string, customerId: number): Promise<Customer> {
return apiRequest<Customer>(`/api/resource/customers/${customerId}`, { token });
}
function resolveCustomerIdFromCustomerInventoryItem(item: CustomerInventoryItem): number | null {
const rawCustomer = item.customer;
if (!rawCustomer) return null;
if (typeof rawCustomer === 'object') {
return rawCustomer.id ? Number(rawCustomer.id) : null;
}
return Number(rawCustomer);
}
export async function fetchCustomerInventoryItems(
token: string,
customerId: number,
includeArchived = false
): Promise<CustomerInventoryItem[]> {
const rows = await apiRequest<CustomerInventoryItem[]>('/api/resource/customerinventoryitems', { token });
return (rows || []).filter((item) => {
if (!includeArchived && item.archived) return false;
return resolveCustomerIdFromCustomerInventoryItem(item) === Number(customerId);
});
}
export async function fetchAllCustomerInventoryItems(
token: string,
includeArchived = false
): Promise<CustomerInventoryItem[]> {
const rows = await apiRequest<CustomerInventoryItem[]>('/api/resource/customerinventoryitems', { token });
if (includeArchived) return rows || [];
return (rows || []).filter((item) => !item.archived);
}
export async function createCustomerInventoryItem(
token: string,
payload: {
customer: number;
name: string;
customerInventoryId?: string | null;
serialNumber?: string | null;
description?: string | null;
quantity?: number | null;
}
): Promise<CustomerInventoryItem> {
const autoInventoryId = `MOB-${Date.now()}`;
return apiRequest<CustomerInventoryItem>('/api/resource/customerinventoryitems', {
method: 'POST',
token,
body: {
customer: payload.customer,
name: payload.name,
customerInventoryId: payload.customerInventoryId?.trim() || autoInventoryId,
serialNumber: payload.serialNumber?.trim() || null,
description: payload.description?.trim() || null,
quantity: Number.isFinite(Number(payload.quantity)) ? Number(payload.quantity) : 1,
},
});
}
export async function fetchPlants(token: string, includeArchived = false): Promise<Plant[]> {
const plants = await apiRequest<Plant[]>('/api/resource/plants', { token });
if (includeArchived) return plants || [];
return (plants || []).filter((plant) => !plant.archived);
}
export async function createPlant(
token: string,
payload: {
name: string;
description?: string | null;
customer?: number | null;
}
): Promise<Plant> {
return apiRequest<Plant>('/api/resource/plants', {
method: 'POST',
token,
body: payload,
});
}
export async function fetchPlantById(token: string, plantId: number): Promise<Plant> {
return apiRequest<Plant>(`/api/resource/plants/${plantId}`, { token });
}
function toQueryString(params: Record<string, unknown>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
searchParams.append(key, String(value));
});
const query = searchParams.toString();
return query ? `?${query}` : '';
}
export async function fetchWikiTree(
token: string,
filters: {
entityType?: string;
entityId?: number | null;
entityUuid?: string | null;
} = {}
): Promise<WikiTreeItem[]> {
const query = toQueryString({
entityType: filters.entityType,
entityId: filters.entityId,
entityUuid: filters.entityUuid,
});
return apiRequest<WikiTreeItem[]>(`/api/wiki/tree${query}`, { token });
}
export async function fetchWikiPageById(token: string, pageId: string): Promise<WikiPage> {
return apiRequest<WikiPage>(`/api/wiki/${encodeURIComponent(pageId)}`, { token });
}
export async function createWikiPage(
token: string,
payload: {
title: string;
parentId?: string | null;
isFolder?: boolean;
entityType?: string;
entityId?: number | null;
entityUuid?: string | null;
}
): Promise<WikiPage> {
return apiRequest<WikiPage>('/api/wiki', {
method: 'POST',
token,
body: payload,
});
}
export async function updateWikiPage(
token: string,
pageId: string,
payload: {
title?: string;
content?: unknown;
parentId?: string | null;
sortOrder?: number;
isFolder?: boolean;
}
): Promise<WikiPage> {
return apiRequest<WikiPage>(`/api/wiki/${encodeURIComponent(pageId)}`, {
method: 'PATCH',
token,
body: payload,
});
}
export async function deleteWikiPage(token: string, pageId: string): Promise<{ success: boolean; deletedId?: string }> {
return apiRequest<{ success: boolean; deletedId?: string }>(`/api/wiki/${encodeURIComponent(pageId)}`, {
method: 'DELETE',
token,
});
}
export async function fetchProjectById(token: string, projectId: number): Promise<Project> {
return apiRequest<Project>(`/api/resource/projects/${projectId}`, { token });
}
function resolveProjectIdFromTask(task: Task): number | null {
const rawProject = task.project;
if (!rawProject) return null;
if (typeof rawProject === 'object') {
return rawProject.id ? Number(rawProject.id) : null;
}
return Number(rawProject);
}
export async function fetchProjectTasks(token: string, projectId: number): Promise<Task[]> {
const tasks = await fetchTasks(token);
return (tasks || []).filter((task) => resolveProjectIdFromTask(task) === Number(projectId));
}
export async function createProjectTask(
token: string,
payload: {
projectId: number;
name: string;
description?: string | null;
userId?: string | null;
categorie?: TaskStatus;
}
): Promise<Task> {
return createTask(token, {
name: payload.name,
description: payload.description || null,
userId: payload.userId || null,
categorie: payload.categorie || 'Offen',
project: payload.projectId,
});
}
function resolveProjectIdFromFile(file: ProjectFile): number | null {
const rawProject = file.project;
if (!rawProject) return null;
if (typeof rawProject === 'object') {
return rawProject.id ? Number(rawProject.id) : null;
}
return Number(rawProject);
}
function resolveCustomerIdFromFile(file: ProjectFile): number | null {
const rawCustomer = file.customer;
if (!rawCustomer) return null;
if (typeof rawCustomer === 'object') {
return rawCustomer.id ? Number(rawCustomer.id) : null;
}
return Number(rawCustomer);
}
function resolvePlantIdFromFile(file: ProjectFile): number | null {
const rawPlant = file.plant;
if (!rawPlant) return null;
if (typeof rawPlant === 'object') {
return rawPlant.id ? Number(rawPlant.id) : null;
}
return Number(rawPlant);
}
function resolveCreatedDocumentIdFromFile(file: ProjectFile): number | null {
const rawCreatedDocument = file.createddocument;
if (!rawCreatedDocument) return null;
if (typeof rawCreatedDocument === 'object') {
return rawCreatedDocument.id ? Number(rawCreatedDocument.id) : null;
}
return Number(rawCreatedDocument);
}
function resolveCustomerIdFromCreatedDocument(doc: CreatedDocument): number | null {
const rawCustomer = doc.customer;
if (!rawCustomer) return null;
if (typeof rawCustomer === 'object') {
return rawCustomer.id ? Number(rawCustomer.id) : null;
}
return Number(rawCustomer);
}
export async function fetchProjectFiles(token: string, projectId: number): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const projectFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolveProjectIdFromFile(file) === Number(projectId);
});
if (projectFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: projectFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function fetchCustomerFiles(token: string, customerId: number): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const customerFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolveCustomerIdFromFile(file) === Number(customerId);
});
if (customerFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: customerFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function fetchPlantFiles(token: string, plantId: number): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const plantFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolvePlantIdFromFile(file) === Number(plantId);
});
if (plantFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: plantFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function fetchCustomerCreatedDocuments(
token: string,
customerId: number
): Promise<CreatedDocument[]> {
const docs = await apiRequest<CreatedDocument[]>('/api/resource/createddocuments', { token });
return (docs || [])
.filter((doc) => !doc.archived && resolveCustomerIdFromCreatedDocument(doc) === Number(customerId))
.sort((a, b) => {
const dateA = new Date(String(a.documentDate || '')).getTime();
const dateB = new Date(String(b.documentDate || '')).getTime();
return dateB - dateA;
});
}
export async function fetchCreatedDocumentFiles(
token: string,
createdDocumentId: number
): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const createdDocumentFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolveCreatedDocumentIdFromFile(file) === Number(createdDocumentId);
});
if (createdDocumentFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: createdDocumentFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function uploadProjectFile(
token: string,
payload: {
projectId: number;
uri: string;
filename: string;
mimeType?: string;
}
): Promise<ProjectFile> {
const formData = new FormData();
formData.append(
'file',
{
uri: payload.uri,
name: payload.filename,
type: payload.mimeType || 'application/octet-stream',
} as any
);
formData.append(
'meta',
JSON.stringify({
project: payload.projectId,
name: payload.filename,
mimeType: payload.mimeType || 'application/octet-stream',
})
);
const response = await fetch(buildUrl('/api/files/upload'), {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/json',
},
body: formData,
});
const parsed = await parseJson(response);
if (!response.ok) {
const message =
(parsed as { message?: string; error?: string } | null)?.message ||
(parsed as { message?: string; error?: string } | null)?.error ||
'Upload fehlgeschlagen.';
throw new Error(message);
}
return parsed as ProjectFile;
}
export async function uploadCustomerFile(
token: string,
payload: {
customerId: number;
uri: string;
filename: string;
mimeType?: string;
}
): Promise<ProjectFile> {
const formData = new FormData();
formData.append(
'file',
{
uri: payload.uri,
name: payload.filename,
type: payload.mimeType || 'application/octet-stream',
} as any
);
formData.append(
'meta',
JSON.stringify({
customer: payload.customerId,
name: payload.filename,
mimeType: payload.mimeType || 'application/octet-stream',
})
);
const response = await fetch(buildUrl('/api/files/upload'), {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/json',
},
body: formData,
});
const parsed = await parseJson(response);
if (!response.ok) {
const message =
(parsed as { message?: string; error?: string } | null)?.message ||
(parsed as { message?: string; error?: string } | null)?.error ||
'Upload fehlgeschlagen.';
throw new Error(message);
}
return parsed as ProjectFile;
}
export async function uploadPlantFile(
token: string,
payload: {
plantId: number;
uri: string;
filename: string;
mimeType?: string;
}
): Promise<ProjectFile> {
const formData = new FormData();
formData.append(
'file',
{
uri: payload.uri,
name: payload.filename,
type: payload.mimeType || 'application/octet-stream',
} as any
);
formData.append(
'meta',
JSON.stringify({
plant: payload.plantId,
name: payload.filename,
mimeType: payload.mimeType || 'application/octet-stream',
})
);
const response = await fetch(buildUrl('/api/files/upload'), {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/json',
},
body: formData,
});
const parsed = await parseJson(response);
if (!response.ok) {
const message =
(parsed as { message?: string; error?: string } | null)?.message ||
(parsed as { message?: string; error?: string } | null)?.error ||
'Upload fehlgeschlagen.';
throw new Error(message);
}
return parsed as ProjectFile;
}

View File

@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
import { hydrateApiBaseUrl } from '@/src/lib/server-config';
import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage';
export type AuthUser = MeResponse['user'];
@@ -82,6 +83,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
async function bootstrap() {
await hydrateApiBaseUrl();
const storedToken = await getStoredToken();
if (!storedToken) {