Compare commits
3
Commits
8a3f498874
...
21268ddd39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21268ddd39 | ||
|
|
7bc8559b44 | ||
|
|
8ad4210908 |
@@ -10,12 +10,14 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.beenvoice.app",
|
"bundleIdentifier": "com.beenvoice.app",
|
||||||
"buildNumber": "11",
|
"buildNumber": "12",
|
||||||
"icon": "./assets/beenvoice.icon",
|
"icon": "./assets/beenvoice.icon",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
|
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
|
||||||
"NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice."
|
"NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice.",
|
||||||
|
"NSCameraUsageDescription": "beenvoice uses the camera to scan expense receipts.",
|
||||||
|
"NSPhotoLibraryUsageDescription": "beenvoice imports receipt photos for expense tracking."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
@@ -28,7 +30,8 @@
|
|||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.USE_BIOMETRIC",
|
"android.permission.USE_BIOMETRIC",
|
||||||
"android.permission.USE_FINGERPRINT"
|
"android.permission.USE_FINGERPRINT",
|
||||||
|
"android.permission.CAMERA"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
@@ -82,7 +85,20 @@
|
|||||||
"@react-native-community/datetimepicker",
|
"@react-native-community/datetimepicker",
|
||||||
"./plugins/withAppIntents.js",
|
"./plugins/withAppIntents.js",
|
||||||
"./plugins/withAppStoreSigning.js",
|
"./plugins/withAppStoreSigning.js",
|
||||||
"expo-sharing"
|
"expo-sharing",
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "beenvoice imports receipt photos for expense tracking.",
|
||||||
|
"cameraPermission": "beenvoice uses the camera to scan expense receipts."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-mlkit-ocr",
|
||||||
|
{
|
||||||
|
"iosEngine": "auto"
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NativeTabs } from "expo-router/unstable-native-tabs";
|
|||||||
|
|
||||||
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
import { AppLockOverlay } from "@/components/AppLockOverlay";
|
||||||
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
import { InvoiceReminderSync } from "@/components/InvoiceReminderSync";
|
||||||
|
import { OnboardingGate } from "@/components/OnboardingGate";
|
||||||
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
import { ShortcutHandler } from "@/components/ShortcutHandler";
|
||||||
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
|
import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
@@ -73,7 +74,16 @@ export default function AppLayout() {
|
|||||||
/>
|
/>
|
||||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="more" contentStyle={tabContentStyle} disableAutomaticContentInsets>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "ellipsis.circle", selected: "ellipsis.circle.fill" }}
|
||||||
|
md="more_horiz"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
<OnboardingGate />
|
||||||
<InvoiceReminderSync />
|
<InvoiceReminderSync />
|
||||||
<TimeClockLiveActivitySync />
|
<TimeClockLiveActivitySync />
|
||||||
<ShortcutHandler />
|
<ShortcutHandler />
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function MoreLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { useLocalSearchParams, router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import {
|
||||||
|
ExpenseFormFields,
|
||||||
|
type ExpenseFormState,
|
||||||
|
} from "@/components/expenses/ExpenseFormFields";
|
||||||
|
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type ReceiptSplitDraft = Pick<
|
||||||
|
ReceiptScanResult,
|
||||||
|
"items" | "subtotal" | "tax" | "total"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function ExpenseDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [form, setForm] = useState<ExpenseFormState>({
|
||||||
|
description: "",
|
||||||
|
amountText: "",
|
||||||
|
date: new Date(),
|
||||||
|
category: "",
|
||||||
|
businessId: "",
|
||||||
|
clientId: "",
|
||||||
|
billable: false,
|
||||||
|
reimbursable: false,
|
||||||
|
taxDeductible: false,
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenseQuery = api.expenses.getById.useQuery(
|
||||||
|
{ id: id ?? "" },
|
||||||
|
{ enabled: Boolean(id) },
|
||||||
|
);
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||||
|
onSuccess: () => void expenseQuery.refetch(),
|
||||||
|
});
|
||||||
|
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
||||||
|
onSuccess: () => void expenseQuery.refetch(),
|
||||||
|
});
|
||||||
|
const updateExpense = api.expenses.update.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.expenses.getAll.invalidate();
|
||||||
|
await expenseQuery.refetch();
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const suggest = api.expenses.suggestFromReceiptText.useMutation();
|
||||||
|
|
||||||
|
const expense = expenseQuery.data;
|
||||||
|
const businesses = businessesQuery.data ?? [];
|
||||||
|
const clients = clientsQuery.data ?? [];
|
||||||
|
|
||||||
|
async function attachAndScan(fromCamera: boolean) {
|
||||||
|
if (!id || !expense) return;
|
||||||
|
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
const result = await scanReceiptImage(
|
||||||
|
fromCamera,
|
||||||
|
{
|
||||||
|
description: expense.description,
|
||||||
|
amountText: String(expense.amount),
|
||||||
|
date: new Date(expense.date),
|
||||||
|
},
|
||||||
|
(input) => suggest.mutateAsync(input),
|
||||||
|
);
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
await uploadReceipt.mutateAsync({
|
||||||
|
expenseId: id,
|
||||||
|
filename: result.image.filename,
|
||||||
|
mimeType: result.image.mimeType,
|
||||||
|
data: result.image.base64,
|
||||||
|
});
|
||||||
|
|
||||||
|
setForm(
|
||||||
|
expenseToForm(expense, {
|
||||||
|
description: result.description,
|
||||||
|
amountText: result.amountText,
|
||||||
|
date: result.date,
|
||||||
|
notes: result.ocrText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setReceiptSplit(
|
||||||
|
result.items.length > 0
|
||||||
|
? {
|
||||||
|
items: result.items,
|
||||||
|
subtotal: result.subtotal,
|
||||||
|
tax: result.tax,
|
||||||
|
total: result.total,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
setEditing(true);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Receipt attached",
|
||||||
|
result.items.length > 0
|
||||||
|
? "Select the owed items, apply the split amount, then save the expense."
|
||||||
|
: "OCR filled the fields below. Save to update this expense.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveEdits() {
|
||||||
|
if (!id) return;
|
||||||
|
const amount = Number(form.amountText);
|
||||||
|
if (!form.description.trim() || !Number.isFinite(amount) || amount <= 0) {
|
||||||
|
Alert.alert("Invalid fields", "Description and amount are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateExpense.mutate({
|
||||||
|
id,
|
||||||
|
description: form.description.trim(),
|
||||||
|
amount,
|
||||||
|
date: form.date,
|
||||||
|
category: form.category || undefined,
|
||||||
|
businessId: form.businessId || undefined,
|
||||||
|
clientId: form.clientId || undefined,
|
||||||
|
billable: form.billable,
|
||||||
|
reimbursable: form.reimbursable,
|
||||||
|
taxDeductible: form.taxDeductible,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing() {
|
||||||
|
if (!expense) return;
|
||||||
|
setForm(expenseToForm(expense));
|
||||||
|
setReceiptSplit(null);
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expenseQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading expense…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}>
|
||||||
|
Expense not found
|
||||||
|
</Text>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
<PageHeader
|
||||||
|
title={expense.description}
|
||||||
|
subtitle={formatDate(expense.date)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
{receiptSplit ? (
|
||||||
|
<ReceiptItemSelector
|
||||||
|
items={receiptSplit.items}
|
||||||
|
subtotal={receiptSplit.subtotal}
|
||||||
|
tax={receiptSplit.tax}
|
||||||
|
total={receiptSplit.total}
|
||||||
|
onApply={(selection) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
amountText: selection.owedTotal.toFixed(2),
|
||||||
|
notes: mergeNotes(selection.notes, current.notes),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ExpenseFormFields
|
||||||
|
value={form}
|
||||||
|
businesses={businesses}
|
||||||
|
clients={clients}
|
||||||
|
onChange={setForm}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Save changes"
|
||||||
|
loading={updateExpense.isPending}
|
||||||
|
onPress={handleSaveEdits}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Cancel edit"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => setEditing(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(expense.amount, expense.currency)}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.metaStack}>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
{expense.category || "No category"}
|
||||||
|
{expense.business?.name ? ` · ${expense.business.name}` : ""}
|
||||||
|
{expense.client?.name ? ` · ${expense.client.name}` : ""}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.badges}>
|
||||||
|
{expense.billable ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ color: colors.primary, borderColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Billable
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{expense.reimbursable ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{
|
||||||
|
color: colors.foreground,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Reimbursable
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{expense.taxDeductible ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ color: colors.success, borderColor: colors.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Tax deductible
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{expense.notes ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{expense.notes}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
title="Edit expense"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={startEditing}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={[styles.section, { color: colors.foreground }]}>
|
||||||
|
Receipts ({expense.receipts.length})
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{expense.receipts.map((receipt) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={receipt.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => deleteReceipt.mutate({ id: receipt.id }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.receiptRow, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
|
{receipt.originalFilename}
|
||||||
|
</Text>
|
||||||
|
</SwipeableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title={scanning ? "Scanning…" : "Scan receipt"}
|
||||||
|
loading={scanning || uploadReceipt.isPending}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void attachAndScan(true)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Import photo"
|
||||||
|
variant="secondary"
|
||||||
|
loading={scanning || uploadReceipt.isPending}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void attachAndScan(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
title="Back"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
body: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
metaStack: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
badges: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 4,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
receiptRow: {
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function expenseToForm(
|
||||||
|
expense: {
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
date: Date | string;
|
||||||
|
category: string | null;
|
||||||
|
businessId: string | null;
|
||||||
|
clientId: string | null;
|
||||||
|
billable: boolean;
|
||||||
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean | null;
|
||||||
|
notes: string | null;
|
||||||
|
},
|
||||||
|
overrides: Partial<ExpenseFormState> = {},
|
||||||
|
): ExpenseFormState {
|
||||||
|
return {
|
||||||
|
description: expense.description,
|
||||||
|
amountText: String(expense.amount),
|
||||||
|
date: new Date(expense.date),
|
||||||
|
category: expense.category ?? "",
|
||||||
|
businessId: expense.businessId ?? "",
|
||||||
|
clientId: expense.clientId ?? "",
|
||||||
|
billable: expense.billable,
|
||||||
|
reimbursable: expense.reimbursable,
|
||||||
|
taxDeductible: expense.taxDeductible ?? false,
|
||||||
|
notes: expense.notes ?? "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeNotes(prefix: string, existing: string) {
|
||||||
|
const trimmed = existing.trim();
|
||||||
|
if (!trimmed) return prefix;
|
||||||
|
if (trimmed.startsWith("Receipt split")) {
|
||||||
|
const ocrStart = trimmed.indexOf("\n\nOCR text:");
|
||||||
|
return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix;
|
||||||
|
}
|
||||||
|
return `${prefix}\n\nOCR text:\n${trimmed}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type ExpenseFilter = "all" | "billable" | "receipts";
|
||||||
|
|
||||||
|
export default function ExpensesScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [filter, setFilter] = useState<ExpenseFilter>("all");
|
||||||
|
const expensesQuery = api.expenses.getAll.useQuery();
|
||||||
|
|
||||||
|
const deleteExpense = api.expenses.delete.useMutation({
|
||||||
|
onSuccess: () => void utils.expenses.getAll.invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenses = expensesQuery.data ?? [];
|
||||||
|
const filteredExpenses = useMemo(
|
||||||
|
() =>
|
||||||
|
expenses.filter((expense) => {
|
||||||
|
if (filter === "billable") return expense.billable;
|
||||||
|
if (filter === "receipts") return expense.receiptCount > 0;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[expenses, filter],
|
||||||
|
);
|
||||||
|
const total = filteredExpenses.reduce(
|
||||||
|
(sum, expense) => sum + expense.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const receiptCount = filteredExpenses.reduce(
|
||||||
|
(sum, expense) => sum + (expense.receiptCount ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (expensesQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading expenses…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expensesQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={[styles.errorTitle, { color: colors.foreground }]}>
|
||||||
|
Could not load expenses
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{formatTrpcErrorMessage(expensesQuery.error)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Expenses"
|
||||||
|
subtitle={`${expenses.length} recorded`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Add expense"
|
||||||
|
onPress={() => router.push("/(app)/more/expenses/new" as never)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={expensesQuery.isRefetching}
|
||||||
|
onRefresh={() => void expensesQuery.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expenses.length === 0 ? (
|
||||||
|
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||||
|
No expenses yet. Add one with a receipt photo or manual entry.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.summary}>
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.summaryLabel,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Visible total
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.summaryValue, { color: colors.foreground }]}
|
||||||
|
>
|
||||||
|
{formatCurrency(total)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryRight}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.summaryLabel,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Receipts
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.summaryValue, { color: colors.foreground }]}
|
||||||
|
>
|
||||||
|
{receiptCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.filters}
|
||||||
|
>
|
||||||
|
<FilterChip
|
||||||
|
label="All"
|
||||||
|
active={filter === "all"}
|
||||||
|
onPress={() => setFilter("all")}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label="Billable"
|
||||||
|
active={filter === "billable"}
|
||||||
|
onPress={() => setFilter("billable")}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label="With receipts"
|
||||||
|
active={filter === "receipts"}
|
||||||
|
onPress={() => setFilter("receipts")}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{filteredExpenses.length === 0 ? (
|
||||||
|
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
|
||||||
|
No expenses match this filter.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
filteredExpenses.map((expense) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={expense.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "open",
|
||||||
|
label: "Open",
|
||||||
|
icon: "open-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () =>
|
||||||
|
router.push(
|
||||||
|
`/(app)/more/expenses/${expense.id}` as never,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => deleteExpense.mutate({ id: expense.id }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() =>
|
||||||
|
router.push(
|
||||||
|
`/(app)/more/expenses/${expense.id}` as never,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.row,
|
||||||
|
pressed && styles.rowPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.meta}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text
|
||||||
|
style={[styles.title, { color: colors.foreground }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{expense.description}
|
||||||
|
</Text>
|
||||||
|
{expense.receiptCount ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.receiptPill,
|
||||||
|
{
|
||||||
|
color: colors.primary,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{expense.receiptCount}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.sub,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{formatDate(expense.date)}
|
||||||
|
{expense.category ? ` · ${expense.category}` : ""}
|
||||||
|
{expense.client?.name
|
||||||
|
? ` · ${expense.client.name}`
|
||||||
|
: ""}
|
||||||
|
{expense.billable ? " · Billable" : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[styles.amount, { color: colors.foreground }]}
|
||||||
|
>
|
||||||
|
{formatCurrency(expense.amount, expense.currency)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</SwipeableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
rowPressed: {
|
||||||
|
opacity: 0.82,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
sub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.cardGlass,
|
||||||
|
},
|
||||||
|
summaryRight: {
|
||||||
|
alignItems: "flex-end",
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 20,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingRight: spacing.lg,
|
||||||
|
},
|
||||||
|
receiptPill: {
|
||||||
|
minWidth: 26,
|
||||||
|
textAlign: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import {
|
||||||
|
defaultExpenseFormState,
|
||||||
|
ExpenseFormFields,
|
||||||
|
type ExpenseFormState,
|
||||||
|
} from "@/components/expenses/ExpenseFormFields";
|
||||||
|
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { mlKitOcrAvailable } from "@/lib/receipt-ocr";
|
||||||
|
import {
|
||||||
|
scanReceiptImage,
|
||||||
|
type PickedReceiptImage,
|
||||||
|
type ReceiptScanResult,
|
||||||
|
} from "@/lib/receipt-scan";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type ReceiptSplitDraft = Pick<
|
||||||
|
ReceiptScanResult,
|
||||||
|
"items" | "subtotal" | "tax" | "total"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function NewExpenseScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const businesses = businessesQuery.data ?? [];
|
||||||
|
const clients = clientsQuery.data ?? [];
|
||||||
|
const defaultBusinessId = useMemo(
|
||||||
|
() =>
|
||||||
|
businesses.find((business) => business.isDefault)?.id ??
|
||||||
|
businesses[0]?.id ??
|
||||||
|
"",
|
||||||
|
[businesses],
|
||||||
|
);
|
||||||
|
const [form, setForm] = useState<ExpenseFormState>(() =>
|
||||||
|
defaultExpenseFormState(),
|
||||||
|
);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [pendingReceipt, setPendingReceipt] =
|
||||||
|
useState<PickedReceiptImage | null>(null);
|
||||||
|
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const createExpense = api.expenses.create.useMutation();
|
||||||
|
const uploadReceipt = api.expenses.uploadReceipt.useMutation();
|
||||||
|
const suggest = api.expenses.suggestFromReceiptText.useMutation();
|
||||||
|
|
||||||
|
async function runScan(fromCamera: boolean) {
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
const result = await scanReceiptImage(
|
||||||
|
fromCamera,
|
||||||
|
{
|
||||||
|
description: form.description,
|
||||||
|
amountText: form.amountText,
|
||||||
|
date: form.date,
|
||||||
|
},
|
||||||
|
(input) => suggest.mutateAsync(input),
|
||||||
|
);
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
description: result.description,
|
||||||
|
amountText: result.amountText,
|
||||||
|
date: result.date,
|
||||||
|
notes: result.ocrText,
|
||||||
|
businessId: current.businessId || defaultBusinessId,
|
||||||
|
}));
|
||||||
|
setPendingReceipt(result.image);
|
||||||
|
setReceiptSplit(
|
||||||
|
result.items.length > 0
|
||||||
|
? {
|
||||||
|
items: result.items,
|
||||||
|
subtotal: result.subtotal,
|
||||||
|
tax: result.tax,
|
||||||
|
total: result.total,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Receipt scanned",
|
||||||
|
result.items.length > 0
|
||||||
|
? "Select the items this person owes, then apply the split amount."
|
||||||
|
: result.amountText
|
||||||
|
? `Filled amount $${result.amountText}${result.description ? ` from ${result.description}` : ""}. Review and save.`
|
||||||
|
: "Review the fields — OCR could not find a total automatically.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const amount = Number(form.amountText);
|
||||||
|
if (!form.description.trim()) {
|
||||||
|
Alert.alert("Description required", "Enter what this expense was for.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
Alert.alert("Amount required", "Enter a valid amount.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expense = await createExpense.mutateAsync({
|
||||||
|
description: form.description.trim(),
|
||||||
|
amount,
|
||||||
|
date: form.date,
|
||||||
|
currency: "USD",
|
||||||
|
category: form.category || undefined,
|
||||||
|
clientId: form.clientId || undefined,
|
||||||
|
businessId: form.businessId || defaultBusinessId || undefined,
|
||||||
|
billable: form.billable,
|
||||||
|
reimbursable: form.reimbursable,
|
||||||
|
taxDeductible: form.taxDeductible,
|
||||||
|
notes: form.notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingReceipt) {
|
||||||
|
await uploadReceipt.mutateAsync({
|
||||||
|
expenseId: expense.id,
|
||||||
|
filename: pendingReceipt.filename,
|
||||||
|
mimeType: pendingReceipt.mimeType,
|
||||||
|
data: pendingReceipt.base64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await utils.expenses.getAll.invalidate();
|
||||||
|
router.replace(`/(app)/more/expenses/${expense.id}` as never);
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(
|
||||||
|
"Could not save expense",
|
||||||
|
err instanceof Error ? err.message : "Try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.body}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<PageHeader
|
||||||
|
title="New expense"
|
||||||
|
subtitle={
|
||||||
|
mlKitOcrAvailable()
|
||||||
|
? "Scan a receipt with on-device ML Kit OCR"
|
||||||
|
: "Manual entry (OCR unavailable on this device)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
title={scanning ? "Scanning…" : "Scan receipt"}
|
||||||
|
variant="secondary"
|
||||||
|
loading={scanning}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void runScan(true)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Import photo"
|
||||||
|
variant="secondary"
|
||||||
|
loading={scanning}
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => void runScan(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{pendingReceipt ? (
|
||||||
|
<Text style={{ color: colors.success, fontSize: 13 }}>
|
||||||
|
Receipt image ready — it will attach when you save.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{receiptSplit ? (
|
||||||
|
<ReceiptItemSelector
|
||||||
|
items={receiptSplit.items}
|
||||||
|
subtotal={receiptSplit.subtotal}
|
||||||
|
tax={receiptSplit.tax}
|
||||||
|
total={receiptSplit.total}
|
||||||
|
onApply={(selection) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
amountText: selection.owedTotal.toFixed(2),
|
||||||
|
notes: mergeNotes(selection.notes, current.notes),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ExpenseFormFields
|
||||||
|
value={{
|
||||||
|
...form,
|
||||||
|
businessId: form.businessId || defaultBusinessId,
|
||||||
|
}}
|
||||||
|
businesses={businesses}
|
||||||
|
clients={clients}
|
||||||
|
onChange={setForm}
|
||||||
|
notesLabel="Notes"
|
||||||
|
notesPlaceholder="Receipt OCR text or internal notes"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Save expense"
|
||||||
|
loading={createExpense.isPending}
|
||||||
|
onPress={() => void handleSave()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Cancel"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
body: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function mergeNotes(prefix: string, existing: string) {
|
||||||
|
const trimmed = existing.trim();
|
||||||
|
if (!trimmed) return prefix;
|
||||||
|
if (trimmed.startsWith("Receipt split")) {
|
||||||
|
const ocrStart = trimmed.indexOf("\n\nOCR text:");
|
||||||
|
return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix;
|
||||||
|
}
|
||||||
|
return `${prefix}\n\nOCR text:\n${trimmed}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
|
||||||
|
type HubItem = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
href: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS: HubItem[] = [
|
||||||
|
{
|
||||||
|
title: "Expenses",
|
||||||
|
subtitle: "Track costs and attach receipts",
|
||||||
|
href: "/(app)/more/expenses",
|
||||||
|
icon: "receipt-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reports",
|
||||||
|
subtitle: "Revenue, hours, and tax summaries",
|
||||||
|
href: "/(app)/more/reports",
|
||||||
|
icon: "bar-chart-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Recurring invoices",
|
||||||
|
subtitle: "Scheduled billing templates",
|
||||||
|
href: "/(app)/more/recurring",
|
||||||
|
icon: "repeat-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Time entries",
|
||||||
|
subtitle: "Full history with edit and delete",
|
||||||
|
href: "/(app)/more/time-entries",
|
||||||
|
icon: "time-outline",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MoreScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView header={<PageHeader title="More" subtitle="Additional tools" />}>
|
||||||
|
<View style={styles.list}>
|
||||||
|
{ITEMS.map((item) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.href}
|
||||||
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||||
|
onPress={() => router.push(item.href as never)}
|
||||||
|
>
|
||||||
|
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||||
|
<Ionicons name={item.icon} size={22} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.copy}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{item.title}</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
{item.subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
list: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
},
|
||||||
|
rowPressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function RecurringScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const query = api.recurringInvoices.getAll.useQuery();
|
||||||
|
|
||||||
|
const pause = api.recurringInvoices.pause.useMutation({
|
||||||
|
onSuccess: () => void query.refetch(),
|
||||||
|
});
|
||||||
|
const resume = api.recurringInvoices.resume.useMutation({
|
||||||
|
onSuccess: () => void query.refetch(),
|
||||||
|
});
|
||||||
|
const generateNow = api.recurringInvoices.generateNow.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void query.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading recurring invoices…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = query.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title="Recurring"
|
||||||
|
subtitle={`${items.length} schedule${items.length === 1 ? "" : "s"}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={query.isRefetching}
|
||||||
|
onRefresh={() => void query.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
No recurring invoices yet. Create them on the web dashboard for now.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={item.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "generate",
|
||||||
|
label: "Run",
|
||||||
|
icon: "play-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => generateNow.mutate({ id: item.id }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "toggle",
|
||||||
|
label: item.status === "active" ? "Pause" : "Resume",
|
||||||
|
icon: item.status === "active" ? "pause-outline" : "play-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.mutedForeground,
|
||||||
|
onPress: () =>
|
||||||
|
item.status === "active"
|
||||||
|
? pause.mutate({ id: item.id })
|
||||||
|
: resume.mutate({ id: item.id }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{item.name}</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
{item.client?.name ?? "Client"} · {item.schedule} · {item.status}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
Next due {formatDate(item.nextDueAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(
|
||||||
|
item.items.reduce((sum, line) => sum + line.hours * line.rate, 0),
|
||||||
|
item.currency,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SwipeableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "rgba(0,0,0,0.08)",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { StatCard } from "@/components/StatCard";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function ReportsScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const statsQuery = api.dashboard.getStats.useQuery();
|
||||||
|
const expensesQuery = api.expenses.getAll.useQuery();
|
||||||
|
const summaryQuery = api.timeEntries.getSummary.useQuery();
|
||||||
|
|
||||||
|
if (statsQuery.isLoading || expensesQuery.isLoading || summaryQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading reports…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statsQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}>
|
||||||
|
{formatTrpcErrorMessage(statsQuery.error)}
|
||||||
|
</Text>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = statsQuery.data!;
|
||||||
|
const expenseTotal = (expensesQuery.data ?? []).reduce((sum, e) => sum + e.amount, 0);
|
||||||
|
const summary = summaryQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView
|
||||||
|
header={<PageHeader title="Reports" subtitle="Business performance snapshot" />}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={statsQuery.isRefetching}
|
||||||
|
onRefresh={() => {
|
||||||
|
void statsQuery.refetch();
|
||||||
|
void expensesQuery.refetch();
|
||||||
|
void summaryQuery.refetch();
|
||||||
|
}}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
<StatCard label="Revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||||
|
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
||||||
|
<StatCard label="Expenses" value={formatCurrency(expenseTotal)} />
|
||||||
|
<StatCard
|
||||||
|
label="Billable hours"
|
||||||
|
value={summary ? summary.totalHours.toFixed(1) : "0"}
|
||||||
|
hint={summary ? `${summary.count} entries` : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card title="Invoice status">
|
||||||
|
{(stats.statusChartData ?? []).map((item) => (
|
||||||
|
<View key={item.status} style={styles.statusRow}>
|
||||||
|
<Text style={{ color: colors.foreground }}>{item.name}</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{item.count} · {formatCurrency(item.value)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Revenue trend (6 mo)">
|
||||||
|
{stats.revenueChartData.map((point) => (
|
||||||
|
<View key={point.month} style={styles.statusRow}>
|
||||||
|
<Text style={{ color: colors.foreground }}>{point.monthLabel}</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>
|
||||||
|
{formatCurrency(point.revenue)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
grid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
statusRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatRunningTimerLabel } from "@/lib/time-clock";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import type { AppRouter } from "beenvoice/server/api/root";
|
||||||
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
|
||||||
|
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
|
||||||
|
|
||||||
|
function groupByDate(entries: TimeEntry[]) {
|
||||||
|
const groups = new Map<string, typeof entries>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const d = new Date(entry.startedAt);
|
||||||
|
const key = d.toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const list = groups.get(key) ?? [];
|
||||||
|
list.push(entry);
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimeEntriesScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
|
const entriesQuery = api.timeEntries.getAll.useQuery();
|
||||||
|
|
||||||
|
const completed = useMemo(
|
||||||
|
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
|
||||||
|
[entriesQuery.data],
|
||||||
|
);
|
||||||
|
const grouped = useMemo(() => groupByDate(completed), [completed]);
|
||||||
|
|
||||||
|
if (entriesQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading time entries…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entriesQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}>
|
||||||
|
{formatTrpcErrorMessage(entriesQuery.error)}
|
||||||
|
</Text>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={entriesQuery.isRefetching}
|
||||||
|
onRefresh={() => void entriesQuery.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{grouped.length === 0 ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
No completed entries yet. Start the timer from the Timer tab.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
grouped.map(([label, entries]) => (
|
||||||
|
<Card key={label} title={label}>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<SwipeableRow
|
||||||
|
key={entry.id}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => setEditEntryId(entry.id),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
{formatRunningTimerLabel(entry.description)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
|
||||||
|
{entry.client?.name ?? "No client"}
|
||||||
|
{entry.invoice
|
||||||
|
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||||
|
: " · not billed"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
{entry.hours ?? "—"}h
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SwipeableRow>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
|
||||||
|
<TimeEntryEditSheet
|
||||||
|
entryId={editEntryId}
|
||||||
|
visible={editEntryId != null}
|
||||||
|
onClose={() => setEditEntryId(null)}
|
||||||
|
/>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function OnboardingScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const statusQuery = api.settings.getOnboardingStatus.useQuery();
|
||||||
|
|
||||||
|
const [businessName, setBusinessName] = useState("");
|
||||||
|
const [clientName, setClientName] = useState("");
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
|
const createBusiness = api.businesses.create.useMutation();
|
||||||
|
const createClient = api.clients.create.useMutation();
|
||||||
|
const complete = api.settings.completeOnboarding.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.settings.getOnboardingStatus.invalidate();
|
||||||
|
await utils.settings.getProfile.invalidate();
|
||||||
|
router.replace("/(app)");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
if (businessName.trim()) {
|
||||||
|
await createBusiness.mutateAsync({
|
||||||
|
name: businessName.trim(),
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (clientName.trim()) {
|
||||||
|
await createClient.mutateAsync({
|
||||||
|
name: clientName.trim(),
|
||||||
|
currency: "USD",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await complete.mutateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: "Welcome to beenvoice",
|
||||||
|
body: "Set up your workspace in a minute — business profile and first client.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Your business",
|
||||||
|
body: "This appears on invoices you send to clients.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "First client",
|
||||||
|
body: "Add someone you bill. You can skip and add clients later.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const current = steps[step]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
<Text style={[styles.kicker, { color: colors.mutedForeground }]}>
|
||||||
|
Step {step + 1} of {steps.length}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{current.title}</Text>
|
||||||
|
<Text style={[styles.bodyText, { color: colors.mutedForeground }]}>{current.body}</Text>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<Input
|
||||||
|
label="Business name"
|
||||||
|
value={businessName}
|
||||||
|
onChangeText={setBusinessName}
|
||||||
|
placeholder="Your studio or company"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 2 ? (
|
||||||
|
<Input
|
||||||
|
label="Client name"
|
||||||
|
value={clientName}
|
||||||
|
onChangeText={setClientName}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{step > 0 ? (
|
||||||
|
<Button title="Back" variant="secondary" onPress={() => setStep((s) => s - 1)} />
|
||||||
|
) : null}
|
||||||
|
{step < steps.length - 1 ? (
|
||||||
|
<Button title="Continue" onPress={() => setStep((s) => s + 1)} />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
title={complete.isPending ? "Finishing…" : "Go to dashboard"}
|
||||||
|
loading={complete.isPending}
|
||||||
|
onPress={() => void finish()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{statusQuery.data && !statusQuery.data.completed ? (
|
||||||
|
<Button title="Skip for now" variant="secondary" onPress={() => void complete.mutateAsync()} />
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
body: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
minHeight: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
kicker: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
fontSize: 32,
|
||||||
|
lineHeight: 36,
|
||||||
|
},
|
||||||
|
bodyText: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -25,9 +25,11 @@
|
|||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-font": "~56.0.7",
|
"expo-font": "~56.0.7",
|
||||||
"expo-image": "^56.0.11",
|
"expo-image": "^56.0.11",
|
||||||
|
"expo-image-picker": "~56.0.18",
|
||||||
"expo-linear-gradient": "~56.0.4",
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-local-authentication": "~56.0.4",
|
"expo-local-authentication": "~56.0.4",
|
||||||
|
"expo-mlkit-ocr": "^0.2.7",
|
||||||
"expo-network": "^56.0.5",
|
"expo-network": "^56.0.5",
|
||||||
"expo-notifications": "^56.0.18",
|
"expo-notifications": "^56.0.18",
|
||||||
"expo-router": "~56.2.11",
|
"expo-router": "~56.2.11",
|
||||||
@@ -696,6 +698,10 @@
|
|||||||
|
|
||||||
"expo-image": ["expo-image@56.0.11", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-k2xwxGk14xi6zxmEGAU4rUTb1lK5qf0y0Qb8+Jaggnul0KaJJxcq9qvyDp9iyJBW35cp9isONAUnNtIiooZ/Pw=="],
|
"expo-image": ["expo-image@56.0.11", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-k2xwxGk14xi6zxmEGAU4rUTb1lK5qf0y0Qb8+Jaggnul0KaJJxcq9qvyDp9iyJBW35cp9isONAUnNtIiooZ/Pw=="],
|
||||||
|
|
||||||
|
"expo-image-loader": ["expo-image-loader@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA=="],
|
||||||
|
|
||||||
|
"expo-image-picker": ["expo-image-picker@56.0.18", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg=="],
|
||||||
|
|
||||||
"expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="],
|
"expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="],
|
||||||
|
|
||||||
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
|
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
|
||||||
@@ -708,6 +714,8 @@
|
|||||||
|
|
||||||
"expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="],
|
"expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="],
|
||||||
|
|
||||||
|
"expo-mlkit-ocr": ["expo-mlkit-ocr@0.2.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-zx+6rZPwfzGc2ck+tKLFiHwkezbFJqt+Xrz3P5Q0dUAxgIIwwIXtXCjMReRQitnC/vfBGLgh26gO2slEGXSuLQ=="],
|
||||||
|
|
||||||
"expo-modules-autolinking": ["expo-modules-autolinking@56.0.16", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": "bin/expo-modules-autolinking.js" }, "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw=="],
|
"expo-modules-autolinking": ["expo-modules-autolinking@56.0.16", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": "bin/expo-modules-autolinking.js" }, "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw=="],
|
||||||
|
|
||||||
"expo-modules-core": ["expo-modules-core@56.0.17", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", "expo-modules-jsi": "~56.0.10", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" } }, "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ=="],
|
"expo-modules-core": ["expo-modules-core@56.0.17", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", "expo-modules-jsi": "~56.0.10", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" } }, "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ=="],
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
/** Redirect new users into onboarding until they complete setup. */
|
||||||
|
export function OnboardingGate() {
|
||||||
|
const statusQuery = api.settings.getOnboardingStatus.useQuery();
|
||||||
|
const redirected = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirected.current || statusQuery.isLoading || !statusQuery.data) return;
|
||||||
|
if (statusQuery.data.completed) return;
|
||||||
|
redirected.current = true;
|
||||||
|
router.push("/(app)/onboarding" as never);
|
||||||
|
}, [statusQuery.data, statusQuery.isLoading]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { ReactNode, useRef } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import Swipeable, {
|
||||||
|
type SwipeableMethods,
|
||||||
|
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||||
|
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
|
||||||
|
export type SwipeAction = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
color: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SwipeableRowProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
actions: SwipeAction[];
|
||||||
|
enabled?: boolean;
|
||||||
|
backgroundColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SwipeableRow({
|
||||||
|
children,
|
||||||
|
actions,
|
||||||
|
enabled = true,
|
||||||
|
backgroundColor,
|
||||||
|
}: SwipeableRowProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createSwipeableRowStyles);
|
||||||
|
const rowBackground = backgroundColor ?? colors.background;
|
||||||
|
const swipeRef = useRef<SwipeableMethods>(null);
|
||||||
|
|
||||||
|
function renderRightActions() {
|
||||||
|
return (
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<Pressable
|
||||||
|
key={action.key}
|
||||||
|
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
|
||||||
|
onPress={() => {
|
||||||
|
swipeRef.current?.close();
|
||||||
|
action.onPress();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={action.icon} size={20} color={action.color} />
|
||||||
|
<Text style={[styles.actionLabel, { color: action.color }]}>{action.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled || actions.length === 0) {
|
||||||
|
return <View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
|
||||||
|
<View style={[styles.row, { backgroundColor: rowBackground }]}>{children}</View>
|
||||||
|
</Swipeable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSwipeableRowStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "stretch",
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
width: 80,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
actionLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { Switch, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { SelectField, type SelectOption } from "@/components/ui/SelectField";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
|
||||||
|
|
||||||
|
const NONE = "__none__";
|
||||||
|
|
||||||
|
export type ExpenseFormState = {
|
||||||
|
description: string;
|
||||||
|
amountText: string;
|
||||||
|
date: Date;
|
||||||
|
category: string;
|
||||||
|
businessId: string;
|
||||||
|
clientId: string;
|
||||||
|
billable: boolean;
|
||||||
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpenseFormFieldsProps = {
|
||||||
|
value: ExpenseFormState;
|
||||||
|
businesses: Array<{ id: string; name: string; isDefault?: boolean | null }>;
|
||||||
|
clients: Array<{ id: string; name: string }>;
|
||||||
|
onChange: (value: ExpenseFormState) => void;
|
||||||
|
notesLabel?: string;
|
||||||
|
notesPlaceholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function defaultExpenseFormState(
|
||||||
|
defaultBusinessId = "",
|
||||||
|
): ExpenseFormState {
|
||||||
|
return {
|
||||||
|
description: "",
|
||||||
|
amountText: "",
|
||||||
|
date: new Date(),
|
||||||
|
category: "",
|
||||||
|
businessId: defaultBusinessId,
|
||||||
|
clientId: "",
|
||||||
|
billable: false,
|
||||||
|
reimbursable: false,
|
||||||
|
taxDeductible: false,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseFormFields({
|
||||||
|
value,
|
||||||
|
businesses,
|
||||||
|
clients,
|
||||||
|
onChange,
|
||||||
|
notesLabel = "Notes",
|
||||||
|
notesPlaceholder = "Internal details or receipt OCR text",
|
||||||
|
}: ExpenseFormFieldsProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
const businessOptions: SelectOption[] = [
|
||||||
|
{ label: "Default business", value: NONE },
|
||||||
|
...businesses.map((business) => ({
|
||||||
|
label: business.isDefault ? `${business.name} (default)` : business.name,
|
||||||
|
value: business.id,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const clientOptions: SelectOption[] = [
|
||||||
|
{ label: "No client", value: NONE },
|
||||||
|
...clients.map((client) => ({ label: client.name, value: client.id })),
|
||||||
|
];
|
||||||
|
const categoryOptions: SelectOption[] = [
|
||||||
|
{ label: "No category", value: NONE },
|
||||||
|
...EXPENSE_CATEGORIES.map((category) => ({
|
||||||
|
label: category,
|
||||||
|
value: category,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const setField = <K extends keyof ExpenseFormState>(
|
||||||
|
field: K,
|
||||||
|
nextValue: ExpenseFormState[K],
|
||||||
|
) => onChange({ ...value, [field]: nextValue });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.stack}>
|
||||||
|
<Input
|
||||||
|
label="Description"
|
||||||
|
required
|
||||||
|
value={value.description}
|
||||||
|
onChangeText={(text) => setField("description", text)}
|
||||||
|
placeholder="e.g. Client lunch"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Amount"
|
||||||
|
required
|
||||||
|
value={value.amountText}
|
||||||
|
onChangeText={(text) => setField("amountText", text)}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
<DateTimeField
|
||||||
|
label="Date"
|
||||||
|
value={value.date}
|
||||||
|
onChange={(date) => setField("date", date)}
|
||||||
|
mode="date"
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Category"
|
||||||
|
placeholder="No category"
|
||||||
|
value={value.category || NONE}
|
||||||
|
options={categoryOptions}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
setField("category", next === NONE ? "" : next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Business"
|
||||||
|
placeholder="Default business"
|
||||||
|
value={value.businessId || NONE}
|
||||||
|
options={businessOptions}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
setField("businessId", next === NONE ? "" : next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Client"
|
||||||
|
placeholder="No client"
|
||||||
|
value={value.clientId || NONE}
|
||||||
|
options={clientOptions}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
setField("clientId", next === NONE ? "" : next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.flags}>
|
||||||
|
<FlagSwitch
|
||||||
|
label="Billable"
|
||||||
|
value={value.billable}
|
||||||
|
onValueChange={(next) => setField("billable", next)}
|
||||||
|
/>
|
||||||
|
<FlagSwitch
|
||||||
|
label="Reimbursable"
|
||||||
|
value={value.reimbursable}
|
||||||
|
onValueChange={(next) => setField("reimbursable", next)}
|
||||||
|
/>
|
||||||
|
<FlagSwitch
|
||||||
|
label="Tax deductible"
|
||||||
|
value={value.taxDeductible}
|
||||||
|
onValueChange={(next) => setField("taxDeductible", next)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label={notesLabel}
|
||||||
|
value={value.notes}
|
||||||
|
onChangeText={(text) => setField("notes", text)}
|
||||||
|
placeholder={notesPlaceholder}
|
||||||
|
multiline
|
||||||
|
style={styles.notesInput}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
function FlagSwitch({
|
||||||
|
label,
|
||||||
|
value: checked,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.flagRow, { borderColor: colors.borderGlass }]}>
|
||||||
|
<Text style={[styles.flagLabel, { color: colors.foreground }]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={checked}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
trackColor={{
|
||||||
|
true: colors.switchTrackOn,
|
||||||
|
false: colors.switchTrackOff,
|
||||||
|
}}
|
||||||
|
thumbColor={colors.switchThumb}
|
||||||
|
ios_backgroundColor={colors.switchIosBackground}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
stack: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
flagRow: {
|
||||||
|
minHeight: 48,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
flagLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
notesInput: {
|
||||||
|
minHeight: 96,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { ReceiptLineItem } from "@/lib/receipt-parse";
|
||||||
|
|
||||||
|
export type ReceiptSplitSelection = {
|
||||||
|
selectedItemIds: string[];
|
||||||
|
selectedSubtotal: number;
|
||||||
|
allocatedTax: number;
|
||||||
|
owedTotal: number;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReceiptItemSelectorProps = {
|
||||||
|
items: ReceiptLineItem[];
|
||||||
|
subtotal: number | null;
|
||||||
|
tax: number | null;
|
||||||
|
total: number | null;
|
||||||
|
onApply: (selection: ReceiptSplitSelection) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReceiptItemSelector({
|
||||||
|
items,
|
||||||
|
subtotal,
|
||||||
|
tax,
|
||||||
|
total,
|
||||||
|
onApply,
|
||||||
|
}: ReceiptItemSelectorProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||||
|
() => new Set(items.length === 1 ? [items[0]!.id] : []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculation = useMemo(() => {
|
||||||
|
const selected = items.filter((item) => selectedIds.has(item.id));
|
||||||
|
const selectedSubtotal = roundMoney(
|
||||||
|
selected.reduce((sum, item) => sum + item.amount, 0),
|
||||||
|
);
|
||||||
|
const receiptSubtotal =
|
||||||
|
subtotal && subtotal > 0
|
||||||
|
? subtotal
|
||||||
|
: roundMoney(items.reduce((sum, item) => sum + item.amount, 0));
|
||||||
|
const knownTax =
|
||||||
|
tax ??
|
||||||
|
(total && receiptSubtotal > 0
|
||||||
|
? Math.max(0, roundMoney(total - receiptSubtotal))
|
||||||
|
: 0);
|
||||||
|
const allocatedTax =
|
||||||
|
receiptSubtotal > 0
|
||||||
|
? roundMoney(knownTax * (selectedSubtotal / receiptSubtotal))
|
||||||
|
: 0;
|
||||||
|
const owedTotal = roundMoney(selectedSubtotal + allocatedTax);
|
||||||
|
const notes = [
|
||||||
|
"Receipt split",
|
||||||
|
...selected.map(
|
||||||
|
(item) => `- ${item.name}: ${formatCurrency(item.amount)}`,
|
||||||
|
),
|
||||||
|
`Selected subtotal: ${formatCurrency(selectedSubtotal)}`,
|
||||||
|
`Allocated tax: ${formatCurrency(allocatedTax)}`,
|
||||||
|
`Owed total: ${formatCurrency(owedTotal)}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return { selected, selectedSubtotal, allocatedTax, owedTotal, notes };
|
||||||
|
}, [items, selectedIds, subtotal, tax, total]);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setSelectedIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.wrap, { borderColor: colors.border }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerCopy}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>
|
||||||
|
Select expense items
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
Pick only the receipt lines that should become this expense. Tax is
|
||||||
|
split proportionally.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.total, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(calculation.owedTotal)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{items.length > 1 ? (
|
||||||
|
<View style={styles.bulkActions}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() =>
|
||||||
|
setSelectedIds(new Set(items.map((item) => item.id)))
|
||||||
|
}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.bulkAction,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.bulkActionText, { color: colors.primary }]}>
|
||||||
|
Select all
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => setSelectedIds(new Set())}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.bulkAction,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.bulkActionText, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.itemList}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const selected = selectedIds.has(item.id);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={item.id}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: selected }}
|
||||||
|
onPress={() => toggle(item.id)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.item,
|
||||||
|
{ borderColor: colors.borderGlass },
|
||||||
|
selected && { backgroundColor: colors.muted },
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={selected ? "checkmark-circle" : "ellipse-outline"}
|
||||||
|
size={21}
|
||||||
|
color={selected ? colors.primary : colors.mutedForeground}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.itemName, { color: colors.foreground }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.itemAmount, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.summary}>
|
||||||
|
<SummaryRow label="Items" value={calculation.selectedSubtotal} />
|
||||||
|
<SummaryRow label="Tax" value={calculation.allocatedTax} />
|
||||||
|
<SummaryRow label="Owed" value={calculation.owedTotal} strong />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title={
|
||||||
|
calculation.selected.length === 0
|
||||||
|
? "Select an item to apply"
|
||||||
|
: "Apply selected items"
|
||||||
|
}
|
||||||
|
disabled={calculation.selected.length === 0}
|
||||||
|
onPress={() =>
|
||||||
|
onApply({
|
||||||
|
selectedItemIds: calculation.selected.map((item) => item.id),
|
||||||
|
selectedSubtotal: calculation.selectedSubtotal,
|
||||||
|
allocatedTax: calculation.allocatedTax,
|
||||||
|
owedTotal: calculation.owedTotal,
|
||||||
|
notes: calculation.notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
function SummaryRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
strong,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
strong?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.summaryValue,
|
||||||
|
{ color: colors.foreground },
|
||||||
|
strong && styles.summaryValueStrong,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formatCurrency(value)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMoney(value: number) {
|
||||||
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: {
|
||||||
|
gap: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
headerCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 18,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
itemList: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
bulkActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
bulkAction: {
|
||||||
|
minHeight: 34,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
bulkActionText: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
minHeight: 48,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
itemAmount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
summaryRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
summaryValueStrong: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const EXPENSE_CATEGORIES = [
|
||||||
|
"Travel",
|
||||||
|
"Meals & Entertainment",
|
||||||
|
"Software & Subscriptions",
|
||||||
|
"Hardware & Equipment",
|
||||||
|
"Office Supplies",
|
||||||
|
"Marketing",
|
||||||
|
"Professional Services",
|
||||||
|
"Utilities",
|
||||||
|
"Other",
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { isSupported, recognizeText } from "expo-mlkit-ocr";
|
||||||
|
|
||||||
|
import { parseReceiptText, type ReceiptParseResult } from "@/lib/receipt-parse";
|
||||||
|
|
||||||
|
export type ReceiptOcrResult = ReceiptParseResult & {
|
||||||
|
rawText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mlKitOcrAvailable(): boolean {
|
||||||
|
try {
|
||||||
|
return isSupported();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImageUri(uri: string): string {
|
||||||
|
if (uri.startsWith("file://") || uri.startsWith("content://")) {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
return `file://${uri}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run on-device ML Kit OCR and parse receipt fields from recognized text. */
|
||||||
|
export async function recognizeReceiptFromImage(uri: string): Promise<ReceiptOcrResult> {
|
||||||
|
if (!mlKitOcrAvailable()) {
|
||||||
|
throw new Error("On-device OCR is not supported on this device.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await recognizeText(normalizeImageUri(uri));
|
||||||
|
const rawText = result.text?.trim() ?? "";
|
||||||
|
const parsed = parseReceiptText(rawText);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
rawText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyReceiptOcrToForm(
|
||||||
|
ocr: ReceiptParseResult,
|
||||||
|
current: { description: string; amountText: string; date: Date },
|
||||||
|
): { description: string; amountText: string; date: Date; ocrText: string } {
|
||||||
|
return {
|
||||||
|
description: ocr.vendor?.trim() || current.description,
|
||||||
|
amountText: ocr.amount != null ? String(ocr.amount) : current.amountText,
|
||||||
|
date: ocr.date ?? current.date,
|
||||||
|
ocrText: ocr.rawLines.join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
export type ReceiptParseResult = {
|
||||||
|
amount: number | null;
|
||||||
|
date: Date | null;
|
||||||
|
subtotal: number | null;
|
||||||
|
tax: number | null;
|
||||||
|
vendor: string | null;
|
||||||
|
items: ReceiptLineItem[];
|
||||||
|
rawLines: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReceiptLineItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
rawLine: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AMOUNT_PATTERNS = [
|
||||||
|
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
||||||
|
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
|
||||||
|
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATE_PATTERNS = [
|
||||||
|
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
|
||||||
|
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUBTOTAL_PATTERNS = [
|
||||||
|
/(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const TAX_PATTERNS = [
|
||||||
|
/(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const NON_ITEM_LINE =
|
||||||
|
/(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i;
|
||||||
|
|
||||||
|
function parseAmount(text: string): number | null {
|
||||||
|
for (const pattern of AMOUNT_PATTERNS) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (!match?.[1]) continue;
|
||||||
|
const value = Number(match[1].replace(/,/g, ""));
|
||||||
|
if (Number.isFinite(value) && value > 0) return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
|
||||||
|
.map((m) => Number(m[1]!.replace(/,/g, "")))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0);
|
||||||
|
|
||||||
|
return amounts.length > 0 ? Math.max(...amounts) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFirstMatchingAmount(
|
||||||
|
text: string,
|
||||||
|
patterns: RegExp[],
|
||||||
|
): number | null {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (!match?.[1]) continue;
|
||||||
|
const value = Number(match[1].replace(/,/g, ""));
|
||||||
|
if (Number.isFinite(value) && value >= 0) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(text: string): Date | null {
|
||||||
|
for (const pattern of DATE_PATTERNS) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (!match?.[1]) continue;
|
||||||
|
const parsed = new Date(match[1]);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVendor(lines: string[]): string | null {
|
||||||
|
const candidate = lines.find((line) => line.trim().length >= 3);
|
||||||
|
return candidate?.trim().slice(0, 120) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLineItems(lines: string[]): ReceiptLineItem[] {
|
||||||
|
const items: ReceiptLineItem[] = [];
|
||||||
|
|
||||||
|
for (const [index, rawLine] of lines.entries()) {
|
||||||
|
const line = rawLine.replace(/\s+/g, " ").trim();
|
||||||
|
if (line.length < 5 || NON_ITEM_LINE.test(line)) continue;
|
||||||
|
|
||||||
|
const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/);
|
||||||
|
if (!match?.[1] || !match[2]) continue;
|
||||||
|
|
||||||
|
const amount = Number(match[2].replace(/,/g, ""));
|
||||||
|
const name = match[1]
|
||||||
|
.replace(/^\d+\s*[xX]\s+/, "")
|
||||||
|
.replace(/\s+\d+\s*[xX]\s*$/, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`,
|
||||||
|
name: name.slice(0, 80),
|
||||||
|
amount,
|
||||||
|
rawLine,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseReceiptText(text: string): ReceiptParseResult {
|
||||||
|
const normalized = text.replace(/\r/g, "\n").trim();
|
||||||
|
const rawLines = normalized
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount: parseAmount(normalized),
|
||||||
|
date: parseDate(normalized),
|
||||||
|
subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS),
|
||||||
|
tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS),
|
||||||
|
vendor: parseVendor(rawLines),
|
||||||
|
items: parseLineItems(rawLines),
|
||||||
|
rawLines,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyReceiptOcrToForm,
|
||||||
|
recognizeReceiptFromImage,
|
||||||
|
} from "@/lib/receipt-ocr";
|
||||||
|
import type { ReceiptLineItem } from "@/lib/receipt-parse";
|
||||||
|
|
||||||
|
export type ReceiptSuggestFn = (input: { text: string }) => Promise<{
|
||||||
|
amount: number | null;
|
||||||
|
date: Date | null;
|
||||||
|
description: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type PickedReceiptImage = {
|
||||||
|
uri: string;
|
||||||
|
base64: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReceiptScanResult = {
|
||||||
|
image: PickedReceiptImage;
|
||||||
|
description: string;
|
||||||
|
amountText: string;
|
||||||
|
date: Date;
|
||||||
|
ocrText: string;
|
||||||
|
items: ReceiptLineItem[];
|
||||||
|
subtotal: number | null;
|
||||||
|
tax: number | null;
|
||||||
|
total: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function requestPermissions(fromCamera: boolean): Promise<boolean> {
|
||||||
|
const permission = fromCamera
|
||||||
|
? await ImagePicker.requestCameraPermissionsAsync()
|
||||||
|
: await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
|
||||||
|
if (permission.granted) return true;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
fromCamera ? "Camera access needed" : "Photos access needed",
|
||||||
|
fromCamera
|
||||||
|
? "Allow camera access to scan receipts."
|
||||||
|
: "Allow photo library access to import receipt images.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickReceiptImage(
|
||||||
|
fromCamera: boolean,
|
||||||
|
): Promise<PickedReceiptImage | null> {
|
||||||
|
if (!(await requestPermissions(fromCamera))) return null;
|
||||||
|
|
||||||
|
const result = fromCamera
|
||||||
|
? await ImagePicker.launchCameraAsync({
|
||||||
|
mediaTypes: ["images"],
|
||||||
|
quality: 0.9,
|
||||||
|
base64: true,
|
||||||
|
})
|
||||||
|
: await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ["images"],
|
||||||
|
quality: 0.9,
|
||||||
|
base64: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets[0]) return null;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
if (!asset.uri || !asset.base64) {
|
||||||
|
Alert.alert(
|
||||||
|
"Could not read image",
|
||||||
|
"Try another photo or lower the image size.",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: asset.uri,
|
||||||
|
base64: asset.base64,
|
||||||
|
filename:
|
||||||
|
asset.fileName ?? (fromCamera ? "receipt.jpg" : "receipt-import.jpg"),
|
||||||
|
mimeType: asset.mimeType ?? "image/jpeg",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanReceiptImage(
|
||||||
|
fromCamera: boolean,
|
||||||
|
current: { description: string; amountText: string; date: Date },
|
||||||
|
suggest?: ReceiptSuggestFn,
|
||||||
|
): Promise<ReceiptScanResult | null> {
|
||||||
|
const image = await pickReceiptImage(fromCamera);
|
||||||
|
if (!image) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ocr = await recognizeReceiptFromImage(image.uri);
|
||||||
|
let next = applyReceiptOcrToForm(ocr, current);
|
||||||
|
next = { ...next, ocrText: ocr.rawText || next.ocrText };
|
||||||
|
|
||||||
|
if (ocr.rawText && suggest) {
|
||||||
|
try {
|
||||||
|
const suggestion = await suggest({ text: ocr.rawText });
|
||||||
|
next = {
|
||||||
|
description: suggestion.description?.trim() || next.description,
|
||||||
|
amountText:
|
||||||
|
suggestion.amount != null
|
||||||
|
? String(suggestion.amount)
|
||||||
|
: next.amountText,
|
||||||
|
date: suggestion.date ? new Date(suggestion.date) : next.date,
|
||||||
|
ocrText: ocr.rawText,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Local parse is enough when server suggest fails offline.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
image,
|
||||||
|
...next,
|
||||||
|
items: ocr.items,
|
||||||
|
subtotal: ocr.subtotal,
|
||||||
|
tax: ocr.tax,
|
||||||
|
total: ocr.amount,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert(
|
||||||
|
"OCR failed",
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Could not read text from the receipt.",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,9 +23,11 @@
|
|||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-font": "~56.0.7",
|
"expo-font": "~56.0.7",
|
||||||
"expo-image": "^56.0.11",
|
"expo-image": "^56.0.11",
|
||||||
|
"expo-image-picker": "~56.0.18",
|
||||||
"expo-linear-gradient": "~56.0.4",
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-local-authentication": "~56.0.4",
|
"expo-local-authentication": "~56.0.4",
|
||||||
|
"expo-mlkit-ocr": "^0.2.7",
|
||||||
"expo-network": "^56.0.5",
|
"expo-network": "^56.0.5",
|
||||||
"expo-notifications": "^56.0.18",
|
"expo-notifications": "^56.0.18",
|
||||||
"expo-router": "~56.2.11",
|
"expo-router": "~56.2.11",
|
||||||
|
|||||||
Reference in New Issue
Block a user