Polish mobile app for App Store review and expand CRUD.
Default to beenvoice.soconnor.dev with server settings hidden behind Advanced; add Entities tab with clients/businesses, invoice creation, UI fixes for dashboard layout, date fields, FAB position, and card-matched button radius. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -1,25 +1,22 @@
|
|||||||
import { DynamicColorIOS, Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { mutedForeground, primary } from "@/lib/beenvoice-theme";
|
|
||||||
import { AppLockProvider } from "@/contexts/AppLockContext";
|
import { AppLockProvider } from "@/contexts/AppLockContext";
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const { colors } = useAppTheme();
|
const { colors, isDark } = useAppTheme();
|
||||||
|
|
||||||
const tintColor =
|
const tintColor = colors.primary;
|
||||||
|
const labelColor = colors.mutedForeground;
|
||||||
|
const tabContentStyle = { backgroundColor: colors.background };
|
||||||
|
const tabBarBlur =
|
||||||
Platform.OS === "ios"
|
Platform.OS === "ios"
|
||||||
? DynamicColorIOS({ light: primary, dark: "#FAFAFA" })
|
? isDark
|
||||||
: colors.primary;
|
? "systemChromeMaterialDark"
|
||||||
|
: "systemChromeMaterialLight"
|
||||||
const labelColor =
|
: undefined;
|
||||||
Platform.OS === "ios"
|
|
||||||
? DynamicColorIOS({ light: mutedForeground, dark: "#A1A1AA" })
|
|
||||||
: colors.mutedForeground;
|
|
||||||
|
|
||||||
const tabContentStyle = { backgroundColor: "transparent" as const };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLockProvider>
|
<AppLockProvider>
|
||||||
@@ -30,6 +27,9 @@ export default function AppLayout() {
|
|||||||
selected: tintColor,
|
selected: tintColor,
|
||||||
}}
|
}}
|
||||||
labelStyle={{ color: labelColor }}
|
labelStyle={{ color: labelColor }}
|
||||||
|
blurEffect={tabBarBlur}
|
||||||
|
disableTransparentOnScrollEdge
|
||||||
|
backgroundColor={Platform.OS === "android" ? colors.background : undefined}
|
||||||
>
|
>
|
||||||
<NativeTabs.Trigger name="index" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
<NativeTabs.Trigger name="index" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||||
<NativeTabs.Trigger.Icon
|
<NativeTabs.Trigger.Icon
|
||||||
@@ -47,6 +47,14 @@ export default function AppLayout() {
|
|||||||
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="entities" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||||
|
<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" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
<NativeTabs.Trigger name="invoices" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||||
<NativeTabs.Trigger.Icon
|
<NativeTabs.Trigger.Icon
|
||||||
sf={{ default: "doc.text", selected: "doc.text.fill" }}
|
sf={{ default: "doc.text", selected: "doc.text.fill" }}
|
||||||
|
|||||||
@@ -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,255 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
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 { 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 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<Pressable
|
||||||
|
key={client.id}
|
||||||
|
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>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
) : 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) => (
|
||||||
|
<Pressable
|
||||||
|
key={business.id}
|
||||||
|
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>
|
||||||
|
</Pressable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -128,40 +128,50 @@ export default function DashboardScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.statsGrid}>
|
<View style={styles.statsGrid}>
|
||||||
|
<View style={styles.statCell}>
|
||||||
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
|
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCell}>
|
||||||
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCell}>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Overdue"
|
label="Overdue"
|
||||||
value={String(stats.overdueCount)}
|
value={String(stats.overdueCount)}
|
||||||
hint={stats.overdueCount === 1 ? "invoice" : "invoices"}
|
hint={stats.overdueCount === 1 ? "invoice" : "invoices"}
|
||||||
/>
|
/>
|
||||||
<StatCard label="Clients" value={String(stats.totalClients)} hint={revenueChange} />
|
</View>
|
||||||
|
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}>
|
||||||
|
<StatCard
|
||||||
|
label="Clients"
|
||||||
|
value={String(stats.totalClients)}
|
||||||
|
hint={revenueChange}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Card title="Revenue (6 months)">
|
<Card title="Revenue (6 months)">
|
||||||
<View style={styles.chart}>
|
<View style={styles.chart}>
|
||||||
{stats.revenueChartData.map((point) => (
|
{stats.revenueChartData.map((point) => {
|
||||||
|
const barHeight = Math.max(4, (point.revenue / maxRevenue) * 80);
|
||||||
|
return (
|
||||||
<View key={point.month} style={styles.chartColumn}>
|
<View key={point.month} style={styles.chartColumn}>
|
||||||
<View style={styles.chartBarTrack}>
|
<View style={styles.chartBarTrack}>
|
||||||
<View
|
<View style={[styles.chartBar, { height: barHeight }]} />
|
||||||
style={[
|
|
||||||
styles.chartBar,
|
|
||||||
{ height: `${Math.max(8, (point.revenue / maxRevenue) * 100)}%` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
|
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
|
||||||
<Text style={styles.chartValue}>
|
<Text style={styles.chartValue}>
|
||||||
{point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
|
{point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Recent invoices">
|
<Card title="Recent invoices">
|
||||||
{stats.recentInvoices.length === 0 ? (
|
{stats.recentInvoices.length === 0 ? (
|
||||||
<Text style={styles.empty}>No invoices yet. Create one on the web app.</Text>
|
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
|
||||||
) : (
|
) : (
|
||||||
stats.recentInvoices.map((invoice) => {
|
stats.recentInvoices.map((invoice) => {
|
||||||
const status = getInvoiceStatus(invoice);
|
const status = getInvoiceStatus(invoice);
|
||||||
@@ -262,12 +272,17 @@ const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
|
alignContent: "flex-start",
|
||||||
|
},
|
||||||
|
statCell: {
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexBasis: "47%",
|
||||||
},
|
},
|
||||||
chart: {
|
chart: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
minHeight: 140,
|
|
||||||
},
|
},
|
||||||
chartColumn: {
|
chartColumn: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
import { AppBackground } from "@/components/AppBackground";
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
@@ -116,6 +116,25 @@ export default function InvoiceDetailScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerBackTitle: "Invoices",
|
||||||
|
headerRight: () =>
|
||||||
|
status !== "paid" ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={promptSendInvoice}
|
||||||
|
disabled={sendInvoice.isPending}
|
||||||
|
style={({ pressed }) => pressed && styles.headerPressed}
|
||||||
|
>
|
||||||
|
<Text style={[styles.headerAction, { color: colors.primary }]}>
|
||||||
|
{status === "draft" ? "Send" : "Resend"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scroll}
|
style={styles.scroll}
|
||||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||||
@@ -376,6 +395,13 @@ const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
|||||||
actions: {
|
actions: {
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
|
headerAction: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
headerPressed: {
|
||||||
|
opacity: 0.65,
|
||||||
|
},
|
||||||
errorBox: {
|
errorBox: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -23,13 +23,33 @@ export default function InvoicesLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
|
title: "Invoices",
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
statusBarTranslucent: true,
|
statusBarTranslucent: true,
|
||||||
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="[id]" options={{ title: "Invoice" }} />
|
<Stack.Screen
|
||||||
<Stack.Screen name="edit/[id]" options={{ title: "Edit" }} />
|
name="new"
|
||||||
|
options={{
|
||||||
|
title: "New invoice",
|
||||||
|
headerBackTitle: "Invoices",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="[id]"
|
||||||
|
options={{
|
||||||
|
title: "Invoice",
|
||||||
|
headerBackTitle: "Invoices",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="edit/[id]"
|
||||||
|
options={{
|
||||||
|
title: "Edit invoice",
|
||||||
|
headerBackTitle: "Invoice",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
import { AppBackground } from "@/components/AppBackground";
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||||
@@ -21,8 +20,9 @@ import { DateTimeField } from "@/components/ui/DateTimeField";
|
|||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { useNativeTabBarHeight, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
|
||||||
import { formatCurrency } from "@/lib/format";
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
@@ -32,8 +32,6 @@ export default function InvoiceEditScreen() {
|
|||||||
const styles = useThemedStyles(createInvoiceEditStyles);
|
const styles = useThemedStyles(createInvoiceEditStyles);
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const tabBarHeight = useNativeTabBarHeight();
|
|
||||||
const scrollPadding = useTabBarScrollPadding();
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
const invoiceQuery = api.invoices.getById.useQuery(
|
const invoiceQuery = api.invoices.getById.useQuery(
|
||||||
@@ -46,7 +44,6 @@ export default function InvoiceEditScreen() {
|
|||||||
const [items, setItems] = useState<EditableLineItem[]>([]);
|
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [footerHeight, setFooterHeight] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const invoice = invoiceQuery.data;
|
const invoice = invoiceQuery.data;
|
||||||
@@ -77,6 +74,16 @@ export default function InvoiceEditScreen() {
|
|||||||
onError: (err) => setError(err.message),
|
onError: (err) => setError(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sendInvoice = api.email.sendInvoice.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Alert.alert("Invoice sent", data.message);
|
||||||
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
const invoice = invoiceQuery.data;
|
const invoice = invoiceQuery.data;
|
||||||
|
|
||||||
const subtotal = useMemo(
|
const subtotal = useMemo(
|
||||||
@@ -106,6 +113,31 @@ export default function InvoiceEditScreen() {
|
|||||||
return <LoadingScreen message="Invoice not found" />;
|
return <LoadingScreen message="Invoice not found" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||||
|
|
||||||
|
function promptSendInvoice() {
|
||||||
|
if (!clientEmail) {
|
||||||
|
Alert.alert(
|
||||||
|
"No client email",
|
||||||
|
"Add an email address to this client on the web app before sending invoices.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
status === "draft" ? "Send invoice" : "Resend invoice",
|
||||||
|
`Email this invoice to ${clientEmail}?`,
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Send",
|
||||||
|
onPress: () => sendInvoice.mutate({ invoiceId: invoice!.id }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
}
|
}
|
||||||
@@ -180,17 +212,15 @@ export default function InvoiceEditScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ headerBackTitle: "Invoice" }} />
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
style={styles.flex}
|
style={styles.flex}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||||
styles.container,
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||||
{ paddingBottom: scrollPadding + footerHeight },
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
]}
|
|
||||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
|
||||||
scrollIndicatorInsets={{ bottom: scrollPadding + footerHeight }}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<View style={styles.hero}>
|
<View style={styles.hero}>
|
||||||
@@ -245,20 +275,19 @@ export default function InvoiceEditScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<View
|
<View style={styles.actions}>
|
||||||
onLayout={(event) => setFooterHeight(event.nativeEvent.layout.height)}
|
|
||||||
style={[
|
|
||||||
styles.footer,
|
|
||||||
{
|
|
||||||
bottom: tabBarHeight,
|
|
||||||
paddingBottom: Math.max(insets.bottom, spacing.sm),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
|
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
|
||||||
|
{status !== "paid" ? (
|
||||||
|
<Button
|
||||||
|
title={status === "draft" ? "Send invoice" : "Resend invoice"}
|
||||||
|
variant="secondary"
|
||||||
|
onPress={promptSendInvoice}
|
||||||
|
loading={sendInvoice.isPending}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</AppBackground>
|
</AppBackground>
|
||||||
);
|
);
|
||||||
@@ -365,14 +394,7 @@ const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
|
|||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
footer: {
|
actions: {
|
||||||
position: "absolute",
|
gap: spacing.sm,
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingTop: spacing.sm,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: colors.border,
|
|
||||||
backgroundColor: colors.cardGlass,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
|
|
||||||
import { AppBackground } from "@/components/AppBackground";
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
import { FilterChip } from "@/components/FilterChip";
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
|
import { FloatingActionButton } from "@/components/FloatingActionButton";
|
||||||
import { GlassSurface } from "@/components/GlassSurface";
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
@@ -127,7 +128,7 @@ export default function InvoicesScreen() {
|
|||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Text style={styles.emptyTitle}>No invoices found</Text>
|
<Text style={styles.emptyTitle}>No invoices found</Text>
|
||||||
<Text style={styles.emptyText}>
|
<Text style={styles.emptyText}>
|
||||||
Create invoices on the web app, then view and edit them here.
|
Tap + to create your first invoice, or pull to refresh.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -166,6 +167,10 @@ export default function InvoicesScreen() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</TabScrollView>
|
</TabScrollView>
|
||||||
|
<FloatingActionButton
|
||||||
|
accessibilityLabel="Create invoice"
|
||||||
|
onPress={() => router.push("/(app)/invoices/new")}
|
||||||
|
/>
|
||||||
</TabPage>
|
</TabPage>
|
||||||
</AppBackground>
|
</AppBackground>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
import { router, Stack } 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 { 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 { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { SelectField } from "@/components/ui/SelectField";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { defaultDueDate, generateInvoiceNumber } from "@/lib/invoice-number";
|
||||||
|
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 { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createNewInvoiceStyles);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
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[]>([
|
||||||
|
{
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
hours: "1",
|
||||||
|
rate: "0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (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() {
|
||||||
|
const nextIndex = items.length;
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
hours: "1",
|
||||||
|
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setExpandedIndex(nextIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
if (items.length <= 1) {
|
||||||
|
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setExpandedIndex((current) => {
|
||||||
|
if (current === null) return null;
|
||||||
|
if (current === index) return null;
|
||||||
|
return current > index ? current - 1 : current;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
setError("Select a client");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoiceNumber.trim()) {
|
||||||
|
setError("Invoice number is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedItems: Array<{
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const hours = Number(item.hours);
|
||||||
|
const rate = Number(item.rate);
|
||||||
|
if (!item.description.trim()) {
|
||||||
|
setError("Each line needs a description");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(hours) || hours < 0) {
|
||||||
|
setError("Hours must be a valid number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(rate) || rate < 0) {
|
||||||
|
setError("Rate must be a valid number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parsedItems.push({
|
||||||
|
date: item.date,
|
||||||
|
description: item.description.trim(),
|
||||||
|
hours,
|
||||||
|
rate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tax = Number(taxRate);
|
||||||
|
if (Number.isNaN(tax) || tax < 0 || tax > 100) {
|
||||||
|
setError("Tax rate must be between 0 and 100");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createInvoice.mutate({
|
||||||
|
clientId,
|
||||||
|
invoiceNumber: invoiceNumber.trim(),
|
||||||
|
issueDate,
|
||||||
|
dueDate,
|
||||||
|
notes,
|
||||||
|
taxRate: tax,
|
||||||
|
currency,
|
||||||
|
items: parsedItems,
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
|
||||||
|
<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="Details">
|
||||||
|
{clientOptions.length === 0 ? (
|
||||||
|
<View style={styles.noClients}>
|
||||||
|
<Text style={styles.noClientsText}>
|
||||||
|
Add a client before creating an invoice.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Add client"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.push("/(app)/entities/clients/new")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<SelectField
|
||||||
|
label="Client"
|
||||||
|
placeholder="Select client…"
|
||||||
|
value={clientId}
|
||||||
|
options={clientOptions}
|
||||||
|
onValueChange={setClientId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="Invoice number"
|
||||||
|
value={invoiceNumber}
|
||||||
|
onChangeText={setInvoiceNumber}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
/>
|
||||||
|
<DateTimeField
|
||||||
|
label="Issue date"
|
||||||
|
mode="date"
|
||||||
|
value={issueDate}
|
||||||
|
onChange={(date) => {
|
||||||
|
setIssueDate(date);
|
||||||
|
setDueDate((current) => (current < date ? defaultDueDate(date) : current));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
|
||||||
|
<Input
|
||||||
|
label="Tax rate (%)"
|
||||||
|
value={taxRate}
|
||||||
|
onChangeText={setTaxRate}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Notes"
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="Optional notes for the client"
|
||||||
|
multiline
|
||||||
|
style={styles.notesInput}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Line items">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<LineItemEditor
|
||||||
|
key={`new-${index}`}
|
||||||
|
item={item}
|
||||||
|
currency={currency}
|
||||||
|
expanded={expandedIndex === index}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedIndex((current) => (current === index ? null : index))
|
||||||
|
}
|
||||||
|
onChange={(patch) => updateItem(index, patch)}
|
||||||
|
onRemove={() => removeItem(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||||
|
<Text style={styles.addLineText}>+ Add line</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.totals}>
|
||||||
|
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
|
||||||
|
{parsedTaxRate > 0 ? (
|
||||||
|
<TotalRow
|
||||||
|
label={`Tax (${parsedTaxRate}%)`}
|
||||||
|
value={formatCurrency(taxAmount, currency)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TotalRow label="Total" value={formatCurrency(total, currency)} bold />
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Create invoice"
|
||||||
|
loading={createInvoice.isPending}
|
||||||
|
disabled={clientOptions.length === 0}
|
||||||
|
onPress={handleCreate}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotalRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
bold,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
bold?: boolean;
|
||||||
|
}) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View style={totalStyles.row}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
totalStyles.label,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
bold && totalStyles.bold,
|
||||||
|
bold && { color: colors.foreground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
totalStyles.value,
|
||||||
|
{ color: colors.foreground },
|
||||||
|
bold && totalStyles.bold,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalStyles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
bold: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createNewInvoiceStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
notesInput: {
|
||||||
|
minHeight: 72,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
},
|
||||||
|
noClients: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
noClientsText: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
addLine: {
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
addLineText: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: colors.destructive,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Constants from "expo-constants";
|
import Constants from "expo-constants";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { Alert, Platform, Pressable, StyleSheet, Switch, Text, View } from "react-native";
|
import { Alert, Platform, Pressable, StyleSheet, Switch, Text, View } from "react-native";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
@@ -38,6 +39,11 @@ export default function SettingsScreen() {
|
|||||||
clearActiveAccount,
|
clearActiveAccount,
|
||||||
} = useAccounts();
|
} = useAccounts();
|
||||||
const { colors, colorMode, setColorMode } = useAppTheme();
|
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 {
|
const {
|
||||||
enabled: lockEnabled,
|
enabled: lockEnabled,
|
||||||
biometricEnabled,
|
biometricEnabled,
|
||||||
@@ -59,6 +65,7 @@ export default function SettingsScreen() {
|
|||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
const [pendingPin, setPendingPin] = useState("");
|
const [pendingPin, setPendingPin] = useState("");
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut() {
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
@@ -268,7 +275,7 @@ export default function SettingsScreen() {
|
|||||||
<Switch
|
<Switch
|
||||||
value={lockEnabled}
|
value={lockEnabled}
|
||||||
onValueChange={handleLockToggle}
|
onValueChange={handleLockToggle}
|
||||||
trackColor={{ false: colors.border, true: colors.primary }}
|
{...switchProps}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -285,7 +292,7 @@ export default function SettingsScreen() {
|
|||||||
<Switch
|
<Switch
|
||||||
value={biometricEnabled}
|
value={biometricEnabled}
|
||||||
onValueChange={handleBiometricToggle}
|
onValueChange={handleBiometricToggle}
|
||||||
trackColor={{ false: colors.border, true: colors.primary }}
|
{...switchProps}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -329,12 +336,28 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Card>
|
</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">
|
<Card title="Server instance">
|
||||||
<InstanceUrlField onSaved={confirmInstanceChange} />
|
<InstanceUrlField onSaved={confirmInstanceChange} />
|
||||||
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}>
|
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}>
|
||||||
Connected to {activeAccount?.instanceUrl ?? apiUrl}
|
Connected to {activeAccount?.instanceUrl ?? apiUrl}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card title="App">
|
<Card title="App">
|
||||||
<View style={styles.appRow}>
|
<View style={styles.appRow}>
|
||||||
@@ -375,6 +398,17 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: fonts.mono,
|
fontFamily: fonts.mono,
|
||||||
},
|
},
|
||||||
|
advancedToggle: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
minHeight: 36,
|
||||||
|
},
|
||||||
|
advancedLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
appRow: {
|
appRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { FullScreen } from "@/components/Screen";
|
import { FullScreen } from "@/components/Screen";
|
||||||
import { AuthBackground } from "@/components/AppBackground";
|
import { AuthBackground } from "@/components/AppBackground";
|
||||||
import { CollapsibleServerField } from "@/components/CollapsibleServerField";
|
|
||||||
import { HeadingText, Logo } from "@/components/Logo";
|
import { HeadingText, Logo } from "@/components/Logo";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
@@ -91,7 +90,6 @@ export default function ForgotPasswordScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
<CollapsibleServerField />
|
|
||||||
</FullScreen>
|
</FullScreen>
|
||||||
</AuthBackground>
|
</AuthBackground>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { FullScreen } from "@/components/Screen";
|
import { FullScreen } from "@/components/Screen";
|
||||||
import { AuthBackground } from "@/components/AppBackground";
|
import { AuthBackground } from "@/components/AppBackground";
|
||||||
import { CollapsibleServerField } from "@/components/CollapsibleServerField";
|
|
||||||
import { HeadingText, Logo } from "@/components/Logo";
|
import { HeadingText, Logo } from "@/components/Logo";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
@@ -149,7 +148,6 @@ export default function RegisterScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
<CollapsibleServerField />
|
|
||||||
</FullScreen>
|
</FullScreen>
|
||||||
</AuthBackground>
|
</AuthBackground>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { AuthBackground } from "@/components/AppBackground";
|
import { AuthBackground } from "@/components/AppBackground";
|
||||||
import { CollapsibleServerField } from "@/components/CollapsibleServerField";
|
|
||||||
import { HeadingText, Logo } from "@/components/Logo";
|
import { HeadingText, Logo } from "@/components/Logo";
|
||||||
import { FullScreen } from "@/components/Screen";
|
import { FullScreen } from "@/components/Screen";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
@@ -123,7 +122,6 @@ export default function SignInScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
<CollapsibleServerField />
|
|
||||||
</FullScreen>
|
</FullScreen>
|
||||||
</AuthBackground>
|
</AuthBackground>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<svg viewBox="0 0 436 436" xmlns="http://www.w3.org/2000/svg">
|
||||||
<svg width="100%" height="100%" viewBox="0 0 252 436" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
<g transform="translate(92, 0)">
|
||||||
<g transform="matrix(1,0,0,1,-1363.75,-282.196)">
|
<g transform="matrix(1,0,0,1,-1363.75,-282.196)">
|
||||||
<g transform="matrix(1,0,0,1,1343.05,673.674)">
|
<g transform="matrix(1,0,0,1,1343.05,673.674)">
|
||||||
<g transform="matrix(488.128,0,0,488.128,0,0)">
|
<g transform="matrix(488.128,0,0,488.128,0,0)">
|
||||||
<path d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
<path
|
||||||
|
d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="0 0 436 436" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(92, 0)">
|
||||||
|
<g transform="matrix(1,0,0,1,-1363.75,-282.196)">
|
||||||
|
<g transform="matrix(1,0,0,1,1343.05,673.674)">
|
||||||
|
<g transform="matrix(488.128,0,0,488.128,0,0)">
|
||||||
|
<path
|
||||||
|
d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
import { Pressable, StyleSheet, Text } from "react-native";
|
||||||
|
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts, radii } from "@/constants/theme";
|
||||||
|
import { useFloatingActionBottom } from "@/lib/tab-bar-insets";
|
||||||
|
|
||||||
|
type FloatingActionButtonProps = {
|
||||||
|
onPress: () => void;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FloatingActionButton({
|
||||||
|
onPress,
|
||||||
|
accessibilityLabel = "Create",
|
||||||
|
}: FloatingActionButtonProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const bottom = useFloatingActionBottom();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.fab,
|
||||||
|
{
|
||||||
|
bottom,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
shadowColor: colors.foreground,
|
||||||
|
},
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.icon, { color: colors.primaryForeground }]}>+</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
fab: {
|
||||||
|
position: "absolute",
|
||||||
|
right: 20,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.9,
|
||||||
|
transform: [{ scale: 0.96 }],
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: 32,
|
||||||
|
lineHeight: 34,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
marginTop: -2,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { StyleSheet, Text, View, type ImageStyle, type ViewStyle } from "react-native";
|
import { StyleSheet, Text, View, type ViewStyle } from "react-native";
|
||||||
|
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { fonts } from "@/constants/theme";
|
import { fonts } from "@/constants/theme";
|
||||||
|
|
||||||
|
const markSource = require("@/assets/images/icon.png");
|
||||||
|
|
||||||
type LogoSize = "xs" | "sm" | "md" | "lg";
|
type LogoSize = "xs" | "sm" | "md" | "lg";
|
||||||
|
|
||||||
const widths: Record<LogoSize, number> = {
|
const widths: Record<LogoSize, number> = {
|
||||||
@@ -42,30 +44,34 @@ export function Logo({ size = "md", style, onDark }: LogoProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Square app icon mark — fixed aspect ratio so flex parents cannot squash it. */
|
/** Square dollar mark from Icon Composer export (1024×1024 PNG). */
|
||||||
export function LogoMark({
|
export function LogoMark({
|
||||||
size = 32,
|
size = 32,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
size?: number;
|
size?: number;
|
||||||
style?: ImageStyle;
|
style?: ViewStyle;
|
||||||
}) {
|
}) {
|
||||||
const flat = StyleSheet.flatten(style);
|
const fromStyle =
|
||||||
const width =
|
typeof style?.width === "number"
|
||||||
typeof flat?.width === "number"
|
? style.width
|
||||||
? flat.width
|
: typeof style?.height === "number"
|
||||||
: typeof flat?.height === "number"
|
? style.height
|
||||||
? flat.height
|
: undefined;
|
||||||
: size;
|
const dimension = fromStyle ?? size;
|
||||||
const height = typeof flat?.height === "number" ? flat.height : width;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.markBox, { width, height }]}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.markBox,
|
||||||
|
{ width: dimension, height: dimension, aspectRatio: 1 },
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/images/icon.png")}
|
source={markSource}
|
||||||
style={styles.markImage}
|
style={{ width: dimension, height: dimension }}
|
||||||
contentFit="contain"
|
contentFit="contain"
|
||||||
accessibilityLabel="beenvoice"
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -98,10 +104,6 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
markImage: {
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
heading: {
|
heading: {
|
||||||
fontFamily: fonts.heading,
|
fontFamily: fonts.heading,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ export function StatCard({ label, value, hint }: StatCardProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
flex: 1,
|
width: "100%",
|
||||||
minWidth: "46%",
|
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
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 type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type BusinessFormValues = {
|
||||||
|
name: string;
|
||||||
|
nickname: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
addressLine1: string;
|
||||||
|
addressLine2: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
website: string;
|
||||||
|
taxId: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyValues: BusinessFormValues = {
|
||||||
|
name: "",
|
||||||
|
nickname: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
addressLine1: "",
|
||||||
|
addressLine2: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
postalCode: "",
|
||||||
|
country: "United States",
|
||||||
|
website: "",
|
||||||
|
taxId: "",
|
||||||
|
isDefault: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type BusinessFormProps = {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
businessId?: string;
|
||||||
|
scrollPadding: number;
|
||||||
|
onSaved: () => void;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BusinessForm({
|
||||||
|
mode,
|
||||||
|
businessId,
|
||||||
|
scrollPadding,
|
||||||
|
onSaved,
|
||||||
|
onDeleted,
|
||||||
|
}: BusinessFormProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createBusinessFormStyles);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const businessQuery = api.businesses.getById.useQuery(
|
||||||
|
{ id: businessId ?? "" },
|
||||||
|
{ enabled: mode === "edit" && Boolean(businessId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
|
||||||
|
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const switchProps = {
|
||||||
|
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
|
||||||
|
thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined,
|
||||||
|
ios_backgroundColor: colors.switchIosBackground,
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const business = businessQuery.data;
|
||||||
|
if (!business) return;
|
||||||
|
setValues({
|
||||||
|
name: business.name,
|
||||||
|
nickname: business.nickname ?? "",
|
||||||
|
email: business.email ?? "",
|
||||||
|
phone: business.phone ?? "",
|
||||||
|
addressLine1: business.addressLine1 ?? "",
|
||||||
|
addressLine2: business.addressLine2 ?? "",
|
||||||
|
city: business.city ?? "",
|
||||||
|
state: business.state ?? "",
|
||||||
|
postalCode: business.postalCode ?? "",
|
||||||
|
country: business.country ?? "United States",
|
||||||
|
website: business.website ?? "",
|
||||||
|
taxId: business.taxId ?? "",
|
||||||
|
isDefault: business.isDefault ?? false,
|
||||||
|
});
|
||||||
|
}, [businessQuery.data]);
|
||||||
|
|
||||||
|
const createBusiness = api.businesses.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.businesses.getAll.invalidate();
|
||||||
|
onSaved();
|
||||||
|
},
|
||||||
|
onError: (err) => setFieldError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBusiness = api.businesses.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.businesses.getAll.invalidate();
|
||||||
|
if (businessId) void utils.businesses.getById.invalidate({ id: businessId });
|
||||||
|
onSaved();
|
||||||
|
},
|
||||||
|
onError: (err) => setFieldError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBusiness = api.businesses.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.businesses.getAll.invalidate();
|
||||||
|
onDeleted?.();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not delete business", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function patch<K extends keyof BusinessFormValues>(field: K, value: BusinessFormValues[K]) {
|
||||||
|
setValues((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setFieldError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload() {
|
||||||
|
return {
|
||||||
|
name: values.name.trim(),
|
||||||
|
nickname: values.nickname.trim(),
|
||||||
|
email: values.email.trim(),
|
||||||
|
phone: values.phone.trim(),
|
||||||
|
addressLine1: values.addressLine1.trim(),
|
||||||
|
addressLine2: values.addressLine2.trim(),
|
||||||
|
city: values.city.trim(),
|
||||||
|
state: values.state.trim(),
|
||||||
|
postalCode: values.postalCode.trim(),
|
||||||
|
country: values.country.trim() || "United States",
|
||||||
|
website: values.website.trim(),
|
||||||
|
taxId: values.taxId.trim(),
|
||||||
|
isDefault: values.isDefault,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!values.name.trim()) {
|
||||||
|
setFieldError("Business name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildPayload();
|
||||||
|
|
||||||
|
if (mode === "create") {
|
||||||
|
createBusiness.mutate(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!businessId) return;
|
||||||
|
updateBusiness.mutate({ id: businessId, ...payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!businessId) return;
|
||||||
|
Alert.alert(
|
||||||
|
"Delete business",
|
||||||
|
"This cannot be undone. Businesses with invoices cannot be deleted.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => deleteBusiness.mutate({ id: businessId }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saving = createBusiness.isPending || updateBusiness.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="Profile">
|
||||||
|
<Input label="Name" value={values.name} onChangeText={(v) => patch("name", v)} />
|
||||||
|
<Input
|
||||||
|
label="Nickname"
|
||||||
|
value={values.nickname}
|
||||||
|
onChangeText={(v) => patch("nickname", v)}
|
||||||
|
placeholder="Optional short name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
value={values.email}
|
||||||
|
onChangeText={(v) => patch("email", v)}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
value={values.phone}
|
||||||
|
onChangeText={(v) => patch("phone", v)}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Website"
|
||||||
|
value={values.website}
|
||||||
|
onChangeText={(v) => patch("website", v)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="url"
|
||||||
|
placeholder="https://"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Tax ID"
|
||||||
|
value={values.taxId}
|
||||||
|
onChangeText={(v) => patch("taxId", v)}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
<View style={styles.switchRow}>
|
||||||
|
<View style={styles.switchCopy}>
|
||||||
|
<Text style={[styles.switchLabel, { color: colors.foreground }]}>
|
||||||
|
Default business
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.switchHint, { color: colors.mutedForeground }]}>
|
||||||
|
Used for new invoices when none is selected
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={values.isDefault}
|
||||||
|
onValueChange={(v) => patch("isDefault", v)}
|
||||||
|
{...switchProps}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Address">
|
||||||
|
<Input
|
||||||
|
label="Address line 1"
|
||||||
|
value={values.addressLine1}
|
||||||
|
onChangeText={(v) => patch("addressLine1", v)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Address line 2"
|
||||||
|
value={values.addressLine2}
|
||||||
|
onChangeText={(v) => patch("addressLine2", v)}
|
||||||
|
/>
|
||||||
|
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} />
|
||||||
|
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} />
|
||||||
|
<Input
|
||||||
|
label="Postal code"
|
||||||
|
value={values.postalCode}
|
||||||
|
onChangeText={(v) => patch("postalCode", v)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Country"
|
||||||
|
value={values.country}
|
||||||
|
onChangeText={(v) => patch("country", v)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title={mode === "create" ? "Create business" : "Save changes"}
|
||||||
|
loading={saving}
|
||||||
|
onPress={handleSave}
|
||||||
|
/>
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<Button
|
||||||
|
title="Delete business"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteBusiness.isPending}
|
||||||
|
onPress={confirmDelete}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBusinessFormStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
switchRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingTop: spacing.xs,
|
||||||
|
},
|
||||||
|
switchCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
switchLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
switchHint: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: colors.destructive,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
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 type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export type ClientFormValues = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
addressLine1: string;
|
||||||
|
addressLine2: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
defaultHourlyRate: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyValues: ClientFormValues = {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
addressLine1: "",
|
||||||
|
addressLine2: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
postalCode: "",
|
||||||
|
country: "United States",
|
||||||
|
defaultHourlyRate: "",
|
||||||
|
currency: "USD",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientFormProps = {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
clientId?: string;
|
||||||
|
scrollPadding: number;
|
||||||
|
onSaved: () => void;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClientForm({
|
||||||
|
mode,
|
||||||
|
clientId,
|
||||||
|
scrollPadding,
|
||||||
|
onSaved,
|
||||||
|
onDeleted,
|
||||||
|
}: ClientFormProps) {
|
||||||
|
const styles = useThemedStyles(createClientFormStyles);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const clientQuery = api.clients.getById.useQuery(
|
||||||
|
{ id: clientId ?? "" },
|
||||||
|
{ enabled: mode === "edit" && Boolean(clientId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [values, setValues] = useState<ClientFormValues>(emptyValues);
|
||||||
|
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const client = clientQuery.data;
|
||||||
|
if (!client) return;
|
||||||
|
setValues({
|
||||||
|
name: client.name,
|
||||||
|
email: client.email ?? "",
|
||||||
|
phone: client.phone ?? "",
|
||||||
|
addressLine1: client.addressLine1 ?? "",
|
||||||
|
addressLine2: client.addressLine2 ?? "",
|
||||||
|
city: client.city ?? "",
|
||||||
|
state: client.state ?? "",
|
||||||
|
postalCode: client.postalCode ?? "",
|
||||||
|
country: client.country ?? "United States",
|
||||||
|
defaultHourlyRate:
|
||||||
|
client.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "",
|
||||||
|
currency: client.currency ?? "USD",
|
||||||
|
});
|
||||||
|
}, [clientQuery.data]);
|
||||||
|
|
||||||
|
const createClient = api.clients.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.clients.getAll.invalidate();
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
onSaved();
|
||||||
|
},
|
||||||
|
onError: (err) => setFieldError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateClient = api.clients.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.clients.getAll.invalidate();
|
||||||
|
if (clientId) void utils.clients.getById.invalidate({ id: clientId });
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
onSaved();
|
||||||
|
},
|
||||||
|
onError: (err) => setFieldError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteClient = api.clients.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.clients.getAll.invalidate();
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
onDeleted?.();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not delete client", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function patch(field: keyof ClientFormValues, value: string) {
|
||||||
|
setValues((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setFieldError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!values.name.trim()) {
|
||||||
|
setFieldError("Name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = values.defaultHourlyRate.trim()
|
||||||
|
? Number(values.defaultHourlyRate)
|
||||||
|
: undefined;
|
||||||
|
if (rate !== undefined && (Number.isNaN(rate) || rate < 0)) {
|
||||||
|
setFieldError("Hourly rate must be a valid number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: values.name.trim(),
|
||||||
|
email: values.email.trim(),
|
||||||
|
phone: values.phone.trim(),
|
||||||
|
addressLine1: values.addressLine1.trim(),
|
||||||
|
addressLine2: values.addressLine2.trim(),
|
||||||
|
city: values.city.trim(),
|
||||||
|
state: values.state.trim(),
|
||||||
|
postalCode: values.postalCode.trim(),
|
||||||
|
country: values.country.trim() || "United States",
|
||||||
|
defaultHourlyRate: rate,
|
||||||
|
currency: values.currency.trim() || "USD",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === "create") {
|
||||||
|
createClient.mutate(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId) return;
|
||||||
|
updateClient.mutate({ id: clientId, ...payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!clientId) return;
|
||||||
|
Alert.alert(
|
||||||
|
"Delete client",
|
||||||
|
"This cannot be undone. Clients with invoices cannot be deleted.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => deleteClient.mutate({ id: clientId }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saving = createClient.isPending || updateClient.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="Contact">
|
||||||
|
<Input label="Name" value={values.name} onChangeText={(v) => patch("name", v)} />
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
value={values.email}
|
||||||
|
onChangeText={(v) => patch("email", v)}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
value={values.phone}
|
||||||
|
onChangeText={(v) => patch("phone", v)}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Address">
|
||||||
|
<Input
|
||||||
|
label="Address line 1"
|
||||||
|
value={values.addressLine1}
|
||||||
|
onChangeText={(v) => patch("addressLine1", v)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Address line 2"
|
||||||
|
value={values.addressLine2}
|
||||||
|
onChangeText={(v) => patch("addressLine2", v)}
|
||||||
|
/>
|
||||||
|
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} />
|
||||||
|
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} />
|
||||||
|
<Input
|
||||||
|
label="Postal code"
|
||||||
|
value={values.postalCode}
|
||||||
|
onChangeText={(v) => patch("postalCode", v)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Country"
|
||||||
|
value={values.country}
|
||||||
|
onChangeText={(v) => patch("country", v)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Billing">
|
||||||
|
<Input
|
||||||
|
label="Default hourly rate"
|
||||||
|
value={values.defaultHourlyRate}
|
||||||
|
onChangeText={(v) => patch("defaultHourlyRate", v)}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Currency"
|
||||||
|
value={values.currency}
|
||||||
|
onChangeText={(v) => patch("currency", v.toUpperCase())}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
maxLength={3}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title={mode === "create" ? "Create client" : "Save changes"}
|
||||||
|
loading={saving}
|
||||||
|
onPress={handleSave}
|
||||||
|
/>
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<Button
|
||||||
|
title="Delete client"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteClient.isPending}
|
||||||
|
onPress={confirmDelete}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createClientFormStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: colors.destructive,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { Pressable, StyleSheet, Text, View } from "react-native";
|
|||||||
|
|
||||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { StepperInput } from "@/components/ui/StepperInput";
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { formatCurrency, formatDate } from "@/lib/format";
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
@@ -79,11 +80,10 @@ export function LineItemEditor({
|
|||||||
|
|
||||||
<View style={styles.inlineRow}>
|
<View style={styles.inlineRow}>
|
||||||
<View style={styles.inlineField}>
|
<View style={styles.inlineField}>
|
||||||
<Input
|
<StepperInput
|
||||||
label="Hours"
|
label="Hours"
|
||||||
value={item.hours}
|
value={item.hours}
|
||||||
onChangeText={(hours) => onChange({ hours })}
|
onChangeText={(hours) => onChange({ hours })}
|
||||||
keyboardType="decimal-pad"
|
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function Button({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
base: {
|
base: {
|
||||||
minHeight: 40,
|
minHeight: 40,
|
||||||
borderRadius: radii.md,
|
borderRadius: radii.lg,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
|
alignItems: "stretch",
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export function DateTimeField({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
|
alignSelf: "stretch",
|
||||||
|
width: "100%",
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -137,8 +139,10 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
alignSelf: "stretch",
|
||||||
|
width: "100%",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: radii.md,
|
borderRadius: radii.lg,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native";
|
||||||
|
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
type StepperInputProps = Omit<TextInputProps, "value" | "onChangeText"> & {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (value: string) => void;
|
||||||
|
step?: number;
|
||||||
|
min?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StepperInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
step = 0.25,
|
||||||
|
min = 0,
|
||||||
|
...props
|
||||||
|
}: StepperInputProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
function adjust(delta: number) {
|
||||||
|
const current = Number.parseFloat(value) || 0;
|
||||||
|
const next = Math.max(min, Math.round((current + delta) * 100) / 100);
|
||||||
|
onChangeText(Number.isInteger(next) ? String(next) : String(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.field,
|
||||||
|
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Decrease ${label}`}
|
||||||
|
hitSlop={6}
|
||||||
|
onPress={() => adjust(-step)}
|
||||||
|
style={({ pressed }) => [styles.stepButton, pressed && styles.stepPressed]}
|
||||||
|
>
|
||||||
|
<Ionicons name="remove" size={18} color={colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
style={[styles.input, { color: colors.foreground }]}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Increase ${label}`}
|
||||||
|
hitSlop={6}
|
||||||
|
onPress={() => adjust(step)}
|
||||||
|
style={({ pressed }) => [styles.stepButton, pressed && styles.stepPressed]}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={18} color={colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
minHeight: 44,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: spacing.xs,
|
||||||
|
},
|
||||||
|
stepButton: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: radii.sm,
|
||||||
|
},
|
||||||
|
stepPressed: {
|
||||||
|
opacity: 0.65,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
saveDraftInstanceUrl,
|
saveDraftInstanceUrl,
|
||||||
type SavedAccount,
|
type SavedAccount,
|
||||||
} from "@/lib/accounts";
|
} from "@/lib/accounts";
|
||||||
import { setRuntimeApiUrl, getApiUrl } from "@/lib/config";
|
import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL } from "@/lib/config";
|
||||||
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
|
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
|
||||||
|
|
||||||
type AccountsContextValue = {
|
type AccountsContextValue = {
|
||||||
@@ -60,6 +60,8 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
|
|||||||
setRuntimeApiUrl(active.instanceUrl);
|
setRuntimeApiUrl(active.instanceUrl);
|
||||||
} else if (draftUrl) {
|
} else if (draftUrl) {
|
||||||
setRuntimeApiUrl(draftUrl);
|
setRuntimeApiUrl(draftUrl);
|
||||||
|
} else {
|
||||||
|
setRuntimeApiUrl(DEFAULT_API_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
setApiUrl(getApiUrl());
|
setApiUrl(getApiUrl());
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Constants from "expo-constants";
|
import Constants from "expo-constants";
|
||||||
|
|
||||||
const fallbackUrl = "http://localhost:3000";
|
/** Production API used by default (App Store review + production builds). */
|
||||||
|
export const DEFAULT_API_URL = "https://beenvoice.soconnor.dev";
|
||||||
|
|
||||||
let runtimeOverride: string | null = null;
|
let runtimeOverride: string | null = null;
|
||||||
|
|
||||||
@@ -15,10 +16,10 @@ export function getApiUrl() {
|
|||||||
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
||||||
|
|
||||||
const hostUri = Constants.expoConfig?.hostUri;
|
const hostUri = Constants.expoConfig?.hostUri;
|
||||||
if (hostUri) {
|
if (hostUri && __DEV__) {
|
||||||
const host = hostUri.split(":")[0];
|
const host = hostUri.split(":")[0];
|
||||||
if (host) return `http://${host}:3000`;
|
if (host) return `http://${host}:3000`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallbackUrl;
|
return DEFAULT_API_URL;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** Matches web invoice-form default numbering. */
|
||||||
|
export function generateInvoiceNumber(): string {
|
||||||
|
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||||
|
return `INV-${date}-${String(Date.now()).slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultDueDate(issueDate: Date): Date {
|
||||||
|
const due = new Date(issueDate);
|
||||||
|
due.setDate(due.getDate() + 30);
|
||||||
|
return due;
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ import { spacing } from "@/constants/theme";
|
|||||||
/** Standard UITabBar content height (home indicator is separate). */
|
/** Standard UITabBar content height (home indicator is separate). */
|
||||||
const IOS_TAB_BAR_HEIGHT = 49;
|
const IOS_TAB_BAR_HEIGHT = 49;
|
||||||
|
|
||||||
/** Slightly less than measured inset so content sits closer to the tab bar. */
|
/** Trim extra inset so scroll content sits closer to the tab bar. */
|
||||||
const TAB_BAR_PADDING_TRIM = spacing.sm;
|
const TAB_BAR_PADDING_TRIM = spacing.lg;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pixels between the bottom of the safe-area layout frame and the window bottom.
|
* Pixels between the bottom of the safe-area layout frame and the window bottom.
|
||||||
@@ -31,17 +31,23 @@ export function useNativeTabBarHeight(): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bottom padding so scroll content can clear the floating native tab bar.
|
* Bottom padding so scroll content can clear the floating native tab bar.
|
||||||
* Uses layout-frame measurement when available, otherwise tab bar + home indicator.
|
* Uses tab bar + home indicator as the target — layout-frame measurement can
|
||||||
|
* over-report and leave a large empty scroll gap above the tab bar.
|
||||||
*/
|
*/
|
||||||
export function useTabBarScrollPadding(): number {
|
export function useTabBarScrollPadding(): number {
|
||||||
const belowLayoutFrame = useBelowLayoutFrame();
|
const { bottom: homeIndicator } = useSafeAreaInsets();
|
||||||
|
const tabBar = useNativeTabBarHeight();
|
||||||
|
const clearance = tabBar + homeIndicator;
|
||||||
|
|
||||||
|
return Math.max(spacing.xs, clearance - TAB_BAR_PADDING_TRIM);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bottom offset for floating action buttons above the tab bar. */
|
||||||
|
export function useFloatingActionBottom(): number {
|
||||||
const { bottom: homeIndicator } = useSafeAreaInsets();
|
const { bottom: homeIndicator } = useSafeAreaInsets();
|
||||||
const tabBar = useNativeTabBarHeight();
|
const tabBar = useNativeTabBarHeight();
|
||||||
|
|
||||||
const raw =
|
return tabBar + homeIndicator + spacing.xs;
|
||||||
belowLayoutFrame > 0 ? belowLayoutFrame : tabBar + homeIndicator;
|
|
||||||
|
|
||||||
return Math.max(tabBar + homeIndicator - TAB_BAR_PADDING_TRIM, raw - TAB_BAR_PADDING_TRIM);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use useTabBarScrollPadding */
|
/** @deprecated Use useTabBarScrollPadding */
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export type ThemeColors = {
|
|||||||
brandDark: string;
|
brandDark: string;
|
||||||
text: string;
|
text: string;
|
||||||
textMuted: string;
|
textMuted: string;
|
||||||
|
switchTrackOn: string;
|
||||||
|
switchTrackOff: string;
|
||||||
|
switchThumb: string;
|
||||||
|
switchIosBackground: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getThemeColors(scheme: ColorSchemeName): ThemeColors {
|
export function getThemeColors(scheme: ColorSchemeName): ThemeColors {
|
||||||
@@ -83,6 +87,10 @@ export function getThemeColors(scheme: ColorSchemeName): ThemeColors {
|
|||||||
brandDark: palette?.foreground ?? light.foreground,
|
brandDark: palette?.foreground ?? light.foreground,
|
||||||
text: palette?.foreground ?? light.foreground,
|
text: palette?.foreground ?? light.foreground,
|
||||||
textMuted: palette?.mutedForeground ?? light.mutedForeground,
|
textMuted: palette?.mutedForeground ?? light.mutedForeground,
|
||||||
|
switchTrackOn: isDark ? (palette?.success ?? "#4ADE80") : light.primary,
|
||||||
|
switchTrackOff: isDark ? "#3F3F46" : light.border,
|
||||||
|
switchThumb: isDark ? "#FAFAFA" : "#FFFFFF",
|
||||||
|
switchIosBackground: isDark ? "#3F3F46" : light.border,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||