Files
beenvoice-app/app/(app)/entities/index.tsx
T
soconnor 6d2711e36e Polish mobile app for App Store review and expand CRUD.
Default to beenvoice.soconnor.dev with server settings hidden behind Advanced; add Entities tab with clients/businesses, invoice creation, UI fixes for dashboard layout, date fields, FAB position, and card-matched button radius.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 23:14:58 -04:00

256 lines
7.7 KiB
TypeScript

import { router } from "expo-router";
import { useState } from "react";
import {
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
import { FloatingActionButton } from "@/components/FloatingActionButton";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type EntityTab = "clients" | "businesses";
const tabs: Array<{ label: string; value: EntityTab }> = [
{ label: "Clients", value: "clients" },
{ label: "Businesses", value: "businesses" },
];
export default function EntitiesScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createEntitiesStyles);
const [tab, setTab] = useState<EntityTab>("clients");
const clientsQuery = api.clients.getAll.useQuery();
const businessesQuery = api.businesses.getAll.useQuery();
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
const isLoading =
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
if (isLoading) {
return <LoadingScreen message="Loading…" />;
}
if (activeQuery.error) {
return (
<AppBackground>
<TabPage>
<View style={styles.errorBox}>
<Text style={styles.errorTitle}>Could not load {tab}</Text>
<Text style={styles.errorText}>{activeQuery.error.message}</Text>
</View>
</TabPage>
</AppBackground>
);
}
const clients = clientsQuery.data ?? [];
const businesses = businessesQuery.data ?? [];
function refresh() {
if (tab === "clients") void clientsQuery.refetch();
else void businessesQuery.refetch();
}
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<PageHeader
title="Entities"
subtitle="Clients you bill and businesses you send from"
/>
}
refreshControl={
<RefreshControl
refreshing={activeQuery.isRefetching}
onRefresh={refresh}
tintColor={colors.primary}
/>
}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabScroll}
contentContainerStyle={styles.tabs}
>
{tabs.map((item) => (
<FilterChip
key={item.value}
label={item.label}
active={tab === item.value}
onPress={() => setTab(item.value)}
/>
))}
</ScrollView>
{tab === "clients" ? (
clients.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No clients yet</Text>
<Text style={styles.emptyText}>
Add a client to start creating invoices.
</Text>
</View>
) : (
clients.map((client) => (
<Pressable
key={client.id}
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<Text style={styles.name}>{client.name}</Text>
{client.email ? (
<Text style={styles.meta}>{client.email}</Text>
) : null}
{client.defaultHourlyRate != null ? (
<Text style={styles.meta}>
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
/hr
</Text>
) : null}
</View>
</GlassSurface>
</Pressable>
))
)
) : businesses.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No businesses yet</Text>
<Text style={styles.emptyText}>
Add your business profile for invoices and email sending.
</Text>
</View>
) : (
businesses.map((business) => (
<Pressable
key={business.id}
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? (
<Text style={styles.badge}>Default</Text>
) : null}
</View>
{business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text>
) : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
</View>
</GlassSurface>
</Pressable>
))
)}
</TabScrollView>
<FloatingActionButton
accessibilityLabel={tab === "clients" ? "Add client" : "Add business"}
onPress={() =>
router.push(
tab === "clients"
? "/(app)/entities/clients/new"
: "/(app)/entities/businesses/new",
)
}
/>
</TabPage>
</AppBackground>
);
}
const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
tabScroll: {
flexGrow: 0,
marginBottom: spacing.sm,
},
tabs: {
gap: spacing.sm,
paddingRight: spacing.md,
},
card: {},
cardInner: {
padding: spacing.md,
gap: 4,
},
nameRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
flexWrap: "wrap",
},
name: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
badge: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.primary,
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: 999,
overflow: "hidden",
},
meta: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
empty: {
padding: spacing.lg,
alignItems: "center",
gap: spacing.sm,
},
emptyTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
emptyText: {
textAlign: "center",
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
errorBox: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
gap: spacing.sm,
},
errorTitle: {
fontSize: 18,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
errorText: {
color: colors.mutedForeground,
fontFamily: fonts.body,
lineHeight: 20,
},
});