Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { Platform } from "react-native";
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
|
||||
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
||||
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
||||
import { OnboardingGate } from "@/components/OnboardingGate";
|
||||
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
||||
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { AppLockProvider } from "@/contexts/AppLockContext";
|
||||
|
||||
export default function AppLayout() {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
|
||||
const tintColor = colors.primary;
|
||||
const labelColor = colors.mutedForeground;
|
||||
const tabContentStyle = { backgroundColor: colors.background };
|
||||
const tabBarBlur =
|
||||
Platform.OS === "ios"
|
||||
? isDark
|
||||
? "systemChromeMaterialDark"
|
||||
: "systemChromeMaterialLight"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<AppLockProvider>
|
||||
<NativeTabs
|
||||
tintColor={tintColor}
|
||||
iconColor={{
|
||||
default: labelColor,
|
||||
selected: tintColor,
|
||||
}}
|
||||
labelStyle={{ color: labelColor }}
|
||||
blurEffect={tabBarBlur}
|
||||
disableTransparentOnScrollEdge
|
||||
backgroundColor={Platform.OS === "android" ? colors.background : undefined}
|
||||
>
|
||||
<NativeTabs.Trigger name="index" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "house", selected: "house.fill" }}
|
||||
md="home"
|
||||
/>
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="timer" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "timer", selected: "timer" }}
|
||||
md="timer"
|
||||
/>
|
||||
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="entities" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "square.stack.3d.up", selected: "square.stack.3d.up.fill" }}
|
||||
md="corporate_fare"
|
||||
/>
|
||||
<NativeTabs.Trigger.Label>Entities</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="invoices" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "doc.text", selected: "doc.text.fill" }}
|
||||
md="description"
|
||||
/>
|
||||
<NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="more" role="more" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: "ellipsis.circle", selected: "ellipsis.circle.fill" }}
|
||||
md="more_horiz"
|
||||
/>
|
||||
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
<OnboardingGate />
|
||||
<InvoiceReminderSync />
|
||||
<TimeClockLiveActivitySync />
|
||||
<ShortcutHandler />
|
||||
<AppLockOverlay />
|
||||
</AppLockProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
import { fonts } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
export default function EntitiesLayout() {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
headerStyle: { backgroundColor: colors.cardGlass },
|
||||
headerTitleStyle: {
|
||||
fontFamily: fonts.heading,
|
||||
fontSize: 18,
|
||||
color: colors.foreground,
|
||||
},
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: colors.foreground,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Entities",
|
||||
headerShown: false,
|
||||
statusBarTranslucent: true,
|
||||
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="clients/new"
|
||||
options={{
|
||||
title: "New client",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="clients/[id]"
|
||||
options={{
|
||||
title: "Client",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="clients/edit/[id]"
|
||||
options={{
|
||||
title: "Edit client",
|
||||
headerBackTitle: "Client",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="businesses/new"
|
||||
options={{
|
||||
title: "New business",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="businesses/[id]"
|
||||
options={{
|
||||
title: "Business",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="businesses/edit/[id]"
|
||||
options={{
|
||||
title: "Edit business",
|
||||
headerBackTitle: "Business",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function BusinessDetailScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createBusinessDetailStyles);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const businessQuery = api.businesses.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
|
||||
const setDefault = api.businesses.setDefault.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.businesses.getAll.invalidate();
|
||||
if (id) void utils.businesses.getById.invalidate({ id });
|
||||
Alert.alert("Default updated", "This business is now your default.");
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not set default", err.message),
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid business" />;
|
||||
}
|
||||
|
||||
if (businessQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading business…" />;
|
||||
}
|
||||
|
||||
const business = businessQuery.data;
|
||||
if (!business) {
|
||||
return <LoadingScreen message="Business not found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? <Text style={styles.badge}>Default</Text> : null}
|
||||
</View>
|
||||
{business.nickname ? <Text style={styles.meta}>{business.nickname}</Text> : null}
|
||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||
{business.phone ? <Text style={styles.meta}>{business.phone}</Text> : null}
|
||||
{business.website ? <Text style={styles.meta}>{business.website}</Text> : null}
|
||||
</View>
|
||||
|
||||
<Card title="Details">
|
||||
{business.taxId ? (
|
||||
<DetailRow label="Tax ID" value={business.taxId} />
|
||||
) : null}
|
||||
<DetailRow
|
||||
label="Email sending"
|
||||
value={business.resendDomain ? "Configured" : "Not configured"}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{(business.addressLine1 || business.city || business.state) && (
|
||||
<Card title="Address">
|
||||
{business.addressLine1 ? (
|
||||
<Text style={styles.body}>{business.addressLine1}</Text>
|
||||
) : null}
|
||||
{business.addressLine2 ? (
|
||||
<Text style={styles.body}>{business.addressLine2}</Text>
|
||||
) : null}
|
||||
{(business.city || business.state || business.postalCode) && (
|
||||
<Text style={styles.body}>
|
||||
{[business.city, business.state, business.postalCode].filter(Boolean).join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
{business.country ? <Text style={styles.body}>{business.country}</Text> : null}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title="Edit business"
|
||||
onPress={() => router.push(`/(app)/entities/businesses/edit/${business.id}`)}
|
||||
/>
|
||||
{!business.isDefault ? (
|
||||
<Button
|
||||
title="Set as default"
|
||||
variant="secondary"
|
||||
loading={setDefault.isPending}
|
||||
onPress={() => setDefault.mutate({ id: business.id })}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={detailStyles.row}>
|
||||
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const detailStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 4,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
const createBusinessDetailStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
gap: 4,
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
badge: {
|
||||
fontSize: 11,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.primary,
|
||||
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
},
|
||||
meta: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
body: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.foreground,
|
||||
lineHeight: 20,
|
||||
},
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { BusinessForm } from "@/components/businesses/BusinessForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function EditBusinessScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Business" }} />
|
||||
<BusinessForm
|
||||
mode="edit"
|
||||
businessId={id}
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Saved", "Business updated", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
Alert.alert("Deleted", "Business removed", [
|
||||
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { BusinessForm } from "@/components/businesses/BusinessForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function NewBusinessScreen() {
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<BusinessForm
|
||||
mode="create"
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Business created", "Your business has been saved.", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function ClientDetailScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createClientDetailStyles);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
const clientQuery = api.clients.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid client" />;
|
||||
}
|
||||
|
||||
if (clientQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading client…" />;
|
||||
}
|
||||
|
||||
const client = clientQuery.data;
|
||||
if (!client) {
|
||||
return <LoadingScreen message="Client not found" />;
|
||||
}
|
||||
|
||||
const invoices = client.invoices ?? [];
|
||||
const totalInvoiced = invoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||
const currency = client.currency ?? "USD";
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.name}>{client.name}</Text>
|
||||
{client.email ? <Text style={styles.meta}>{client.email}</Text> : null}
|
||||
{client.phone ? <Text style={styles.meta}>{client.phone}</Text> : null}
|
||||
</View>
|
||||
|
||||
<Card title="Summary">
|
||||
<DetailRow label="Total invoiced" value={formatCurrency(totalInvoiced, currency)} />
|
||||
<DetailRow label="Invoices" value={String(invoices.length)} />
|
||||
{client.defaultHourlyRate != null ? (
|
||||
<DetailRow
|
||||
label="Default rate"
|
||||
value={`${formatCurrency(client.defaultHourlyRate, currency)}/hr`}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{(client.addressLine1 || client.city || client.state) && (
|
||||
<Card title="Address">
|
||||
{client.addressLine1 ? <Text style={styles.body}>{client.addressLine1}</Text> : null}
|
||||
{client.addressLine2 ? <Text style={styles.body}>{client.addressLine2}</Text> : null}
|
||||
{(client.city || client.state || client.postalCode) && (
|
||||
<Text style={styles.body}>
|
||||
{[client.city, client.state, client.postalCode].filter(Boolean).join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
{client.country ? <Text style={styles.body}>{client.country}</Text> : null}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Invoices">
|
||||
{invoices.length === 0 ? (
|
||||
<Text style={styles.muted}>No invoices for this client yet.</Text>
|
||||
) : (
|
||||
invoices.map((invoice) => {
|
||||
const status = getInvoiceStatus(invoice);
|
||||
return (
|
||||
<Pressable
|
||||
key={invoice.id}
|
||||
style={styles.invoiceRow}
|
||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||
>
|
||||
<View style={styles.invoiceMeta}>
|
||||
<Text style={styles.invoiceTitle}>
|
||||
{invoice.invoicePrefix}
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={styles.muted}>Due {formatDate(invoice.dueDate)}</Text>
|
||||
</View>
|
||||
<View style={styles.invoiceRight}>
|
||||
<Text style={styles.invoiceAmount}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title="Edit client"
|
||||
onPress={() => router.push(`/(app)/entities/clients/edit/${client.id}`)}
|
||||
/>
|
||||
<Button
|
||||
title="New invoice"
|
||||
variant="secondary"
|
||||
onPress={() => router.push("/(app)/invoices/new")}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={detailStyles.row}>
|
||||
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const detailStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 4,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
const createClientDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
gap: 4,
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
meta: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
body: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.foreground,
|
||||
lineHeight: 20,
|
||||
},
|
||||
muted: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
invoiceRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
invoiceMeta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
invoiceTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 15,
|
||||
},
|
||||
invoiceRight: {
|
||||
alignItems: "flex-end",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
invoiceAmount: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 15,
|
||||
},
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { ClientForm } from "@/components/clients/ClientForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function EditClientScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Client" }} />
|
||||
<ClientForm
|
||||
mode="edit"
|
||||
clientId={id}
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Saved", "Client updated", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
Alert.alert("Deleted", "Client removed", [
|
||||
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { ClientForm } from "@/components/clients/ClientForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function NewClientScreen() {
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<ClientForm
|
||||
mode="create"
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Client created", "Your client has been saved.", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { FloatingActionButton } from "@/components/FloatingActionButton";
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type EntityTab = "clients" | "businesses";
|
||||
|
||||
const tabs: Array<{ label: string; value: EntityTab }> = [
|
||||
{ label: "Clients", value: "clients" },
|
||||
{ label: "Businesses", value: "businesses" },
|
||||
];
|
||||
|
||||
export default function EntitiesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createEntitiesStyles);
|
||||
const [tab, setTab] = useState<EntityTab>("clients");
|
||||
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
const businessesQuery = api.businesses.getAll.useQuery();
|
||||
const deleteClient = api.clients.delete.useMutation({
|
||||
onSuccess: () => void clientsQuery.refetch(),
|
||||
});
|
||||
const deleteBusiness = api.businesses.delete.useMutation({
|
||||
onSuccess: () => void businessesQuery.refetch(),
|
||||
});
|
||||
|
||||
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
||||
const isLoading =
|
||||
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen message="Loading…" />;
|
||||
}
|
||||
|
||||
if (activeQuery.error) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorTitle}>Could not load {tab}</Text>
|
||||
<Text style={styles.errorText}>{activeQuery.error.message}</Text>
|
||||
</View>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const clients = clientsQuery.data ?? [];
|
||||
const businesses = businessesQuery.data ?? [];
|
||||
|
||||
function refresh() {
|
||||
if (tab === "clients") void clientsQuery.refetch();
|
||||
else void businessesQuery.refetch();
|
||||
}
|
||||
|
||||
function confirmDelete(id: string, name: string) {
|
||||
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
if (tab === "clients") deleteClient.mutate({ id });
|
||||
else deleteBusiness.mutate({ id });
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader
|
||||
title="Entities"
|
||||
subtitle="Clients you bill and businesses you send from"
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={activeQuery.isRefetching}
|
||||
onRefresh={refresh}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tabScroll}
|
||||
contentContainerStyle={styles.tabs}
|
||||
>
|
||||
{tabs.map((item) => (
|
||||
<FilterChip
|
||||
key={item.value}
|
||||
label={item.label}
|
||||
active={tab === item.value}
|
||||
onPress={() => setTab(item.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{tab === "clients" ? (
|
||||
clients.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>No clients yet</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Add a client to start creating invoices.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
clients.map((client) => (
|
||||
<SwipeableRow
|
||||
key={client.id}
|
||||
backgroundColor={colors.cardGlass}
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: "trash-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.destructive,
|
||||
onPress: () => confirmDelete(client.id, client.name),
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<Text style={styles.name}>{client.name}</Text>
|
||||
{client.email ? (
|
||||
<Text style={styles.meta}>{client.email}</Text>
|
||||
) : null}
|
||||
{client.defaultHourlyRate != null ? (
|
||||
<Text style={styles.meta}>
|
||||
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
||||
/hr
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</SwipeableRow>
|
||||
))
|
||||
)
|
||||
) : businesses.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>No businesses yet</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Add your business profile for invoices and email sending.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
businesses.map((business) => (
|
||||
<SwipeableRow
|
||||
key={business.id}
|
||||
backgroundColor={colors.cardGlass}
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: "trash-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.destructive,
|
||||
onPress: () => confirmDelete(business.id, business.name),
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? (
|
||||
<Text style={styles.badge}>Default</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{business.nickname ? (
|
||||
<Text style={styles.meta}>{business.nickname}</Text>
|
||||
) : null}
|
||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</SwipeableRow>
|
||||
))
|
||||
)}
|
||||
</TabScrollView>
|
||||
<FloatingActionButton
|
||||
accessibilityLabel={tab === "clients" ? "Add client" : "Add business"}
|
||||
onPress={() =>
|
||||
router.push(
|
||||
tab === "clients"
|
||||
? "/(app)/entities/clients/new"
|
||||
: "/(app)/entities/businesses/new",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
tabScroll: {
|
||||
flexGrow: 0,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
tabs: {
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.md,
|
||||
},
|
||||
card: {},
|
||||
cardInner: {
|
||||
padding: spacing.md,
|
||||
gap: 4,
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
badge: {
|
||||
fontSize: 11,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.primary,
|
||||
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
},
|
||||
meta: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
empty: {
|
||||
padding: spacing.lg,
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,612 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Screen } from "@/components/Screen";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useSession } from "@/contexts/AuthContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
|
||||
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type ActionItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
tone: "warning" | "primary" | "success";
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createDashboardStyles);
|
||||
const { data: session } = useSession();
|
||||
const statsQuery = api.dashboard.getStats.useQuery();
|
||||
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const runningElapsed = useRunningElapsed(runningQuery.data?.startedAt);
|
||||
|
||||
if (statsQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading home…" />;
|
||||
}
|
||||
|
||||
if (statsQuery.error && !statsQuery.data) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<Screen>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorTitle}>Could not load home</Text>
|
||||
<Text style={styles.errorText}>{formatTrpcErrorMessage(statsQuery.error)}</Text>
|
||||
</View>
|
||||
</Screen>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = statsQuery.data;
|
||||
if (!stats) {
|
||||
return <LoadingScreen message="Loading home…" />;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const running = runningQuery.data;
|
||||
const runningClient = running?.client?.name ?? "No client";
|
||||
const monthInvoices = stats.monthInvoices ?? [];
|
||||
const monthTotal = monthInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
|
||||
const drafts = stats.recentInvoices.filter((invoice) => invoice.status === "draft");
|
||||
const pendingInvoices = monthInvoices.filter((invoice) => {
|
||||
const status = getInvoiceStatus(invoice);
|
||||
return status === "sent" || status === "overdue";
|
||||
});
|
||||
const overdueInvoices = monthInvoices.filter((invoice) => getInvoiceStatus(invoice) === "overdue");
|
||||
const displayName = session?.user.name?.trim();
|
||||
const firstName =
|
||||
(displayName ? displayName.split(/\s+/)[0] : undefined) ??
|
||||
session?.user.email?.split("@")[0] ??
|
||||
"there";
|
||||
|
||||
const actionItems: ActionItem[] = [
|
||||
...(running
|
||||
? [
|
||||
{
|
||||
key: "running",
|
||||
title: "Timer running",
|
||||
detail: `${formatElapsedHoursMinutes(runningElapsed)} on ${runningClient}`,
|
||||
icon: "timer-outline" as const,
|
||||
tone: "success" as const,
|
||||
onPress: () => router.push("/(app)/timer"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(overdueInvoices.length > 0
|
||||
? [
|
||||
{
|
||||
key: "overdue",
|
||||
title: `${overdueInvoices.length} overdue ${overdueInvoices.length === 1 ? "invoice" : "invoices"}`,
|
||||
detail: `${formatCurrency(overdueInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0))} needs follow-up`,
|
||||
icon: "alert-circle-outline" as const,
|
||||
tone: "warning" as const,
|
||||
onPress: () => router.push("/(app)/invoices"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(drafts.length > 0
|
||||
? [
|
||||
{
|
||||
key: "drafts",
|
||||
title: `${drafts.length} draft ${drafts.length === 1 ? "invoice" : "invoices"}`,
|
||||
detail: "Review and send when ready",
|
||||
icon: "document-text-outline" as const,
|
||||
tone: "primary" as const,
|
||||
onPress: () => router.push("/(app)/invoices"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pendingInvoices.length > 0
|
||||
? [
|
||||
{
|
||||
key: "pending",
|
||||
title: `${pendingInvoices.length} awaiting payment`,
|
||||
detail: `${formatCurrency(pendingInvoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0))} outstanding this month`,
|
||||
icon: "card-outline" as const,
|
||||
tone: "primary" as const,
|
||||
onPress: () => router.push("/(app)/invoices"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (actionItems.length === 0) {
|
||||
actionItems.push({
|
||||
key: "clear",
|
||||
title: "No urgent action items",
|
||||
detail: "You are clear for the moment",
|
||||
icon: "checkmark-circle-outline",
|
||||
tone: "success",
|
||||
onPress: () => router.push("/(app)/invoices"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<TabScrollView
|
||||
header={<PageHeader title={`Hello, ${firstName}`} subtitle="What needs attention now" />}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={statsQuery.isRefetching || runningQuery.isRefetching}
|
||||
onRefresh={() => {
|
||||
void statsQuery.refetch();
|
||||
void runningQuery.refetch();
|
||||
}}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View style={styles.quickActions}>
|
||||
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
|
||||
<Button
|
||||
title="Invoices"
|
||||
variant="secondary"
|
||||
onPress={() => router.push("/(app)/invoices")}
|
||||
/>
|
||||
<Button
|
||||
title="Reports"
|
||||
variant="secondary"
|
||||
onPress={() => router.push("/(app)/more/reports" as never)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Card title="Action items">
|
||||
<View style={styles.actionList}>
|
||||
{actionItems.map((item) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
key={item.key}
|
||||
onPress={item.onPress}
|
||||
style={({ pressed }) => [styles.actionRow, pressed && styles.pressed]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.actionIcon,
|
||||
{
|
||||
backgroundColor:
|
||||
item.tone === "warning"
|
||||
? colors.warningBg
|
||||
: item.tone === "success"
|
||||
? colors.successBg
|
||||
: colors.muted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={item.icon}
|
||||
size={20}
|
||||
color={
|
||||
item.tone === "warning"
|
||||
? colors.warning
|
||||
: item.tone === "success"
|
||||
? colors.success
|
||||
: colors.primary
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionCopy}>
|
||||
<Text style={styles.actionTitle}>{item.title}</Text>
|
||||
<Text style={styles.actionDetail}>{item.detail}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{running ? (
|
||||
<Pressable onPress={() => router.push("/(app)/timer")}>
|
||||
<GlassSurface style={styles.runningGlass}>
|
||||
<View style={styles.runningRow}>
|
||||
<View style={styles.runningDot} />
|
||||
<View style={styles.runningMeta}>
|
||||
<Text style={styles.runningTitle}>
|
||||
{resolveClockDescription(running.description)}
|
||||
</Text>
|
||||
<Text style={styles.runningSub}>
|
||||
{runningClient}
|
||||
{running.invoice
|
||||
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||
: ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.runningTime}>
|
||||
{formatElapsedHoursMinutes(runningElapsed)}
|
||||
</Text>
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
title={now.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
>
|
||||
<View style={styles.monthSummary}>
|
||||
<View>
|
||||
<Text style={styles.monthValue}>{formatCurrency(monthTotal)}</Text>
|
||||
<Text style={styles.monthLabel}>
|
||||
{monthInvoices.length} {monthInvoices.length === 1 ? "invoice" : "invoices"} this month
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
title="New invoice"
|
||||
variant="secondary"
|
||||
onPress={() => router.push("/(app)/invoices/new")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.monthList}>
|
||||
{monthInvoices.slice(0, 4).map((invoice) => {
|
||||
const status = getInvoiceStatus(invoice);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
key={invoice.id}
|
||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||
style={({ pressed }) => [styles.monthInvoiceRow, pressed && styles.pressed]}
|
||||
>
|
||||
<View style={styles.invoiceMeta}>
|
||||
<Text style={styles.invoiceTitle}>
|
||||
{invoice.invoicePrefix}
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={styles.invoiceClient}>{invoice.client?.name ?? "Client"}</Text>
|
||||
</View>
|
||||
<View style={styles.invoiceRight}>
|
||||
<Text style={styles.invoiceAmount}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{monthInvoices.length === 0 ? (
|
||||
<Text style={styles.empty}>No invoices in this month yet.</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{stats.currentDraft ? (
|
||||
<GlassSurface style={styles.draftGlass}>
|
||||
<Pressable
|
||||
style={styles.draftBanner}
|
||||
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
|
||||
>
|
||||
<View style={styles.draftCopy}>
|
||||
<Text style={styles.draftTitle}>Current draft</Text>
|
||||
<Text style={styles.draftText}>
|
||||
{stats.currentDraft.client?.name ?? "Client"} ·{" "}
|
||||
{formatCurrency(stats.currentDraft.totalAmount)} ·{" "}
|
||||
{stats.currentDraft.totalHours.toFixed(1)}h logged
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<View style={styles.statsGrid}>
|
||||
<View style={styles.statCell}>
|
||||
<StatCard label="Revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||
</View>
|
||||
<View style={styles.statCell}>
|
||||
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
||||
</View>
|
||||
<View style={styles.statCell}>
|
||||
<StatCard label="Overdue" value={String(stats.overdueCount)} />
|
||||
</View>
|
||||
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}>
|
||||
<StatCard label="Clients" value={String(stats.totalClients)} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Card title="Revenue trend">
|
||||
<View style={styles.chart}>
|
||||
{stats.revenueChartData.map((point) => {
|
||||
const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80);
|
||||
return (
|
||||
<View key={point.month} style={styles.chartColumn}>
|
||||
<View style={styles.chartBarTrack}>
|
||||
<View style={[styles.chartBar, { height: barHeight }]} />
|
||||
</View>
|
||||
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card title="Recent invoices">
|
||||
{stats.recentInvoices.length === 0 ? (
|
||||
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
|
||||
) : (
|
||||
stats.recentInvoices.map((invoice) => {
|
||||
const status = getInvoiceStatus(invoice);
|
||||
return (
|
||||
<Pressable
|
||||
key={invoice.id}
|
||||
style={({ pressed }) => [styles.recentRow, pressed && styles.pressed]}
|
||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||
>
|
||||
<View style={styles.invoiceMeta}>
|
||||
<Text style={styles.invoiceTitle}>
|
||||
{invoice.invoicePrefix}
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={styles.invoiceClient}>{invoice.client?.name ?? "Client"}</Text>
|
||||
<Text style={styles.invoiceDate}>{formatDate(invoice.issueDate)}</Text>
|
||||
</View>
|
||||
<View style={styles.invoiceRight}>
|
||||
<Text style={styles.invoiceAmount}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Card>
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
actionList: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
minHeight: 58,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
actionIcon: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: radii.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
actionCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
actionTitle: {
|
||||
color: colors.foreground,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
},
|
||||
actionDetail: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
},
|
||||
runningGlass: {
|
||||
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "#BBF7D0",
|
||||
},
|
||||
runningRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
runningDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.success,
|
||||
},
|
||||
runningMeta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
runningTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
},
|
||||
runningSub: {
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 12,
|
||||
},
|
||||
runningTime: {
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 18,
|
||||
color: colors.success,
|
||||
},
|
||||
monthSummary: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
},
|
||||
monthValue: {
|
||||
color: colors.foreground,
|
||||
fontFamily: fonts.heading,
|
||||
fontSize: 24,
|
||||
lineHeight: 30,
|
||||
},
|
||||
monthLabel: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
},
|
||||
monthList: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
monthInvoiceRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
quickActions: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
draftGlass: {
|
||||
borderColor: isDark ? "rgba(59, 130, 246, 0.32)" : "#BFDBFE",
|
||||
},
|
||||
draftBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
draftCopy: {
|
||||
flex: 1,
|
||||
gap: 3,
|
||||
},
|
||||
draftTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
},
|
||||
draftText: {
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
statsGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.md,
|
||||
alignContent: "flex-start",
|
||||
},
|
||||
statCell: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
flexBasis: "47%",
|
||||
},
|
||||
chart: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
chartColumn: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
},
|
||||
chartBarTrack: {
|
||||
width: "100%",
|
||||
height: 80,
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
},
|
||||
chartBar: {
|
||||
width: "70%",
|
||||
minHeight: 4,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: radii.sm,
|
||||
},
|
||||
chartLabel: {
|
||||
fontSize: 10,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
empty: {
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
recentRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
invoiceMeta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
invoiceTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 15,
|
||||
},
|
||||
invoiceClient: {
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
invoiceDate: {
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
invoiceRight: {
|
||||
alignItems: "flex-end",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
invoiceAmount: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 15,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
||||
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function InvoiceDetailScreen() {
|
||||
const styles = useThemedStyles(createInvoiceDetailStyles);
|
||||
const { colors } = useAppTheme();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const utils = api.useUtils();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const [section, setSection] = useState<InvoiceViewSection>("details");
|
||||
|
||||
const invoiceQuery = api.invoices.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
|
||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (err) => Alert.alert("Update failed", err.message),
|
||||
});
|
||||
|
||||
const sendPaymentReminder = api.invoices.sendReminder.useMutation({
|
||||
onSuccess: () => {
|
||||
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
||||
});
|
||||
|
||||
const previewInput = useMemo(
|
||||
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
|
||||
[invoiceQuery.data],
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
}
|
||||
|
||||
if (invoiceQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading invoice…" />;
|
||||
}
|
||||
|
||||
if (invoiceQuery.error || !invoiceQuery.data) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorTitle}>Could not load invoice</Text>
|
||||
<Text style={styles.errorText}>
|
||||
{invoiceQuery.error?.message ?? "Invoice not found"}
|
||||
</Text>
|
||||
<Button title="Go back" variant="secondary" onPress={() => router.back()} />
|
||||
</View>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const invoice = invoiceQuery.data;
|
||||
const status = getInvoiceStatus(invoice);
|
||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||
|
||||
function openSendScreen() {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client before sending invoices.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (invoice.items.length === 0) {
|
||||
Alert.alert(
|
||||
"No line items",
|
||||
"Add line items or clock time to this invoice before sending.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.push(`/(app)/invoices/send/${invoice.id}`);
|
||||
}
|
||||
|
||||
function promptPaymentReminder() {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client before sending payment reminders.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
"Send payment reminder",
|
||||
`Email a payment reminder to ${clientEmail}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Send",
|
||||
onPress: () => sendPaymentReminder.mutate({ id: invoice.id }),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function promptStatusChange(current: InvoiceStatus) {
|
||||
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||
if (current !== "sent" && current !== "overdue") {
|
||||
options.push({ label: "Mark as sent", status: "sent" });
|
||||
}
|
||||
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
|
||||
if (options.length === 0) return;
|
||||
|
||||
Alert.alert("Update status", "Choose a new status", [
|
||||
...options.map((option) => ({
|
||||
text: option.label,
|
||||
onPress: () => updateStatus.mutate({ id: invoice.id, status: option.status }),
|
||||
})),
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.headerMeta}>
|
||||
<Text style={styles.invoiceNumber}>
|
||||
{invoice.invoicePrefix}
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
|
||||
</View>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
<Text style={styles.total}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<InvoiceViewChips
|
||||
section={section}
|
||||
onSectionChange={setSection}
|
||||
status={status}
|
||||
onEdit={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
|
||||
onSend={openSendScreen}
|
||||
/>
|
||||
|
||||
{section === "preview" ? (
|
||||
<Card title="PDF preview">
|
||||
<InvoicePdfPreview input={previewInput} />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card title="Details">
|
||||
<DetailRow label="Business" value={invoice.business?.name ?? "—"} />
|
||||
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} />
|
||||
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
|
||||
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
|
||||
<DetailRow label="Currency" value={invoice.currency} />
|
||||
{invoice.taxRate > 0 ? (
|
||||
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
|
||||
) : null}
|
||||
{invoice.status === "draft" && invoice.sendReminderAt ? (
|
||||
<DetailRow
|
||||
label="Send reminder"
|
||||
value={
|
||||
new Date(invoice.sendReminderAt) <= new Date()
|
||||
? "Due now"
|
||||
: formatDate(invoice.sendReminderAt)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Line items">
|
||||
{invoice.items.length === 0 ? (
|
||||
<Text style={styles.emptyLines}>
|
||||
No line items yet. Clock time to this invoice from the Timer tab, or edit to
|
||||
add lines manually.
|
||||
</Text>
|
||||
) : (
|
||||
invoice.items.map((item) => {
|
||||
const line = (
|
||||
<View style={styles.lineItem}>
|
||||
<View style={styles.lineMeta}>
|
||||
<Text style={styles.lineDescription}>{item.description}</Text>
|
||||
<Text style={styles.lineSub}>
|
||||
{formatDate(item.date)} · {item.hours}h ×{" "}
|
||||
{formatCurrency(item.rate, invoice.currency)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.lineAmount}>
|
||||
{formatCurrency(item.amount, invoice.currency)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (invoice.status !== "draft") {
|
||||
return <View key={item.id}>{line}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SwipeableRow
|
||||
key={item.id}
|
||||
backgroundColor={colors.card}
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{line}
|
||||
</SwipeableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<InvoiceTotals
|
||||
subtotal={formatCurrency(subtotal, invoice.currency)}
|
||||
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
|
||||
taxAmount={
|
||||
invoice.taxRate > 0 ? formatCurrency(taxAmount, invoice.currency) : undefined
|
||||
}
|
||||
total={formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{invoice.notes ? (
|
||||
<Card title="Notes">
|
||||
<Text style={styles.notes}>{invoice.notes}</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<InvoiceDetailActions
|
||||
status={status}
|
||||
clientEmail={clientEmail}
|
||||
onPaymentReminder={
|
||||
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
|
||||
}
|
||||
paymentReminderLoading={sendPaymentReminder.isPending}
|
||||
onUpdateStatus={() => promptStatusChange(status)}
|
||||
updateStatusLoading={updateStatus.isPending}
|
||||
onTrackTime={() =>
|
||||
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={detailStyles.row}>
|
||||
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const detailStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
});
|
||||
|
||||
const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: spacing.md,
|
||||
},
|
||||
headerMeta: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 22,
|
||||
lineHeight: 26,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
clientName: {
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
total: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: 28,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
lineItem: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
lineMeta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
lineDescription: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
},
|
||||
lineSub: {
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 12,
|
||||
},
|
||||
lineAmount: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
},
|
||||
emptyLines: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notes: {
|
||||
fontFamily: fonts.body,
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
import { fonts } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
export default function InvoicesLayout() {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
headerStyle: { backgroundColor: colors.cardGlass },
|
||||
headerTitleStyle: {
|
||||
fontFamily: fonts.heading,
|
||||
fontSize: 18,
|
||||
color: colors.foreground,
|
||||
},
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: colors.foreground,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Invoices",
|
||||
headerShown: false,
|
||||
statusBarTranslucent: true,
|
||||
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="new"
|
||||
options={{
|
||||
title: "New invoice",
|
||||
headerBackTitle: "Invoices",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="[id]"
|
||||
options={{
|
||||
title: "Invoice",
|
||||
headerBackTitle: "Invoices",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="send/[id]"
|
||||
options={{
|
||||
title: "Send invoice",
|
||||
headerBackTitle: "Invoice",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="edit/[id]"
|
||||
options={{
|
||||
title: "Edit invoice",
|
||||
headerBackTitle: "Invoice",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import {
|
||||
InvoiceEditorSectionTabs,
|
||||
type InvoiceEditorSection,
|
||||
} from "@/components/invoices/InvoiceEditorSectionTabs";
|
||||
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { isValidTaxRate, validateLineItems } from "@/lib/form-validation";
|
||||
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
|
||||
import { ensureNotificationPermissions } from "@/lib/invoice-send-reminders";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function InvoiceEditScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createInvoiceEditStyles);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const utils = api.useUtils();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
const invoiceQuery = api.invoices.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
const businessesQuery = api.businesses.getAll.useQuery();
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
|
||||
const [businessId, setBusinessId] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [dueDate, setDueDate] = useState(() => new Date());
|
||||
const [taxRate, setTaxRate] = useState("0");
|
||||
const [sendReminderAt, setSendReminderAt] = useState<Date | null>(null);
|
||||
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||
const [section, setSection] = useState<InvoiceEditorSection>("setup");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const invoice = invoiceQuery.data;
|
||||
if (!invoice) return;
|
||||
setBusinessId(invoice.businessId ?? invoice.business?.id ?? "");
|
||||
setClientId(invoice.clientId);
|
||||
setNotes(invoice.notes ?? "");
|
||||
setDueDate(new Date(invoice.dueDate));
|
||||
setTaxRate(String(invoice.taxRate));
|
||||
setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null);
|
||||
setItems(
|
||||
invoice.items.map((item) => ({
|
||||
id: item.id,
|
||||
date: new Date(item.date),
|
||||
description: item.description,
|
||||
hours: String(item.hours),
|
||||
rate: String(item.rate),
|
||||
})),
|
||||
);
|
||||
}, [invoiceQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (businessId || !businessesQuery.data?.length) return;
|
||||
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
|
||||
}, [businessId, businessesQuery.data]);
|
||||
|
||||
const updateInvoice = api.invoices.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.invoices.getAll.invalidate({ status: "draft" });
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
Alert.alert("Saved", "Invoice updated", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
const invoice = invoiceQuery.data;
|
||||
const isDraft = invoice?.status === "draft";
|
||||
|
||||
const businessOptions = useMemo(
|
||||
() =>
|
||||
(businessesQuery.data ?? []).map((business) => ({
|
||||
label: business.name,
|
||||
value: business.id,
|
||||
})),
|
||||
[businessesQuery.data],
|
||||
);
|
||||
|
||||
const clientOptions = useMemo(
|
||||
() =>
|
||||
(clientsQuery.data ?? []).map((client) => ({
|
||||
label: client.name,
|
||||
value: client.id,
|
||||
})),
|
||||
[clientsQuery.data],
|
||||
);
|
||||
|
||||
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
||||
const currency = selectedClient?.currency ?? invoice?.currency ?? "USD";
|
||||
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||
|
||||
const subtotal = useMemo(
|
||||
() =>
|
||||
items.reduce((sum, item) => {
|
||||
const hours = Number(item.hours) || 0;
|
||||
const rate = Number(item.rate) || 0;
|
||||
return sum + hours * rate;
|
||||
}, 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const parsedTaxRate = Number(taxRate) || 0;
|
||||
const taxAmount = subtotal * (parsedTaxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
const lineItemsError = isDraft ? validateLineItems(items) : null;
|
||||
const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null;
|
||||
const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined;
|
||||
const clientError = isDraft && !clientId ? "Select a client" : undefined;
|
||||
const canSave = isDraft
|
||||
? !lineItemsError && !taxError && !businessError && !clientError
|
||||
: true;
|
||||
|
||||
const previewInput = useMemo(() => {
|
||||
if (!invoice) return null;
|
||||
return buildPreviewPdfInput({
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoicePrefix: invoice.invoicePrefix,
|
||||
businessId: resolvedBusinessId,
|
||||
clientId,
|
||||
issueDate: new Date(invoice.issueDate),
|
||||
dueDate,
|
||||
status: invoice.status as "draft" | "sent" | "paid",
|
||||
notes,
|
||||
taxRate: parsedTaxRate,
|
||||
currency,
|
||||
items,
|
||||
});
|
||||
}, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]);
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
}
|
||||
|
||||
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading invoice…" />;
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return <LoadingScreen message="Invoice not found" />;
|
||||
}
|
||||
|
||||
const status = getInvoiceStatus(invoice);
|
||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||
|
||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function duplicateItem(index: number) {
|
||||
setItems((prev) => {
|
||||
const source = prev[index];
|
||||
if (!source) return prev;
|
||||
const copy = { ...source, id: undefined };
|
||||
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!canSave) return;
|
||||
setError(null);
|
||||
|
||||
if (isDraft && sendReminderAt) {
|
||||
const granted = await ensureNotificationPermissions();
|
||||
if (!granted) {
|
||||
Alert.alert(
|
||||
"Notifications disabled",
|
||||
"Turn on notifications in Settings to get reminded when it's time to send this invoice.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const parsedItems: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
parsedItems.push({
|
||||
date: item.date,
|
||||
description: item.description.trim(),
|
||||
hours: Number(item.hours),
|
||||
rate: Number(item.rate),
|
||||
});
|
||||
}
|
||||
|
||||
updateInvoice.mutate({
|
||||
id,
|
||||
notes,
|
||||
dueDate,
|
||||
sendReminderAt,
|
||||
...(isDraft
|
||||
? {
|
||||
businessId: resolvedBusinessId,
|
||||
clientId,
|
||||
taxRate: parsedTaxRate,
|
||||
currency,
|
||||
items: parsedItems,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Invoice" }} />
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.invoiceNumber}>
|
||||
{invoice.invoicePrefix}
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={styles.clientName}>
|
||||
{selectedClient?.name ?? invoice.client?.name ?? "Client"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
|
||||
|
||||
{section === "preview" ? (
|
||||
<Card title="PDF preview">
|
||||
<InvoicePdfPreview input={previewInput} />
|
||||
</Card>
|
||||
) : section === "setup" ? (
|
||||
<Card title="Invoice setup">
|
||||
<InvoiceSetupForm
|
||||
businessId={businessId}
|
||||
onBusinessIdChange={setBusinessId}
|
||||
businessOptions={businessOptions}
|
||||
businessError={businessError}
|
||||
businessReadOnly={!isDraft}
|
||||
clientId={clientId}
|
||||
onClientIdChange={setClientId}
|
||||
clientOptions={clientOptions}
|
||||
clientError={clientError}
|
||||
clientReadOnly={!isDraft}
|
||||
invoiceNumber={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
|
||||
invoiceNumberReadOnly
|
||||
issueDate={new Date(invoice.issueDate)}
|
||||
issueDateReadOnly
|
||||
dueDate={dueDate}
|
||||
onDueDateChange={setDueDate}
|
||||
taxRate={taxRate}
|
||||
onTaxRateChange={isDraft ? setTaxRate : undefined}
|
||||
taxRateReadOnly={!isDraft}
|
||||
notes={notes}
|
||||
onNotesChange={setNotes}
|
||||
sendReminderAt={sendReminderAt}
|
||||
onSendReminderAtChange={isDraft ? setSendReminderAt : undefined}
|
||||
showSendReminder={isDraft}
|
||||
/>
|
||||
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card title="Line items">
|
||||
{!isDraft ? (
|
||||
<Text style={styles.lockedHint}>
|
||||
Line items are locked after an invoice is sent. Mark as draft on the invoice
|
||||
screen to edit entries.
|
||||
</Text>
|
||||
) : items.length === 0 ? (
|
||||
<Text style={styles.emptyLines}>
|
||||
No line items yet. Add lines here or clock time to this invoice from the
|
||||
Timer tab.
|
||||
</Text>
|
||||
) : null}
|
||||
{items.map((item, index) => (
|
||||
<LineItemEditor
|
||||
key={item.id ?? `new-${index}`}
|
||||
index={index}
|
||||
item={item}
|
||||
currency={currency}
|
||||
isLast={index === items.length - 1}
|
||||
onChange={(patch) => updateItem(index, patch)}
|
||||
onRemove={() => removeItem(index)}
|
||||
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
|
||||
readOnly={!isDraft}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isDraft ? (
|
||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||
<Text style={styles.addLineText}>+ Add another line</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<InvoiceTotals
|
||||
subtotal={formatCurrency(subtotal, currency)}
|
||||
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
||||
taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined}
|
||||
total={formatCurrency(total, currency)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<InvoiceEditorFooter
|
||||
primaryTitle="Save changes"
|
||||
onPrimary={handleSave}
|
||||
primaryLoading={updateInvoice.isPending}
|
||||
primaryDisabled={!canSave}
|
||||
secondary={
|
||||
status !== "paid"
|
||||
? {
|
||||
title: status === "draft" ? "Send invoice" : "Resend invoice",
|
||||
subtitle: clientEmail
|
||||
? items.length === 0
|
||||
? "Add line items before sending"
|
||||
: `Review PDF and email to ${clientEmail}`
|
||||
: "Add a client email first",
|
||||
icon: "mail-outline",
|
||||
onPress: () => {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client before sending invoices.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
Alert.alert(
|
||||
"No line items",
|
||||
"Add line items or clock time to this invoice before sending.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.push(`/(app)/invoices/send/${invoice.id}`);
|
||||
},
|
||||
disabled: !clientEmail || items.length === 0,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
gap: 4,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
clientName: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
lockedHint: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
color: colors.mutedForeground,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptyLines: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: colors.mutedForeground,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
addLine: {
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
addLineText: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
},
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { FloatingActionButton } from "@/components/FloatingActionButton";
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
||||
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Sent", value: "sent" },
|
||||
{ label: "Paid", value: "paid" },
|
||||
{ label: "Overdue", value: "overdue" },
|
||||
];
|
||||
|
||||
export default function InvoicesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createInvoicesStyles);
|
||||
const [filter, setFilter] = useState<(typeof filters)[number]["value"]>("all");
|
||||
const utils = api.useUtils();
|
||||
const invoicesQuery = api.invoices.getAll.useQuery();
|
||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.invoices.getAll.invalidate();
|
||||
utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (err) => Alert.alert("Update failed", err.message),
|
||||
});
|
||||
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.invoices.getAll.invalidate();
|
||||
utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (err) => Alert.alert("Delete failed", err.message),
|
||||
});
|
||||
|
||||
if (invoicesQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading invoices…" />;
|
||||
}
|
||||
|
||||
if (invoicesQuery.error) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorTitle}>Could not load invoices</Text>
|
||||
<Text style={styles.errorText}>{formatTrpcErrorMessage(invoicesQuery.error)}</Text>
|
||||
</View>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const invoices = (invoicesQuery.data ?? []).filter((invoice) => {
|
||||
if (filter === "all") return true;
|
||||
return getInvoiceStatus(invoice) === filter;
|
||||
});
|
||||
|
||||
function promptStatusChange(invoiceId: string, current: InvoiceStatus) {
|
||||
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||
|
||||
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||
if (current !== "sent" && current !== "overdue") {
|
||||
options.push({ label: "Mark as sent", status: "sent" });
|
||||
}
|
||||
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
|
||||
|
||||
if (options.length === 0) return;
|
||||
|
||||
Alert.alert("Update status", "Choose a new status", [
|
||||
...options.map((option) => ({
|
||||
text: option.label,
|
||||
onPress: () => {
|
||||
updateStatus.mutate({ id: invoiceId, status: option.status });
|
||||
},
|
||||
})),
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
]);
|
||||
}
|
||||
|
||||
function confirmDelete(invoiceId: string, label: string) {
|
||||
Alert.alert("Delete invoice?", `Remove ${label}? This cannot be undone.`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => deleteInvoice.mutate({ id: invoiceId }),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={invoicesQuery.isRefetching}
|
||||
onRefresh={() => invoicesQuery.refetch()}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.filterScroll}
|
||||
contentContainerStyle={styles.filters}
|
||||
>
|
||||
{filters.map((item) => (
|
||||
<FilterChip
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
active={filter === item.value}
|
||||
onPress={() => setFilter(item.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>No invoices found</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Tap + to create your first invoice, or pull to refresh.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
invoices.map((invoice) => {
|
||||
const status = getInvoiceStatus(invoice);
|
||||
const label = `${invoice.invoicePrefix}${invoice.invoiceNumber}`;
|
||||
const actions = [
|
||||
{
|
||||
key: "open",
|
||||
label: "Open",
|
||||
icon: "open-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/invoices/${invoice.id}`),
|
||||
},
|
||||
...(status === "draft"
|
||||
? [
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
icon: "create-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.mutedForeground,
|
||||
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||
},
|
||||
{
|
||||
key: "send",
|
||||
label: "Send",
|
||||
icon: "send-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.success,
|
||||
onPress: () => router.push(`/(app)/invoices/send/${invoice.id}`),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: "trash-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.destructive,
|
||||
onPress: () => confirmDelete(invoice.id, label),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
icon: "flag-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.warning,
|
||||
onPress: () => promptStatusChange(invoice.id, status),
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
return (
|
||||
<SwipeableRow
|
||||
key={invoice.id}
|
||||
actions={actions}
|
||||
backgroundColor={colors.cardGlass}
|
||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||
onLongPress={() => promptStatusChange(invoice.id, status)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.cardTop}>
|
||||
<View style={styles.cardMeta}>
|
||||
<Text style={styles.invoiceNumber}>{label}</Text>
|
||||
<Text style={styles.clientName}>
|
||||
{invoice.client?.name ?? "Client"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.amount}>
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardBottom}>
|
||||
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
|
||||
<StatusBadge status={status} />
|
||||
</View>
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</SwipeableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TabScrollView>
|
||||
<FloatingActionButton
|
||||
accessibilityLabel="Create invoice"
|
||||
onPress={() => {
|
||||
Alert.alert("Create invoice", "Choose how to start", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "With line items",
|
||||
onPress: () => router.push("/(app)/invoices/new"),
|
||||
},
|
||||
{
|
||||
text: "Blank (for timer)",
|
||||
onPress: () => router.push("/(app)/invoices/new?blank=1"),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createInvoicesStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
filterScroll: {
|
||||
flexGrow: 0,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
filters: {
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.md,
|
||||
},
|
||||
card: {},
|
||||
cardInner: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
cardTop: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
},
|
||||
cardMeta: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
invoiceNumber: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
clientName: {
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
cardBottom: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
date: {
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
empty: {
|
||||
padding: spacing.lg,
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import {
|
||||
InvoiceEditorSectionTabs,
|
||||
type InvoiceEditorSection,
|
||||
} from "@/components/invoices/InvoiceEditorSectionTabs";
|
||||
import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm";
|
||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import {
|
||||
isRequiredString,
|
||||
isValidTaxRate,
|
||||
validateLineItems,
|
||||
} from "@/lib/form-validation";
|
||||
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
|
||||
import { buildPreviewPdfInput } from "@/lib/invoice-pdf-input";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function NewInvoiceScreen() {
|
||||
const styles = useThemedStyles(createNewInvoiceStyles);
|
||||
const utils = api.useUtils();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const { blank } = useLocalSearchParams<{ blank?: string }>();
|
||||
const isBlank = blank === "1" || blank === "true";
|
||||
|
||||
const businessesQuery = api.businesses.getAll.useQuery();
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
|
||||
const [businessId, setBusinessId] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber);
|
||||
const [issueDate, setIssueDate] = useState(() => new Date());
|
||||
const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date()));
|
||||
const [notes, setNotes] = useState("");
|
||||
const [taxRate, setTaxRate] = useState("0");
|
||||
const [items, setItems] = useState<EditableLineItem[]>(() =>
|
||||
isBlank
|
||||
? []
|
||||
: [
|
||||
{
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: "0",
|
||||
},
|
||||
],
|
||||
);
|
||||
const [section, setSection] = useState<InvoiceEditorSection>("setup");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (businessId || !businessesQuery.data?.length) return;
|
||||
setBusinessId(resolveInvoiceBusinessId(null, businessesQuery.data));
|
||||
}, [businessId, businessesQuery.data]);
|
||||
|
||||
const businessOptions = useMemo(
|
||||
() =>
|
||||
(businessesQuery.data ?? []).map((business) => ({
|
||||
label: business.name,
|
||||
value: business.id,
|
||||
})),
|
||||
[businessesQuery.data],
|
||||
);
|
||||
|
||||
const clientOptions = useMemo(
|
||||
() =>
|
||||
(clientsQuery.data ?? []).map((client) => ({
|
||||
label: client.name,
|
||||
value: client.id,
|
||||
})),
|
||||
[clientsQuery.data],
|
||||
);
|
||||
|
||||
const selectedClient = clientsQuery.data?.find((client) => client.id === clientId);
|
||||
const currency = selectedClient?.currency ?? "USD";
|
||||
const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedClient?.defaultHourlyRate) return;
|
||||
setItems((prev) =>
|
||||
prev.map((item, index) =>
|
||||
index === 0 && (item.rate === "0" || item.rate === "")
|
||||
? { ...item, rate: String(selectedClient.defaultHourlyRate) }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}, [selectedClient?.defaultHourlyRate, selectedClient?.id]);
|
||||
|
||||
const createInvoice = api.invoices.create.useMutation({
|
||||
onSuccess: (invoice) => {
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
Alert.alert("Invoice created", "Your draft invoice is ready.", [
|
||||
{
|
||||
text: "View invoice",
|
||||
onPress: () => router.replace(`/(app)/invoices/${invoice.id}`),
|
||||
},
|
||||
]);
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
const subtotal = useMemo(
|
||||
() =>
|
||||
items.reduce((sum, item) => {
|
||||
const hours = Number(item.hours) || 0;
|
||||
const rate = Number(item.rate) || 0;
|
||||
return sum + hours * rate;
|
||||
}, 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const parsedTaxRate = Number(taxRate) || 0;
|
||||
const taxAmount = subtotal * (parsedTaxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
const previewInput = useMemo(
|
||||
() =>
|
||||
buildPreviewPdfInput({
|
||||
invoiceNumber,
|
||||
businessId: resolvedBusinessId,
|
||||
clientId,
|
||||
issueDate,
|
||||
dueDate,
|
||||
taxRate: parsedTaxRate,
|
||||
currency,
|
||||
notes,
|
||||
items,
|
||||
}),
|
||||
[
|
||||
invoiceNumber,
|
||||
resolvedBusinessId,
|
||||
clientId,
|
||||
issueDate,
|
||||
dueDate,
|
||||
parsedTaxRate,
|
||||
currency,
|
||||
notes,
|
||||
items,
|
||||
],
|
||||
);
|
||||
|
||||
const businessError = resolvedBusinessId ? undefined : "Select a business";
|
||||
const clientError = clientId ? undefined : "Select a client";
|
||||
const invoiceNumberError = isRequiredString(invoiceNumber)
|
||||
? undefined
|
||||
: "Invoice number is required";
|
||||
const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100";
|
||||
const lineItemsError = validateLineItems(items);
|
||||
const canCreate =
|
||||
businessOptions.length > 0 &&
|
||||
clientOptions.length > 0 &&
|
||||
!businessError &&
|
||||
!clientError &&
|
||||
!invoiceNumberError &&
|
||||
!taxError &&
|
||||
!lineItemsError;
|
||||
|
||||
if (businessesQuery.isLoading || clientsQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading…" />;
|
||||
}
|
||||
|
||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: "1",
|
||||
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function duplicateItem(index: number) {
|
||||
setItems((prev) => {
|
||||
const source = prev[index];
|
||||
if (!source) return prev;
|
||||
const copy = { ...source, id: undefined };
|
||||
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
if (!canCreate) return;
|
||||
setError(null);
|
||||
|
||||
const parsedItems: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
parsedItems.push({
|
||||
date: item.date,
|
||||
description: item.description.trim(),
|
||||
hours: Number(item.hours),
|
||||
rate: Number(item.rate),
|
||||
});
|
||||
}
|
||||
|
||||
createInvoice.mutate({
|
||||
businessId: resolvedBusinessId,
|
||||
clientId,
|
||||
invoiceNumber: invoiceNumber.trim(),
|
||||
issueDate,
|
||||
dueDate,
|
||||
notes,
|
||||
taxRate: Number(taxRate),
|
||||
currency,
|
||||
items: parsedItems,
|
||||
status: "draft",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerBackTitle: "Invoices",
|
||||
title: isBlank ? "Blank invoice" : "New invoice",
|
||||
}}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<InvoiceEditorSectionTabs value={section} onChange={setSection} />
|
||||
|
||||
{section === "preview" ? (
|
||||
<Card title="PDF preview">
|
||||
<InvoicePdfPreview input={previewInput} />
|
||||
</Card>
|
||||
) : section === "setup" ? (
|
||||
<Card title="Invoice setup">
|
||||
{clientOptions.length === 0 || businessOptions.length === 0 ? (
|
||||
<View style={styles.noEntities}>
|
||||
<Text style={styles.noEntitiesText}>
|
||||
{businessOptions.length === 0
|
||||
? "Add a business before creating an invoice."
|
||||
: "Add a client before creating an invoice."}
|
||||
</Text>
|
||||
<Button
|
||||
title={businessOptions.length === 0 ? "Add business" : "Add client"}
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
router.push(
|
||||
businessOptions.length === 0
|
||||
? "/(app)/entities/businesses/new"
|
||||
: "/(app)/entities/clients/new",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<InvoiceSetupForm
|
||||
businessId={businessId}
|
||||
onBusinessIdChange={setBusinessId}
|
||||
businessOptions={businessOptions}
|
||||
businessError={businessError}
|
||||
clientId={clientId}
|
||||
onClientIdChange={setClientId}
|
||||
clientOptions={clientOptions}
|
||||
clientError={clientError}
|
||||
invoiceNumber={invoiceNumber}
|
||||
onInvoiceNumberChange={setInvoiceNumber}
|
||||
issueDate={issueDate}
|
||||
onIssueDateChange={setIssueDate}
|
||||
dueDate={dueDate}
|
||||
onDueDateChange={setDueDate}
|
||||
taxRate={taxRate}
|
||||
onTaxRateChange={setTaxRate}
|
||||
notes={notes}
|
||||
onNotesChange={setNotes}
|
||||
/>
|
||||
)}
|
||||
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
|
||||
{invoiceNumberError ? (
|
||||
<Text style={styles.error}>{invoiceNumberError}</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card title="Line items">
|
||||
{isBlank && items.length === 0 ? (
|
||||
<Text style={styles.emptyLines}>
|
||||
No line items yet. Save this draft and clock time to it from the Timer tab,
|
||||
or add lines here.
|
||||
</Text>
|
||||
) : null}
|
||||
{items.map((item, index) => (
|
||||
<LineItemEditor
|
||||
key={`new-${index}`}
|
||||
index={index}
|
||||
item={item}
|
||||
currency={currency}
|
||||
isLast={index === items.length - 1}
|
||||
onChange={(patch) => updateItem(index, patch)}
|
||||
onRemove={() => removeItem(index)}
|
||||
onDuplicate={() => duplicateItem(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||
<Text style={styles.addLineText}>+ Add another line</Text>
|
||||
</Pressable>
|
||||
|
||||
<InvoiceTotals
|
||||
subtotal={formatCurrency(subtotal, currency)}
|
||||
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
|
||||
taxAmount={
|
||||
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
|
||||
}
|
||||
total={formatCurrency(total, currency)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<InvoiceEditorFooter
|
||||
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
|
||||
onPrimary={handleCreate}
|
||||
primaryLoading={createInvoice.isPending}
|
||||
primaryDisabled={!canCreate}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
noEntities: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
noEntitiesText: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.mutedForeground,
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyLines: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: colors.mutedForeground,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
addLine: {
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
addLineText: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
color: colors.primary,
|
||||
},
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||
import { buildPreviewPdfInputFromInvoice } from "@/lib/invoice-pdf-input";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function InvoiceSendScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createSendStyles);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const utils = api.useUtils();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
|
||||
const invoiceQuery = api.invoices.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
|
||||
const sendInvoice = api.email.sendInvoice.useMutation({
|
||||
onSuccess: (data) => {
|
||||
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
Alert.alert("Invoice sent", data.message, [
|
||||
{ text: "OK", onPress: () => router.replace(`/(app)/invoices/${id}`) },
|
||||
]);
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||
});
|
||||
|
||||
const previewInput = useMemo(
|
||||
() =>
|
||||
invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
|
||||
[invoiceQuery.data],
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid invoice" />;
|
||||
}
|
||||
|
||||
if (invoiceQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading invoice…" />;
|
||||
}
|
||||
|
||||
if (!invoiceQuery.data) {
|
||||
return <LoadingScreen message="Invoice not found" />;
|
||||
}
|
||||
|
||||
const invoice = invoiceQuery.data;
|
||||
const status = getInvoiceStatus(invoice);
|
||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||
const businessName = invoice.business?.name ?? "Your business";
|
||||
const sendLabel = status === "draft" ? "Send invoice" : "Resend invoice";
|
||||
|
||||
function handleSend() {
|
||||
if (!clientEmail) {
|
||||
Alert.alert(
|
||||
"No client email",
|
||||
"Add an email address to this client before sending invoices.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (invoice.items.length === 0) {
|
||||
Alert.alert(
|
||||
"No line items",
|
||||
"Add line items or clock time to this invoice before sending.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sendInvoice.mutate({
|
||||
invoiceId: invoice.id,
|
||||
customMessage: customMessage.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ title: sendLabel, headerBackTitle: "Invoice" }} />
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card title="Email summary">
|
||||
<SummaryRow label="From" value={businessName} />
|
||||
<SummaryRow label="To" value={clientEmail || "No client email on file"} />
|
||||
<SummaryRow
|
||||
label="Invoice"
|
||||
value={`${invoice.invoicePrefix}${invoice.invoiceNumber}`}
|
||||
/>
|
||||
<SummaryRow label="Due" value={formatDate(invoice.dueDate)} />
|
||||
<SummaryRow
|
||||
label="Amount"
|
||||
value={formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
bold
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="PDF attachment">
|
||||
<InvoicePdfPreview input={previewInput} height={480} />
|
||||
</Card>
|
||||
|
||||
<Card title="Message">
|
||||
<Text style={[styles.messageHint, { color: colors.mutedForeground }]}>
|
||||
Optional note included in the email body.
|
||||
</Text>
|
||||
<Input
|
||||
label="Personal message"
|
||||
value={customMessage}
|
||||
onChangeText={setCustomMessage}
|
||||
placeholder="Thanks for your business!"
|
||||
multiline
|
||||
style={styles.messageInput}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title={sendLabel}
|
||||
onPress={handleSend}
|
||||
loading={sendInvoice.isPending}
|
||||
disabled={!clientEmail || invoice.items.length === 0}
|
||||
/>
|
||||
{!clientEmail ? (
|
||||
<Text style={[styles.warning, { color: colors.destructive }]}>
|
||||
Add a client email address before sending.
|
||||
</Text>
|
||||
) : invoice.items.length === 0 ? (
|
||||
<Text style={[styles.warning, { color: colors.destructive }]}>
|
||||
Add line items before sending this invoice.
|
||||
</Text>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({
|
||||
label,
|
||||
value,
|
||||
bold,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
bold?: boolean;
|
||||
}) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<View style={summaryStyles.row}>
|
||||
<Text style={[summaryStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||
<Text
|
||||
style={[
|
||||
summaryStyles.value,
|
||||
{ color: colors.foreground },
|
||||
bold && summaryStyles.bold,
|
||||
]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const summaryStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
textAlign: "right",
|
||||
},
|
||||
bold: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const createSendStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
messageHint: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
messageInput: {
|
||||
minHeight: 96,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
warning: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function MoreLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import {
|
||||
ExpenseFormFields,
|
||||
type ExpenseFormState,
|
||||
} from "@/components/expenses/ExpenseFormFields";
|
||||
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type ReceiptSplitDraft = Pick<
|
||||
ReceiptScanResult,
|
||||
"items" | "subtotal" | "tax" | "total"
|
||||
>;
|
||||
|
||||
export default function ExpenseDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { colors } = useAppTheme();
|
||||
const utils = api.useUtils();
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
|
||||
null,
|
||||
);
|
||||
const [form, setForm] = useState<ExpenseFormState>({
|
||||
description: "",
|
||||
amountText: "",
|
||||
date: new Date(),
|
||||
category: "",
|
||||
businessId: "",
|
||||
clientId: "",
|
||||
billable: false,
|
||||
reimbursable: false,
|
||||
taxDeductible: false,
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const expenseQuery = api.expenses.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
const businessesQuery = api.businesses.getAll.useQuery();
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
|
||||
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||
onSuccess: () => void expenseQuery.refetch(),
|
||||
});
|
||||
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
||||
onSuccess: () => void expenseQuery.refetch(),
|
||||
});
|
||||
const updateExpense = api.expenses.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.expenses.getAll.invalidate();
|
||||
await expenseQuery.refetch();
|
||||
setEditing(false);
|
||||
},
|
||||
});
|
||||
const suggest = api.expenses.suggestFromReceiptText.useMutation();
|
||||
|
||||
const expense = expenseQuery.data;
|
||||
const businesses = businessesQuery.data ?? [];
|
||||
const clients = clientsQuery.data ?? [];
|
||||
|
||||
async function attachAndScan(fromCamera: boolean) {
|
||||
if (!id || !expense) return;
|
||||
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await scanReceiptImage(
|
||||
fromCamera,
|
||||
{
|
||||
description: expense.description,
|
||||
amountText: String(expense.amount),
|
||||
date: new Date(expense.date),
|
||||
},
|
||||
(input) => suggest.mutateAsync(input),
|
||||
);
|
||||
if (!result) return;
|
||||
|
||||
await uploadReceipt.mutateAsync({
|
||||
expenseId: id,
|
||||
filename: result.image.filename,
|
||||
mimeType: result.image.mimeType,
|
||||
data: result.image.base64,
|
||||
});
|
||||
|
||||
setForm(
|
||||
expenseToForm(expense, {
|
||||
description: result.description,
|
||||
amountText: result.amountText,
|
||||
date: result.date,
|
||||
notes: result.ocrText,
|
||||
}),
|
||||
);
|
||||
setReceiptSplit(
|
||||
result.items.length > 0
|
||||
? {
|
||||
items: result.items,
|
||||
subtotal: result.subtotal,
|
||||
tax: result.tax,
|
||||
total: result.total,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setEditing(true);
|
||||
|
||||
Alert.alert(
|
||||
"Receipt attached",
|
||||
result.items.length > 0
|
||||
? "Select the owed items, apply the split amount, then save the expense."
|
||||
: "We filled in what we could. Review and save to update this expense.",
|
||||
);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveEdits() {
|
||||
if (!id) return;
|
||||
const amount = Number(form.amountText);
|
||||
if (!form.description.trim() || !Number.isFinite(amount) || amount <= 0) {
|
||||
Alert.alert("Invalid fields", "Description and amount are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateExpense.mutate({
|
||||
id,
|
||||
description: form.description.trim(),
|
||||
amount,
|
||||
date: form.date,
|
||||
category: form.category || undefined,
|
||||
businessId: form.businessId || undefined,
|
||||
clientId: form.clientId || undefined,
|
||||
billable: form.billable,
|
||||
reimbursable: form.reimbursable,
|
||||
taxDeductible: form.taxDeductible,
|
||||
notes: form.notes.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
if (!expense) return;
|
||||
setForm(expenseToForm(expense));
|
||||
setReceiptSplit(null);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
if (expenseQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading expense…" />;
|
||||
}
|
||||
|
||||
if (!expense) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}>
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
Expense not found
|
||||
</Text>
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader
|
||||
title={expense.description}
|
||||
subtitle={formatDate(expense.date)}
|
||||
/>
|
||||
}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
{receiptSplit ? (
|
||||
<ReceiptItemSelector
|
||||
items={receiptSplit.items}
|
||||
subtotal={receiptSplit.subtotal}
|
||||
tax={receiptSplit.tax}
|
||||
total={receiptSplit.total}
|
||||
onApply={(selection) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
amountText: selection.owedTotal.toFixed(2),
|
||||
notes: mergeNotes(selection.notes, current.notes),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<ExpenseFormFields
|
||||
value={form}
|
||||
businesses={businesses}
|
||||
clients={clients}
|
||||
onChange={setForm}
|
||||
/>
|
||||
<Button
|
||||
title="Save changes"
|
||||
loading={updateExpense.isPending}
|
||||
onPress={handleSaveEdits}
|
||||
/>
|
||||
<Button
|
||||
title="Cancel edit"
|
||||
variant="secondary"
|
||||
onPress={() => setEditing(false)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</Text>
|
||||
<View style={styles.metaStack}>
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
{expense.category || "No category"}
|
||||
{expense.business?.name ? ` · ${expense.business.name}` : ""}
|
||||
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||
</Text>
|
||||
<View style={styles.badges}>
|
||||
{expense.billable ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.badge,
|
||||
{ color: colors.primary, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
Billable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.reimbursable ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.badge,
|
||||
{
|
||||
color: colors.foreground,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Reimbursable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.taxDeductible ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.badge,
|
||||
{ color: colors.success, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
Tax deductible
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{expense.notes ? (
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
{expense.notes}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
title="Edit expense"
|
||||
variant="secondary"
|
||||
onPress={startEditing}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={[styles.section, { color: colors.foreground }]}>
|
||||
Receipts ({expense.receipts.length})
|
||||
</Text>
|
||||
|
||||
{expense.receipts.map((receipt) => (
|
||||
<SwipeableRow
|
||||
key={receipt.id}
|
||||
actions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: "trash-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.destructive,
|
||||
onPress: () => deleteReceipt.mutate({ id: receipt.id }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.receiptRow, { color: colors.mutedForeground }]}
|
||||
>
|
||||
{receipt.originalFilename}
|
||||
</Text>
|
||||
</SwipeableRow>
|
||||
))}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title={scanning ? "Scanning…" : "Scan receipt"}
|
||||
loading={scanning || uploadReceipt.isPending}
|
||||
style={styles.actionButton}
|
||||
onPress={() => void attachAndScan(true)}
|
||||
/>
|
||||
<Button
|
||||
title="Import photo"
|
||||
variant="secondary"
|
||||
loading={scanning || uploadReceipt.isPending}
|
||||
style={styles.actionButton}
|
||||
onPress={() => void attachAndScan(false)}
|
||||
/>
|
||||
</View>
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
amount: {
|
||||
fontSize: 28,
|
||||
fontWeight: "600",
|
||||
},
|
||||
metaStack: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
meta: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
badges: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
badge: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
},
|
||||
section: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
receiptRow: {
|
||||
padding: spacing.md,
|
||||
fontSize: 14,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
function expenseToForm(
|
||||
expense: {
|
||||
description: string;
|
||||
amount: number;
|
||||
date: Date | string;
|
||||
category: string | null;
|
||||
businessId: string | null;
|
||||
clientId: string | null;
|
||||
billable: boolean;
|
||||
reimbursable: boolean;
|
||||
taxDeductible: boolean | null;
|
||||
notes: string | null;
|
||||
},
|
||||
overrides: Partial<ExpenseFormState> = {},
|
||||
): ExpenseFormState {
|
||||
return {
|
||||
description: expense.description,
|
||||
amountText: String(expense.amount),
|
||||
date: new Date(expense.date),
|
||||
category: expense.category ?? "",
|
||||
businessId: expense.businessId ?? "",
|
||||
clientId: expense.clientId ?? "",
|
||||
billable: expense.billable,
|
||||
reimbursable: expense.reimbursable,
|
||||
taxDeductible: expense.taxDeductible ?? false,
|
||||
notes: expense.notes ?? "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeNotes(prefix: string, existing: string) {
|
||||
const trimmed = existing.trim();
|
||||
if (!trimmed) return prefix;
|
||||
if (trimmed.startsWith("Receipt split")) {
|
||||
const detailsStart = trimmed.indexOf("\n\nReceipt details:");
|
||||
const legacyStart = trimmed.indexOf("\n\nOCR text:");
|
||||
const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
|
||||
return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
|
||||
}
|
||||
return `${prefix}\n\nReceipt details:\n${trimmed}`;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import type { AppRouter } from "beenvoice/server/api/root";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
|
||||
type ExpenseFilter = "all" | "billable" | "receipts";
|
||||
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
|
||||
|
||||
export default function ExpensesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createStyles);
|
||||
const utils = api.useUtils();
|
||||
const [filter, setFilter] = useState<ExpenseFilter>("all");
|
||||
const expensesQuery = api.expenses.getAll.useQuery();
|
||||
|
||||
const deleteExpense = api.expenses.delete.useMutation({
|
||||
onSuccess: () => void utils.expenses.getAll.invalidate(),
|
||||
});
|
||||
|
||||
const expenses = expensesQuery.data ?? [];
|
||||
const filteredExpenses = useMemo(
|
||||
() =>
|
||||
expenses.filter((expense) => {
|
||||
if (filter === "billable") return expense.billable;
|
||||
if (filter === "receipts") return expense.receiptCount > 0;
|
||||
return true;
|
||||
}),
|
||||
[expenses, filter],
|
||||
);
|
||||
const summary = useMemo(() => {
|
||||
const total = filteredExpenses.reduce(
|
||||
(sum, expense) => sum + expense.amount,
|
||||
0,
|
||||
);
|
||||
const billable = filteredExpenses.reduce(
|
||||
(sum, expense) => sum + (expense.billable ? expense.amount : 0),
|
||||
0,
|
||||
);
|
||||
const receiptCount = filteredExpenses.reduce(
|
||||
(sum, expense) => sum + (expense.receiptCount ?? 0),
|
||||
0,
|
||||
);
|
||||
return { total, billable, receiptCount };
|
||||
}, [filteredExpenses]);
|
||||
const groupedExpenses = useMemo(
|
||||
() => groupExpensesByMonth(filteredExpenses),
|
||||
[filteredExpenses],
|
||||
);
|
||||
|
||||
if (expensesQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading expenses..." />;
|
||||
}
|
||||
|
||||
if (expensesQuery.error) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<View style={styles.errorBox}>
|
||||
<PageHeader title="Expenses" subtitle="Expense tracking" />
|
||||
<Text style={[styles.errorTitle, { color: colors.foreground }]}>
|
||||
Could not load expenses
|
||||
</Text>
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
{formatTrpcErrorMessage(expensesQuery.error)}
|
||||
</Text>
|
||||
</View>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView
|
||||
header={
|
||||
<View style={styles.header}>
|
||||
<PageHeader
|
||||
title="Expenses"
|
||||
subtitle={`${expenses.length} recorded expense${expenses.length === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<Button
|
||||
title="Add expense"
|
||||
onPress={() => router.push("/(app)/more/expenses/new" as never)}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={expensesQuery.isRefetching}
|
||||
onRefresh={() => void expensesQuery.refetch()}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{expenses.length === 0 ? (
|
||||
<View
|
||||
style={[
|
||||
styles.emptyCard,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name="receipt-outline" size={24} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
||||
No expenses yet
|
||||
</Text>
|
||||
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||
Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed.
|
||||
</Text>
|
||||
<Button
|
||||
title="Add expense"
|
||||
onPress={() => router.push("/(app)/more/expenses/new" as never)}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.summaryGrid}>
|
||||
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
|
||||
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
|
||||
<SummaryTile label="Receipts" value={String(summary.receiptCount)} />
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filters}
|
||||
>
|
||||
<FilterChip
|
||||
label="All"
|
||||
active={filter === "all"}
|
||||
onPress={() => setFilter("all")}
|
||||
/>
|
||||
<FilterChip
|
||||
label="Billable"
|
||||
active={filter === "billable"}
|
||||
onPress={() => setFilter("billable")}
|
||||
/>
|
||||
<FilterChip
|
||||
label="With receipts"
|
||||
active={filter === "receipts"}
|
||||
onPress={() => setFilter("receipts")}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{filteredExpenses.length === 0 ? (
|
||||
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||
No expenses match this filter.
|
||||
</Text>
|
||||
) : (
|
||||
groupedExpenses.map(([monthLabel, group]) => (
|
||||
<View key={monthLabel} style={styles.monthGroup}>
|
||||
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
|
||||
{monthLabel}
|
||||
</Text>
|
||||
{group.map((expense) => (
|
||||
<ExpenseRow
|
||||
key={expense.id}
|
||||
expense={expense}
|
||||
onDelete={() => deleteExpense.mutate({ id: expense.id })}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryTile({ label, value }: { label: string; value: string }) {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createStyles);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.summaryTile,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createStyles);
|
||||
|
||||
return (
|
||||
<SwipeableRow
|
||||
backgroundColor={colors.cardGlass}
|
||||
contentStyle={({ pressed }) => [
|
||||
styles.row,
|
||||
{ borderColor: colors.border },
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
onPress={() => router.push(`/(app)/more/expenses/${expense.id}` as never)}
|
||||
actions={[
|
||||
{
|
||||
key: "open",
|
||||
label: "Open",
|
||||
icon: "open-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/more/expenses/${expense.id}` as never),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: "trash-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.destructive,
|
||||
onPress: onDelete,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
|
||||
{expense.description}
|
||||
</Text>
|
||||
{expense.receiptCount ? (
|
||||
<View
|
||||
style={[
|
||||
styles.receiptPill,
|
||||
{ borderColor: colors.border, backgroundColor: colors.background },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
|
||||
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
|
||||
{expense.receiptCount}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
|
||||
{formatDate(expense.date)}
|
||||
{expense.category ? ` · ${expense.category}` : ""}
|
||||
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||
</Text>
|
||||
<View style={styles.tagRow}>
|
||||
{expense.billable ? (
|
||||
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
|
||||
Billable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.reimbursable ? (
|
||||
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
|
||||
Reimbursable
|
||||
</Text>
|
||||
) : null}
|
||||
{expense.taxDeductible ? (
|
||||
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
|
||||
Tax
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.amountStack}>
|
||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
|
||||
</View>
|
||||
</SwipeableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function groupExpensesByMonth(expenses: Expense[]) {
|
||||
const groups = new Map<string, Expense[]>();
|
||||
for (const expense of expenses) {
|
||||
const date = new Date(expense.date);
|
||||
const key = date.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(expense);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
}
|
||||
|
||||
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
|
||||
const normalized = category?.toLowerCase() ?? "";
|
||||
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline";
|
||||
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline";
|
||||
if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline";
|
||||
if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline";
|
||||
if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-outline";
|
||||
return "receipt-outline";
|
||||
}
|
||||
|
||||
const createStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
header: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 12,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.lg,
|
||||
},
|
||||
rowPressed: {
|
||||
opacity: 0.82,
|
||||
},
|
||||
categoryIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
meta: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
sub: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
},
|
||||
tagRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
tag: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.pill,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 11,
|
||||
overflow: "hidden",
|
||||
},
|
||||
amountStack: {
|
||||
alignItems: "flex-end",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
amount: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
summaryGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
summaryTile: {
|
||||
flexGrow: 1,
|
||||
flexBasis: "30%",
|
||||
minWidth: 104,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 12,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
summaryValue: {
|
||||
marginTop: 2,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 20,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
monthGroup: {
|
||||
gap: 2,
|
||||
},
|
||||
monthLabel: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
filters: {
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.lg,
|
||||
},
|
||||
receiptPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.pill,
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
receiptPillText: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
},
|
||||
empty: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyCard: {
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
padding: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.lg,
|
||||
},
|
||||
emptyIcon: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: radii.lg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
emptyTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 17,
|
||||
},
|
||||
errorBox: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
errorTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 18,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { router } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import {
|
||||
defaultExpenseFormState,
|
||||
ExpenseFormFields,
|
||||
type ExpenseFormState,
|
||||
} from "@/components/expenses/ExpenseFormFields";
|
||||
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
scanReceiptImage,
|
||||
type PickedReceiptImage,
|
||||
type ReceiptScanResult,
|
||||
} from "@/lib/receipt-scan";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type ReceiptSplitDraft = Pick<
|
||||
ReceiptScanResult,
|
||||
"items" | "subtotal" | "tax" | "total"
|
||||
>;
|
||||
|
||||
export default function NewExpenseScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const utils = api.useUtils();
|
||||
const businessesQuery = api.businesses.getAll.useQuery();
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
|
||||
const businesses = businessesQuery.data ?? [];
|
||||
const clients = clientsQuery.data ?? [];
|
||||
const defaultBusinessId = useMemo(
|
||||
() =>
|
||||
businesses.find((business) => business.isDefault)?.id ??
|
||||
businesses[0]?.id ??
|
||||
"",
|
||||
[businesses],
|
||||
);
|
||||
const [form, setForm] = useState<ExpenseFormState>(() =>
|
||||
defaultExpenseFormState(),
|
||||
);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [pendingReceipt, setPendingReceipt] =
|
||||
useState<PickedReceiptImage | null>(null);
|
||||
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const createExpense = api.expenses.create.useMutation();
|
||||
const uploadReceipt = api.expenses.uploadReceipt.useMutation();
|
||||
const suggest = api.expenses.suggestFromReceiptText.useMutation();
|
||||
|
||||
async function runScan(fromCamera: boolean) {
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await scanReceiptImage(
|
||||
fromCamera,
|
||||
{
|
||||
description: form.description,
|
||||
amountText: form.amountText,
|
||||
date: form.date,
|
||||
},
|
||||
(input) => suggest.mutateAsync(input),
|
||||
);
|
||||
if (!result) return;
|
||||
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
description: result.description,
|
||||
amountText: result.amountText,
|
||||
date: result.date,
|
||||
notes: result.ocrText,
|
||||
businessId: current.businessId || defaultBusinessId,
|
||||
}));
|
||||
setPendingReceipt(result.image);
|
||||
setReceiptSplit(
|
||||
result.items.length > 0
|
||||
? {
|
||||
items: result.items,
|
||||
subtotal: result.subtotal,
|
||||
tax: result.tax,
|
||||
total: result.total,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
Alert.alert(
|
||||
"Receipt scanned",
|
||||
result.items.length > 0
|
||||
? "Select the items this person owes, then apply the split amount."
|
||||
: result.amountText
|
||||
? `Filled amount $${result.amountText}${result.description ? ` from ${result.description}` : ""}. Review and save.`
|
||||
: "Review the fields and enter the total before saving.",
|
||||
);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const amount = Number(form.amountText);
|
||||
if (!form.description.trim()) {
|
||||
Alert.alert("Description required", "Enter what this expense was for.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
Alert.alert("Amount required", "Enter a valid amount.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const expense = await createExpense.mutateAsync({
|
||||
description: form.description.trim(),
|
||||
amount,
|
||||
date: form.date,
|
||||
currency: "USD",
|
||||
category: form.category || undefined,
|
||||
clientId: form.clientId || undefined,
|
||||
businessId: form.businessId || defaultBusinessId || undefined,
|
||||
billable: form.billable,
|
||||
reimbursable: form.reimbursable,
|
||||
taxDeductible: form.taxDeductible,
|
||||
notes: form.notes.trim() || undefined,
|
||||
});
|
||||
|
||||
if (pendingReceipt) {
|
||||
await uploadReceipt.mutateAsync({
|
||||
expenseId: expense.id,
|
||||
filename: pendingReceipt.filename,
|
||||
mimeType: pendingReceipt.mimeType,
|
||||
data: pendingReceipt.base64,
|
||||
});
|
||||
}
|
||||
|
||||
await utils.expenses.getAll.invalidate();
|
||||
router.replace(`/(app)/more/expenses/${expense.id}` as never);
|
||||
} catch (err) {
|
||||
Alert.alert(
|
||||
"Could not save expense",
|
||||
err instanceof Error ? err.message : "Try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader
|
||||
title="New expense"
|
||||
subtitle="Add a receipt, fill the details, and save it"
|
||||
/>
|
||||
}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card title="Receipt">
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title={scanning ? "Scanning..." : "Take photo"}
|
||||
variant="secondary"
|
||||
leftIcon="camera-outline"
|
||||
loading={scanning}
|
||||
style={styles.actionButton}
|
||||
onPress={() => void runScan(true)}
|
||||
/>
|
||||
<Button
|
||||
title="Choose photo"
|
||||
variant="secondary"
|
||||
leftIcon="image-outline"
|
||||
loading={scanning}
|
||||
style={styles.actionButton}
|
||||
onPress={() => void runScan(false)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{pendingReceipt ? (
|
||||
<View style={[styles.notice, { backgroundColor: colors.successBg }]}>
|
||||
<Text style={[styles.noticeText, { color: colors.success }]}>
|
||||
Receipt attached. Review the details below before saving.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{receiptSplit ? (
|
||||
<ReceiptItemSelector
|
||||
items={receiptSplit.items}
|
||||
subtotal={receiptSplit.subtotal}
|
||||
tax={receiptSplit.tax}
|
||||
total={receiptSplit.total}
|
||||
onApply={(selection) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
amountText: selection.owedTotal.toFixed(2),
|
||||
notes: mergeNotes(selection.notes, current.notes),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card title="Details">
|
||||
<ExpenseFormFields
|
||||
value={{
|
||||
...form,
|
||||
businessId: form.businessId || defaultBusinessId,
|
||||
}}
|
||||
businesses={businesses}
|
||||
clients={clients}
|
||||
onChange={setForm}
|
||||
notesLabel="Notes"
|
||||
notesPlaceholder="Internal details"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<View style={styles.saveActions}>
|
||||
<Button
|
||||
title="Save expense"
|
||||
leftIcon="checkmark-circle-outline"
|
||||
loading={createExpense.isPending}
|
||||
onPress={() => void handleSave()}
|
||||
/>
|
||||
<Button
|
||||
title="Cancel"
|
||||
leftIcon="close-circle-outline"
|
||||
variant="secondary"
|
||||
onPress={() => router.back()}
|
||||
/>
|
||||
</View>
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
saveActions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
notice: {
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
noticeText: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
function mergeNotes(prefix: string, existing: string) {
|
||||
const trimmed = existing.trim();
|
||||
if (!trimmed) return prefix;
|
||||
if (trimmed.startsWith("Receipt split")) {
|
||||
const detailsStart = trimmed.indexOf("\n\nReceipt details:");
|
||||
const legacyStart = trimmed.indexOf("\n\nOCR text:");
|
||||
const noteStart = detailsStart >= 0 ? detailsStart : legacyStart;
|
||||
return noteStart >= 0 ? `${prefix}${trimmed.slice(noteStart)}` : prefix;
|
||||
}
|
||||
return `${prefix}\n\nReceipt details:\n${trimmed}`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
|
||||
type HubItem = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
href: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
};
|
||||
|
||||
const ITEMS: HubItem[] = [
|
||||
{
|
||||
title: "Expenses",
|
||||
subtitle: "Track costs and attach receipts",
|
||||
href: "/(app)/more/expenses",
|
||||
icon: "receipt-outline",
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
subtitle: "Revenue, hours, and tax summaries",
|
||||
href: "/(app)/more/reports",
|
||||
icon: "bar-chart-outline",
|
||||
},
|
||||
{
|
||||
title: "Recurring invoices",
|
||||
subtitle: "Scheduled billing templates",
|
||||
href: "/(app)/more/recurring",
|
||||
icon: "repeat-outline",
|
||||
},
|
||||
{
|
||||
title: "Time entries",
|
||||
subtitle: "Full history with edit and delete",
|
||||
href: "/(app)/more/time-entries",
|
||||
icon: "time-outline",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
subtitle: "Account, security, and app preferences",
|
||||
href: "/(app)/more/settings",
|
||||
icon: "settings-outline",
|
||||
},
|
||||
];
|
||||
|
||||
export default function MoreScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createStyles);
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<TabScrollView header={<PageHeader title="More" subtitle="Additional tools" />}>
|
||||
<View style={styles.list}>
|
||||
{ITEMS.map((item) => (
|
||||
<Pressable
|
||||
key={item.href}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push(item.href as never)}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={item.icon} size={22} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>{item.title}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
list: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.card,
|
||||
},
|
||||
rowPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: radii.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
title: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 16,
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function RecurringScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const utils = api.useUtils();
|
||||
const query = api.recurringInvoices.getAll.useQuery();
|
||||
|
||||
const pause = api.recurringInvoices.pause.useMutation({
|
||||
onSuccess: () => void query.refetch(),
|
||||
});
|
||||
const resume = api.recurringInvoices.resume.useMutation({
|
||||
onSuccess: () => void query.refetch(),
|
||||
});
|
||||
const generateNow = api.recurringInvoices.generateNow.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return <LoadingScreen message="Loading recurring invoices…" />;
|
||||
}
|
||||
|
||||
const items = query.data ?? [];
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader
|
||||
title="Recurring"
|
||||
subtitle={`${items.length} schedule${items.length === 1 ? "" : "s"}`}
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={query.isRefetching}
|
||||
onRefresh={() => void query.refetch()}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||
No recurring invoices yet. Create them on the web dashboard for now.
|
||||
</Text>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<SwipeableRow
|
||||
key={item.id}
|
||||
actions={[
|
||||
{
|
||||
key: "generate",
|
||||
label: "Run",
|
||||
icon: "play-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => generateNow.mutate({ id: item.id }),
|
||||
},
|
||||
{
|
||||
key: "toggle",
|
||||
label: item.status === "active" ? "Pause" : "Resume",
|
||||
icon: item.status === "active" ? "pause-outline" : "play-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.mutedForeground,
|
||||
onPress: () =>
|
||||
item.status === "active"
|
||||
? pause.mutate({ id: item.id })
|
||||
: resume.mutate({ id: item.id }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>{item.name}</Text>
|
||||
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||
{item.client?.name ?? "Client"} · {item.schedule} · {item.status}
|
||||
</Text>
|
||||
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||
Next due {formatDate(item.nextDueAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||
{formatCurrency(
|
||||
item.items.reduce((sum, line) => sum + line.hours * line.rate, 0),
|
||||
item.currency,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</SwipeableRow>
|
||||
))
|
||||
)}
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
title: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function ReportsScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const statsQuery = api.dashboard.getStats.useQuery();
|
||||
const expensesQuery = api.expenses.getAll.useQuery();
|
||||
const summaryQuery = api.timeEntries.getSummary.useQuery();
|
||||
|
||||
if (statsQuery.isLoading || expensesQuery.isLoading || summaryQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading reports…" />;
|
||||
}
|
||||
|
||||
if (statsQuery.error) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<View style={styles.errorBox}>
|
||||
<PageHeader title="Reports" subtitle="Business performance snapshot" />
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
{formatTrpcErrorMessage(statsQuery.error)}
|
||||
</Text>
|
||||
</View>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = statsQuery.data!;
|
||||
const expenseTotal = (expensesQuery.data ?? []).reduce((sum, e) => sum + e.amount, 0);
|
||||
const summary = summaryQuery.data;
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView
|
||||
header={<PageHeader title="Reports" subtitle="Business performance snapshot" />}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={statsQuery.isRefetching}
|
||||
onRefresh={() => {
|
||||
void statsQuery.refetch();
|
||||
void expensesQuery.refetch();
|
||||
void summaryQuery.refetch();
|
||||
}}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View style={styles.grid}>
|
||||
<StatCard label="Revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
||||
<StatCard label="Expenses" value={formatCurrency(expenseTotal)} />
|
||||
<StatCard
|
||||
label="Billable hours"
|
||||
value={summary ? summary.totalHours.toFixed(1) : "0"}
|
||||
hint={summary ? `${summary.count} entries` : undefined}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Card title="Invoice status">
|
||||
{(stats.statusChartData ?? []).map((item) => (
|
||||
<View key={item.status} style={styles.statusRow}>
|
||||
<Text style={{ color: colors.foreground }}>{item.name}</Text>
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
{item.count} · {formatCurrency(item.value)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card title="Revenue trend (6 mo)">
|
||||
{stats.revenueChartData.map((point) => (
|
||||
<View key={point.month} style={styles.statusRow}>
|
||||
<Text style={{ color: colors.foreground }}>{point.monthLabel}</Text>
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
{formatCurrency(point.revenue)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.md,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
errorBox: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,700 @@
|
||||
import { useState } from "react";
|
||||
import Constants from "expo-constants";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
Alert,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { InstanceUrlField } from "@/components/InstanceUrlField";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { PinPrompt } from "@/components/PinPrompt";
|
||||
import { ShortcutsSetupCard } from "@/components/ShortcutsSetupCard";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppLock } from "@/contexts/AppLockContext";
|
||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||
import {
|
||||
confirmRemoveAccount,
|
||||
finishAccountRemoval,
|
||||
} from "@/lib/account-actions";
|
||||
import { performAuthReset } from "@/lib/auth-session";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
];
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const authClient = useAuthClient();
|
||||
const { data: session } = useSession();
|
||||
const {
|
||||
accounts,
|
||||
activeAccount,
|
||||
activeAccountId,
|
||||
apiUrl,
|
||||
switchAccount,
|
||||
removeAccount,
|
||||
refreshAccounts,
|
||||
clearActiveAccount,
|
||||
} = useAccounts();
|
||||
const { colors, colorMode, setColorMode } = useAppTheme();
|
||||
const switchProps = {
|
||||
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
|
||||
thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined,
|
||||
ios_backgroundColor: colors.switchIosBackground,
|
||||
};
|
||||
const {
|
||||
enabled: lockEnabled,
|
||||
biometricEnabled,
|
||||
biometricAvailable,
|
||||
biometricLabel,
|
||||
enableLock,
|
||||
disableLock,
|
||||
changePin,
|
||||
setUseBiometric,
|
||||
lock,
|
||||
} = useAppLock();
|
||||
const profileQuery = api.settings.getProfile.useQuery();
|
||||
const deleteAccountMutation = api.settings.deleteAccount.useMutation();
|
||||
|
||||
const [pinPrompt, setPinPrompt] = useState<
|
||||
| { mode: "create" }
|
||||
| { mode: "confirm-disable" }
|
||||
| { mode: "change-current" }
|
||||
| { mode: "change-next" }
|
||||
| null
|
||||
>(null);
|
||||
const [pendingPin, setPendingPin] = useState("");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [refreshingAccounts, setRefreshingAccounts] = useState(false);
|
||||
|
||||
async function handleRefreshAccounts() {
|
||||
setRefreshingAccounts(true);
|
||||
try {
|
||||
await refreshAccounts();
|
||||
await profileQuery.refetch();
|
||||
} finally {
|
||||
setRefreshingAccounts(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveAccount(accountId: string, label: string) {
|
||||
confirmRemoveAccount(
|
||||
label,
|
||||
() => removeAccount(accountId),
|
||||
async (result) => {
|
||||
await finishAccountRemoval({
|
||||
result,
|
||||
authClient,
|
||||
clearActiveAccount,
|
||||
activeAccountId,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await performAuthReset({
|
||||
authClient,
|
||||
clearActiveAccount,
|
||||
activeAccountId,
|
||||
});
|
||||
router.replace("/(auth)/sign-in");
|
||||
}
|
||||
|
||||
function confirmSignOut() {
|
||||
Alert.alert("Sign out", "Sign out of this account on this device?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign out",
|
||||
style: "destructive",
|
||||
onPress: () => void handleSignOut(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (!activeAccountId) return;
|
||||
|
||||
try {
|
||||
await deleteAccountMutation.mutateAsync({
|
||||
confirmText: "DELETE MY ACCOUNT",
|
||||
});
|
||||
const result = await removeAccount(activeAccountId);
|
||||
await finishAccountRemoval({
|
||||
result,
|
||||
authClient,
|
||||
clearActiveAccount,
|
||||
activeAccountId,
|
||||
});
|
||||
if (result.remainingCount > 0) {
|
||||
router.replace("/(auth)/select-account");
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Could not delete account",
|
||||
error instanceof Error ? error.message : "Please try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteAccount() {
|
||||
Alert.alert(
|
||||
"Permanently delete account?",
|
||||
"This deletes your account, invoices, clients, businesses, expenses, time entries, uploaded files, and sign-in data. This cannot be undone.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete Account",
|
||||
style: "destructive",
|
||||
onPress: () => void handleDeleteAccount(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function confirmInstanceChange() {
|
||||
Alert.alert(
|
||||
"Server updated",
|
||||
"You may need to sign in again if you switched to a different instance.",
|
||||
[{ text: "OK" }],
|
||||
);
|
||||
}
|
||||
|
||||
function handleLockToggle(next: boolean) {
|
||||
if (next) {
|
||||
setPinPrompt({ mode: "create" });
|
||||
return;
|
||||
}
|
||||
setPinPrompt({ mode: "confirm-disable" });
|
||||
}
|
||||
|
||||
function handleChangePin() {
|
||||
setPendingPin("");
|
||||
setPinPrompt({ mode: "change-current" });
|
||||
}
|
||||
|
||||
function handleBiometricToggle(next: boolean) {
|
||||
void setUseBiometric(next);
|
||||
}
|
||||
|
||||
async function handlePinPromptSubmit(pin: string) {
|
||||
if (pinPrompt?.mode === "create") {
|
||||
try {
|
||||
await enableLock(pin);
|
||||
setPinPrompt(null);
|
||||
} catch (err) {
|
||||
Alert.alert(
|
||||
"Could not enable lock",
|
||||
err instanceof Error ? err.message : "Try again",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pinPrompt?.mode === "confirm-disable") {
|
||||
const success = await disableLock(pin);
|
||||
if (!success) {
|
||||
Alert.alert("Incorrect PIN", "Could not disable app lock.");
|
||||
return;
|
||||
}
|
||||
setPinPrompt(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pinPrompt?.mode === "change-current") {
|
||||
setPendingPin(pin);
|
||||
setPinPrompt({ mode: "change-next" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pinPrompt?.mode === "change-next") {
|
||||
const success = await changePin(pendingPin, pin);
|
||||
if (!success) {
|
||||
Alert.alert(
|
||||
"Could not change PIN",
|
||||
"Check your current PIN and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPendingPin("");
|
||||
setPinPrompt(null);
|
||||
Alert.alert("PIN updated", "Your app lock PIN has been changed.");
|
||||
}
|
||||
}
|
||||
|
||||
if (profileQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading profile…" />;
|
||||
}
|
||||
|
||||
const profile = profileQuery.data;
|
||||
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<PinPrompt
|
||||
visible={pinPrompt !== null}
|
||||
title={
|
||||
pinPrompt?.mode === "create"
|
||||
? "Create PIN"
|
||||
: pinPrompt?.mode === "confirm-disable"
|
||||
? "Disable app lock"
|
||||
: pinPrompt?.mode === "change-current"
|
||||
? "Current PIN"
|
||||
: "New PIN"
|
||||
}
|
||||
message={
|
||||
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
|
||||
? "Choose a 4–6 digit PIN."
|
||||
: pinPrompt?.mode === "confirm-disable"
|
||||
? "Enter your PIN to turn off app lock."
|
||||
: "Enter your current PIN."
|
||||
}
|
||||
confirmLabel={
|
||||
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
|
||||
? "Save"
|
||||
: "Continue"
|
||||
}
|
||||
requireConfirmation={
|
||||
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
|
||||
}
|
||||
onCancel={() => {
|
||||
setPendingPin("");
|
||||
setPinPrompt(null);
|
||||
}}
|
||||
onSubmit={(pin) => void handlePinPromptSubmit(pin)}
|
||||
/>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
subtitle="Account and app preferences"
|
||||
/>
|
||||
}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Card title="Account">
|
||||
<Text style={[styles.name, { color: colors.foreground }]}>
|
||||
{profile?.name ?? session?.user.name ?? "User"}
|
||||
</Text>
|
||||
<Text style={[styles.email, { color: colors.mutedForeground }]}>
|
||||
{profile?.email ?? session?.user.email}
|
||||
</Text>
|
||||
{profile?.role ? (
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
Role: {profile.role}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Accounts">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
return (
|
||||
<View
|
||||
key={account.id}
|
||||
style={[
|
||||
styles.accountRow,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: isActive ? colors.muted : "transparent",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => void switchAccount(account.id)}
|
||||
style={({ pressed }) => [
|
||||
styles.accountMain,
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.accountMeta}>
|
||||
<Text
|
||||
style={[
|
||||
styles.accountName,
|
||||
{ color: colors.foreground },
|
||||
]}
|
||||
>
|
||||
{account.name || account.email}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.accountSub,
|
||||
{ color: colors.mutedForeground },
|
||||
]}
|
||||
>
|
||||
{account.email}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.accountSub,
|
||||
{ color: colors.mutedForeground },
|
||||
]}
|
||||
>
|
||||
{account.instanceUrl.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
</View>
|
||||
{isActive ? (
|
||||
<Text
|
||||
style={[styles.activeBadge, { color: colors.primary }]}
|
||||
>
|
||||
Active
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Remove ${account.name || account.email}`}
|
||||
hitSlop={8}
|
||||
onPress={() =>
|
||||
handleRemoveAccount(
|
||||
account.id,
|
||||
account.name || account.email,
|
||||
)
|
||||
}
|
||||
style={({ pressed }) => [
|
||||
styles.removeButton,
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name="trash-outline"
|
||||
size={18}
|
||||
color={colors.destructive}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
title={refreshingAccounts ? "Refreshing…" : "Refresh accounts"}
|
||||
variant="secondary"
|
||||
disabled={refreshingAccounts}
|
||||
onPress={() => void handleRefreshAccounts()}
|
||||
/>
|
||||
<Button
|
||||
title="Add another account"
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
void startAdditionalAccountSignIn(clearActiveAccount)
|
||||
}
|
||||
/>
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
Tap an account to switch. Refresh updates names from saved sign-in
|
||||
data.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{Platform.OS === "ios" ? (
|
||||
<Card title="Shortcuts & Siri">
|
||||
<ShortcutsSetupCard />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Security">
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingCopy}>
|
||||
<Text
|
||||
style={[styles.settingTitle, { color: colors.foreground }]}
|
||||
>
|
||||
App lock
|
||||
</Text>
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
Require a PIN when reopening the app
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={lockEnabled}
|
||||
onValueChange={handleLockToggle}
|
||||
{...switchProps}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{lockEnabled && biometricAvailable ? (
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingCopy}>
|
||||
<Text
|
||||
style={[styles.settingTitle, { color: colors.foreground }]}
|
||||
>
|
||||
{biometricLabel}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.meta, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Unlock with {biometricLabel.toLowerCase()} when available
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={biometricEnabled}
|
||||
onValueChange={handleBiometricToggle}
|
||||
{...switchProps}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{lockEnabled ? (
|
||||
<>
|
||||
<Button
|
||||
title="Change PIN"
|
||||
variant="secondary"
|
||||
onPress={handleChangePin}
|
||||
/>
|
||||
<Button title="Lock now" variant="secondary" onPress={lock} />
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Appearance">
|
||||
<View style={styles.themeRow}>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const selected = colorMode === option.value;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
accessibilityRole="button"
|
||||
onPress={() => void setColorMode(option.value)}
|
||||
style={[
|
||||
styles.themeChip,
|
||||
{
|
||||
borderColor: selected ? colors.primary : colors.border,
|
||||
backgroundColor: selected
|
||||
? colors.muted
|
||||
: "transparent",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.themeChipLabel,
|
||||
{
|
||||
color: selected
|
||||
? colors.foreground
|
||||
: colors.mutedForeground,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card title="App">
|
||||
<View style={styles.appRow}>
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
Version
|
||||
</Text>
|
||||
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
||||
{appVersion}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.appRow}>
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
Platform
|
||||
</Text>
|
||||
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
||||
{Constants.platform?.ios ? "iOS" : "Other"}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card title="Delete account">
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
Permanently delete this account and all of its data from the
|
||||
server. This cannot be undone.
|
||||
</Text>
|
||||
<Button
|
||||
title={
|
||||
deleteAccountMutation.isPending
|
||||
? "Deleting account…"
|
||||
: "Delete Account"
|
||||
}
|
||||
variant="danger"
|
||||
loading={deleteAccountMutation.isPending}
|
||||
disabled={deleteAccountMutation.isPending}
|
||||
onPress={confirmDeleteAccount}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: showAdvanced }}
|
||||
onPress={() => setShowAdvanced((open) => !open)}
|
||||
style={styles.advancedToggle}
|
||||
>
|
||||
<Text
|
||||
style={[styles.advancedLabel, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Advanced
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={showAdvanced ? "chevron-up" : "chevron-down"}
|
||||
size={16}
|
||||
color={colors.mutedForeground}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{showAdvanced ? (
|
||||
<Card title="Server instance">
|
||||
<InstanceUrlField onSaved={confirmInstanceChange} />
|
||||
<Text
|
||||
style={[
|
||||
styles.currentServer,
|
||||
{ color: colors.mutedForeground },
|
||||
]}
|
||||
>
|
||||
Connected to {activeAccount?.instanceUrl ?? apiUrl}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title="Sign Out"
|
||||
variant="danger"
|
||||
onPress={confirmSignOut}
|
||||
/>
|
||||
</View>
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
name: {
|
||||
fontSize: 20,
|
||||
fontFamily: fonts.heading,
|
||||
},
|
||||
email: {
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
meta: {
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
currentServer: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.mono,
|
||||
},
|
||||
advancedToggle: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
minHeight: 36,
|
||||
},
|
||||
advancedLabel: {
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
appRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
appValue: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
accountRow: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.sm,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
accountMain: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
removeButton: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.92,
|
||||
},
|
||||
accountMeta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
accountName: {
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
},
|
||||
accountSub: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
activeBadge: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
},
|
||||
themeRow: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
themeChip: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
minHeight: 40,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
themeChipLabel: {
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
lineHeight: 18,
|
||||
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
},
|
||||
settingCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
},
|
||||
actions: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatRunningTimerLabel } from "@/lib/time-clock";
|
||||
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||
import { api } from "@/lib/trpc";
|
||||
import type { AppRouter } from "beenvoice/server/api/root";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
|
||||
|
||||
function groupByDate(entries: TimeEntry[]) {
|
||||
const groups = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
const d = new Date(entry.startedAt);
|
||||
const key = d.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(entry);
|
||||
groups.set(key, list);
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
}
|
||||
|
||||
export default function TimeEntriesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
const entriesQuery = api.timeEntries.getAll.useQuery();
|
||||
|
||||
const completed = useMemo(
|
||||
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
|
||||
[entriesQuery.data],
|
||||
);
|
||||
const grouped = useMemo(() => groupByDate(completed), [completed]);
|
||||
|
||||
if (entriesQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading time entries…" />;
|
||||
}
|
||||
|
||||
if (entriesQuery.error) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<View style={styles.errorBox}>
|
||||
<PageHeader title="Time entries" subtitle="Completed work history" />
|
||||
<Text style={{ color: colors.mutedForeground }}>
|
||||
{formatTrpcErrorMessage(entriesQuery.error)}
|
||||
</Text>
|
||||
</View>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage showMoreBack>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={entriesQuery.isRefetching}
|
||||
onRefresh={() => void entriesQuery.refetch()}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{grouped.length === 0 ? (
|
||||
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||
No completed entries yet. Start the timer from the Timer tab.
|
||||
</Text>
|
||||
) : (
|
||||
grouped.map(([label, entries]) => (
|
||||
<Card key={label} title={label}>
|
||||
{entries.map((entry) => (
|
||||
<SwipeableRow
|
||||
key={entry.id}
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => setEditEntryId(entry.id),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||
{formatRunningTimerLabel(entry.description)}
|
||||
</Text>
|
||||
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||
{entry.client?.name ?? "No client"}
|
||||
{entry.invoice
|
||||
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||
: " · not billed"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||
{entry.hours ?? "—"}h
|
||||
</Text>
|
||||
</View>
|
||||
</SwipeableRow>
|
||||
))}
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabScrollView>
|
||||
</TabPage>
|
||||
|
||||
<TimeEntryEditSheet
|
||||
entryId={editEntryId}
|
||||
visible={editEntryId != null}
|
||||
onClose={() => setEditEntryId(null)}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
title: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
errorBox: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const utils = api.useUtils();
|
||||
const statusQuery = api.settings.getOnboardingStatus.useQuery();
|
||||
|
||||
const [businessName, setBusinessName] = useState("");
|
||||
const [clientName, setClientName] = useState("");
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const createBusiness = api.businesses.create.useMutation();
|
||||
const createClient = api.clients.create.useMutation();
|
||||
const complete = api.settings.completeOnboarding.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.settings.getOnboardingStatus.invalidate();
|
||||
await utils.settings.getProfile.invalidate();
|
||||
router.replace("/(app)");
|
||||
},
|
||||
});
|
||||
|
||||
async function finish() {
|
||||
if (businessName.trim()) {
|
||||
await createBusiness.mutateAsync({
|
||||
name: businessName.trim(),
|
||||
isDefault: true,
|
||||
});
|
||||
}
|
||||
if (clientName.trim()) {
|
||||
await createClient.mutateAsync({
|
||||
name: clientName.trim(),
|
||||
currency: "USD",
|
||||
});
|
||||
}
|
||||
await complete.mutateAsync();
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: "Welcome to beenvoice",
|
||||
body: "Set up your workspace in a minute — business profile and first client.",
|
||||
},
|
||||
{
|
||||
title: "Your business",
|
||||
body: "This appears on invoices you send to clients.",
|
||||
},
|
||||
{
|
||||
title: "First client",
|
||||
body: "Add someone you bill. You can skip and add clients later.",
|
||||
},
|
||||
];
|
||||
|
||||
const current = steps[step]!;
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<Text style={[styles.kicker, { color: colors.mutedForeground }]}>
|
||||
Step {step + 1} of {steps.length}
|
||||
</Text>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>{current.title}</Text>
|
||||
<Text style={[styles.bodyText, { color: colors.mutedForeground }]}>{current.body}</Text>
|
||||
|
||||
{step === 1 ? (
|
||||
<Input
|
||||
label="Business name"
|
||||
value={businessName}
|
||||
onChangeText={setBusinessName}
|
||||
placeholder="Your studio or company"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<Input
|
||||
label="Client name"
|
||||
value={clientName}
|
||||
onChangeText={setClientName}
|
||||
placeholder="Acme Corp"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{step > 0 ? (
|
||||
<Button title="Back" variant="secondary" onPress={() => setStep((s) => s - 1)} />
|
||||
) : null}
|
||||
{step < steps.length - 1 ? (
|
||||
<Button title="Continue" onPress={() => setStep((s) => s + 1)} />
|
||||
) : (
|
||||
<Button
|
||||
title={complete.isPending ? "Finishing…" : "Go to dashboard"}
|
||||
loading={complete.isPending}
|
||||
onPress={() => void finish()}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{statusQuery.data && !statusQuery.data.completed ? (
|
||||
<Button title="Skip for now" variant="secondary" onPress={() => void complete.mutateAsync()} />
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
body: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
minHeight: "100%",
|
||||
justifyContent: "center",
|
||||
},
|
||||
kicker: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
},
|
||||
title: {
|
||||
fontFamily: fonts.heading,
|
||||
fontSize: 32,
|
||||
lineHeight: 36,
|
||||
},
|
||||
bodyText: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TimeClockPanel } from "@/components/time-clock/TimeClockPanel";
|
||||
|
||||
export default function TimerScreen() {
|
||||
const params = useLocalSearchParams<{
|
||||
clientId?: string | string[];
|
||||
invoiceId?: string | string[];
|
||||
}>();
|
||||
const clientId = Array.isArray(params.clientId) ? params.clientId[0] : params.clientId;
|
||||
const invoiceId = Array.isArray(params.invoiceId) ? params.invoiceId[0] : params.invoiceId;
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<TimeClockPanel
|
||||
header={
|
||||
<PageHeader
|
||||
title="Time clock"
|
||||
subtitle="Track billable hours and link them to invoices"
|
||||
/>
|
||||
}
|
||||
defaultClientId={clientId ?? ""}
|
||||
defaultInvoiceId={invoiceId ?? ""}
|
||||
compact
|
||||
/>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { FullScreen } from "@/components/Screen";
|
||||
import { AuthBackground } from "@/components/AppBackground";
|
||||
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
||||
import { HeadingText, Logo } from "@/components/Logo";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { requestPasswordReset } from "@/lib/auth-api";
|
||||
import { isValidEmail, useFieldVisibility } from "@/lib/form-validation";
|
||||
|
||||
export default function ForgotPasswordScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [serverReady, setServerReady] = useState(true);
|
||||
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||
|
||||
const emailValidationError = !email.trim()
|
||||
? "Email is required"
|
||||
: isValidEmail(email)
|
||||
? undefined
|
||||
: "Enter a valid email";
|
||||
const canSubmit = isValidEmail(email) && serverReady;
|
||||
|
||||
async function handleSubmit() {
|
||||
markSubmitted();
|
||||
if (!canSubmit) return;
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await requestPasswordReset(email.trim());
|
||||
setMessage(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Request failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthBackground>
|
||||
<FullScreen style={styles.safe}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Text style={[styles.back, { color: colors.mutedForeground }]}>← Back</Text>
|
||||
</Pressable>
|
||||
|
||||
<AuthServerPicker onReadyChange={setServerReady} />
|
||||
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Logo size="md" />
|
||||
<HeadingText style={styles.title}>Reset password</HeadingText>
|
||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||
Enter your email and we'll send reset instructions if an account exists.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<Input
|
||||
label="Email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
onBlur={() => touch("email")}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
error={visible("email") ? emailValidationError : undefined}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||
) : null}
|
||||
{message ? (
|
||||
<Text style={[styles.success, { color: colors.foreground }]}>{message}</Text>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
title="Send reset link"
|
||||
loading={loading}
|
||||
disabled={!canSubmit}
|
||||
onPress={handleSubmit}
|
||||
/>
|
||||
<Button
|
||||
title="Have a reset token?"
|
||||
variant="ghost"
|
||||
onPress={() => router.push("/(auth)/reset-password")}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</FullScreen>
|
||||
</AuthBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1 },
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.md,
|
||||
justifyContent: "center",
|
||||
},
|
||||
back: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 16,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
card: { gap: spacing.lg },
|
||||
header: { gap: spacing.sm },
|
||||
title: { fontSize: 28 },
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
form: { gap: spacing.md },
|
||||
error: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
success: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Redirect } from "expo-router";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
|
||||
export default function AuthIndex() {
|
||||
const { accounts, activeAccountId } = useAccounts();
|
||||
|
||||
if (!activeAccountId && accounts.length > 0) {
|
||||
return <Redirect href="/(auth)/select-account" />;
|
||||
}
|
||||
|
||||
return <Redirect href="/(auth)/sign-in" />;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Link } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AuthCard } from "@/components/auth/AuthCard";
|
||||
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
|
||||
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
|
||||
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
||||
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAuthClient } from "@/contexts/AuthContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { registerAccount } from "@/lib/auth-api";
|
||||
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
||||
import {
|
||||
isRequiredString,
|
||||
isValidEmail,
|
||||
isValidPassword,
|
||||
useFieldVisibility,
|
||||
} from "@/lib/form-validation";
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const authClient = useAuthClient();
|
||||
const { apiUrl, activeAccountId, registerAccount: saveAccount } = useAccounts();
|
||||
const { colors } = useAppTheme();
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [serverReady, setServerReady] = useState(true);
|
||||
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||
|
||||
const firstNameError = isRequiredString(firstName) ? undefined : "First name is required";
|
||||
const lastNameError = isRequiredString(lastName) ? undefined : "Last name is required";
|
||||
const emailValidationError = isValidEmail(email)
|
||||
? undefined
|
||||
: email.trim()
|
||||
? "Enter a valid email"
|
||||
: "Email is required";
|
||||
const passwordValidationError = isValidPassword(password)
|
||||
? undefined
|
||||
: password
|
||||
? "Password must be at least 8 characters"
|
||||
: "Password is required";
|
||||
const canRegister =
|
||||
isRequiredString(firstName) &&
|
||||
isRequiredString(lastName) &&
|
||||
isValidEmail(email) &&
|
||||
isValidPassword(password) &&
|
||||
serverReady;
|
||||
|
||||
async function handleRegister() {
|
||||
markSubmitted();
|
||||
if (!canRegister) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await registerAccount({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
|
||||
const { error: signInError } = await authClient.signIn.email({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
setError(signInError.message || "Account created but sign-in failed. Try signing in.");
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await authClient.getSession();
|
||||
const user = session.data?.user;
|
||||
if (user) {
|
||||
const completed = await completeSignInAfterAuth(authClient, {
|
||||
apiUrl,
|
||||
activeAccountId,
|
||||
registerAccount: saveAccount,
|
||||
});
|
||||
if (!completed) {
|
||||
setError("Account created but session setup failed. Try signing in.");
|
||||
}
|
||||
} else {
|
||||
setError("Account created. Sign in with your email and password.");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Registration failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthScreenLayout>
|
||||
<AuthCard>
|
||||
<AuthCardHeader
|
||||
title="Create your account"
|
||||
description="Get started with your workspace"
|
||||
/>
|
||||
|
||||
<AuthServerPicker onReadyChange={setServerReady} embedded />
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.half}>
|
||||
<Input
|
||||
label="First name"
|
||||
leftIcon="person-outline"
|
||||
value={firstName}
|
||||
onChangeText={setFirstName}
|
||||
onBlur={() => touch("firstName")}
|
||||
autoComplete="given-name"
|
||||
placeholder="John"
|
||||
required
|
||||
error={visible("firstName") ? firstNameError : undefined}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.half}>
|
||||
<Input
|
||||
label="Last name"
|
||||
leftIcon="person-outline"
|
||||
value={lastName}
|
||||
onChangeText={setLastName}
|
||||
onBlur={() => touch("lastName")}
|
||||
autoComplete="family-name"
|
||||
placeholder="Doe"
|
||||
required
|
||||
error={visible("lastName") ? lastNameError : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
leftIcon="mail-outline"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
onBlur={() => touch("email")}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
error={visible("email") ? emailValidationError : undefined}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
leftIcon="lock-closed-outline"
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
onBlur={() => touch("password")}
|
||||
placeholder="••••••••"
|
||||
hint="At least 8 characters"
|
||||
required
|
||||
error={visible("password") ? passwordValidationError : undefined}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
title={loading ? "Creating account…" : "Create account"}
|
||||
loading={loading}
|
||||
disabled={!canRegister}
|
||||
showArrow={!loading}
|
||||
onPress={handleRegister}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
|
||||
Already have an account?{" "}
|
||||
<Link href="/(auth)/sign-in" style={[styles.link, { color: colors.foreground }]}>
|
||||
Sign in
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<LegalAgreementNotice action="creating an account" />
|
||||
</AuthCard>
|
||||
</AuthScreenLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
form: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.md,
|
||||
},
|
||||
half: {
|
||||
flex: 1,
|
||||
},
|
||||
error: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
footer: {
|
||||
textAlign: "center",
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
link: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { FullScreen } from "@/components/Screen";
|
||||
import { AuthBackground } from "@/components/AppBackground";
|
||||
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
||||
import { HeadingText } from "@/components/Logo";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { resetPassword } from "@/lib/auth-api";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { isRequiredString, isValidPassword } from "@/lib/form-validation";
|
||||
|
||||
export default function ResetPasswordScreen() {
|
||||
const styles = useThemedStyles(createResetPasswordStyles);
|
||||
const { token: tokenParam } = useLocalSearchParams<{ token?: string }>();
|
||||
const [token, setToken] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [serverReady, setServerReady] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof tokenParam === "string" && tokenParam.length > 0) {
|
||||
setToken(tokenParam);
|
||||
}
|
||||
}, [tokenParam]);
|
||||
|
||||
const tokenError = isRequiredString(token) ? undefined : "Reset token is required";
|
||||
const passwordError = isValidPassword(password)
|
||||
? undefined
|
||||
: password
|
||||
? "Password must be at least 8 characters"
|
||||
: "Password is required";
|
||||
const confirmError =
|
||||
confirmPassword && password !== confirmPassword ? "Passwords do not match" : undefined;
|
||||
const canSubmit =
|
||||
serverReady &&
|
||||
isRequiredString(token) &&
|
||||
isValidPassword(password) &&
|
||||
password === confirmPassword &&
|
||||
confirmPassword.length > 0;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
setError(null);
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await resetPassword(token.trim(), password);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Reset failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthBackground>
|
||||
<FullScreen style={styles.safe}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Text style={styles.back}>← Back</Text>
|
||||
</Pressable>
|
||||
|
||||
<AuthServerPicker onReadyChange={setServerReady} />
|
||||
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<HeadingText style={styles.title}>Set new password</HeadingText>
|
||||
<Text style={styles.subtitle}>
|
||||
Paste the reset token from your email, or open the link on this device.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{success ? (
|
||||
<View style={styles.successBox}>
|
||||
<Text style={styles.successTitle}>Password updated</Text>
|
||||
<Text style={styles.successText}>
|
||||
You can now sign in with your new password.
|
||||
</Text>
|
||||
<Button
|
||||
title="Go to sign in"
|
||||
onPress={() => router.replace("/(auth)/sign-in")}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.form}>
|
||||
<Input
|
||||
label="Reset token"
|
||||
autoCapitalize="none"
|
||||
value={token}
|
||||
onChangeText={setToken}
|
||||
placeholder="Paste token from email"
|
||||
required
|
||||
error={tokenError}
|
||||
/>
|
||||
<Input
|
||||
label="New password"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="At least 8 characters"
|
||||
required
|
||||
error={passwordError}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm password"
|
||||
secureTextEntry
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
placeholder="Repeat password"
|
||||
required
|
||||
error={confirmError}
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title="Update password"
|
||||
loading={loading}
|
||||
disabled={!canSubmit}
|
||||
onPress={handleSubmit}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</FullScreen>
|
||||
</AuthBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createResetPasswordStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
safe: { flex: 1 },
|
||||
flex: { flex: 1 },
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
justifyContent: "center",
|
||||
},
|
||||
back: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 16,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
card: { gap: spacing.lg },
|
||||
header: { gap: spacing.sm },
|
||||
title: { fontSize: 28 },
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
lineHeight: 20,
|
||||
},
|
||||
form: { gap: spacing.md },
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
successBox: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.muted,
|
||||
borderRadius: radii.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
successTitle: {
|
||||
fontSize: 20,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
successText: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Redirect, router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AuthCard } from "@/components/auth/AuthCard";
|
||||
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
|
||||
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||
import { formatServerHost } from "@/lib/server-mode";
|
||||
|
||||
function initials(name: string, email: string) {
|
||||
const source = name.trim() || email.trim();
|
||||
const parts = source.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
|
||||
}
|
||||
return (source[0] ?? "?").toUpperCase();
|
||||
}
|
||||
|
||||
export default function SelectAccountScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const { accounts, activeAccountId, switchAccount, clearActiveAccount } = useAccounts();
|
||||
const [selectingId, setSelectingId] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
async function handleSelect(accountId: string) {
|
||||
if (selectingId) return;
|
||||
setSelectingId(accountId);
|
||||
try {
|
||||
await switchAccount(accountId);
|
||||
router.replace("/(auth)/sign-in");
|
||||
} finally {
|
||||
setSelectingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddAccount() {
|
||||
if (adding) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await startAdditionalAccountSignIn(clearActiveAccount);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeAccountId || accounts.length === 0) {
|
||||
return <Redirect href="/(auth)/sign-in" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthScreenLayout>
|
||||
<AuthCard>
|
||||
<AuthCardHeader
|
||||
title="Choose account"
|
||||
description="Select the workspace account to use on this device"
|
||||
/>
|
||||
|
||||
<View style={styles.list}>
|
||||
{accounts.map((account) => {
|
||||
const isSelecting = selectingId === account.id;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={Boolean(selectingId)}
|
||||
key={account.id}
|
||||
onPress={() => void handleSelect(account.id)}
|
||||
style={({ pressed }) => [
|
||||
styles.accountRow,
|
||||
{ backgroundColor: colors.muted, borderColor: colors.border },
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
|
||||
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
|
||||
{initials(account.name, account.email)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.accountMeta}>
|
||||
<Text style={[styles.accountName, { color: colors.foreground }]}>
|
||||
{account.name || account.email}
|
||||
</Text>
|
||||
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||
{account.email}
|
||||
</Text>
|
||||
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||
{formatServerHost(account.instanceUrl)}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelecting ? (
|
||||
<ActivityIndicator color={colors.primary} size="small" />
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
disabled={Boolean(selectingId)}
|
||||
loading={adding}
|
||||
onPress={() => void handleAddAccount()}
|
||||
title="Sign in to another account"
|
||||
variant="secondary"
|
||||
/>
|
||||
</AuthCard>
|
||||
</AuthScreenLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
accountRow: {
|
||||
minHeight: 72,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
avatar: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
avatarText: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
},
|
||||
accountMeta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
accountName: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
},
|
||||
accountSub: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Link, router } from "expo-router";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AuthCard } from "@/components/auth/AuthCard";
|
||||
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
|
||||
import { AuthDivider } from "@/components/auth/AuthDivider";
|
||||
import { AuthNotice } from "@/components/auth/AuthNotice";
|
||||
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
|
||||
import { AuthServerPicker } from "@/components/AuthServerPicker";
|
||||
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAuthClient } from "@/contexts/AuthContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
|
||||
import { signInWithAuthentik } from "@/lib/auth-oauth";
|
||||
import { prepareAuthScreenSession } from "@/lib/auth-session";
|
||||
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
||||
import { formatAuthErrorMessage } from "@/lib/trpc-errors";
|
||||
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
|
||||
|
||||
export default function SignInScreen() {
|
||||
const authClient = useAuthClient();
|
||||
const { apiUrl, activeAccountId, clearActiveAccount, registerAccount } = useAccounts();
|
||||
const { colors } = useAppTheme();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [serverReady, setServerReady] = useState(true);
|
||||
const [authentikEnabled, setAuthentikEnabled] = useState(false);
|
||||
const [signupsDisabled, setSignupsDisabled] = useState(false);
|
||||
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||
|
||||
useEffect(() => {
|
||||
void prepareAuthScreenSession(authClient, activeAccountId, clearActiveAccount);
|
||||
}, [authClient, activeAccountId, clearActiveAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void fetchAuthCapabilities(apiUrl).then((capabilities) => {
|
||||
if (cancelled) return;
|
||||
setAuthentikEnabled(capabilities.authentik);
|
||||
setSignupsDisabled(capabilities.signupsDisabled);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [apiUrl]);
|
||||
|
||||
const emailValidationError = !email.trim()
|
||||
? "Email is required"
|
||||
: isValidEmail(email)
|
||||
? undefined
|
||||
: "Enter a valid email";
|
||||
const passwordValidationError = password.trim() ? undefined : "Password is required";
|
||||
const canSignIn = isValidEmail(email) && isRequiredString(password) && serverReady;
|
||||
|
||||
async function finishSignIn() {
|
||||
const completed = await completeSignInAfterAuth(authClient, {
|
||||
apiUrl,
|
||||
activeAccountId,
|
||||
registerAccount,
|
||||
});
|
||||
|
||||
if (!completed) {
|
||||
setError("Signed in but session was not available. Try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignIn() {
|
||||
markSubmitted();
|
||||
if (!canSignIn) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { error: signInError } = await authClient.signIn.email({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
setError(formatAuthErrorMessage(signInError));
|
||||
return;
|
||||
}
|
||||
|
||||
await finishSignIn();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthentikSignIn() {
|
||||
if (!serverReady) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { error: oauthError } = await signInWithAuthentik(
|
||||
authClient,
|
||||
Linking.createURL("/"),
|
||||
);
|
||||
|
||||
if (oauthError) {
|
||||
setError(formatAuthErrorMessage(oauthError));
|
||||
return;
|
||||
}
|
||||
|
||||
await finishSignIn();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthScreenLayout>
|
||||
<AuthCard>
|
||||
<AuthCardHeader title="Welcome back" description="Sign in to your workspace" />
|
||||
|
||||
<AuthServerPicker onReadyChange={setServerReady} embedded />
|
||||
|
||||
{signupsDisabled ? (
|
||||
<AuthNotice>New account registration is currently disabled.</AuthNotice>
|
||||
) : null}
|
||||
|
||||
{authentikEnabled ? (
|
||||
<View style={styles.ssoSection}>
|
||||
<Button
|
||||
title="Sign in with Authentik"
|
||||
variant="secondary"
|
||||
loading={loading}
|
||||
disabled={!serverReady}
|
||||
onPress={() => void handleAuthentikSignIn()}
|
||||
/>
|
||||
<AuthDivider />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.form}>
|
||||
<Input
|
||||
label="Email"
|
||||
leftIcon="mail-outline"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
onBlur={() => touch("email")}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
error={visible("email") ? emailValidationError : undefined}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
leftIcon="lock-closed-outline"
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
onBlur={() => touch("password")}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
error={visible("password") ? passwordValidationError : undefined}
|
||||
labelAccessory={
|
||||
<Pressable onPress={() => router.push("/(auth)/forgot-password")} hitSlop={8}>
|
||||
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
|
||||
Forgot password?
|
||||
</Text>
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
title={loading ? "Signing in…" : "Sign in"}
|
||||
loading={loading}
|
||||
disabled={!canSignIn}
|
||||
showArrow={!loading}
|
||||
onPress={handleSignIn}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!signupsDisabled ? (
|
||||
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
|
||||
Don't have an account?{" "}
|
||||
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
|
||||
Create account
|
||||
</Link>
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<LegalAgreementNotice action="signing in" />
|
||||
</AuthCard>
|
||||
</AuthScreenLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
ssoSection: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
form: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
forgot: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
},
|
||||
error: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
footer: {
|
||||
textAlign: "center",
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
link: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||
|
||||
<Link href="/" style={styles.link}>
|
||||
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
color: '#2e78b7',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Stack } from "expo-router";
|
||||
import {
|
||||
Inter_400Regular,
|
||||
Inter_500Medium,
|
||||
Inter_600SemiBold,
|
||||
Inter_700Bold,
|
||||
} from "@expo-google-fonts/inter";
|
||||
import {
|
||||
PlayfairDisplay_600SemiBold,
|
||||
PlayfairDisplay_700Bold,
|
||||
} from "@expo-google-fonts/playfair-display";
|
||||
import { useFonts } from "expo-font";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import "react-native-reanimated";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
|
||||
import { BrandBackground } from "@/components/BrandBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { SessionSync } from "@/components/SessionSync";
|
||||
import { ShortcutLinkCapture } from "@/components/ShortcutLinkCapture";
|
||||
import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
|
||||
import { AuthProvider, useSession } from "@/contexts/AuthContext";
|
||||
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { TRPCProvider } from "@/lib/trpc";
|
||||
export { ErrorBoundary } from "expo-router";
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
function AppServices({ children }: { children: ReactNode }) {
|
||||
const { apiUrl, authStoragePrefix, activeAccountId } = useAccounts();
|
||||
const remountKey = `${activeAccountId ?? "guest"}:${apiUrl}`;
|
||||
|
||||
return (
|
||||
<AuthProvider apiUrl={apiUrl} storagePrefix={authStoragePrefix} key={remountKey}>
|
||||
<TRPCProvider apiUrl={apiUrl} key={remountKey}>
|
||||
<SessionSync />
|
||||
<ShortcutLinkCapture />
|
||||
{children}
|
||||
</TRPCProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemedChrome({ children }: { children: ReactNode }) {
|
||||
const { isDark } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "transparent" }}>
|
||||
<BrandBackground />
|
||||
<View style={{ flex: 1, zIndex: 1 }}>
|
||||
<StatusBar style={isDark ? "light" : "dark"} />
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||
Inter_400Regular,
|
||||
Inter_500Medium,
|
||||
Inter_600SemiBold,
|
||||
Inter_700Bold,
|
||||
PlayfairDisplay_600SemiBold,
|
||||
PlayfairDisplay_700Bold,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) throw error;
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<ThemeProvider>
|
||||
<ThemedChrome>
|
||||
<AccountsProvider>
|
||||
<AppServices>
|
||||
<RootNavigator />
|
||||
</AppServices>
|
||||
</AccountsProvider>
|
||||
</ThemedChrome>
|
||||
</ThemeProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
function RootNavigator() {
|
||||
const { data: session, isPending } = useSession();
|
||||
const { activeAccountId } = useAccounts();
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingScreen message="Checking session…" />;
|
||||
}
|
||||
|
||||
const isAuthenticated = Boolean(session?.user && activeAccountId);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
}}
|
||||
>
|
||||
<Stack.Protected guard={!isAuthenticated}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={isAuthenticated}>
|
||||
<Stack.Screen name="(app)" />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user