Move production to beenvoice.app with migrated accounts, refreshed auth and timer UX, and expanded invoice flows.
Official URL migration preserves sessions, shortcuts prefs, and last clock-in client; auth screens match web with legal links; time clock and invoice editor/send flows are updated for the new domain and UI patterns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { DEFAULT_API_URL } from "@/lib/config";
|
||||
import { DEFAULT_API_URL, OFFICIAL_SERVER_PLACEHOLDER, invalidServerUrlMessage } from "@/lib/config";
|
||||
import {
|
||||
formatServerHost,
|
||||
isServerConfigValid,
|
||||
@@ -80,7 +80,7 @@ export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServer
|
||||
async function commitSelfHostedUrl() {
|
||||
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
|
||||
if (!resolved) {
|
||||
setUrlError("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
|
||||
setUrlError(invalidServerUrlMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServer
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
placeholder="beenvoice.app or localhost:3000"
|
||||
placeholder={OFFICIAL_SERVER_PLACEHOLDER}
|
||||
required
|
||||
error={urlError ?? undefined}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { hasConfiguredInstanceUrl } from "@/lib/accounts";
|
||||
import { OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
|
||||
|
||||
type CollapsibleServerFieldProps = {
|
||||
defaultExpanded?: boolean;
|
||||
@@ -100,7 +101,7 @@ export function CollapsibleServerField({ defaultExpanded = false }: CollapsibleS
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
placeholder="beenvoice.app or localhost:3000"
|
||||
placeholder={OFFICIAL_SERVER_PLACEHOLDER}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { invalidServerUrlMessage, OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
|
||||
import { normalizeInstanceUrl } from "@/lib/instance-url";
|
||||
|
||||
type InstanceUrlFieldProps = {
|
||||
@@ -30,7 +31,7 @@ export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
|
||||
|
||||
const normalized = normalizeInstanceUrl(trimmed);
|
||||
if (!normalized) {
|
||||
setError("Enter a valid URL like beenvoice.app or localhost:3000");
|
||||
setError(invalidServerUrlMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,7 +56,7 @@ export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
placeholder="beenvoice.app or localhost:3000"
|
||||
placeholder={OFFICIAL_SERVER_PLACEHOLDER}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
resolveEffectiveHourlyRate,
|
||||
} from "@/lib/time-clock";
|
||||
import { getLastTimeClockClientId } from "@/lib/time-clock-prefs";
|
||||
import {
|
||||
endTimeClockLiveActivity,
|
||||
syncTimeClockLiveActivity,
|
||||
} from "@/lib/time-clock-live-activity";
|
||||
import type { ParsedShortcut } from "@/lib/shortcuts";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
@@ -75,6 +79,7 @@ export function ShortcutHandler() {
|
||||
}
|
||||
|
||||
await clockOut.mutateAsync({});
|
||||
await endTimeClockLiveActivity();
|
||||
await Promise.all([
|
||||
utils.timeEntries.getRunning.invalidate(),
|
||||
utils.timeEntries.getAll.invalidate(),
|
||||
@@ -121,6 +126,13 @@ export function ShortcutHandler() {
|
||||
rate: rate ?? undefined,
|
||||
});
|
||||
await utils.timeEntries.getRunning.invalidate();
|
||||
const running = await utils.timeEntries.getRunning.fetch();
|
||||
if (running) {
|
||||
const seconds = Math.floor(
|
||||
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
|
||||
);
|
||||
await syncTimeClockLiveActivity(running, seconds);
|
||||
}
|
||||
await clearPendingShortcut();
|
||||
setPending(null);
|
||||
router.push("/(app)/timer");
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Linking from "expo-linking";
|
||||
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { SHORTCUT_URLS } from "@/lib/shortcuts";
|
||||
|
||||
const SHORTCUT_ACTIONS = [
|
||||
{ title: "Clock In", subtitle: "Start timer with your last client", url: SHORTCUT_URLS.clockIn },
|
||||
{ title: "Clock Out", subtitle: "Stop the running timer", url: SHORTCUT_URLS.clockOut },
|
||||
{ title: "Open Time Clock", subtitle: "Jump to the timer tab", url: SHORTCUT_URLS.openTimer },
|
||||
] as const;
|
||||
|
||||
export function ShortcutsSetupCard() {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
if (Platform.OS !== "ios") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.stack}>
|
||||
<Text style={[styles.lead, { color: colors.mutedForeground }]}>
|
||||
beenvoice actions appear when you build a shortcut — they are not pre-installed in your
|
||||
library. After installing a native build (not Expo Go), open the app once, then:
|
||||
</Text>
|
||||
|
||||
<View style={[styles.steps, { borderColor: colors.border, backgroundColor: colors.muted }]}>
|
||||
<Text style={[styles.step, { color: colors.foreground }]}>
|
||||
1. Open the Shortcuts app → tap + → Add Action
|
||||
</Text>
|
||||
<Text style={[styles.step, { color: colors.foreground }]}>
|
||||
2. Search <Text style={styles.emphasis}>beenvoice</Text> (or Clock In / Clock Out)
|
||||
</Text>
|
||||
<Text style={[styles.step, { color: colors.foreground }]}>
|
||||
3. Pick Clock In, Clock Out, or Open Time Clock
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
You can also ask Siri: “Clock in with beenvoice”. Pick a client once on the Timer tab
|
||||
before your first clock-in shortcut.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||
If nothing shows up, reinstall from a fresh native build (TestFlight or{" "}
|
||||
<Text style={styles.emphasis}>bun run ios</Text>). Shortcuts require iOS 18+.
|
||||
</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
{SHORTCUT_ACTIONS.map((action) => (
|
||||
<Pressable
|
||||
key={action.title}
|
||||
accessibilityRole="button"
|
||||
onPress={() => void Linking.openURL(action.url)}
|
||||
style={({ pressed }) => [
|
||||
styles.actionRow,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: pressed ? colors.muted : "transparent",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.actionCopy}>
|
||||
<Text style={[styles.actionTitle, { color: colors.foreground }]}>{action.title}</Text>
|
||||
<Text style={[styles.actionSubtitle, { color: colors.mutedForeground }]}>
|
||||
{action.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Open Shortcuts app"
|
||||
variant="secondary"
|
||||
onPress={() => void Linking.openURL("shortcuts://")}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
stack: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
lead: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
steps: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
},
|
||||
step: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
emphasis: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
meta: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionRow: {
|
||||
alignItems: "center",
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
flexDirection: "row",
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
actionCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
actionTitle: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 15,
|
||||
},
|
||||
actionSubtitle: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { spacing } from "@/constants/theme";
|
||||
|
||||
const AUTH_CARD_RADIUS = 24;
|
||||
|
||||
type AuthCardProps = {
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export function AuthCard({ children, style }: AuthCardProps) {
|
||||
return (
|
||||
<GlassSurface radius={AUTH_CARD_RADIUS} style={style}>
|
||||
<View style={styles.inner}>{children}</View>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inner: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.lg,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type AuthCardHeaderProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function AuthCardHeader({ title, description }: AuthCardHeaderProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Logo size="md" />
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
|
||||
<Text style={[styles.description, { color: colors.mutedForeground }]}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
copy: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontFamily: fonts.headingSemi,
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
export function AuthDivider() {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.line, { backgroundColor: colors.border }]} />
|
||||
<Text style={[styles.label, { color: colors.mutedForeground }]}>or</Text>
|
||||
<View style={[styles.line, { backgroundColor: colors.border }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
line: {
|
||||
flex: 1,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { StyleSheet, Text } from "react-native";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type AuthNoticeProps = {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export function AuthNotice({ children }: AuthNoticeProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
styles.notice,
|
||||
{
|
||||
color: colors.mutedForeground,
|
||||
backgroundColor: colors.muted,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
notice: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: radii.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View, type ViewProps } from "react-native";
|
||||
|
||||
import { AuthBackground } from "@/components/AppBackground";
|
||||
import { FullScreen } from "@/components/Screen";
|
||||
import { spacing } from "@/constants/theme";
|
||||
|
||||
export function AuthScreenLayout({ children, style, ...props }: ViewProps) {
|
||||
return (
|
||||
<AuthBackground>
|
||||
<FullScreen style={styles.safe}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={[styles.content, style]} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</FullScreen>
|
||||
</AuthBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: {
|
||||
flex: 1,
|
||||
},
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xl,
|
||||
},
|
||||
content: {
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
alignSelf: "center",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import type { InvoiceStatus } from "@/lib/invoice-status";
|
||||
|
||||
type ActionItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
onPress: () => void;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type InvoiceDetailActionsProps = {
|
||||
status: InvoiceStatus;
|
||||
clientEmail: string;
|
||||
onPaymentReminder?: () => void;
|
||||
paymentReminderLoading?: boolean;
|
||||
onUpdateStatus: () => void;
|
||||
updateStatusLoading?: boolean;
|
||||
onTrackTime: () => void;
|
||||
};
|
||||
|
||||
export function InvoiceDetailActions({
|
||||
status,
|
||||
clientEmail,
|
||||
onPaymentReminder,
|
||||
paymentReminderLoading,
|
||||
onUpdateStatus,
|
||||
updateStatusLoading,
|
||||
onTrackTime,
|
||||
}: InvoiceDetailActionsProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
const rows: ActionItem[] = [];
|
||||
|
||||
if ((status === "sent" || status === "overdue") && onPaymentReminder) {
|
||||
rows.push({
|
||||
key: "reminder",
|
||||
title: "Send payment reminder",
|
||||
subtitle: clientEmail ? `Nudge ${clientEmail}` : "Add a client email first",
|
||||
icon: "notifications-outline",
|
||||
onPress: onPaymentReminder,
|
||||
loading: paymentReminderLoading,
|
||||
});
|
||||
}
|
||||
|
||||
rows.push(
|
||||
{
|
||||
key: "status",
|
||||
title: "Update status",
|
||||
subtitle: "Draft, sent, or paid",
|
||||
icon: "swap-horizontal-outline",
|
||||
onPress: onUpdateStatus,
|
||||
loading: updateStatusLoading,
|
||||
},
|
||||
{
|
||||
key: "timer",
|
||||
title: "Track time",
|
||||
subtitle: "Clock hours to this invoice",
|
||||
icon: "timer-outline",
|
||||
onPress: onTrackTime,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.list}>
|
||||
{rows.map((row, index) => (
|
||||
<View key={row.key}>
|
||||
{index > 0 ? (
|
||||
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||
) : null}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={row.loading}
|
||||
onPress={row.onPress}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={row.icon} size={20} color={colors.foreground} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>{row.title}</Text>
|
||||
{row.subtitle ? (
|
||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||
{row.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.lg,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
list: {
|
||||
gap: 0,
|
||||
},
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
rowPressed: {
|
||||
opacity: 0.75,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type SecondaryAction = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
onPress: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type InvoiceEditorFooterProps = {
|
||||
primaryTitle: string;
|
||||
onPrimary: () => void;
|
||||
primaryLoading?: boolean;
|
||||
primaryDisabled?: boolean;
|
||||
secondary?: SecondaryAction;
|
||||
};
|
||||
|
||||
export function InvoiceEditorFooter({
|
||||
primaryTitle,
|
||||
onPrimary,
|
||||
primaryLoading,
|
||||
primaryDisabled,
|
||||
secondary,
|
||||
}: InvoiceEditorFooterProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
title={primaryTitle}
|
||||
loading={primaryLoading}
|
||||
disabled={primaryDisabled}
|
||||
onPress={onPrimary}
|
||||
/>
|
||||
|
||||
{secondary ? (
|
||||
<>
|
||||
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={secondary.disabled || secondary.loading}
|
||||
onPress={secondary.onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.secondaryRow,
|
||||
(pressed || secondary.loading) && styles.secondaryPressed,
|
||||
(secondary.disabled || secondary.loading) && styles.secondaryDisabled,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={secondary.icon} size={20} color={colors.foreground} />
|
||||
</View>
|
||||
<View style={styles.secondaryCopy}>
|
||||
<Text style={[styles.secondaryTitle, { color: colors.foreground }]}>
|
||||
{secondary.title}
|
||||
</Text>
|
||||
{secondary.subtitle ? (
|
||||
<Text style={[styles.secondarySubtitle, { color: colors.mutedForeground }]}>
|
||||
{secondary.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.lg,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
secondaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
secondaryPressed: {
|
||||
opacity: 0.75,
|
||||
},
|
||||
secondaryDisabled: {
|
||||
opacity: 0.45,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
secondaryCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
minWidth: 0,
|
||||
},
|
||||
secondaryTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
secondarySubtitle: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -3,21 +3,34 @@ import { ScrollView, StyleSheet, View } from "react-native";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { spacing } from "@/constants/theme";
|
||||
|
||||
export type InvoiceEditorSection = "edit" | "preview";
|
||||
export type InvoiceEditorSection = "setup" | "lines" | "preview";
|
||||
export type InvoiceViewSection = "details" | "preview";
|
||||
|
||||
type InvoiceEditorSectionTabsProps = {
|
||||
value: InvoiceEditorSection;
|
||||
onChange: (value: InvoiceEditorSection) => void;
|
||||
editLabel?: string;
|
||||
previewLabel?: string;
|
||||
};
|
||||
type InvoiceEditorSectionTabsProps =
|
||||
| {
|
||||
mode?: "edit";
|
||||
value: InvoiceEditorSection;
|
||||
onChange: (value: InvoiceEditorSection) => void;
|
||||
}
|
||||
| {
|
||||
mode: "view";
|
||||
value: InvoiceViewSection;
|
||||
onChange: (value: InvoiceViewSection) => void;
|
||||
};
|
||||
|
||||
export function InvoiceEditorSectionTabs(props: InvoiceEditorSectionTabsProps) {
|
||||
const tabs =
|
||||
props.mode === "view"
|
||||
? [
|
||||
{ id: "details" as const, label: "Details" },
|
||||
{ id: "preview" as const, label: "PDF" },
|
||||
]
|
||||
: [
|
||||
{ id: "setup" as const, label: "Setup" },
|
||||
{ id: "lines" as const, label: "Line items" },
|
||||
{ id: "preview" as const, label: "PDF preview" },
|
||||
];
|
||||
|
||||
export function InvoiceEditorSectionTabs({
|
||||
value,
|
||||
onChange,
|
||||
editLabel = "Edit",
|
||||
previewLabel = "PDF preview",
|
||||
}: InvoiceEditorSectionTabsProps) {
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
@@ -25,16 +38,14 @@ export function InvoiceEditorSectionTabs({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
<FilterChip
|
||||
label={editLabel}
|
||||
active={value === "edit"}
|
||||
onPress={() => onChange("edit")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={previewLabel}
|
||||
active={value === "preview"}
|
||||
onPress={() => onChange("preview")}
|
||||
/>
|
||||
{tabs.map((tab) => (
|
||||
<FilterChip
|
||||
key={tab.id}
|
||||
label={tab.label}
|
||||
active={props.value === tab.id}
|
||||
onPress={() => props.onChange(tab.id as never)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { SelectField } from "@/components/ui/SelectField";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { defaultDueDate } from "@/lib/invoice-number";
|
||||
|
||||
type SelectOption = { label: string; value: string };
|
||||
|
||||
type InvoiceSetupFormProps = {
|
||||
businessId: string;
|
||||
onBusinessIdChange: (value: string) => void;
|
||||
businessOptions: SelectOption[];
|
||||
businessError?: string;
|
||||
businessReadOnly?: boolean;
|
||||
clientId: string;
|
||||
onClientIdChange: (value: string) => void;
|
||||
clientOptions: SelectOption[];
|
||||
clientError?: string;
|
||||
clientReadOnly?: boolean;
|
||||
invoiceNumber: string;
|
||||
onInvoiceNumberChange?: (value: string) => void;
|
||||
invoiceNumberReadOnly?: boolean;
|
||||
issueDate: Date;
|
||||
onIssueDateChange?: (date: Date) => void;
|
||||
issueDateReadOnly?: boolean;
|
||||
dueDate: Date;
|
||||
onDueDateChange: (date: Date) => void;
|
||||
taxRate: string;
|
||||
onTaxRateChange?: (value: string) => void;
|
||||
taxRateReadOnly?: boolean;
|
||||
notes: string;
|
||||
onNotesChange: (value: string) => void;
|
||||
sendReminderAt?: Date | null;
|
||||
onSendReminderAtChange?: (date: Date | null) => void;
|
||||
showSendReminder?: boolean;
|
||||
};
|
||||
|
||||
export function InvoiceSetupForm({
|
||||
businessId,
|
||||
onBusinessIdChange,
|
||||
businessOptions,
|
||||
businessError,
|
||||
businessReadOnly = false,
|
||||
clientId,
|
||||
onClientIdChange,
|
||||
clientOptions,
|
||||
clientError,
|
||||
clientReadOnly = false,
|
||||
invoiceNumber,
|
||||
onInvoiceNumberChange,
|
||||
invoiceNumberReadOnly = false,
|
||||
issueDate,
|
||||
onIssueDateChange,
|
||||
issueDateReadOnly = false,
|
||||
dueDate,
|
||||
onDueDateChange,
|
||||
taxRate,
|
||||
onTaxRateChange,
|
||||
taxRateReadOnly = false,
|
||||
notes,
|
||||
onNotesChange,
|
||||
sendReminderAt,
|
||||
onSendReminderAtChange,
|
||||
showSendReminder = false,
|
||||
}: InvoiceSetupFormProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.form}>
|
||||
{businessOptions.length === 0 ? (
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||
Add a business in Entities before invoicing.
|
||||
</Text>
|
||||
) : (
|
||||
<SelectField
|
||||
label="Business"
|
||||
placeholder="Select business…"
|
||||
value={businessId}
|
||||
options={businessOptions}
|
||||
required
|
||||
error={businessError}
|
||||
disabled={businessReadOnly}
|
||||
onValueChange={onBusinessIdChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clientOptions.length === 0 ? (
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||
Add a client in Entities before invoicing.
|
||||
</Text>
|
||||
) : (
|
||||
<SelectField
|
||||
label="Client"
|
||||
placeholder="Select client…"
|
||||
value={clientId}
|
||||
options={clientOptions}
|
||||
required
|
||||
error={clientError}
|
||||
disabled={clientReadOnly}
|
||||
onValueChange={onClientIdChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{invoiceNumberReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
Invoice number
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
{invoiceNumber}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Input
|
||||
label="Invoice number"
|
||||
value={invoiceNumber}
|
||||
onChangeText={onInvoiceNumberChange}
|
||||
autoCapitalize="characters"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{issueDateReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
Issue date
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
{issueDate.toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<DateTimeField
|
||||
label="Issue date"
|
||||
mode="date"
|
||||
value={issueDate}
|
||||
onChange={(date) => {
|
||||
onIssueDateChange?.(date);
|
||||
if (dueDate < date) onDueDateChange(defaultDueDate(date));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={onDueDateChange} />
|
||||
|
||||
{taxRateReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
Tax rate
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
{taxRate}%
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Input
|
||||
label="Tax rate (%)"
|
||||
value={taxRate}
|
||||
onChangeText={onTaxRateChange}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSendReminder && onSendReminderAtChange ? (
|
||||
<>
|
||||
<DateTimeField
|
||||
label="Remind me to send"
|
||||
mode="date"
|
||||
value={sendReminderAt ?? dueDate}
|
||||
minimumDate={new Date()}
|
||||
maximumDate={new Date(2100, 0, 1)}
|
||||
onChange={onSendReminderAtChange}
|
||||
/>
|
||||
{sendReminderAt ? (
|
||||
<Pressable onPress={() => onSendReminderAtChange(null)}>
|
||||
<Text style={[styles.clearReminder, { color: colors.primary }]}>
|
||||
Clear send reminder
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChangeText={onNotesChange}
|
||||
placeholder="Optional notes for the client"
|
||||
multiline
|
||||
style={styles.notesInput}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
form: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
hint: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
readOnlyField: {
|
||||
gap: 4,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
readOnlyLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
},
|
||||
readOnlyValue: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 15,
|
||||
},
|
||||
notesInput: {
|
||||
minHeight: 72,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
clearReminder: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -65,10 +65,10 @@ function TotalRow({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
totals: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
gap: 6,
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ScrollView, StyleSheet, View } from "react-native";
|
||||
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { spacing } from "@/constants/theme";
|
||||
import type { InvoiceStatus } from "@/lib/invoice-status";
|
||||
|
||||
export type InvoiceViewSection = "details" | "preview";
|
||||
|
||||
type InvoiceViewChipsProps = {
|
||||
section: InvoiceViewSection;
|
||||
onSectionChange: (section: InvoiceViewSection) => void;
|
||||
status: InvoiceStatus;
|
||||
onEdit: () => void;
|
||||
onSend: () => void;
|
||||
};
|
||||
|
||||
export function InvoiceViewChips({
|
||||
section,
|
||||
onSectionChange,
|
||||
status,
|
||||
onEdit,
|
||||
onSend,
|
||||
}: InvoiceViewChipsProps) {
|
||||
const sendLabel = status === "draft" ? "Send" : "Resend";
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
<FilterChip
|
||||
label="Details"
|
||||
active={section === "details"}
|
||||
onPress={() => onSectionChange("details")}
|
||||
/>
|
||||
<FilterChip label="Edit" onPress={onEdit} />
|
||||
{status !== "paid" ? (
|
||||
<FilterChip label={sendLabel} onPress={onSend} />
|
||||
) : null}
|
||||
<FilterChip
|
||||
label="View PDF"
|
||||
active={section === "preview"}
|
||||
onPress={() => onSectionChange("preview")}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -25,28 +25,10 @@ type LineItemEditorProps = {
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
export function LineItemsTableHeader() {
|
||||
function FieldLabel({ children }: { children: string }) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={[headerStyles.row, { borderBottomColor: colors.border }]}>
|
||||
<Text style={[headerStyles.cell, headerStyles.desc, { color: colors.mutedForeground }]}>
|
||||
Description
|
||||
</Text>
|
||||
<Text style={[headerStyles.cell, headerStyles.date, { color: colors.mutedForeground }]}>
|
||||
Date
|
||||
</Text>
|
||||
<Text style={[headerStyles.cell, headerStyles.hours, { color: colors.mutedForeground }]}>
|
||||
Hrs
|
||||
</Text>
|
||||
<Text style={[headerStyles.cell, headerStyles.rate, { color: colors.mutedForeground }]}>
|
||||
Rate
|
||||
</Text>
|
||||
<Text style={[headerStyles.cell, headerStyles.amt, { color: colors.mutedForeground }]}>
|
||||
Amt
|
||||
</Text>
|
||||
<View style={headerStyles.spacer} />
|
||||
</View>
|
||||
<Text style={[styles.fieldLabel, { color: colors.mutedForeground }]}>{children}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,20 +50,20 @@ export function LineItemEditor({
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.row,
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
|
||||
styles.readBlock,
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
|
||||
<View style={styles.descCol}>
|
||||
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={2}>
|
||||
{item.description.trim() || "Untitled line"}
|
||||
</Text>
|
||||
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
|
||||
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.amount, { color: colors.foreground }]}>
|
||||
<Text style={[styles.readIndex, { color: colors.mutedForeground }]}>
|
||||
Line {index + 1}
|
||||
</Text>
|
||||
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={3}>
|
||||
{item.description.trim() || "Untitled line"}
|
||||
</Text>
|
||||
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
|
||||
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
||||
</Text>
|
||||
<Text style={[styles.readAmount, { color: colors.foreground }]}>
|
||||
{formatCurrency(amount, currency)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -92,154 +74,159 @@ export function LineItemEditor({
|
||||
<View
|
||||
style={[
|
||||
styles.editBlock,
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: 1 },
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
|
||||
]}
|
||||
>
|
||||
<View style={styles.editTop}>
|
||||
<Text style={[styles.index, { color: colors.mutedForeground }]}>{index + 1}</Text>
|
||||
<TextInput
|
||||
value={item.description}
|
||||
onChangeText={(description) => onChange({ description })}
|
||||
placeholder="What was done?"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[
|
||||
styles.descriptionInput,
|
||||
{
|
||||
color: colors.foreground,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.lineLabel, { color: colors.mutedForeground }]}>Line {index + 1}</Text>
|
||||
|
||||
<View style={styles.metricsRow}>
|
||||
<CompactDateField
|
||||
value={item.date}
|
||||
onChange={(date) => onChange({ date })}
|
||||
style={styles.dateField}
|
||||
/>
|
||||
<CompactStepperInput
|
||||
value={item.hours}
|
||||
onChangeText={(hours) => onChange({ hours })}
|
||||
step={0.25}
|
||||
style={styles.hoursField}
|
||||
/>
|
||||
<View style={[styles.rateField, { borderColor: colors.border, backgroundColor: colors.cardGlass }]}>
|
||||
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
|
||||
<TextInput
|
||||
value={item.rate}
|
||||
onChangeText={(rate) => onChange({ rate })}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[styles.rateInput, { color: colors.foreground }]}
|
||||
<TextInput
|
||||
value={item.description}
|
||||
onChangeText={(description) => onChange({ description })}
|
||||
placeholder="What was done?"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[
|
||||
styles.descriptionInput,
|
||||
{
|
||||
color: colors.foreground,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View style={styles.fieldsRow}>
|
||||
<View style={styles.fieldCol}>
|
||||
<FieldLabel>Date</FieldLabel>
|
||||
<CompactDateField
|
||||
value={item.date}
|
||||
onChange={(date) => onChange({ date })}
|
||||
style={styles.fieldControl}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.amount, styles.amountEdit, { color: colors.foreground }]}>
|
||||
{formatCurrency(amount, currency)}
|
||||
</Text>
|
||||
<View style={styles.fieldCol}>
|
||||
<FieldLabel>Hours</FieldLabel>
|
||||
<CompactStepperInput
|
||||
value={item.hours}
|
||||
onChangeText={(hours) => onChange({ hours })}
|
||||
step={0.25}
|
||||
style={styles.fieldControl}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.fieldCol}>
|
||||
<FieldLabel>Rate</FieldLabel>
|
||||
<View
|
||||
style={[
|
||||
styles.rateField,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
|
||||
<TextInput
|
||||
value={item.rate}
|
||||
onChangeText={(rate) => onChange({ rate })}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[styles.rateInput, { color: colors.foreground }]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footerRow}>
|
||||
<View style={styles.amountGroup}>
|
||||
<Text style={[styles.amountLabel, { color: colors.mutedForeground }]}>Amount</Text>
|
||||
<Text style={[styles.amountValue, { color: colors.foreground }]}>
|
||||
{formatCurrency(amount, currency)}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Remove line item"
|
||||
onPress={onRemove}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [styles.remove, pressed && styles.removePressed]}
|
||||
style={({ pressed }) => [
|
||||
styles.remove,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
pressed && styles.removePressed,
|
||||
]}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={17} color={colors.destructive} />
|
||||
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const headerStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
const styles = StyleSheet.create({
|
||||
readBlock: {
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.xs,
|
||||
paddingBottom: spacing.xs,
|
||||
marginBottom: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
cell: {
|
||||
readIndex: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 11,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
desc: { flex: 1, paddingLeft: 22 },
|
||||
date: { width: 72 },
|
||||
hours: { width: 88, textAlign: "center" },
|
||||
rate: { width: 72, textAlign: "center" },
|
||||
amt: { width: 64, textAlign: "right" },
|
||||
spacer: { width: 32 },
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
editBlock: {
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
editTop: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
index: {
|
||||
width: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 12,
|
||||
textAlign: "center",
|
||||
},
|
||||
descCol: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
readTitle: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 14,
|
||||
lineHeight: 18,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
},
|
||||
readSub: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
},
|
||||
readAmount: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
marginTop: 2,
|
||||
},
|
||||
editBlock: {
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
lineLabel: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 11,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
descriptionInput: {
|
||||
flex: 1,
|
||||
minHeight: 36,
|
||||
width: "100%",
|
||||
minHeight: 40,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
paddingVertical: 6,
|
||||
fontSize: 15,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
metricsRow: {
|
||||
fieldsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
paddingLeft: 22,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
dateField: {
|
||||
width: 72,
|
||||
fieldCol: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
minWidth: 0,
|
||||
},
|
||||
hoursField: {
|
||||
width: 88,
|
||||
fieldLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 11,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
fieldControl: {
|
||||
width: "100%",
|
||||
},
|
||||
rateField: {
|
||||
width: 72,
|
||||
minHeight: 36,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
minHeight: 36,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
ratePrefix: {
|
||||
@@ -248,23 +235,40 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
rateInput: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.body,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
paddingVertical: 4,
|
||||
textAlign: "right",
|
||||
minWidth: 0,
|
||||
},
|
||||
amount: {
|
||||
width: 64,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
textAlign: "right",
|
||||
footerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
amountEdit: {
|
||||
amountGroup: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
amountLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
amountValue: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 17,
|
||||
},
|
||||
remove: {
|
||||
width: 32,
|
||||
height: 36,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.md,
|
||||
borderWidth: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { StyleSheet, Text } from "react-native";
|
||||
|
||||
import { fonts } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type LegalAgreementNoticeProps = {
|
||||
action: string;
|
||||
};
|
||||
|
||||
function openLegalPage(baseUrl: string, path: "/terms" | "/privacy") {
|
||||
const origin = baseUrl.replace(/\/$/, "");
|
||||
void WebBrowser.openBrowserAsync(`${origin}${path}`);
|
||||
}
|
||||
|
||||
export function LegalAgreementNotice({ action }: LegalAgreementNoticeProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const { apiUrl } = useAccounts();
|
||||
|
||||
return (
|
||||
<Text style={[styles.text, { color: colors.mutedForeground }]}>
|
||||
By {action}, you agree to our{" "}
|
||||
<Text
|
||||
style={[styles.link, { color: colors.foreground }]}
|
||||
onPress={() => openLegalPage(apiUrl, "/terms")}
|
||||
>
|
||||
Terms of Service
|
||||
</Text>{" "}
|
||||
and{" "}
|
||||
<Text
|
||||
style={[styles.link, { color: colors.foreground }]}
|
||||
onPress={() => openLegalPage(apiUrl, "/privacy")}
|
||||
>
|
||||
Privacy Policy
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
text: {
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 18,
|
||||
},
|
||||
link: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
|
||||
import {
|
||||
endTimeClockLiveActivity,
|
||||
syncTimeClockLiveActivity,
|
||||
} from "@/lib/time-clock-live-activity";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
/** Keeps the iOS Live Activity in sync while a timer runs — app-wide, not just on the Timer tab. */
|
||||
export function TimeClockLiveActivitySync() {
|
||||
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const running = runningQuery.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
void endTimeClockLiveActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
const seconds = Math.floor(
|
||||
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
|
||||
);
|
||||
void syncTimeClockLiveActivity(running, seconds);
|
||||
};
|
||||
|
||||
sync();
|
||||
|
||||
const interval = setInterval(sync, 60_000);
|
||||
const subscription = AppState.addEventListener("change", (state) => {
|
||||
if (state === "active") sync();
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
subscription.remove();
|
||||
};
|
||||
}, [running]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -31,12 +31,12 @@ import {
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import {
|
||||
endTimeClockLiveActivity,
|
||||
syncTimeClockLiveActivity,
|
||||
} from "@/lib/time-clock-live-activity";
|
||||
import {
|
||||
DEFAULT_CLOCK_DESCRIPTION,
|
||||
describeClockOutOutcome,
|
||||
formatElapsedSeconds,
|
||||
formatRunningTimerLabel,
|
||||
resolveClockDescription,
|
||||
resolveEffectiveHourlyRate,
|
||||
startedAtFromMinutesAgo,
|
||||
@@ -90,6 +90,7 @@ export function TimeClockPanel({
|
||||
const [clientId, setClientId] = useState(defaultClientId);
|
||||
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
|
||||
const [description, setDescription] = useState("");
|
||||
const [stopNote, setStopNote] = useState("");
|
||||
const [rateText, setRateText] = useState("");
|
||||
const [startedAt, setStartedAt] = useState(() => new Date());
|
||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||
@@ -139,18 +140,6 @@ export function TimeClockPanel({
|
||||
},
|
||||
});
|
||||
|
||||
const updateRunning = api.timeEntries.updateRunning.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.timeEntries.getRunning.invalidate(),
|
||||
utils.invoices.getBillable.invalidate(),
|
||||
]);
|
||||
},
|
||||
onError: (err) => {
|
||||
Alert.alert("Could not update timer", err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
await endTimeClockLiveActivity();
|
||||
@@ -175,6 +164,7 @@ export function TimeClockPanel({
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
setDescription("");
|
||||
setStopNote("");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -194,7 +184,7 @@ export function TimeClockPanel({
|
||||
if (!running) return;
|
||||
setClientId(running.clientId ?? "");
|
||||
setInvoiceId(running.invoiceId ?? "");
|
||||
setDescription(running.description?.trim() ?? "");
|
||||
setStopNote("");
|
||||
setRateText(running.rate != null ? String(running.rate) : "");
|
||||
}, [running]);
|
||||
|
||||
@@ -247,24 +237,6 @@ export function TimeClockPanel({
|
||||
setFeaturedClientIds(ids.slice(0, 1));
|
||||
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
void endTimeClockLiveActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
const seconds = Math.floor(
|
||||
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
|
||||
);
|
||||
void syncTimeClockLiveActivity({ ...running, description }, seconds);
|
||||
};
|
||||
|
||||
sync();
|
||||
const interval = setInterval(sync, 15_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [running, description]);
|
||||
|
||||
const selectedClient = clients.find((client) => client.id === clientId);
|
||||
const rateCurrency = selectedClient?.currency ?? "USD";
|
||||
const effectiveRate = resolveEffectiveHourlyRate(
|
||||
@@ -404,43 +376,19 @@ export function TimeClockPanel({
|
||||
async function handleClockOut() {
|
||||
try {
|
||||
await clockOut.mutateAsync({
|
||||
description: description.trim() ? description.trim() : undefined,
|
||||
description: stopNote.trim() ? stopNote.trim() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunningClientChange(nextClientId: string) {
|
||||
if (!running) return;
|
||||
setClientId(nextClientId);
|
||||
setInvoiceId("");
|
||||
try {
|
||||
await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" });
|
||||
const client = clients.find((c) => c.id === nextClientId);
|
||||
setRateText(clientRateText(client));
|
||||
await persistClientChoice(nextClientId);
|
||||
} catch {
|
||||
setClientId(running.clientId ?? "");
|
||||
setInvoiceId(running.invoiceId ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunningInvoiceChange(nextInvoiceId: string) {
|
||||
if (!running) return;
|
||||
const previous = invoiceId;
|
||||
setInvoiceId(nextInvoiceId);
|
||||
try {
|
||||
await updateRunning.mutateAsync({ invoiceId: nextInvoiceId });
|
||||
} catch {
|
||||
setInvoiceId(previous);
|
||||
}
|
||||
}
|
||||
|
||||
if (runningQuery.isLoading || clientsQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading time clock…" />;
|
||||
}
|
||||
|
||||
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||
const runningMeta = [
|
||||
running?.client?.name ?? (running ? "No client" : null),
|
||||
running?.invoice
|
||||
@@ -451,7 +399,6 @@ export function TimeClockPanel({
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
const controlsDisabled = Boolean(running && updateRunning.isPending);
|
||||
|
||||
function renderClientChip(client: (typeof clients)[number]) {
|
||||
return (
|
||||
@@ -459,11 +406,7 @@ export function TimeClockPanel({
|
||||
key={client.id}
|
||||
label={client.name}
|
||||
active={clientId === client.id}
|
||||
onPress={() => {
|
||||
if (controlsDisabled) return;
|
||||
if (running) void handleRunningClientChange(client.id);
|
||||
else selectClient(client.id);
|
||||
}}
|
||||
onPress={() => selectClient(client.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -487,18 +430,18 @@ export function TimeClockPanel({
|
||||
>
|
||||
{running || !compact ? (
|
||||
<GlassSurface style={running ? styles.runningCard : undefined}>
|
||||
<View style={styles.hero}>
|
||||
<View style={[styles.hero, running && styles.heroRunning]}>
|
||||
{running ? (
|
||||
<>
|
||||
<View style={styles.heroHeader}>
|
||||
<View style={styles.pulseDot} />
|
||||
<Text style={styles.heroLabel}>Running</Text>
|
||||
<Text style={styles.heroLabelRunning}>Timer running</Text>
|
||||
</View>
|
||||
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
|
||||
<Text style={styles.runningMeta}>
|
||||
Started {formatDateTime(running.startedAt)}
|
||||
{runningMeta ? ` · ${runningMeta}` : ""}
|
||||
</Text>
|
||||
<Text style={styles.runningTitle}>{runningTitle}</Text>
|
||||
{runningMeta ? (
|
||||
<Text style={styles.runningMeta}>{runningMeta}</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.idleHint}>
|
||||
@@ -510,13 +453,37 @@ export function TimeClockPanel({
|
||||
) : null}
|
||||
|
||||
<GlassSurface style={styles.setupCard}>
|
||||
<Input
|
||||
label="Title"
|
||||
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
|
||||
|
||||
{running ? (
|
||||
<View style={styles.formSection}>
|
||||
<Input
|
||||
label="Note on stop (optional)"
|
||||
value={stopNote}
|
||||
onChangeText={setStopNote}
|
||||
placeholder={
|
||||
running.description?.trim()
|
||||
? running.description
|
||||
: "Update description when you stop"
|
||||
}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<Button
|
||||
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
|
||||
variant="danger"
|
||||
loading={clockOut.isPending}
|
||||
onPress={handleClockOut}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="What are you working on?"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
returnKeyType="done"
|
||||
style={[styles.titleInput, !description.trim() && styles.titleInputPlaceholder]}
|
||||
style={[styles.titleField, { color: colors.foreground }]}
|
||||
/>
|
||||
|
||||
<View style={styles.setupSection}>
|
||||
@@ -533,10 +500,7 @@ export function TimeClockPanel({
|
||||
<FilterChip
|
||||
label={clientsExpanded ? "Show less" : "Show more"}
|
||||
active={clientsExpanded}
|
||||
onPress={() => {
|
||||
if (controlsDisabled) return;
|
||||
setClientsExpanded((open) => !open);
|
||||
}}
|
||||
onPress={() => setClientsExpanded((open) => !open)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -547,7 +511,7 @@ export function TimeClockPanel({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{clockInErrors.clientId && !running ? (
|
||||
{clockInErrors.clientId ? (
|
||||
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -559,11 +523,7 @@ export function TimeClockPanel({
|
||||
<FilterChip
|
||||
label="Entry only"
|
||||
active={!invoiceId}
|
||||
onPress={() => {
|
||||
if (controlsDisabled) return;
|
||||
if (running) void handleRunningInvoiceChange("");
|
||||
else setInvoiceId("");
|
||||
}}
|
||||
onPress={() => setInvoiceId("")}
|
||||
/>
|
||||
{billableInvoices.map((invoice) => {
|
||||
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
||||
@@ -572,11 +532,7 @@ export function TimeClockPanel({
|
||||
key={invoice.id}
|
||||
label={label}
|
||||
active={invoiceId === invoice.id}
|
||||
onPress={() => {
|
||||
if (controlsDisabled) return;
|
||||
if (running) void handleRunningInvoiceChange(invoice.id);
|
||||
else setInvoiceId(invoice.id);
|
||||
}}
|
||||
onPress={() => setInvoiceId(invoice.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -584,7 +540,7 @@ export function TimeClockPanel({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!running && clientId ? (
|
||||
{clientId ? (
|
||||
<View style={styles.setupSection}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
@@ -690,28 +646,20 @@ export function TimeClockPanel({
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
title={clockIn.isPending ? "Starting…" : "Start timer"}
|
||||
loading={clockIn.isPending}
|
||||
disabled={!canClockIn || clients.length === 0}
|
||||
showArrow={!clockIn.isPending}
|
||||
onPress={handleClockIn}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</GlassSurface>
|
||||
|
||||
{running ? (
|
||||
<Button
|
||||
title="Clock out"
|
||||
variant="danger"
|
||||
loading={clockOut.isPending}
|
||||
onPress={handleClockOut}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
title="Clock in"
|
||||
loading={clockIn.isPending}
|
||||
disabled={!canClockIn || clients.length === 0}
|
||||
onPress={handleClockIn}
|
||||
/>
|
||||
)}
|
||||
|
||||
{todayEntries.length > 0 ? (
|
||||
<Card title="Today">
|
||||
<Card title="Today's entries">
|
||||
{todayEntries.map((entry) => {
|
||||
const invoiceLabel = entry.invoice
|
||||
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||
@@ -720,7 +668,7 @@ export function TimeClockPanel({
|
||||
const row = (
|
||||
<>
|
||||
<View style={styles.entryMeta}>
|
||||
<Text style={styles.entryTitle}>{resolveClockDescription(entry.description)}</Text>
|
||||
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
|
||||
<Text style={styles.entrySub}>
|
||||
{entry.client?.name ?? "No client"}
|
||||
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
|
||||
@@ -762,41 +710,52 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
flex: 1,
|
||||
},
|
||||
runningCard: {
|
||||
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
|
||||
borderColor: isDark ? "rgba(250, 250, 250, 0.12)" : "rgba(24, 24, 27, 0.12)",
|
||||
backgroundColor: isDark ? "rgba(250, 250, 250, 0.06)" : "rgba(24, 24, 27, 0.04)",
|
||||
},
|
||||
hero: {
|
||||
padding: spacing.md,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
heroRunning: {
|
||||
alignItems: "center",
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
pulseDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
heroLabel: {
|
||||
fontSize: 13,
|
||||
heroLabelRunning: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
color: colors.mutedForeground,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
color: colors.primary,
|
||||
},
|
||||
timerValue: {
|
||||
fontSize: 52,
|
||||
lineHeight: 56,
|
||||
fontSize: 56,
|
||||
lineHeight: 60,
|
||||
fontFamily: fonts.mono,
|
||||
color: colors.foreground,
|
||||
color: colors.primary,
|
||||
fontVariant: ["tabular-nums"],
|
||||
textAlign: "center",
|
||||
},
|
||||
runningTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
color: colors.foreground,
|
||||
textAlign: "center",
|
||||
},
|
||||
runningMeta: {
|
||||
fontSize: 13,
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
textAlign: "center",
|
||||
},
|
||||
idleHint: {
|
||||
fontSize: 14,
|
||||
@@ -805,20 +764,27 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
lineHeight: 20,
|
||||
},
|
||||
setupCard: {
|
||||
padding: spacing.md,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
formSection: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
titleField: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
minHeight: 48,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
setupSection: {
|
||||
gap: spacing.sm,
|
||||
paddingTop: spacing.lg,
|
||||
},
|
||||
titleInput: {
|
||||
minHeight: 44,
|
||||
textAlignVertical: "center",
|
||||
},
|
||||
titleInputPlaceholder: {
|
||||
textAlign: "center",
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type PressableProps,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
@@ -15,6 +17,7 @@ type ButtonProps = PressableProps & {
|
||||
loading?: boolean;
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
style?: ViewStyle;
|
||||
showArrow?: boolean;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
@@ -23,6 +26,7 @@ export function Button({
|
||||
variant = "primary",
|
||||
disabled,
|
||||
style,
|
||||
showArrow = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const { colors } = useAppTheme();
|
||||
@@ -68,7 +72,17 @@ export function Button({
|
||||
color={variant === "primary" ? colors.primaryForeground : colors.primary}
|
||||
/>
|
||||
) : (
|
||||
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
|
||||
<View style={styles.content}>
|
||||
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
|
||||
{showArrow ? (
|
||||
<Ionicons
|
||||
name="arrow-forward"
|
||||
size={16}
|
||||
color={labelStyles[variant].color}
|
||||
style={styles.arrow}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -76,12 +90,20 @@ export function Button({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
minHeight: 40,
|
||||
minHeight: 44,
|
||||
borderRadius: radii.lg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
content: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
arrow: {
|
||||
marginTop: 1,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.92,
|
||||
},
|
||||
|
||||
+72
-20
@@ -5,6 +5,7 @@ import {
|
||||
View,
|
||||
type TextInputProps,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
@@ -13,31 +14,60 @@ type InputProps = TextInputProps & {
|
||||
label: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
leftIcon?: keyof typeof Ionicons.glyphMap;
|
||||
labelAccessory?: React.ReactNode;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function Input({ label, error, required, style, ...props }: InputProps) {
|
||||
export function Input({
|
||||
label,
|
||||
error,
|
||||
required,
|
||||
leftIcon,
|
||||
labelAccessory,
|
||||
hint,
|
||||
style,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Text style={[styles.label, { color: colors.foreground }]}>
|
||||
{label}
|
||||
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
color: colors.foreground,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
error && { borderColor: colors.destructive },
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={[styles.label, { color: colors.foreground }]}>
|
||||
{label}
|
||||
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
||||
</Text>
|
||||
{labelAccessory}
|
||||
</View>
|
||||
<View style={styles.field}>
|
||||
{leftIcon ? (
|
||||
<Ionicons
|
||||
name={leftIcon}
|
||||
size={16}
|
||||
color={colors.mutedForeground}
|
||||
style={styles.leftIcon}
|
||||
/>
|
||||
) : null}
|
||||
<TextInput
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[
|
||||
styles.input,
|
||||
leftIcon && styles.inputWithIcon,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
color: colors.foreground,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
error && { borderColor: colors.destructive },
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
{hint && !error ? (
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>{hint}</Text>
|
||||
) : null}
|
||||
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
@@ -47,18 +77,40 @@ const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
field: {
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
},
|
||||
leftIcon: {
|
||||
position: "absolute",
|
||||
left: spacing.md,
|
||||
zIndex: 1,
|
||||
},
|
||||
input: {
|
||||
minHeight: 40,
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
inputWithIcon: {
|
||||
paddingLeft: spacing.md + 24,
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
error: {
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.body,
|
||||
|
||||
Reference in New Issue
Block a user