Stabilize mobile auth session handling
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.beenvoice.app",
|
"bundleIdentifier": "com.beenvoice.app",
|
||||||
"buildNumber": "13",
|
"buildNumber": "17",
|
||||||
"icon": "./assets/beenvoice.icon",
|
"icon": "./assets/beenvoice.icon",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Pressable,
|
Pressable,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -15,6 +16,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
|
|||||||
import { GlassSurface } from "@/components/GlassSurface";
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
@@ -38,6 +40,12 @@ export default function EntitiesScreen() {
|
|||||||
|
|
||||||
const clientsQuery = api.clients.getAll.useQuery();
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
const businessesQuery = api.businesses.getAll.useQuery();
|
const businessesQuery = api.businesses.getAll.useQuery();
|
||||||
|
const deleteClient = api.clients.delete.useMutation({
|
||||||
|
onSuccess: () => void clientsQuery.refetch(),
|
||||||
|
});
|
||||||
|
const deleteBusiness = api.businesses.delete.useMutation({
|
||||||
|
onSuccess: () => void businessesQuery.refetch(),
|
||||||
|
});
|
||||||
|
|
||||||
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
||||||
const isLoading =
|
const isLoading =
|
||||||
@@ -68,6 +76,20 @@ export default function EntitiesScreen() {
|
|||||||
else void businessesQuery.refetch();
|
else void businessesQuery.refetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmDelete(id: string, name: string) {
|
||||||
|
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
if (tab === "clients") deleteClient.mutate({ id });
|
||||||
|
else deleteBusiness.mutate({ id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
<TabPage>
|
<TabPage>
|
||||||
@@ -112,25 +134,45 @@ export default function EntitiesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
clients.map((client) => (
|
clients.map((client) => (
|
||||||
<Pressable
|
<SwipeableRow
|
||||||
key={client.id}
|
key={client.id}
|
||||||
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
|
backgroundColor={colors.cardGlass}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => confirmDelete(client.id, client.name),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<GlassSurface style={styles.card}>
|
<Pressable onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}>
|
||||||
<View style={styles.cardInner}>
|
<GlassSurface style={styles.card}>
|
||||||
<Text style={styles.name}>{client.name}</Text>
|
<View style={styles.cardInner}>
|
||||||
{client.email ? (
|
<Text style={styles.name}>{client.name}</Text>
|
||||||
<Text style={styles.meta}>{client.email}</Text>
|
{client.email ? (
|
||||||
) : null}
|
<Text style={styles.meta}>{client.email}</Text>
|
||||||
{client.defaultHourlyRate != null ? (
|
) : null}
|
||||||
<Text style={styles.meta}>
|
{client.defaultHourlyRate != null ? (
|
||||||
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
<Text style={styles.meta}>
|
||||||
/hr
|
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
||||||
</Text>
|
/hr
|
||||||
) : null}
|
</Text>
|
||||||
</View>
|
) : null}
|
||||||
</GlassSurface>
|
</View>
|
||||||
</Pressable>
|
</GlassSurface>
|
||||||
|
</Pressable>
|
||||||
|
</SwipeableRow>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
) : businesses.length === 0 ? (
|
) : businesses.length === 0 ? (
|
||||||
@@ -142,25 +184,45 @@ export default function EntitiesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
businesses.map((business) => (
|
businesses.map((business) => (
|
||||||
<Pressable
|
<SwipeableRow
|
||||||
key={business.id}
|
key={business.id}
|
||||||
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
|
backgroundColor={colors.cardGlass}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => confirmDelete(business.id, business.name),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<GlassSurface style={styles.card}>
|
<Pressable onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}>
|
||||||
<View style={styles.cardInner}>
|
<GlassSurface style={styles.card}>
|
||||||
<View style={styles.nameRow}>
|
<View style={styles.cardInner}>
|
||||||
<Text style={styles.name}>{business.name}</Text>
|
<View style={styles.nameRow}>
|
||||||
{business.isDefault ? (
|
<Text style={styles.name}>{business.name}</Text>
|
||||||
<Text style={styles.badge}>Default</Text>
|
{business.isDefault ? (
|
||||||
|
<Text style={styles.badge}>Default</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{business.nickname ? (
|
||||||
|
<Text style={styles.meta}>{business.nickname}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
{business.nickname ? (
|
</GlassSurface>
|
||||||
<Text style={styles.meta}>{business.nickname}</Text>
|
</Pressable>
|
||||||
) : null}
|
</SwipeableRow>
|
||||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
|
||||||
</View>
|
|
||||||
</GlassSurface>
|
|
||||||
</Pressable>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TabScrollView>
|
</TabScrollView>
|
||||||
|
|||||||
+37
-2
@@ -20,6 +20,7 @@ import { useThemedStyles } from "@/lib/use-themed-styles";
|
|||||||
import { getInvoiceStatus } from "@/lib/invoice-status";
|
import { getInvoiceStatus } from "@/lib/invoice-status";
|
||||||
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
|
import { formatElapsedHoursMinutes, resolveClockDescription } from "@/lib/time-clock";
|
||||||
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
export default function DashboardScreen() {
|
export default function DashboardScreen() {
|
||||||
@@ -41,7 +42,7 @@ export default function DashboardScreen() {
|
|||||||
<Screen>
|
<Screen>
|
||||||
<View style={styles.errorBox}>
|
<View style={styles.errorBox}>
|
||||||
<Text style={styles.errorTitle}>Could not load dashboard</Text>
|
<Text style={styles.errorTitle}>Could not load dashboard</Text>
|
||||||
<Text style={styles.errorText}>{statsQuery.error.message}</Text>
|
<Text style={styles.errorText}>{formatTrpcErrorMessage(statsQuery.error)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</Screen>
|
</Screen>
|
||||||
</AppBackground>
|
</AppBackground>
|
||||||
@@ -62,7 +63,7 @@ export default function DashboardScreen() {
|
|||||||
: "No change vs last month";
|
: "No change vs last month";
|
||||||
|
|
||||||
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
|
const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1);
|
||||||
const sendReminderDue = stats.sendReminderDue ?? [];
|
const sendReminderDue = stats.recentInvoices.filter((inv) => inv.status === "draft");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
@@ -146,8 +147,31 @@ export default function DashboardScreen() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() => router.push("/(app)/invoices")}
|
onPress={() => router.push("/(app)/invoices")}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
title="Reports"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.push("/(app)/more/reports" as never)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{stats.currentDraft ? (
|
||||||
|
<GlassSurface style={styles.alertGlass}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.alertBanner}
|
||||||
|
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
|
||||||
|
>
|
||||||
|
<Text style={styles.alertTitle}>
|
||||||
|
Draft {stats.currentDraft.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.alertText}>
|
||||||
|
{stats.currentDraft.client?.name ?? "Client"} ·{" "}
|
||||||
|
{formatCurrency(stats.currentDraft.totalAmount)} ·{" "}
|
||||||
|
{stats.currentDraft.totalHours.toFixed(1)}h logged
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View style={styles.statsGrid}>
|
<View style={styles.statsGrid}>
|
||||||
<View style={styles.statCell}>
|
<View style={styles.statCell}>
|
||||||
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
|
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||||
@@ -190,6 +214,17 @@ export default function DashboardScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Invoice status">
|
||||||
|
{(stats.statusChartData ?? []).map((item) => (
|
||||||
|
<View key={item.status} style={styles.invoiceRow}>
|
||||||
|
<Text style={styles.invoiceClient}>{item.name}</Text>
|
||||||
|
<Text style={styles.invoiceDate}>
|
||||||
|
{item.count} · {formatCurrency(item.value)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="Recent invoices">
|
<Card title="Recent invoices">
|
||||||
{stats.recentInvoices.length === 0 ? (
|
{stats.recentInvoices.length === 0 ? (
|
||||||
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
|
<Text style={styles.empty}>No invoices yet. Create one from the Invoices tab.</Text>
|
||||||
|
|||||||
+42
-15
@@ -8,6 +8,7 @@ import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
|
|||||||
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
|
||||||
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
|
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
@@ -23,6 +24,7 @@ import { api } from "@/lib/trpc";
|
|||||||
|
|
||||||
export default function InvoiceDetailScreen() {
|
export default function InvoiceDetailScreen() {
|
||||||
const styles = useThemedStyles(createInvoiceDetailStyles);
|
const styles = useThemedStyles(createInvoiceDetailStyles);
|
||||||
|
const { colors } = useAppTheme();
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const scrollPadding = useTabBarScrollPadding();
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
@@ -50,10 +52,9 @@ export default function InvoiceDetailScreen() {
|
|||||||
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
onError: (err) => Alert.alert("Could not send reminder", err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const invoice = invoiceQuery.data;
|
|
||||||
const previewInput = useMemo(
|
const previewInput = useMemo(
|
||||||
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
|
() => (invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null),
|
||||||
[invoice],
|
[invoiceQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -78,6 +79,7 @@ export default function InvoiceDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
const status = getInvoiceStatus(invoice);
|
const status = getInvoiceStatus(invoice);
|
||||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||||
@@ -209,20 +211,45 @@ export default function InvoiceDetailScreen() {
|
|||||||
add lines manually.
|
add lines manually.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
invoice.items.map((item) => (
|
invoice.items.map((item) => {
|
||||||
<View key={item.id} style={styles.lineItem}>
|
const line = (
|
||||||
<View style={styles.lineMeta}>
|
<View style={styles.lineItem}>
|
||||||
<Text style={styles.lineDescription}>{item.description}</Text>
|
<View style={styles.lineMeta}>
|
||||||
<Text style={styles.lineSub}>
|
<Text style={styles.lineDescription}>{item.description}</Text>
|
||||||
{formatDate(item.date)} · {item.hours}h ×{" "}
|
<Text style={styles.lineSub}>
|
||||||
{formatCurrency(item.rate, invoice.currency)}
|
{formatDate(item.date)} · {item.hours}h ×{" "}
|
||||||
|
{formatCurrency(item.rate, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.lineAmount}>
|
||||||
|
{formatCurrency(item.amount, invoice.currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.lineAmount}>
|
);
|
||||||
{formatCurrency(item.amount, invoice.currency)}
|
|
||||||
</Text>
|
if (invoice.status !== "draft") {
|
||||||
</View>
|
return <View key={item.id}>{line}</View>;
|
||||||
))
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeableRow
|
||||||
|
key={item.id}
|
||||||
|
backgroundColor={colors.card}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</SwipeableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
<InvoiceTotals
|
<InvoiceTotals
|
||||||
subtotal={formatCurrency(subtotal, invoice.currency)}
|
subtotal={formatCurrency(subtotal, invoice.currency)}
|
||||||
|
|||||||
@@ -196,6 +196,15 @@ export default function InvoiceEditScreen() {
|
|||||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateItem(index: number) {
|
||||||
|
setItems((prev) => {
|
||||||
|
const source = prev[index];
|
||||||
|
if (!source) return prev;
|
||||||
|
const copy = { ...source, id: undefined };
|
||||||
|
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -325,6 +334,7 @@ export default function InvoiceEditScreen() {
|
|||||||
isLast={index === items.length - 1}
|
isLast={index === items.length - 1}
|
||||||
onChange={(patch) => updateItem(index, patch)}
|
onChange={(patch) => updateItem(index, patch)}
|
||||||
onRemove={() => removeItem(index)}
|
onRemove={() => removeItem(index)}
|
||||||
|
onDuplicate={isDraft ? () => duplicateItem(index) : undefined}
|
||||||
readOnly={!isDraft}
|
readOnly={!isDraft}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
|
|||||||
import { GlassSurface } from "@/components/GlassSurface";
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
@@ -25,6 +26,7 @@ import { formatCurrency, formatDate } from "@/lib/format";
|
|||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
import { getInvoiceStatus, type InvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
|
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
|
||||||
@@ -49,6 +51,14 @@ export default function InvoicesScreen() {
|
|||||||
onError: (err) => Alert.alert("Update failed", err.message),
|
onError: (err) => Alert.alert("Update failed", err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteInvoice = api.invoices.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.invoices.getAll.invalidate();
|
||||||
|
utils.dashboard.getStats.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Delete failed", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
if (invoicesQuery.isLoading) {
|
if (invoicesQuery.isLoading) {
|
||||||
return <LoadingScreen message="Loading invoices…" />;
|
return <LoadingScreen message="Loading invoices…" />;
|
||||||
}
|
}
|
||||||
@@ -59,7 +69,7 @@ export default function InvoicesScreen() {
|
|||||||
<TabPage>
|
<TabPage>
|
||||||
<View style={styles.errorBox}>
|
<View style={styles.errorBox}>
|
||||||
<Text style={styles.errorTitle}>Could not load invoices</Text>
|
<Text style={styles.errorTitle}>Could not load invoices</Text>
|
||||||
<Text style={styles.errorText}>{invoicesQuery.error.message}</Text>
|
<Text style={styles.errorText}>{formatTrpcErrorMessage(invoicesQuery.error)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</TabPage>
|
</TabPage>
|
||||||
</AppBackground>
|
</AppBackground>
|
||||||
@@ -93,6 +103,17 @@ export default function InvoicesScreen() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmDelete(invoiceId: string, label: string) {
|
||||||
|
Alert.alert("Delete invoice?", `Remove ${label}? This cannot be undone.`, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => deleteInvoice.mutate({ id: invoiceId }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
<TabPage>
|
<TabPage>
|
||||||
@@ -134,35 +155,82 @@ export default function InvoicesScreen() {
|
|||||||
) : (
|
) : (
|
||||||
invoices.map((invoice) => {
|
invoices.map((invoice) => {
|
||||||
const status = getInvoiceStatus(invoice);
|
const status = getInvoiceStatus(invoice);
|
||||||
|
const label = `${invoice.invoicePrefix}${invoice.invoiceNumber}`;
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
key: "open",
|
||||||
|
label: "Open",
|
||||||
|
icon: "open-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/${invoice.id}`),
|
||||||
|
},
|
||||||
|
...(status === "draft"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: "create-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.mutedForeground,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/edit/${invoice.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "send",
|
||||||
|
label: "Send",
|
||||||
|
icon: "send-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
onPress: () => router.push(`/(app)/invoices/send/${invoice.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: () => confirmDelete(invoice.id, label),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Status",
|
||||||
|
icon: "flag-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
onPress: () => promptStatusChange(invoice.id, status),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<SwipeableRow key={invoice.id} actions={actions} backgroundColor={colors.cardGlass}>
|
||||||
key={invoice.id}
|
<Pressable
|
||||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||||
onLongPress={() => promptStatusChange(invoice.id, status)}
|
onLongPress={() => promptStatusChange(invoice.id, status)}
|
||||||
>
|
>
|
||||||
<GlassSurface style={styles.card}>
|
<GlassSurface style={styles.card}>
|
||||||
<View style={styles.cardInner}>
|
<View style={styles.cardInner}>
|
||||||
<View style={styles.cardTop}>
|
<View style={styles.cardTop}>
|
||||||
<View style={styles.cardMeta}>
|
<View style={styles.cardMeta}>
|
||||||
<Text style={styles.invoiceNumber}>
|
<Text style={styles.invoiceNumber}>{label}</Text>
|
||||||
{invoice.invoicePrefix}
|
<Text style={styles.clientName}>
|
||||||
{invoice.invoiceNumber}
|
{invoice.client?.name ?? "Client"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.clientName}>
|
</View>
|
||||||
{invoice.client?.name ?? "Client"}
|
<Text style={styles.amount}>
|
||||||
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.amount}>
|
<View style={styles.cardBottom}>
|
||||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
|
||||||
</Text>
|
<StatusBadge status={status} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.cardBottom}>
|
</GlassSurface>
|
||||||
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
|
</Pressable>
|
||||||
<StatusBadge status={status} />
|
</SwipeableRow>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</GlassSurface>
|
|
||||||
</Pressable>
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -203,6 +203,15 @@ export default function NewInvoiceScreen() {
|
|||||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateItem(index: number) {
|
||||||
|
setItems((prev) => {
|
||||||
|
const source = prev[index];
|
||||||
|
if (!source) return prev;
|
||||||
|
const copy = { ...source, id: undefined };
|
||||||
|
return [...prev.slice(0, index + 1), copy, ...prev.slice(index + 1)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
if (!canCreate) return;
|
if (!canCreate) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -327,6 +336,7 @@ export default function NewInvoiceScreen() {
|
|||||||
isLast={index === items.length - 1}
|
isLast={index === items.length - 1}
|
||||||
onChange={(patch) => updateItem(index, patch)}
|
onChange={(patch) => updateItem(index, patch)}
|
||||||
onRemove={() => removeItem(index)}
|
onRemove={() => removeItem(index)}
|
||||||
|
onDuplicate={() => duplicateItem(index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ export default function InvoiceSendScreen() {
|
|||||||
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const invoice = invoiceQuery.data;
|
|
||||||
const previewInput = useMemo(
|
const previewInput = useMemo(
|
||||||
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
|
() =>
|
||||||
[invoice],
|
invoiceQuery.data ? buildPreviewPdfInputFromInvoice(invoiceQuery.data) : null,
|
||||||
|
[invoiceQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -65,10 +65,11 @@ export default function InvoiceSendScreen() {
|
|||||||
return <LoadingScreen message="Loading invoice…" />;
|
return <LoadingScreen message="Loading invoice…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoiceQuery.data) {
|
||||||
return <LoadingScreen message="Invoice not found" />;
|
return <LoadingScreen message="Invoice not found" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
const status = getInvoiceStatus(invoice);
|
const status = getInvoiceStatus(invoice);
|
||||||
const clientEmail = invoice.client?.email?.trim() ?? "";
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||||
const businessName = invoice.business?.name ?? "Your business";
|
const businessName = invoice.business?.name ?? "Your business";
|
||||||
@@ -91,7 +92,7 @@ export default function InvoiceSendScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendInvoice.mutate({
|
sendInvoice.mutate({
|
||||||
invoiceId: invoice!.id,
|
invoiceId: invoice.id,
|
||||||
customMessage: customMessage.trim() || undefined,
|
customMessage: customMessage.trim() || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
|||||||
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||||||
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
|
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
|
||||||
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
|
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
|
||||||
@@ -89,16 +90,20 @@ export default function SettingsScreen() {
|
|||||||
async (result) => {
|
async (result) => {
|
||||||
await finishAccountRemoval({
|
await finishAccountRemoval({
|
||||||
result,
|
result,
|
||||||
|
authClient,
|
||||||
clearActiveAccount,
|
clearActiveAccount,
|
||||||
signOut: () => authClient.signOut(),
|
activeAccountId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut() {
|
||||||
await authClient.signOut();
|
await performAuthReset({
|
||||||
await clearActiveAccount();
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
});
|
||||||
router.replace("/(auth)/sign-in");
|
router.replace("/(auth)/sign-in");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,11 +81,16 @@ export default function RegisterScreen() {
|
|||||||
const session = await authClient.getSession();
|
const session = await authClient.getSession();
|
||||||
const user = session.data?.user;
|
const user = session.data?.user;
|
||||||
if (user) {
|
if (user) {
|
||||||
await completeSignInAfterAuth(authClient, {
|
const completed = await completeSignInAfterAuth(authClient, {
|
||||||
apiUrl,
|
apiUrl,
|
||||||
activeAccountId,
|
activeAccountId,
|
||||||
registerAccount: saveAccount,
|
registerAccount: saveAccount,
|
||||||
});
|
});
|
||||||
|
if (!completed) {
|
||||||
|
setError("Account created but session setup failed. Try signing in.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError("Account created. Sign in with your email and password.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Registration failed");
|
setError(err instanceof Error ? err.message : "Registration failed");
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
|
|||||||
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
|
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
|
||||||
import { signInWithAuthentik } from "@/lib/auth-oauth";
|
import { signInWithAuthentik } from "@/lib/auth-oauth";
|
||||||
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
|
||||||
|
import { formatAuthErrorMessage } from "@/lib/trpc-errors";
|
||||||
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
|
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
|
||||||
|
|
||||||
export default function SignInScreen() {
|
export default function SignInScreen() {
|
||||||
@@ -81,12 +82,7 @@ export default function SignInScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (signInError) {
|
if (signInError) {
|
||||||
const message = signInError.message ?? "";
|
setError(formatAuthErrorMessage(signInError));
|
||||||
if (message.toLowerCase().includes("internal") || message.includes("500")) {
|
|
||||||
setError("Server error — is the API running with Postgres? Check beenvoice dev + docker.");
|
|
||||||
} else {
|
|
||||||
setError(message || "Invalid email or password");
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +104,7 @@ export default function SignInScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (oauthError) {
|
if (oauthError) {
|
||||||
setError(oauthError.message ?? "Could not sign in with Authentik");
|
setError(formatAuthErrorMessage(oauthError));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-13
@@ -16,6 +16,7 @@ import { View } from "react-native";
|
|||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
|
||||||
import { BrandBackground } from "@/components/BrandBackground";
|
import { BrandBackground } from "@/components/BrandBackground";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
@@ -84,28 +85,30 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<ThemeProvider>
|
<SafeAreaProvider>
|
||||||
<ThemedChrome>
|
<ThemeProvider>
|
||||||
<AccountsProvider>
|
<ThemedChrome>
|
||||||
<AppServices>
|
<AccountsProvider>
|
||||||
<RootNavigator />
|
<AppServices>
|
||||||
</AppServices>
|
<RootNavigator />
|
||||||
</AccountsProvider>
|
</AppServices>
|
||||||
</ThemedChrome>
|
</AccountsProvider>
|
||||||
</ThemeProvider>
|
</ThemedChrome>
|
||||||
</SafeAreaProvider>
|
</ThemeProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RootNavigator() {
|
function RootNavigator() {
|
||||||
const { data: session, isPending, error } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return <LoadingScreen message="Checking session…" />;
|
return <LoadingScreen message="Checking session…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthenticated = Boolean(session?.user) && !error;
|
const isAuthenticated = Boolean(session?.user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
|
|||||||
@@ -92,8 +92,9 @@ export function AccountSwitcher() {
|
|||||||
}
|
}
|
||||||
await finishAccountRemoval({
|
await finishAccountRemoval({
|
||||||
result,
|
result,
|
||||||
|
authClient,
|
||||||
clearActiveAccount,
|
clearActiveAccount,
|
||||||
signOut: () => authClient.signOut(),
|
activeAccountId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { AppState, type AppStateStatus } from "react-native";
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
|
||||||
import { useSession } from "@/contexts/AuthContext";
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||||
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
|
import { isRateLimitError } from "@/lib/trpc-errors";
|
||||||
|
|
||||||
/** Refetch auth session when the app returns to the foreground. */
|
/** Refetch auth session when the app returns to the foreground. */
|
||||||
export function SessionSync() {
|
export function SessionSync() {
|
||||||
const { refetch } = useSession();
|
const authClient = useAuthClient();
|
||||||
|
const { activeAccountId, clearActiveAccount } = useAccounts();
|
||||||
|
const { data: session, refetch } = useSession();
|
||||||
const wasBackgrounded = useRef(false);
|
const wasBackgrounded = useRef(false);
|
||||||
|
const resettingRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
||||||
@@ -17,11 +23,30 @@ export function SessionSync() {
|
|||||||
|
|
||||||
if (nextState !== "active" || !wasBackgrounded.current) return;
|
if (nextState !== "active" || !wasBackgrounded.current) return;
|
||||||
wasBackgrounded.current = false;
|
wasBackgrounded.current = false;
|
||||||
void refetch();
|
|
||||||
|
void (async () => {
|
||||||
|
await refetch();
|
||||||
|
const next = await authClient.getSession();
|
||||||
|
if (next.error && isRateLimitError(next.error)) return;
|
||||||
|
if (next.data?.user) return;
|
||||||
|
if (!session?.user || resettingRef.current) return;
|
||||||
|
|
||||||
|
resettingRef.current = true;
|
||||||
|
try {
|
||||||
|
await performAuthReset({
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
refetchSession: refetch,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
resettingRef.current = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => subscription.remove();
|
return () => subscription.remove();
|
||||||
}, [refetch]);
|
}, [authClient, refetch, session?.user, activeAccountId, clearActiveAccount]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
|||||||
|
|
||||||
import { CompactDateField } from "@/components/ui/CompactDateField";
|
import { CompactDateField } from "@/components/ui/CompactDateField";
|
||||||
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
|
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { fonts, radii, spacing } from "@/constants/theme";
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { formatCurrency, formatShortDate } from "@/lib/format";
|
import { formatCurrency, formatShortDate } from "@/lib/format";
|
||||||
@@ -21,6 +22,7 @@ type LineItemEditorProps = {
|
|||||||
currency: string;
|
currency: string;
|
||||||
onChange: (patch: Partial<EditableLineItem>) => void;
|
onChange: (patch: Partial<EditableLineItem>) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
};
|
};
|
||||||
@@ -38,6 +40,7 @@ export function LineItemEditor({
|
|||||||
currency,
|
currency,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onDuplicate,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
isLast = false,
|
isLast = false,
|
||||||
}: LineItemEditorProps) {
|
}: LineItemEditorProps) {
|
||||||
@@ -70,7 +73,7 @@ export function LineItemEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.editBlock,
|
styles.editBlock,
|
||||||
@@ -156,6 +159,35 @@ export function LineItemEditor({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const swipeActions = [
|
||||||
|
...(onDuplicate
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "duplicate",
|
||||||
|
label: "Copy",
|
||||||
|
icon: "copy-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: onDuplicate,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: "trash-outline" as const,
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.destructive,
|
||||||
|
onPress: onRemove,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeableRow actions={swipeActions} backgroundColor={colors.card}>
|
||||||
|
{content}
|
||||||
|
</SwipeableRow>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { router } from "expo-router";
|
|||||||
import { FilterChip } from "@/components/FilterChip";
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
import { GlassSurface } from "@/components/GlassSurface";
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
@@ -98,6 +100,8 @@ export function TimeClockPanel({
|
|||||||
const [agoMinutesText, setAgoMinutesText] = useState("60");
|
const [agoMinutesText, setAgoMinutesText] = useState("60");
|
||||||
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||||
const [clientsExpanded, setClientsExpanded] = useState(false);
|
const [clientsExpanded, setClientsExpanded] = useState(false);
|
||||||
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
|
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
|
||||||
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
|
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
|
||||||
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
|
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
|
||||||
const [prefsLoaded, setPrefsLoaded] = useState(false);
|
const [prefsLoaded, setPrefsLoaded] = useState(false);
|
||||||
@@ -140,6 +144,15 @@ export function TimeClockPanel({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateRunning = api.timeEntries.updateRunning.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
utils.timeEntries.getRunning.invalidate(),
|
||||||
|
utils.invoices.getBillable.invalidate(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
await endTimeClockLiveActivity();
|
await endTimeClockLiveActivity();
|
||||||
@@ -184,8 +197,10 @@ export function TimeClockPanel({
|
|||||||
if (!running) return;
|
if (!running) return;
|
||||||
setClientId(running.clientId ?? "");
|
setClientId(running.clientId ?? "");
|
||||||
setInvoiceId(running.invoiceId ?? "");
|
setInvoiceId(running.invoiceId ?? "");
|
||||||
|
setDescription(running.description ?? "");
|
||||||
setStopNote("");
|
setStopNote("");
|
||||||
setRateText(running.rate != null ? String(running.rate) : "");
|
setRateText(running.rate != null ? String(running.rate) : "");
|
||||||
|
setRunningStartedAt(new Date(running.startedAt));
|
||||||
}, [running]);
|
}, [running]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -269,8 +284,7 @@ export function TimeClockPanel({
|
|||||||
}, [agoMinutes, startMode, startedAt]);
|
}, [agoMinutes, startMode, startedAt]);
|
||||||
|
|
||||||
const clockInErrors = useMemo(() => {
|
const clockInErrors = useMemo(() => {
|
||||||
const next: { clientId?: string; rate?: string; start?: string } = {};
|
const next: { rate?: string; start?: string } = {};
|
||||||
if (!clientId) next.clientId = "Choose a client to start";
|
|
||||||
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
|
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
|
||||||
next.rate = "Enter a valid hourly rate";
|
next.rate = "Enter a valid hourly rate";
|
||||||
}
|
}
|
||||||
@@ -278,7 +292,7 @@ export function TimeClockPanel({
|
|||||||
next.start = "Enter how long ago you started";
|
next.start = "Enter how long ago you started";
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}, [agoMinutes, clientId, rateText, startMode]);
|
}, [agoMinutes, rateText, startMode]);
|
||||||
|
|
||||||
const canClockIn = Object.keys(clockInErrors).length === 0;
|
const canClockIn = Object.keys(clockInErrors).length === 0;
|
||||||
|
|
||||||
@@ -307,6 +321,14 @@ export function TimeClockPanel({
|
|||||||
|
|
||||||
function selectClient(nextClientId: string) {
|
function selectClient(nextClientId: string) {
|
||||||
const client = clients.find((c) => c.id === nextClientId);
|
const client = clients.find((c) => c.id === nextClientId);
|
||||||
|
if (running) {
|
||||||
|
updateRunning.mutate({
|
||||||
|
clientId: nextClientId,
|
||||||
|
invoiceId: "",
|
||||||
|
rate: client?.defaultHourlyRate ?? undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setClientId(nextClientId);
|
setClientId(nextClientId);
|
||||||
setInvoiceId("");
|
setInvoiceId("");
|
||||||
setRateText(clientRateText(client));
|
setRateText(clientRateText(client));
|
||||||
@@ -316,6 +338,27 @@ export function TimeClockPanel({
|
|||||||
void persistClientChoice(nextClientId);
|
void persistClientChoice(nextClientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectInvoice(nextInvoiceId: string) {
|
||||||
|
if (running) {
|
||||||
|
updateRunning.mutate({ invoiceId: nextInvoiceId || "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInvoiceId(nextInvoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRunningDescriptionBlur() {
|
||||||
|
if (!running) return;
|
||||||
|
const next = resolveClockDescription(description);
|
||||||
|
if (next === (running.description ?? "")) return;
|
||||||
|
updateRunning.mutate({ description: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRunningStartedAtChange(date: Date) {
|
||||||
|
setRunningStartedAt(date);
|
||||||
|
if (!running || date > new Date()) return;
|
||||||
|
updateRunning.mutate({ startedAt: date });
|
||||||
|
}
|
||||||
|
|
||||||
function selectStartMode(mode: StartMode) {
|
function selectStartMode(mode: StartMode) {
|
||||||
setStartMode(mode);
|
setStartMode(mode);
|
||||||
if (mode !== "now") setOptionsExpanded(true);
|
if (mode !== "now") setOptionsExpanded(true);
|
||||||
@@ -363,7 +406,9 @@ export function TimeClockPanel({
|
|||||||
rate: effectiveRate ?? undefined,
|
rate: effectiveRate ?? undefined,
|
||||||
startedAt: backdated,
|
startedAt: backdated,
|
||||||
});
|
});
|
||||||
await persistClientChoice(clientId);
|
if (clientId) {
|
||||||
|
await persistClientChoice(clientId);
|
||||||
|
}
|
||||||
setStartMode("now");
|
setStartMode("now");
|
||||||
setStartedAt(new Date());
|
setStartedAt(new Date());
|
||||||
setAgoMinutes(60);
|
setAgoMinutes(60);
|
||||||
@@ -445,7 +490,7 @@ export function TimeClockPanel({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.idleHint}>
|
<Text style={styles.idleHint}>
|
||||||
Choose a client and clock in. A draft invoice is created automatically if needed.
|
Start the timer anytime — add client, invoice, and details later.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -457,6 +502,62 @@ export function TimeClockPanel({
|
|||||||
|
|
||||||
{running ? (
|
{running ? (
|
||||||
<View style={styles.formSection}>
|
<View style={styles.formSection}>
|
||||||
|
<Input
|
||||||
|
label="What are you working on?"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
onBlur={handleRunningDescriptionBlur}
|
||||||
|
placeholder="What are you working on?"
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateTimeField
|
||||||
|
label="Started at"
|
||||||
|
value={runningStartedAt}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
onChange={handleRunningStartedAtChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.setupSection}>
|
||||||
|
<Text style={styles.sectionLabel}>Client</Text>
|
||||||
|
<View style={styles.chipWrap}>
|
||||||
|
<FilterChip
|
||||||
|
label="None"
|
||||||
|
active={!clientId}
|
||||||
|
onPress={() => selectClient("")}
|
||||||
|
/>
|
||||||
|
{clients.map((client) => (
|
||||||
|
<FilterChip
|
||||||
|
key={client.id}
|
||||||
|
label={client.name}
|
||||||
|
active={clientId === client.id}
|
||||||
|
onPress={() => selectClient(client.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{clientId ? (
|
||||||
|
<View style={styles.setupSection}>
|
||||||
|
<Text style={styles.sectionLabel}>Invoice</Text>
|
||||||
|
<View style={styles.chipWrap}>
|
||||||
|
<FilterChip
|
||||||
|
label="Entry only"
|
||||||
|
active={!invoiceId}
|
||||||
|
onPress={() => selectInvoice("")}
|
||||||
|
/>
|
||||||
|
{billableInvoices.map((invoice) => (
|
||||||
|
<FilterChip
|
||||||
|
key={invoice.id}
|
||||||
|
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
|
||||||
|
active={invoiceId === invoice.id}
|
||||||
|
onPress={() => selectInvoice(invoice.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Note on stop (optional)"
|
label="Note on stop (optional)"
|
||||||
value={stopNote}
|
value={stopNote}
|
||||||
@@ -490,7 +591,7 @@ export function TimeClockPanel({
|
|||||||
<Text style={styles.sectionLabel}>Client</Text>
|
<Text style={styles.sectionLabel}>Client</Text>
|
||||||
{clients.length === 0 ? (
|
{clients.length === 0 ? (
|
||||||
<Text style={styles.emptyClients}>
|
<Text style={styles.emptyClients}>
|
||||||
Add a client first to start tracking time.
|
No clients yet — you can still start the timer and assign a client later.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -511,20 +612,15 @@ export function TimeClockPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{clockInErrors.clientId ? (
|
</View>
|
||||||
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{clientId ? (
|
<View style={styles.setupSection}>
|
||||||
<View style={styles.setupSection}>
|
<Text style={styles.sectionLabel}>Invoice (optional)</Text>
|
||||||
<Text style={styles.sectionLabel}>Invoice</Text>
|
{!clientId ? (
|
||||||
|
<Text style={styles.emptyClients}>Pick a client to attach a draft invoice.</Text>
|
||||||
|
) : (
|
||||||
<View style={styles.chipWrap}>
|
<View style={styles.chipWrap}>
|
||||||
<FilterChip
|
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
|
||||||
label="Entry only"
|
|
||||||
active={!invoiceId}
|
|
||||||
onPress={() => setInvoiceId("")}
|
|
||||||
/>
|
|
||||||
{billableInvoices.map((invoice) => {
|
{billableInvoices.map((invoice) => {
|
||||||
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
||||||
return (
|
return (
|
||||||
@@ -537,11 +633,10 @@ export function TimeClockPanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
)}
|
||||||
) : null}
|
</View>
|
||||||
|
|
||||||
{clientId ? (
|
<View style={styles.setupSection}>
|
||||||
<View style={styles.setupSection}>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityState={{ expanded: optionsExpanded }}
|
accessibilityState={{ expanded: optionsExpanded }}
|
||||||
@@ -647,12 +742,11 @@ export function TimeClockPanel({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title={clockIn.isPending ? "Starting…" : "Start timer"}
|
title={clockIn.isPending ? "Starting…" : "Start timer"}
|
||||||
loading={clockIn.isPending}
|
loading={clockIn.isPending}
|
||||||
disabled={!canClockIn || clients.length === 0}
|
disabled={!canClockIn}
|
||||||
showArrow={!clockIn.isPending}
|
showArrow={!clockIn.isPending}
|
||||||
onPress={handleClockIn}
|
onPress={handleClockIn}
|
||||||
/>
|
/>
|
||||||
@@ -667,41 +761,55 @@ export function TimeClockPanel({
|
|||||||
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const row = (
|
|
||||||
<>
|
|
||||||
<View style={styles.entryMeta}>
|
|
||||||
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
|
|
||||||
<Text style={styles.entrySub}>
|
|
||||||
{entry.client?.name ?? "No client"}
|
|
||||||
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!entry.invoice) {
|
|
||||||
return (
|
|
||||||
<View key={entry.id} style={styles.entryRow}>
|
|
||||||
{row}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<SwipeableRow
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
accessibilityRole="button"
|
actions={[
|
||||||
accessibilityLabel={`View invoice ${invoiceLabel}`}
|
{
|
||||||
onPress={() => router.push(`/(app)/invoices/${entry.invoice!.id}`)}
|
key: "edit",
|
||||||
style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
|
label: "Edit",
|
||||||
|
icon: "create-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
onPress: () => setEditEntryId(entry.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "invoice",
|
||||||
|
label: "Invoice",
|
||||||
|
icon: "document-text-outline",
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: colors.mutedForeground,
|
||||||
|
onPress: () => {
|
||||||
|
if (entry.invoice?.id) {
|
||||||
|
router.push(`/(app)/invoices/${entry.invoice.id}`);
|
||||||
|
} else {
|
||||||
|
setEditEntryId(entry.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{row}
|
<View style={styles.entryRow}>
|
||||||
</Pressable>
|
<View style={styles.entryMeta}>
|
||||||
|
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
|
||||||
|
<Text style={styles.entrySub}>
|
||||||
|
{entry.client?.name ?? "No client"}
|
||||||
|
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
|
||||||
|
</View>
|
||||||
|
</SwipeableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<TimeEntryEditSheet
|
||||||
|
entryId={editEntryId}
|
||||||
|
visible={editEntryId != null}
|
||||||
|
onClose={() => setEditEntryId(null)}
|
||||||
|
/>
|
||||||
</TabScrollView>
|
</TabScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { parseNonNegativeNumber } from "@/lib/form-validation";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
type TimeEntryEditSheetProps = {
|
||||||
|
entryId: string | null;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimeEntryEditSheet({ entryId, visible, onClose }: TimeEntryEditSheetProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createStyles);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const entryQuery = api.timeEntries.getById.useQuery(
|
||||||
|
{ id: entryId ?? "" },
|
||||||
|
{ enabled: visible && Boolean(entryId) },
|
||||||
|
);
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery(undefined, { enabled: visible });
|
||||||
|
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [clientId, setClientId] = useState("");
|
||||||
|
const [invoiceId, setInvoiceId] = useState("");
|
||||||
|
const [rateText, setRateText] = useState("");
|
||||||
|
const [startedAt, setStartedAt] = useState(() => new Date());
|
||||||
|
const [endedAt, setEndedAt] = useState(() => new Date());
|
||||||
|
|
||||||
|
const billableQuery = api.invoices.getBillable.useQuery(
|
||||||
|
clientId ? { clientId } : undefined,
|
||||||
|
{ enabled: visible && Boolean(clientId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const entry = entryQuery.data;
|
||||||
|
if (!entry) return;
|
||||||
|
setDescription(entry.description ?? "");
|
||||||
|
setClientId(entry.clientId ?? "");
|
||||||
|
setInvoiceId(entry.invoiceId ?? "");
|
||||||
|
setRateText(entry.rate != null ? String(entry.rate) : "");
|
||||||
|
setStartedAt(new Date(entry.startedAt));
|
||||||
|
setEndedAt(entry.endedAt ? new Date(entry.endedAt) : new Date());
|
||||||
|
}, [entryQuery.data]);
|
||||||
|
|
||||||
|
const hoursPreview = useMemo(() => {
|
||||||
|
if (endedAt <= startedAt) return null;
|
||||||
|
return Math.max(0, (endedAt.getTime() - startedAt.getTime()) / 3_600_000);
|
||||||
|
}, [endedAt, startedAt]);
|
||||||
|
|
||||||
|
const rate = parseNonNegativeNumber(rateText);
|
||||||
|
|
||||||
|
const updateEntry = api.timeEntries.update.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
utils.timeEntries.getAll.invalidate(),
|
||||||
|
utils.timeEntries.getById.invalidate(),
|
||||||
|
utils.invoices.getAll.invalidate(),
|
||||||
|
utils.dashboard.getStats.invalidate(),
|
||||||
|
]);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not save", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteEntry = api.timeEntries.delete.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
utils.timeEntries.getAll.invalidate(),
|
||||||
|
utils.invoices.getAll.invalidate(),
|
||||||
|
utils.dashboard.getStats.invalidate(),
|
||||||
|
]);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not delete", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!entryId) return;
|
||||||
|
if (endedAt <= startedAt) {
|
||||||
|
Alert.alert("Invalid times", "End time must be after start time.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEntry.mutate({
|
||||||
|
id: entryId,
|
||||||
|
description,
|
||||||
|
clientId: clientId || "",
|
||||||
|
invoiceId: invoiceId || "",
|
||||||
|
rate: rate ?? undefined,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
hours: hoursPreview ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!entryId) return;
|
||||||
|
Alert.alert("Delete time entry?", "This removes the entry and any linked invoice line.", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => deleteEntry.mutate({ id: entryId }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>Edit time entry</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={8}>
|
||||||
|
<Text style={[styles.close, { color: colors.mutedForeground }]}>Close</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
|
||||||
|
{entryQuery.isLoading ? (
|
||||||
|
<Text style={{ color: colors.mutedForeground }}>Loading…</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input label="Description" value={description} onChangeText={setDescription} />
|
||||||
|
|
||||||
|
<Text style={[styles.label, { color: colors.foreground }]}>Client</Text>
|
||||||
|
<View style={styles.chipWrap}>
|
||||||
|
<FilterChip
|
||||||
|
label="None"
|
||||||
|
active={!clientId}
|
||||||
|
onPress={() => {
|
||||||
|
setClientId("");
|
||||||
|
setInvoiceId("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{(clientsQuery.data ?? []).map((client) => (
|
||||||
|
<FilterChip
|
||||||
|
key={client.id}
|
||||||
|
label={client.name}
|
||||||
|
active={clientId === client.id}
|
||||||
|
onPress={() => {
|
||||||
|
setClientId(client.id);
|
||||||
|
setInvoiceId("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{clientId ? (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.label, { color: colors.foreground }]}>Invoice</Text>
|
||||||
|
<View style={styles.chipWrap}>
|
||||||
|
<FilterChip
|
||||||
|
label="Not on invoice"
|
||||||
|
active={!invoiceId}
|
||||||
|
onPress={() => setInvoiceId("")}
|
||||||
|
/>
|
||||||
|
{(billableQuery.data ?? []).map((invoice) => (
|
||||||
|
<FilterChip
|
||||||
|
key={invoice.id}
|
||||||
|
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
|
||||||
|
active={invoiceId === invoice.id}
|
||||||
|
onPress={() => setInvoiceId(invoice.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Hourly rate"
|
||||||
|
value={rateText}
|
||||||
|
onChangeText={setRateText}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateTimeField
|
||||||
|
label="Started"
|
||||||
|
value={startedAt}
|
||||||
|
maximumDate={endedAt}
|
||||||
|
onChange={setStartedAt}
|
||||||
|
/>
|
||||||
|
<DateTimeField label="Ended" value={endedAt} minimumDate={startedAt} onChange={setEndedAt} />
|
||||||
|
|
||||||
|
{hoursPreview != null ? (
|
||||||
|
<Text style={[styles.preview, { color: colors.mutedForeground }]}>
|
||||||
|
{hoursPreview.toFixed(2)}h
|
||||||
|
{rate != null && rate > 0
|
||||||
|
? ` · ${formatCurrency(hoursPreview * rate)}`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button title="Save changes" loading={updateEntry.isPending} onPress={handleSave} />
|
||||||
|
<Button
|
||||||
|
title="Delete entry"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteEntry.isPending}
|
||||||
|
onPress={confirmDelete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (colors: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
close: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
chipWrap: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
+13
-4
@@ -1,24 +1,33 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
|
import type { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
|
import type { RemoveAccountResult } from "@/contexts/AccountsContext";
|
||||||
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
|
|
||||||
|
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||||
|
|
||||||
type FinishAccountRemovalInput = {
|
type FinishAccountRemovalInput = {
|
||||||
result: RemoveAccountResult;
|
result: RemoveAccountResult;
|
||||||
|
authClient: AuthClient;
|
||||||
clearActiveAccount: () => Promise<void>;
|
clearActiveAccount: () => Promise<void>;
|
||||||
signOut: () => Promise<unknown>;
|
activeAccountId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Navigate to sign-in when the last saved account was removed. */
|
/** Navigate to sign-in when the last saved account was removed. */
|
||||||
export async function finishAccountRemoval({
|
export async function finishAccountRemoval({
|
||||||
result,
|
result,
|
||||||
|
authClient,
|
||||||
clearActiveAccount,
|
clearActiveAccount,
|
||||||
signOut,
|
activeAccountId,
|
||||||
}: FinishAccountRemovalInput): Promise<void> {
|
}: FinishAccountRemovalInput): Promise<void> {
|
||||||
if (result.remainingCount > 0) return;
|
if (result.remainingCount > 0) return;
|
||||||
|
|
||||||
await signOut();
|
await performAuthReset({
|
||||||
await clearActiveAccount();
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
});
|
||||||
router.replace("/(auth)/sign-in");
|
router.replace("/(auth)/sign-in");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -1,11 +1,5 @@
|
|||||||
import { getApiUrl } from "@/lib/config";
|
import { getApiUrl } from "@/lib/config";
|
||||||
|
import { readHttpErrorMessage } from "@/lib/trpc-errors";
|
||||||
type ApiError = { error?: string; message?: string };
|
|
||||||
|
|
||||||
async function parseError(res: Response) {
|
|
||||||
const data = (await res.json().catch(() => ({}))) as ApiError;
|
|
||||||
return data.error ?? data.message ?? "Something went wrong";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerAccount(input: {
|
export async function registerAccount(input: {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -20,7 +14,7 @@ export async function registerAccount(input: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await parseError(res));
|
throw new Error(await readHttpErrorMessage(res));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +26,7 @@ export async function requestPasswordReset(email: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await parseError(res));
|
throw new Error(await readHttpErrorMessage(res));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as { message?: string };
|
const data = (await res.json()) as { message?: string };
|
||||||
@@ -47,6 +41,6 @@ export async function resetPassword(token: string, password: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await parseError(res));
|
throw new Error(await readHttpErrorMessage(res));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { getCookie as serializeStoredCookies } from "@better-auth/expo/client";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import type { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
|
||||||
|
|
||||||
|
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||||
|
|
||||||
|
const CHUNK_MARKER = "\u0001ba-chunks:";
|
||||||
|
|
||||||
|
function readSecureStoreValueSync(key: string): string | null {
|
||||||
|
const value = SecureStore.getItem(key);
|
||||||
|
if (value == null) return null;
|
||||||
|
if (!value.startsWith(CHUNK_MARKER)) return value;
|
||||||
|
|
||||||
|
const count = Number(value.slice(CHUNK_MARKER.length));
|
||||||
|
if (!Number.isInteger(count) || count < 1) return null;
|
||||||
|
|
||||||
|
let assembled = "";
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const chunk = SecureStore.getItem(`${key}.${index}`);
|
||||||
|
if (chunk == null) return null;
|
||||||
|
assembled += chunk;
|
||||||
|
}
|
||||||
|
return assembled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read session cookie string for tRPC requests (Expo client plugin + SecureStore fallback). */
|
||||||
|
export function getAuthCookie(
|
||||||
|
authClient: AuthClient,
|
||||||
|
storagePrefix: string,
|
||||||
|
): string | null {
|
||||||
|
const fromClient = (
|
||||||
|
authClient as AuthClient & { getCookie?: () => string }
|
||||||
|
).getCookie?.();
|
||||||
|
if (fromClient?.trim()) return fromClient.trim();
|
||||||
|
|
||||||
|
const raw = readSecureStoreValueSync(
|
||||||
|
normalizeSecureStoreKey(`${storagePrefix}_cookie`),
|
||||||
|
);
|
||||||
|
if (!raw || raw === "{}") return null;
|
||||||
|
|
||||||
|
const cookie = serializeStoredCookies(raw);
|
||||||
|
return cookie.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthCookieHeaders(
|
||||||
|
authClient: AuthClient,
|
||||||
|
storagePrefix: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const cookie = getAuthCookie(authClient, storagePrefix);
|
||||||
|
return cookie
|
||||||
|
? { cookie, Cookie: cookie, "x-beenvoice-auth-cookie": cookie }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
import { authStoragePrefix } from "@/lib/accounts";
|
||||||
|
import {
|
||||||
|
clearAuthStorage,
|
||||||
|
GUEST_AUTH_STORAGE_PREFIX,
|
||||||
|
prepareForAdditionalSignIn,
|
||||||
|
} from "@/lib/auth-storage";
|
||||||
|
|
||||||
|
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||||
|
|
||||||
|
type PerformAuthResetInput = {
|
||||||
|
authClient: AuthClient;
|
||||||
|
clearActiveAccount: () => Promise<void>;
|
||||||
|
activeAccountId?: string | null;
|
||||||
|
refetchSession?: () => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sign out, wipe local auth storage, and return to guest mode for a clean sign-in screen. */
|
||||||
|
export async function performAuthReset({
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
refetchSession,
|
||||||
|
}: PerformAuthResetInput): Promise<void> {
|
||||||
|
const accountPrefix = activeAccountId ? authStoragePrefix(activeAccountId) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authClient.signOut();
|
||||||
|
} catch {
|
||||||
|
// Continue clearing local state even when the server session is already gone.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountPrefix) {
|
||||||
|
await clearAuthStorage(accountPrefix);
|
||||||
|
}
|
||||||
|
await clearAuthStorage(GUEST_AUTH_STORAGE_PREFIX);
|
||||||
|
await clearActiveAccount();
|
||||||
|
await refetchSession?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the auth stack is shown, discard stale SecureStore sessions so sign-in starts clean.
|
||||||
|
* Expired account sessions switch back to guest storage; orphaned guest copies are cleared.
|
||||||
|
*/
|
||||||
|
export async function prepareAuthScreenSession(
|
||||||
|
authClient: AuthClient,
|
||||||
|
activeAccountId: string | null,
|
||||||
|
clearActiveAccount: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await authClient.getSession();
|
||||||
|
if (session.data?.user) return;
|
||||||
|
|
||||||
|
if (activeAccountId) {
|
||||||
|
await clearAuthStorage(authStoragePrefix(activeAccountId));
|
||||||
|
await clearActiveAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
await prepareForAdditionalSignIn();
|
||||||
|
}
|
||||||
@@ -74,6 +74,8 @@ export async function migrateAuthStorage(fromPrefix: string, toPrefix: string):
|
|||||||
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
|
copySecureStoreEntry(storageKeyForPrefix(fromPrefix, suffix), storageKeyForPrefix(toPrefix, suffix)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await clearAuthStorage(fromPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearAuthStorage(prefix: string): Promise<void> {
|
export async function clearAuthStorage(prefix: string): Promise<void> {
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { isUnauthorizedError } from "@/lib/trpc-errors";
|
import { isRateLimitError, isUnauthorizedError } from "@/lib/trpc-errors";
|
||||||
|
|
||||||
export function createAppQueryClient(onUnauthorized: () => void) {
|
export function createAppQueryClient(onUnauthorized: () => void) {
|
||||||
const handleError = (error: unknown) => {
|
const handleError = (error: unknown) => {
|
||||||
@@ -16,7 +16,13 @@ export function createAppQueryClient(onUnauthorized: () => void) {
|
|||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: (failureCount, error) => {
|
retry: (failureCount, error) => {
|
||||||
if (isUnauthorizedError(error)) return false;
|
if (isUnauthorizedError(error) || isRateLimitError(error)) return false;
|
||||||
|
return failureCount < 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
if (isRateLimitError(error)) return false;
|
||||||
return failureCount < 1;
|
return failureCount < 1;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+116
-3
@@ -1,8 +1,121 @@
|
|||||||
import { TRPCClientError } from "@trpc/client";
|
import { TRPCClientError } from "@trpc/client";
|
||||||
|
|
||||||
export function isUnauthorizedError(error: unknown): boolean {
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof TRPCClientError) return error.message;
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === "object" && error !== null && "message" in error) {
|
||||||
|
const message = (error as { message: unknown }).message;
|
||||||
|
if (typeof message === "string") return message;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorStatus(error: unknown): number | undefined {
|
||||||
|
if (typeof error !== "object" || error === null || !("status" in error)) return undefined;
|
||||||
|
const status = (error as { status: unknown }).status;
|
||||||
|
return typeof status === "number" ? status : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRetryAfterSeconds(value: string | number | null | undefined): number | null {
|
||||||
|
if (value == null || value === "") return null;
|
||||||
|
const seconds = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||||
|
return Math.ceil(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRateLimitError(error: unknown): boolean {
|
||||||
|
if (errorStatus(error) === 429) return true;
|
||||||
|
|
||||||
|
if (error instanceof TRPCClientError && error.data?.code === "TOO_MANY_REQUESTS") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = errorMessage(error).toLowerCase();
|
||||||
return (
|
return (
|
||||||
error instanceof TRPCClientError &&
|
message.includes("too many") ||
|
||||||
(error.data?.code === "UNAUTHORIZED" || error.message === "UNAUTHORIZED")
|
message.includes("rate limit") ||
|
||||||
|
message.includes("try again later")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUnauthorizedError(error: unknown): boolean {
|
||||||
|
if (isRateLimitError(error)) return false;
|
||||||
|
|
||||||
|
if (error instanceof TRPCClientError) {
|
||||||
|
if (error.data?.code === "UNAUTHORIZED") return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorStatus(error) === 401) return true;
|
||||||
|
|
||||||
|
const message = errorMessage(error).toLowerCase();
|
||||||
|
return message === "unauthorized" || message.includes("not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRateLimitMessage(retryAfterSeconds?: number | null): string {
|
||||||
|
const retryAfter = parseRetryAfterSeconds(retryAfterSeconds ?? null);
|
||||||
|
if (retryAfter != null) {
|
||||||
|
if (retryAfter < 60) {
|
||||||
|
return `Too many attempts. Wait ${retryAfter} second${retryAfter === 1 ? "" : "s"} and try again.`;
|
||||||
|
}
|
||||||
|
const minutes = Math.ceil(retryAfter / 60);
|
||||||
|
return `Too many attempts. Wait about ${minutes} minute${minutes === 1 ? "" : "s"} and try again.`;
|
||||||
|
}
|
||||||
|
return "Too many attempts. Please wait a moment and try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTrpcErrorMessage(error: unknown, fallback = "Something went wrong"): string {
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
return formatRateLimitMessage();
|
||||||
|
}
|
||||||
|
if (isUnauthorizedError(error)) {
|
||||||
|
return "Your session expired. Sign in again to continue.";
|
||||||
|
}
|
||||||
|
if (error instanceof TRPCClientError) {
|
||||||
|
return error.message || fallback;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message || fallback;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthLikeError = {
|
||||||
|
message?: string;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatAuthErrorMessage(error: AuthLikeError | null | undefined): string {
|
||||||
|
if (!error) return "Something went wrong";
|
||||||
|
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
return formatRateLimitMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error.message ?? "";
|
||||||
|
if (message.toLowerCase().includes("internal") || message.includes("500")) {
|
||||||
|
return "Server error — is the API running with Postgres? Check beenvoice dev + docker.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return message || "Invalid email or password";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readHttpErrorMessage(response: Response): Promise<string> {
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfter = parseRetryAfterSeconds(
|
||||||
|
response.headers.get("x-retry-after") ?? response.headers.get("retry-after"),
|
||||||
|
);
|
||||||
|
return formatRateLimitMessage(retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = data.error ?? data.message;
|
||||||
|
if (message && isRateLimitError({ message, status: response.status })) {
|
||||||
|
return formatRateLimitMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return message ?? "Something went wrong";
|
||||||
|
}
|
||||||
|
|||||||
+50
-10
@@ -1,25 +1,65 @@
|
|||||||
import { httpBatchLink } from "@trpc/client";
|
import { httpBatchLink } from "@trpc/client";
|
||||||
import { createTRPCReact } from "@trpc/react-query";
|
import { createTRPCReact } from "@trpc/react-query";
|
||||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||||
|
import { getAuthCookieHeaders } from "@/lib/auth-cookie";
|
||||||
|
import { performAuthReset } from "@/lib/auth-session";
|
||||||
import { createAppQueryClient } from "@/lib/query-client";
|
import { createAppQueryClient } from "@/lib/query-client";
|
||||||
import type { AppRouter } from "beenvoice/server/api/root";
|
import type { AppRouter } from "beenvoice/server/api/root";
|
||||||
|
|
||||||
export const api = createTRPCReact<AppRouter>();
|
export const api = createTRPCReact<AppRouter>();
|
||||||
|
|
||||||
export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: ReactNode }) {
|
export function TRPCProvider({
|
||||||
|
apiUrl,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
apiUrl: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
const authClient = useAuthClient();
|
const authClient = useAuthClient();
|
||||||
|
const { authStoragePrefix, activeAccountId, clearActiveAccount } =
|
||||||
|
useAccounts();
|
||||||
const { refetch } = useSession();
|
const { refetch } = useSession();
|
||||||
|
const authStoragePrefixRef = useRef(authStoragePrefix);
|
||||||
|
authStoragePrefixRef.current = authStoragePrefix;
|
||||||
|
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const resettingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleUnauthorized = useCallback(async () => {
|
const handleUnauthorized = useCallback(async () => {
|
||||||
|
if (!activeAccountId || resettingRef.current || !mountedRef.current) return;
|
||||||
|
|
||||||
const session = await authClient.getSession();
|
const session = await authClient.getSession();
|
||||||
if (!session.data?.user) {
|
if (session.data?.user || !mountedRef.current) return;
|
||||||
await authClient.signOut();
|
|
||||||
await refetch();
|
resettingRef.current = true;
|
||||||
|
try {
|
||||||
|
await performAuthReset({
|
||||||
|
authClient,
|
||||||
|
clearActiveAccount,
|
||||||
|
activeAccountId,
|
||||||
|
refetchSession: refetch,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
resettingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [authClient, refetch]);
|
}, [authClient, clearActiveAccount, activeAccountId, refetch]);
|
||||||
|
|
||||||
const onUnauthorizedRef = useRef(handleUnauthorized);
|
const onUnauthorizedRef = useRef(handleUnauthorized);
|
||||||
onUnauthorizedRef.current = handleUnauthorized;
|
onUnauthorizedRef.current = handleUnauthorized;
|
||||||
@@ -37,10 +77,10 @@ export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: R
|
|||||||
url: `${apiUrl}/api/trpc`,
|
url: `${apiUrl}/api/trpc`,
|
||||||
transformer: SuperJSON,
|
transformer: SuperJSON,
|
||||||
headers() {
|
headers() {
|
||||||
const cookie = (
|
return getAuthCookieHeaders(
|
||||||
authClient as { getCookie?: () => string | null | undefined }
|
authClient,
|
||||||
).getCookie?.();
|
authStoragePrefixRef.current,
|
||||||
return cookie ? { cookie } : {};
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user