Polish mobile and web experience

This commit is contained in:
2026-08-17 00:18:55 -04:00
parent 6c74436092
commit 9929d7321d
28 changed files with 835 additions and 688 deletions
+3 -5
View File
@@ -2,7 +2,6 @@ import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import {
Alert, Alert,
RefreshControl,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
@@ -15,6 +14,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 { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow"; 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";
@@ -71,8 +71,7 @@ export default function EntitiesScreen() {
const businesses = businessesQuery.data ?? []; const businesses = businessesQuery.data ?? [];
function refresh() { function refresh() {
if (tab === "clients") void clientsQuery.refetch(); return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch();
else void businessesQuery.refetch();
} }
function confirmDelete(id: string, name: string) { function confirmDelete(id: string, name: string) {
@@ -100,8 +99,7 @@ export default function EntitiesScreen() {
/> />
} }
refreshControl={ refreshControl={
<RefreshControl <PullToRefresh
refreshing={activeQuery.isRefetching}
onRefresh={refresh} onRefresh={refresh}
tintColor={colors.primary} tintColor={colors.primary}
/> />
+27 -9
View File
@@ -1,11 +1,12 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router"; import { router } from "expo-router";
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
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 { PullToRefresh } from "@/components/PullToRefresh";
import { Screen } from "@/components/Screen"; import { Screen } from "@/components/Screen";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
@@ -152,12 +153,8 @@ export default function DashboardScreen() {
<TabScrollView <TabScrollView
header={<PageHeader title={`Hello, ${firstName}`} subtitle="What needs attention now" />} header={<PageHeader title={`Hello, ${firstName}`} subtitle="What needs attention now" />}
refreshControl={ refreshControl={
<RefreshControl <PullToRefresh
refreshing={statsQuery.isRefetching || runningQuery.isRefetching} onRefresh={() => Promise.all([statsQuery.refetch(), runningQuery.refetch()])}
onRefresh={() => {
void statsQuery.refetch();
void runningQuery.refetch();
}}
tintColor={colors.primary} tintColor={colors.primary}
/> />
} }
@@ -221,7 +218,10 @@ export default function DashboardScreen() {
</Card> </Card>
{running ? ( {running ? (
<Pressable onPress={() => router.push("/(app)/timer")}> <Pressable
accessibilityRole="button"
onPress={() => router.push("/(app)/timer")}
>
<GlassSurface style={styles.runningGlass}> <GlassSurface style={styles.runningGlass}>
<View style={styles.runningRow}> <View style={styles.runningRow}>
<View style={styles.runningDot} /> <View style={styles.runningDot} />
@@ -299,6 +299,11 @@ export default function DashboardScreen() {
{stats.currentDraft ? ( {stats.currentDraft ? (
<GlassSurface style={styles.draftGlass}> <GlassSurface style={styles.draftGlass}>
<Pressable <Pressable
accessible
accessibilityLabel={`Current draft, ${
stats.currentDraft.client?.name ?? "Client"
}, ${formatCurrency(stats.currentDraft.totalAmount)}, ${stats.currentDraft.totalHours.toFixed(1)} hours logged`}
accessibilityRole="button"
style={styles.draftBanner} style={styles.draftBanner}
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)} onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
> >
@@ -325,7 +330,12 @@ export default function DashboardScreen() {
<View style={styles.statCell}> <View style={styles.statCell}>
<StatCard label="Overdue" value={String(stats.overdueCount)} /> <StatCard label="Overdue" value={String(stats.overdueCount)} />
</View> </View>
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}> <Pressable
accessibilityRole="button"
accessibilityLabel={`Clients, ${stats.totalClients}`}
style={styles.statCell}
onPress={() => router.push("/(app)/entities")}
>
<StatCard label="Clients" value={String(stats.totalClients)} /> <StatCard label="Clients" value={String(stats.totalClients)} />
</Pressable> </Pressable>
</View> </View>
@@ -354,6 +364,14 @@ export default function DashboardScreen() {
const status = getInvoiceStatus(invoice); const status = getInvoiceStatus(invoice);
return ( return (
<Pressable <Pressable
accessible
accessibilityLabel={`${invoice.invoicePrefix}${invoice.invoiceNumber}, ${
invoice.client?.name ?? "Client"
}, ${formatDate(invoice.issueDate)}, ${formatCurrency(
invoice.totalAmount,
invoice.currency,
)}, ${status}`}
accessibilityRole="button"
key={invoice.id} key={invoice.id}
style={({ pressed }) => [styles.recentRow, pressed && styles.pressed]} style={({ pressed }) => [styles.recentRow, pressed && styles.pressed]}
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)} onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
+2 -3
View File
@@ -2,7 +2,6 @@ import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import {
Alert, Alert,
RefreshControl,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
@@ -15,6 +14,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 { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow"; 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";
@@ -121,8 +121,7 @@ export default function InvoicesScreen() {
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" /> <PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
} }
refreshControl={ refreshControl={
<RefreshControl <PullToRefresh
refreshing={invoicesQuery.isRefetching}
onRefresh={() => invoicesQuery.refetch()} onRefresh={() => invoicesQuery.refetch()}
tintColor={colors.primary} tintColor={colors.primary}
/> />
+21 -8
View File
@@ -29,6 +29,7 @@ import { formatCurrency } from "@/lib/format";
import { import {
isRequiredString, isRequiredString,
isValidTaxRate, isValidTaxRate,
useFieldVisibility,
validateLineItems, validateLineItems,
} from "@/lib/form-validation"; } from "@/lib/form-validation";
import { resolveInvoiceBusinessId } from "@/lib/invoice-business"; import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
@@ -70,6 +71,7 @@ export default function NewInvoiceScreen() {
); );
const [section, setSection] = useState<InvoiceEditorSection>("setup"); const [section, setSection] = useState<InvoiceEditorSection>("setup");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { touch, visible, markSubmitted } = useFieldVisibility();
useEffect(() => { useEffect(() => {
if (businessId || !businessesQuery.data?.length) return; if (businessId || !businessesQuery.data?.length) return;
@@ -184,10 +186,12 @@ export default function NewInvoiceScreen() {
} }
function updateItem(index: number, patch: Partial<EditableLineItem>) { function updateItem(index: number, patch: Partial<EditableLineItem>) {
touch("lineItems");
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
} }
function addItem() { function addItem() {
touch("lineItems");
setItems((prev) => [ setItems((prev) => [
...prev, ...prev,
{ {
@@ -200,10 +204,12 @@ export default function NewInvoiceScreen() {
} }
function removeItem(index: number) { function removeItem(index: number) {
touch("lineItems");
setItems((prev) => prev.filter((_, i) => i !== index)); setItems((prev) => prev.filter((_, i) => i !== index));
} }
function duplicateItem(index: number) { function duplicateItem(index: number) {
touch("lineItems");
setItems((prev) => { setItems((prev) => {
const source = prev[index]; const source = prev[index];
if (!source) return prev; if (!source) return prev;
@@ -213,6 +219,7 @@ export default function NewInvoiceScreen() {
} }
function handleCreate() { function handleCreate() {
markSubmitted();
if (!canCreate) return; if (!canCreate) return;
setError(null); setError(null);
@@ -296,27 +303,31 @@ export default function NewInvoiceScreen() {
businessId={businessId} businessId={businessId}
onBusinessIdChange={setBusinessId} onBusinessIdChange={setBusinessId}
businessOptions={businessOptions} businessOptions={businessOptions}
businessError={businessError} businessError={visible("business") ? businessError : undefined}
onBusinessBlur={() => touch("business")}
clientId={clientId} clientId={clientId}
onClientIdChange={setClientId} onClientIdChange={setClientId}
clientOptions={clientOptions} clientOptions={clientOptions}
clientError={clientError} clientError={visible("client") ? clientError : undefined}
onClientBlur={() => touch("client")}
invoiceNumber={invoiceNumber} invoiceNumber={invoiceNumber}
onInvoiceNumberChange={setInvoiceNumber} onInvoiceNumberChange={setInvoiceNumber}
invoiceNumberError={
visible("invoiceNumber") ? invoiceNumberError : undefined
}
onInvoiceNumberBlur={() => touch("invoiceNumber")}
issueDate={issueDate} issueDate={issueDate}
onIssueDateChange={setIssueDate} onIssueDateChange={setIssueDate}
dueDate={dueDate} dueDate={dueDate}
onDueDateChange={setDueDate} onDueDateChange={setDueDate}
taxRate={taxRate} taxRate={taxRate}
onTaxRateChange={setTaxRate} onTaxRateChange={setTaxRate}
taxRateError={visible("taxRate") ? taxError : undefined}
onTaxRateBlur={() => touch("taxRate")}
notes={notes} notes={notes}
onNotesChange={setNotes} onNotesChange={setNotes}
/> />
)} )}
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
{invoiceNumberError ? (
<Text style={styles.error}>{invoiceNumberError}</Text>
) : null}
</Card> </Card>
) : ( ) : (
<> <>
@@ -354,11 +365,13 @@ export default function NewInvoiceScreen() {
/> />
</Card> </Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null} {visible("lineItems") && lineItemsError ? (
<Text selectable style={styles.error}>{lineItemsError}</Text>
) : null}
</> </>
)} )}
{error ? <Text style={styles.error}>{error}</Text> : null} {error ? <Text selectable style={styles.error}>{error}</Text> : null}
<InvoiceEditorFooter <InvoiceEditorFooter
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"} primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router"; import { router } from "expo-router";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
RefreshControl,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
@@ -15,6 +14,7 @@ import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip"; import { FilterChip } from "@/components/FilterChip";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow"; 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";
@@ -110,9 +110,8 @@ export default function ExpensesScreen() {
</View> </View>
} }
refreshControl={ refreshControl={
<RefreshControl <PullToRefresh
refreshing={expensesQuery.isRefetching} onRefresh={() => expensesQuery.refetch()}
onRefresh={() => void expensesQuery.refetch()}
tintColor={colors.primary} tintColor={colors.primary}
/> />
} }
+4 -4
View File
@@ -1,8 +1,9 @@
import { RefreshControl, StyleSheet, Text, View } from "react-native"; import { StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow"; 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";
@@ -46,9 +47,8 @@ export default function RecurringScreen() {
/> />
} }
refreshControl={ refreshControl={
<RefreshControl <PullToRefresh
refreshing={query.isRefetching} onRefresh={() => query.refetch()}
onRefresh={() => void query.refetch()}
tintColor={colors.primary} tintColor={colors.primary}
/> />
} }
+10 -8
View File
@@ -1,8 +1,9 @@
import { RefreshControl, StyleSheet, Text, View } from "react-native"; import { StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { TabPage } from "@/components/TabPage"; import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView"; import { TabScrollView } from "@/components/TabScrollView";
@@ -48,13 +49,14 @@ export default function ReportsScreen() {
<TabScrollView <TabScrollView
header={<PageHeader title="Reports" subtitle="Business performance snapshot" />} header={<PageHeader title="Reports" subtitle="Business performance snapshot" />}
refreshControl={ refreshControl={
<RefreshControl <PullToRefresh
refreshing={statsQuery.isRefetching} onRefresh={() =>
onRefresh={() => { Promise.all([
void statsQuery.refetch(); statsQuery.refetch(),
void expensesQuery.refetch(); expensesQuery.refetch(),
void summaryQuery.refetch(); summaryQuery.refetch(),
}} ])
}
tintColor={colors.primary} tintColor={colors.primary}
/> />
} }
+4 -4
View File
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { RefreshControl, StyleSheet, Text, View } from "react-native"; import { StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground"; import { AppBackground } from "@/components/AppBackground";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow"; 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";
@@ -74,9 +75,8 @@ export default function TimeEntriesScreen() {
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} /> <PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
} }
refreshControl={ refreshControl={
<RefreshControl <PullToRefresh
refreshing={entriesQuery.isRefetching} onRefresh={() => entriesQuery.refetch()}
onRefresh={() => void entriesQuery.refetch()}
tintColor={colors.primary} tintColor={colors.primary}
/> />
} }
+14 -5
View File
@@ -21,7 +21,11 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { resetPassword } from "@/lib/auth-api"; import { resetPassword } from "@/lib/auth-api";
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 { isRequiredString, isValidPassword } from "@/lib/form-validation"; import {
isRequiredString,
isValidPassword,
useFieldVisibility,
} from "@/lib/form-validation";
export default function ResetPasswordScreen() { export default function ResetPasswordScreen() {
const styles = useThemedStyles(createResetPasswordStyles); const styles = useThemedStyles(createResetPasswordStyles);
@@ -33,6 +37,7 @@ export default function ResetPasswordScreen() {
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true); const [serverReady, setServerReady] = useState(true);
const { touch, visible, markSubmitted } = useFieldVisibility();
useEffect(() => { useEffect(() => {
if (typeof tokenParam === "string" && tokenParam.length > 0) { if (typeof tokenParam === "string" && tokenParam.length > 0) {
@@ -56,6 +61,7 @@ export default function ResetPasswordScreen() {
confirmPassword.length > 0; confirmPassword.length > 0;
async function handleSubmit() { async function handleSubmit() {
markSubmitted();
if (!canSubmit) return; if (!canSubmit) return;
setError(null); setError(null);
@@ -111,30 +117,33 @@ export default function ResetPasswordScreen() {
autoCapitalize="none" autoCapitalize="none"
value={token} value={token}
onChangeText={setToken} onChangeText={setToken}
onBlur={() => touch("token")}
placeholder="Paste token from email" placeholder="Paste token from email"
required required
error={tokenError} error={visible("token") ? tokenError : undefined}
/> />
<Input <Input
label="New password" label="New password"
secureTextEntry secureTextEntry
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="At least 8 characters" placeholder="At least 8 characters"
required required
error={passwordError} error={visible("password") ? passwordError : undefined}
/> />
<Input <Input
label="Confirm password" label="Confirm password"
secureTextEntry secureTextEntry
value={confirmPassword} value={confirmPassword}
onChangeText={setConfirmPassword} onChangeText={setConfirmPassword}
onBlur={() => touch("confirmPassword")}
placeholder="Repeat password" placeholder="Repeat password"
required required
error={confirmError} error={visible("confirmPassword") ? confirmError : undefined}
/> />
{error ? <Text style={styles.error}>{error}</Text> : null} {error ? <Text selectable style={styles.error}>{error}</Text> : null}
<Button <Button
title="Update password" title="Update password"
+4 -1
View File
@@ -14,7 +14,10 @@ export function FilterChip({ label, active, onPress }: FilterChipProps) {
return ( return (
<Pressable <Pressable
accessible
accessibilityLabel={label}
accessibilityRole="button" accessibilityRole="button"
accessibilityState={{ selected: Boolean(active) }}
onPress={onPress} onPress={onPress}
style={[ style={[
styles.chip, styles.chip,
@@ -39,7 +42,7 @@ export function FilterChip({ label, active, onPress }: FilterChipProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
chip: { chip: {
height: 32, minHeight: 44,
borderWidth: 1, borderWidth: 1,
borderRadius: radii.pill, borderRadius: radii.pill,
overflow: "hidden", overflow: "hidden",
+34
View File
@@ -0,0 +1,34 @@
import { useCallback, useState } from "react";
import { RefreshControl, type RefreshControlProps } from "react-native";
type PullToRefreshProps = Omit<RefreshControlProps, "onRefresh" | "refreshing"> & {
onRefresh: () => Promise<unknown> | unknown;
};
/**
* Keeps the native refresh indicator tied to an actual pull gesture.
* Query `isRefetching` also covers background polling and invalidations, which
* can repeatedly move an offscreen iOS scroll view when used as `refreshing`.
*/
export function PullToRefresh({ onRefresh, ...props }: PullToRefreshProps) {
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
try {
await onRefresh();
} finally {
setRefreshing(false);
}
}, [onRefresh, refreshing]);
return (
<RefreshControl
{...props}
refreshing={refreshing}
onRefresh={() => void handleRefresh()}
/>
);
}
+57 -19
View File
@@ -4,9 +4,15 @@ import { Pressable, type PressableProps, StyleSheet, Text, View } from "react-na
import Swipeable, { import Swipeable, {
type SwipeableMethods, type SwipeableMethods,
} from "react-native-gesture-handler/ReanimatedSwipeable"; } from "react-native-gesture-handler/ReanimatedSwipeable";
import Animated, {
interpolate,
useAnimatedStyle,
type SharedValue,
} from "react-native-reanimated";
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 { resolveActionForeground } from "@/lib/action-contrast";
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";
@@ -29,6 +35,38 @@ type SwipeableRowProps = {
contentStyle?: PressableProps["style"]; contentStyle?: PressableProps["style"];
}; };
type SwipeActionsProps = {
actions: SwipeAction[];
progress: SharedValue<number>;
onActionPress: (action: SwipeAction) => void;
};
function SwipeActions({ actions, progress, onActionPress }: SwipeActionsProps) {
const styles = useThemedStyles(createSwipeableRowStyles);
const revealStyle = useAnimatedStyle(() => ({
opacity: interpolate(progress.value, [0, 0.02, 0.15], [0, 0, 1], "clamp"),
}));
return (
<Animated.View style={[styles.actions, revealStyle]}>
{actions.map((action) => {
const foreground = resolveActionForeground(action.backgroundColor, action.color);
return (
<Pressable
key={action.key}
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
onPress={() => onActionPress(action)}
>
<Ionicons name={action.icon} size={20} color={foreground} />
<Text style={[styles.actionLabel, { color: foreground }]}>{action.label}</Text>
</Pressable>
);
})}
</Animated.View>
);
}
export function SwipeableRow({ export function SwipeableRow({
children, children,
actions, actions,
@@ -92,25 +130,20 @@ export function SwipeableRow({
); );
} }
function renderRightActions() { function handleActionPress(action: SwipeAction) {
suppressContentPress();
swipeRef.current?.close();
rowOpenRef.current = false;
action.onPress();
}
function renderRightActions(progress: SharedValue<number>) {
return ( return (
<View style={styles.actions}> <SwipeActions
{actions.map((action) => ( actions={actions}
<Pressable progress={progress}
key={action.key} onActionPress={handleActionPress}
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]} />
onPress={() => {
suppressContentPress();
swipeRef.current?.close();
rowOpenRef.current = false;
action.onPress();
}}
>
<Ionicons name={action.icon} size={20} color={action.color} />
<Text style={[styles.actionLabel, { color: action.color }]}>{action.label}</Text>
</Pressable>
))}
</View>
); );
} }
@@ -121,6 +154,7 @@ export function SwipeableRow({
return ( return (
<Swipeable <Swipeable
ref={swipeRef} ref={swipeRef}
containerStyle={styles.container}
renderRightActions={renderRightActions} renderRightActions={renderRightActions}
overshootRight={false} overshootRight={false}
onSwipeableOpenStartDrag={suppressContentPress} onSwipeableOpenStartDrag={suppressContentPress}
@@ -141,11 +175,15 @@ export function SwipeableRow({
const createSwipeableRowStyles = (colors: ThemeColors) => const createSwipeableRowStyles = (colors: ThemeColors) =>
StyleSheet.create({ StyleSheet.create({
container: {
borderRadius: radii.lg,
overflow: "hidden",
marginBottom: spacing.xs,
},
row: { row: {
backgroundColor: colors.background, backgroundColor: colors.background,
borderRadius: radii.lg, borderRadius: radii.lg,
overflow: "hidden", overflow: "hidden",
marginBottom: spacing.xs,
}, },
actions: { actions: {
flexDirection: "row", flexDirection: "row",
@@ -17,7 +17,7 @@ import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
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 { isRequiredString } from "@/lib/form-validation"; import { isRequiredString, useFieldVisibility } from "@/lib/form-validation";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
type BusinessFormValues = { type BusinessFormValues = {
@@ -78,6 +78,7 @@ export function BusinessForm({
const [values, setValues] = useState<BusinessFormValues>(emptyValues); const [values, setValues] = useState<BusinessFormValues>(emptyValues);
const [fieldError, setFieldError] = useState<string | null>(null); const [fieldError, setFieldError] = useState<string | null>(null);
const { touch, visible, markSubmitted } = useFieldVisibility();
const switchProps = { const switchProps = {
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn }, trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
@@ -154,6 +155,7 @@ export function BusinessForm({
} }
function handleSave() { function handleSave() {
markSubmitted();
if (!canSave) return; if (!canSave) return;
const payload = buildPayload(); const payload = buildPayload();
@@ -203,8 +205,9 @@ export function BusinessForm({
label="Name" label="Name"
value={values.name} value={values.name}
onChangeText={(v) => patch("name", v)} onChangeText={(v) => patch("name", v)}
onBlur={() => touch("name")}
required required
error={nameError} error={visible("name") ? nameError : undefined}
/> />
<Input <Input
label="Nickname" label="Nickname"
@@ -281,7 +284,7 @@ export function BusinessForm({
/> />
</Card> </Card>
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null} {fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null}
<View style={styles.actions}> <View style={styles.actions}>
<Button <Button
+12 -4
View File
@@ -15,7 +15,11 @@ import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
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 { isRequiredString, parseNonNegativeNumber } from "@/lib/form-validation"; import {
isRequiredString,
parseNonNegativeNumber,
useFieldVisibility,
} from "@/lib/form-validation";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
export type ClientFormValues = { export type ClientFormValues = {
@@ -71,6 +75,7 @@ export function ClientForm({
const [values, setValues] = useState<ClientFormValues>(emptyValues); const [values, setValues] = useState<ClientFormValues>(emptyValues);
const [fieldError, setFieldError] = useState<string | null>(null); const [fieldError, setFieldError] = useState<string | null>(null);
const { touch, visible, markSubmitted } = useFieldVisibility();
useEffect(() => { useEffect(() => {
const client = clientQuery.data; const client = clientQuery.data;
@@ -125,6 +130,7 @@ export function ClientForm({
} }
function handleSave() { function handleSave() {
markSubmitted();
if (!canSave) return; if (!canSave) return;
const rate = values.defaultHourlyRate.trim() const rate = values.defaultHourlyRate.trim()
@@ -199,8 +205,9 @@ export function ClientForm({
label="Name" label="Name"
value={values.name} value={values.name}
onChangeText={(v) => patch("name", v)} onChangeText={(v) => patch("name", v)}
onBlur={() => touch("name")}
required required
error={nameError} error={visible("name") ? nameError : undefined}
/> />
<Input <Input
label="Email" label="Email"
@@ -247,9 +254,10 @@ export function ClientForm({
label="Default hourly rate" label="Default hourly rate"
value={values.defaultHourlyRate} value={values.defaultHourlyRate}
onChangeText={(v) => patch("defaultHourlyRate", v)} onChangeText={(v) => patch("defaultHourlyRate", v)}
onBlur={() => touch("defaultHourlyRate")}
keyboardType="decimal-pad" keyboardType="decimal-pad"
placeholder="Optional" placeholder="Optional"
error={rateError} error={visible("defaultHourlyRate") ? rateError : undefined}
/> />
<Input <Input
label="Currency" label="Currency"
@@ -260,7 +268,7 @@ export function ClientForm({
/> />
</Card> </Card>
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null} {fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null}
<View style={styles.actions}> <View style={styles.actions}>
<Button <Button
@@ -14,14 +14,18 @@ type InvoiceSetupFormProps = {
onBusinessIdChange: (value: string) => void; onBusinessIdChange: (value: string) => void;
businessOptions: SelectOption[]; businessOptions: SelectOption[];
businessError?: string; businessError?: string;
onBusinessBlur?: () => void;
businessReadOnly?: boolean; businessReadOnly?: boolean;
clientId: string; clientId: string;
onClientIdChange: (value: string) => void; onClientIdChange: (value: string) => void;
clientOptions: SelectOption[]; clientOptions: SelectOption[];
clientError?: string; clientError?: string;
onClientBlur?: () => void;
clientReadOnly?: boolean; clientReadOnly?: boolean;
invoiceNumber: string; invoiceNumber: string;
onInvoiceNumberChange?: (value: string) => void; onInvoiceNumberChange?: (value: string) => void;
invoiceNumberError?: string;
onInvoiceNumberBlur?: () => void;
invoiceNumberReadOnly?: boolean; invoiceNumberReadOnly?: boolean;
issueDate: Date; issueDate: Date;
onIssueDateChange?: (date: Date) => void; onIssueDateChange?: (date: Date) => void;
@@ -30,6 +34,8 @@ type InvoiceSetupFormProps = {
onDueDateChange: (date: Date) => void; onDueDateChange: (date: Date) => void;
taxRate: string; taxRate: string;
onTaxRateChange?: (value: string) => void; onTaxRateChange?: (value: string) => void;
taxRateError?: string;
onTaxRateBlur?: () => void;
taxRateReadOnly?: boolean; taxRateReadOnly?: boolean;
notes: string; notes: string;
onNotesChange: (value: string) => void; onNotesChange: (value: string) => void;
@@ -43,14 +49,18 @@ export function InvoiceSetupForm({
onBusinessIdChange, onBusinessIdChange,
businessOptions, businessOptions,
businessError, businessError,
onBusinessBlur,
businessReadOnly = false, businessReadOnly = false,
clientId, clientId,
onClientIdChange, onClientIdChange,
clientOptions, clientOptions,
clientError, clientError,
onClientBlur,
clientReadOnly = false, clientReadOnly = false,
invoiceNumber, invoiceNumber,
onInvoiceNumberChange, onInvoiceNumberChange,
invoiceNumberError,
onInvoiceNumberBlur,
invoiceNumberReadOnly = false, invoiceNumberReadOnly = false,
issueDate, issueDate,
onIssueDateChange, onIssueDateChange,
@@ -59,6 +69,8 @@ export function InvoiceSetupForm({
onDueDateChange, onDueDateChange,
taxRate, taxRate,
onTaxRateChange, onTaxRateChange,
taxRateError,
onTaxRateBlur,
taxRateReadOnly = false, taxRateReadOnly = false,
notes, notes,
onNotesChange, onNotesChange,
@@ -84,6 +96,7 @@ export function InvoiceSetupForm({
error={businessError} error={businessError}
disabled={businessReadOnly} disabled={businessReadOnly}
onValueChange={onBusinessIdChange} onValueChange={onBusinessIdChange}
onBlur={onBusinessBlur}
/> />
)} )}
@@ -101,6 +114,7 @@ export function InvoiceSetupForm({
error={clientError} error={clientError}
disabled={clientReadOnly} disabled={clientReadOnly}
onValueChange={onClientIdChange} onValueChange={onClientIdChange}
onBlur={onClientBlur}
/> />
)} )}
@@ -118,8 +132,10 @@ export function InvoiceSetupForm({
label="Invoice number" label="Invoice number"
value={invoiceNumber} value={invoiceNumber}
onChangeText={onInvoiceNumberChange} onChangeText={onInvoiceNumberChange}
onBlur={onInvoiceNumberBlur}
autoCapitalize="characters" autoCapitalize="characters"
required required
error={invoiceNumberError}
/> />
)} )}
@@ -160,7 +176,9 @@ export function InvoiceSetupForm({
label="Tax rate (%)" label="Tax rate (%)"
value={taxRate} value={taxRate}
onChangeText={onTaxRateChange} onChangeText={onTaxRateChange}
onBlur={onTaxRateBlur}
keyboardType="decimal-pad" keyboardType="decimal-pad"
error={taxRateError}
/> />
)} )}
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
import { import {
Alert, Alert,
Pressable, Pressable,
RefreshControl,
StyleSheet, StyleSheet,
Text, Text,
TextInput, TextInput,
@@ -13,6 +12,7 @@ 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 { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow"; import { SwipeableRow } from "@/components/SwipeableRow";
import { TabScrollView } from "@/components/TabScrollView"; import { TabScrollView } from "@/components/TabScrollView";
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet"; import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
@@ -20,16 +20,14 @@ 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";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext"; import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDateTime } from "@/lib/format"; import { formatCurrency, formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation"; import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
import { import { setLastTimeClockClientId } from "@/lib/time-clock-prefs";
getLastTimeClockClientId,
setLastTimeClockClientId,
} from "@/lib/time-clock-prefs";
import { useThemedStyles } from "@/lib/use-themed-styles"; import { useThemedStyles } from "@/lib/use-themed-styles";
import { import {
endTimeClockLiveActivity, endTimeClockLiveActivity,
@@ -99,12 +97,8 @@ export function TimeClockPanel({
const [agoMinutes, setAgoMinutes] = useState(60); const [agoMinutes, setAgoMinutes] = useState(60);
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 [editEntryId, setEditEntryId] = useState<string | null>(null); const [editEntryId, setEditEntryId] = useState<string | null>(null);
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date()); const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
const [prefsLoaded, setPrefsLoaded] = useState(false);
const running = runningQuery.data; const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt); const elapsed = useRunningElapsed(running?.startedAt);
@@ -124,19 +118,6 @@ export function TimeClockPanel({
const entriesQuery = api.timeEntries.getAll.useQuery(); const entriesQuery = api.timeEntries.getAll.useQuery();
const recentClientIds = useMemo(() => {
const seen = new Set<string>();
const ids: string[] = [];
for (const entry of entriesQuery.data ?? []) {
if (entry.clientId && !seen.has(entry.clientId)) {
seen.add(entry.clientId);
ids.push(entry.clientId);
if (ids.length >= 2) break;
}
}
return ids;
}, [entriesQuery.data]);
const clockIn = api.timeEntries.clockIn.useMutation({ const clockIn = api.timeEntries.clockIn.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.timeEntries.getRunning.invalidate(); await utils.timeEntries.getRunning.invalidate();
@@ -180,18 +161,6 @@ export function TimeClockPanel({
}, },
}); });
useEffect(() => {
if (!activeAccountId) {
setPrefsLoaded(true);
return;
}
setPrefsLoaded(false);
void getLastTimeClockClientId(activeAccountId).then((id) => {
setStoredLastClientId(id);
setPrefsLoaded(true);
});
}, [activeAccountId]);
useEffect(() => { useEffect(() => {
if (!running) return; if (!running) return;
setClientId(running.clientId ?? ""); setClientId(running.clientId ?? "");
@@ -209,22 +178,6 @@ export function TimeClockPanel({
setRateText((current) => current.trim() || clientRateText(client)); setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]); }, [clientId, clients, running]);
useEffect(() => {
if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return;
const ids: string[] = [];
const add = (id: string | null | undefined) => {
if (!id || ids.includes(id)) return;
if (!clients.some((client) => client.id === id)) return;
ids.push(id);
};
add(storedLastClientId);
for (const id of recentClientIds) add(id);
setFeaturedClientIds(ids.slice(0, 1));
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
const selectedClient = clients.find((client) => client.id === clientId); const selectedClient = clients.find((client) => client.id === clientId);
const rateCurrency = selectedClient?.currency ?? "USD"; const rateCurrency = selectedClient?.currency ?? "USD";
const effectiveRate = resolveEffectiveHourlyRate( const effectiveRate = resolveEffectiveHourlyRate(
@@ -235,21 +188,6 @@ export function TimeClockPanel({
? (running.rate ?? effectiveRate ?? 0) ? (running.rate ?? effectiveRate ?? 0)
: (effectiveRate ?? 0); : (effectiveRate ?? 0);
const featuredClients = useMemo(
() =>
featuredClientIds
.map((id) => clients.find((client) => client.id === id))
.filter((client) => client != null),
[clients, featuredClientIds],
);
const moreClients = useMemo(() => {
const featuredIds = new Set(featuredClientIds);
return clients
.filter((client) => !featuredIds.has(client.id))
.sort((a, b) => a.name.localeCompare(b.name));
}, [clients, featuredClientIds]);
const resolvedStartAt = useMemo(() => { const resolvedStartAt = useMemo(() => {
if (startMode === "now") return new Date(); if (startMode === "now") return new Date();
if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes); if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes);
@@ -285,11 +223,14 @@ export function TimeClockPanel({
), ),
[entriesQuery.data, todayStart], [entriesQuery.data, todayStart],
); );
const todayHours = useMemo(
() => todayEntries.reduce((total, entry) => total + Number(entry.hours ?? 0), 0),
[todayEntries],
);
async function persistClientChoice(nextClientId: string, syncState = false) { async function persistClientChoice(nextClientId: string) {
if (!activeAccountId || !nextClientId) return; if (!activeAccountId || !nextClientId) return;
await setLastTimeClockClientId(activeAccountId, nextClientId); await setLastTimeClockClientId(activeAccountId, nextClientId);
if (syncState) setStoredLastClientId(nextClientId);
} }
function selectClient(nextClientId: string) { function selectClient(nextClientId: string) {
@@ -305,9 +246,6 @@ export function TimeClockPanel({
setClientId(nextClientId); setClientId(nextClientId);
setInvoiceId(""); setInvoiceId("");
setRateText(clientRateText(client)); setRateText(clientRateText(client));
if (nextClientId && !featuredClientIds.includes(nextClientId)) {
setClientsExpanded(true);
}
if (nextClientId) { if (nextClientId) {
void persistClientChoice(nextClientId); void persistClientChoice(nextClientId);
} }
@@ -420,63 +358,83 @@ export function TimeClockPanel({
.join(" · "); .join(" · ");
function renderClientChip(client: (typeof clients)[number]) {
return (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => selectClient(client.id)}
/>
);
}
return ( return (
<TabScrollView <TabScrollView
style={styles.scroll} style={styles.scroll}
header={header} header={header}
refreshControl={ refreshControl={
<RefreshControl <PullToRefresh
refreshing={runningQuery.isRefetching} onRefresh={() =>
onRefresh={() => { Promise.all([
void runningQuery.refetch(); runningQuery.refetch(),
void clientsQuery.refetch(); clientsQuery.refetch(),
void billableQuery.refetch(); billableQuery.refetch(),
void entriesQuery.refetch(); entriesQuery.refetch(),
}} ])
}
tintColor={colors.primary} tintColor={colors.primary}
/> />
} }
> >
{running || !compact ? ( {running ? (
<GlassSurface style={running ? styles.runningCard : undefined}> <GlassSurface style={running ? styles.runningCard : undefined}>
<View style={[styles.hero, running && styles.heroRunning]}> <View style={[styles.hero, running && styles.heroRunning]}>
{running ? ( <View style={styles.heroHeader}>
<> <View style={styles.pulseDot} />
<View style={styles.heroHeader}> <Text style={styles.heroLabelRunning}>In progress</Text>
<View style={styles.pulseDot} /> </View>
<Text style={styles.heroLabelRunning}>Timer running</Text> <Text selectable style={styles.timerValue}>
</View> {formatElapsedSeconds(elapsed)}
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text> </Text>
<Text style={styles.runningTitle}>{runningTitle}</Text> <Text selectable style={styles.runningTitle}>{runningTitle}</Text>
{runningMeta ? ( {runningMeta ? (
<Text style={styles.runningMeta}>{runningMeta}</Text> <Text selectable style={styles.runningMeta}>{runningMeta}</Text>
) : null} ) : null}
</>
) : (
<Text style={styles.idleHint}>
Start the timer anytime add client, invoice, and details later.
</Text>
)}
</View> </View>
</GlassSurface> </GlassSurface>
) : null} ) : null}
<GlassSurface style={styles.setupCard}> <GlassSurface style={styles.setupCard}>
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text> {!running ? (
<View style={styles.idleIntro}>
<Text style={styles.idleEyebrow}>Ready to start</Text>
<Text style={styles.idleTitle}>What are you working on?</Text>
<Text style={styles.idleCopy}>
Add what you know now. You can update the rest while the timer runs.
</Text>
</View>
) : null}
{running ? ( {running ? (
<View style={styles.formSection}> <>
<Button
title={clockOut.isPending ? "Stopping…" : "Stop & save entry"}
variant="danger"
leftIcon="stop"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
<Pressable
accessible
accessibilityRole="button"
accessibilityState={{ expanded: optionsExpanded }}
onPress={() => setOptionsExpanded((open) => !open)}
style={({ pressed }) => [
styles.optionsToggle,
pressed && styles.optionsTogglePressed,
]}
>
<View style={styles.optionsToggleText}>
<Text style={styles.optionsToggleLabel}>Edit timer details</Text>
<Text style={styles.optionsToggleSummary}>
Started {formatDateTime(runningStartedAt)}
</Text>
</View>
<Text style={styles.optionsChevron}>{optionsExpanded ? "" : "+"}</Text>
</Pressable>
{optionsExpanded ? <View style={styles.formSection}>
<Input <Input
label="What are you working on?" label="What are you working on?"
value={description} value={description}
@@ -544,79 +502,49 @@ export function TimeClockPanel({
} }
returnKeyType="done" returnKeyType="done"
/> />
<Button </View> : null}
title={clockOut.isPending ? "Stopping…" : "Stop & save"} </>
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
</View>
) : ( ) : (
<> <>
<TextInput <View style={styles.idleFields}>
value={description} <Input
onChangeText={setDescription} label="Description"
placeholder="What are you working on?" value={description}
placeholderTextColor={colors.mutedForeground} onChangeText={setDescription}
returnKeyType="done" placeholder="e.g. Client kickoff…"
style={[styles.titleField, { color: colors.foreground }]} returnKeyType="done"
/> style={styles.titleField}
containerStyle={styles.timerField}
/>
<View style={styles.setupSection}> <SelectField
<Text style={styles.sectionLabel}>Client</Text> label="Client"
{clients.length === 0 ? ( placeholder="Select a client"
<Text style={styles.emptyClients}> value={clientId}
No clients yet you can still start the timer and assign a client later. options={[
</Text> { label: "No client", value: "" },
) : ( ...clients.map((client) => ({ label: client.name, value: client.id })),
<> ]}
<View style={styles.chipWrap}> onValueChange={selectClient}
<FilterChip containerStyle={styles.timerField}
label="No client" />
active={!clientId}
onPress={() => selectClient("")}
/>
{featuredClients.map((client) => renderClientChip(client))}
{moreClients.length > 0 ? (
<FilterChip
label={clientsExpanded ? "Show less" : "Show more"}
active={clientsExpanded}
onPress={() => setClientsExpanded((open) => !open)}
/>
) : null}
</View>
{clientsExpanded && moreClients.length > 0 ? (
<View style={[styles.chipWrap, styles.moreClientsWrap]}>
{moreClients.map((client) => renderClientChip(client))}
</View>
) : null}
</>
)}
</View>
<View style={styles.setupSection}> <SelectField
<Text style={styles.sectionLabel}>Invoice (optional)</Text> label="Invoice (optional)"
{!clientId ? ( placeholder={clientId ? "Select an invoice" : "Choose a client first"}
<Text style={styles.emptyClients}> value={clientId ? invoiceId : "__client_required__"}
No invoice for now. Add a client and invoice later if this becomes billable. disabled={!clientId}
</Text> options={[
) : ( { label: "Entry only — no invoice", value: "" },
<View style={styles.chipWrap}> ...billableInvoices.map((invoice) => ({
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} /> label: `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`,
{billableInvoices.map((invoice) => { value: invoice.id,
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`; })),
return ( ]}
<FilterChip onValueChange={selectInvoice}
key={invoice.id} containerStyle={styles.timerField}
label={label} />
active={invoiceId === invoice.id}
onPress={() => setInvoiceId(invoice.id)}
/>
);
})}
</View> </View>
)}
</View>
<View style={styles.setupSection}> <View style={styles.setupSection}>
<Pressable <Pressable
@@ -736,9 +664,8 @@ export function TimeClockPanel({
)} )}
</GlassSurface> </GlassSurface>
{todayEntries.length > 0 ? ( <Card title={`Today · ${todayHours.toFixed(2)}h`}>
<Card title="Today's entries"> {todayEntries.length > 0 ? todayEntries.map((entry) => {
{todayEntries.map((entry) => {
const invoiceLabel = entry.invoice const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}` ? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null; : null;
@@ -783,9 +710,20 @@ export function TimeClockPanel({
</View> </View>
</SwipeableRow> </SwipeableRow>
); );
})} }) : (
</Card> <View style={styles.todayEmpty}>
) : null} <Text style={styles.todayEmptyTitle}>No time logged yet</Text>
<Text style={styles.todayEmptyCopy}>
Start your first timer or open history to add an entry manually.
</Text>
</View>
)}
<Button
title="View time history"
variant="secondary"
onPress={() => router.push("/(app)/more/time-entries")}
/>
</Card>
<TimeEntryEditSheet <TimeEntryEditSheet
entryId={editEntryId} entryId={editEntryId}
@@ -857,25 +795,45 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
}, },
setupCard: { setupCard: {
padding: spacing.lg, padding: spacing.lg,
gap: spacing.lg, gap: spacing.md,
}, },
cardTitle: { idleIntro: {
fontSize: 16, gap: spacing.xs,
paddingBottom: spacing.xs,
},
idleEyebrow: {
fontSize: 11,
fontFamily: fonts.bodySemiBold, fontFamily: fonts.bodySemiBold,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.7,
},
idleTitle: {
fontSize: 22,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground, color: colors.foreground,
}, },
idleCopy: {
fontSize: 13,
lineHeight: 18,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
formSection: { formSection: {
gap: spacing.md, gap: spacing.md,
}, },
titleField: { titleField: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
minHeight: 48, minHeight: 48,
paddingVertical: spacing.xs, },
idleFields: {
gap: spacing.md,
},
timerField: {
gap: 6,
}, },
setupSection: { setupSection: {
gap: spacing.sm, gap: spacing.sm,
paddingTop: spacing.lg,
}, },
sectionLabel: { sectionLabel: {
fontSize: 11, fontSize: 11,
@@ -892,9 +850,6 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
flexWrap: "wrap", flexWrap: "wrap",
gap: spacing.sm, gap: spacing.sm,
}, },
moreClientsWrap: {
paddingTop: spacing.xs,
},
emptyClients: { emptyClients: {
fontSize: 14, fontSize: 14,
fontFamily: fonts.body, fontFamily: fonts.body,
@@ -996,4 +951,23 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
color: colors.foreground, color: colors.foreground,
fontSize: 14, fontSize: 14,
}, },
todayEmpty: {
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.md,
},
todayEmptyTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
textAlign: "center",
},
todayEmptyCopy: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 13,
lineHeight: 18,
textAlign: "center",
},
}); });
@@ -62,7 +62,12 @@ export function DateTimeField({
<View style={styles.wrapper}> <View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text> <Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Pressable <Pressable
accessible
accessibilityLabel={`${label}, ${
mode === "date" ? formatDate(value) : formatDateTime(value)
}`}
accessibilityRole="button" accessibilityRole="button"
accessibilityState={{ expanded: open }}
onPress={openPicker} onPress={openPicker}
style={({ pressed }) => [ style={({ pressed }) => [
styles.trigger, styles.trigger,
+5 -1
View File
@@ -3,7 +3,9 @@ import {
Text, Text,
TextInput, TextInput,
View, View,
type StyleProp,
type TextInputProps, type TextInputProps,
type ViewStyle,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
@@ -17,6 +19,7 @@ type InputProps = TextInputProps & {
leftIcon?: keyof typeof Ionicons.glyphMap; leftIcon?: keyof typeof Ionicons.glyphMap;
labelAccessory?: React.ReactNode; labelAccessory?: React.ReactNode;
hint?: string; hint?: string;
containerStyle?: StyleProp<ViewStyle>;
}; };
export function Input({ export function Input({
@@ -26,13 +29,14 @@ export function Input({
leftIcon, leftIcon,
labelAccessory, labelAccessory,
hint, hint,
containerStyle,
style, style,
...props ...props
}: InputProps) { }: InputProps) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
return ( return (
<View style={styles.wrapper}> <View style={[styles.wrapper, containerStyle]}>
<View style={styles.labelRow}> <View style={styles.labelRow}>
<Text style={[styles.label, { color: colors.foreground }]}> <Text style={[styles.label, { color: colors.foreground }]}>
{label} {label}
+19 -5
View File
@@ -7,6 +7,8 @@ import {
StyleSheet, StyleSheet,
Text, Text,
View, View,
type StyleProp,
type ViewStyle,
} from "react-native"; } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
@@ -26,6 +28,8 @@ type SelectFieldProps = {
required?: boolean; required?: boolean;
error?: string; error?: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
onBlur?: () => void;
containerStyle?: StyleProp<ViewStyle>;
}; };
export function SelectField({ export function SelectField({
@@ -37,19 +41,29 @@ export function SelectField({
required, required,
error, error,
onValueChange, onValueChange,
onBlur,
containerStyle,
}: SelectFieldProps) { }: SelectFieldProps) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const selected = options.find((option) => option.value === value); const selected = options.find((option) => option.value === value);
function close() {
setOpen(false);
onBlur?.();
}
return ( return (
<View style={styles.wrapper}> <View style={[styles.wrapper, containerStyle]}>
<Text style={[styles.label, { color: colors.foreground }]}> <Text style={[styles.label, { color: colors.foreground }]}>
{label} {label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null} {required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text> </Text>
<Pressable <Pressable
accessible
accessibilityLabel={`${label}, ${selected?.label ?? placeholder}`}
accessibilityRole="button" accessibilityRole="button"
accessibilityState={{ disabled: Boolean(disabled), expanded: open }}
disabled={disabled} disabled={disabled}
onPress={() => setOpen(true)} onPress={() => setOpen(true)}
style={({ pressed }) => [ style={({ pressed }) => [
@@ -77,18 +91,18 @@ export function SelectField({
<Modal <Modal
animationType="slide" animationType="slide"
onRequestClose={() => setOpen(false)} onRequestClose={close}
transparent transparent
visible={open} visible={open}
> >
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}> <Pressable style={styles.backdrop} onPress={close}>
<Pressable <Pressable
style={[styles.sheet, { backgroundColor: colors.background }]} style={[styles.sheet, { backgroundColor: colors.background }]}
onPress={(event) => event.stopPropagation()} onPress={(event) => event.stopPropagation()}
> >
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}> <View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text> <Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}> <Pressable accessibilityRole="button" onPress={close}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text> <Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable> </Pressable>
</View> </View>
@@ -101,7 +115,7 @@ export function SelectField({
accessibilityRole="button" accessibilityRole="button"
onPress={() => { onPress={() => {
onValueChange(option.value); onValueChange(option.value);
setOpen(false); close();
}} }}
style={({ pressed }) => [ style={({ pressed }) => [
styles.option, styles.option,
+56
View File
@@ -0,0 +1,56 @@
const DARK_ACTION_FOREGROUND = "#18181B";
const LIGHT_ACTION_FOREGROUND = "#FFFFFF";
const MIN_TEXT_CONTRAST = 4.5;
function parseHexColor(color: string): [number, number, number] | null {
const value = color.trim().replace(/^#/, "");
const expanded =
value.length === 3
? value
.split("")
.map((character) => character.repeat(2))
.join("")
: value.slice(0, 6);
if (expanded.length !== 6 || !/^[0-9a-f]+$/i.test(expanded)) return null;
return [
Number.parseInt(expanded.slice(0, 2), 16),
Number.parseInt(expanded.slice(2, 4), 16),
Number.parseInt(expanded.slice(4, 6), 16),
];
}
function luminance(color: string): number | null {
const rgb = parseHexColor(color);
if (!rgb) return null;
const channels = rgb.map((channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
}
function contrastRatio(foreground: string, background: string): number | null {
const foregroundLuminance = luminance(foreground);
const backgroundLuminance = luminance(background);
if (foregroundLuminance == null || backgroundLuminance == null) return null;
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
/** Chooses a readable action label/icon color while preserving a valid requested color. */
export function resolveActionForeground(background: string, requested: string): string {
const requestedContrast = contrastRatio(requested, background);
if (requestedContrast == null || requestedContrast >= MIN_TEXT_CONTRAST) {
return requested;
}
const darkContrast = contrastRatio(DARK_ACTION_FOREGROUND, background) ?? 0;
const lightContrast = contrastRatio(LIGHT_ACTION_FOREGROUND, background) ?? 0;
return darkContrast >= lightContrast ? DARK_ACTION_FOREGROUND : LIGHT_ACTION_FOREGROUND;
}
+35 -37
View File
@@ -11,6 +11,18 @@ const CHUNK_MARKER = "\u0001ba-chunks:";
const SESSION_TOKEN_COOKIE_PART = const SESSION_TOKEN_COOKIE_PART =
/(?:^|;\s*)(?:__Secure-)?[^=]*session_token=([^;]+)/; /(?:^|;\s*)(?:__Secure-)?[^=]*session_token=([^;]+)/;
const AUTH_COOKIE_DEBUG = process.env.EXPO_PUBLIC_AUTH_COOKIE_DEBUG === "1"; const AUTH_COOKIE_DEBUG = process.env.EXPO_PUBLIC_AUTH_COOKIE_DEBUG === "1";
const lastAuthCookieDebugState = new Map<string, string>();
function debugAuthCookie(event: string, storagePrefix: string, details: Record<string, unknown>) {
if (!AUTH_COOKIE_DEBUG) return;
const key = `${event}:${storagePrefix}`;
const state = JSON.stringify(details);
if (lastAuthCookieDebugState.get(key) === state) return;
lastAuthCookieDebugState.set(key, state);
console.info(`[auth-cookie] ${event}`, { storagePrefix, ...details });
}
function readSecureStoreValueSync(key: string): string | null { function readSecureStoreValueSync(key: string): string | null {
const value = SecureStore.getItem(key); const value = SecureStore.getItem(key);
@@ -56,25 +68,19 @@ export function getAuthCookie(
).getCookie?.(); ).getCookie?.();
if (fromClient?.trim()) { if (fromClient?.trim()) {
const cookie = fromClient.trim(); const cookie = fromClient.trim();
if (AUTH_COOKIE_DEBUG) { debugAuthCookie("using client cookie", storagePrefix, {
console.info("[auth-cookie] using client cookie", { length: cookie.length,
storagePrefix, names: cookieNames(cookie),
length: cookie.length, });
names: cookieNames(cookie),
});
}
return cookie; return cookie;
} }
const fromPrefix = readStoredCookie(storagePrefix); const fromPrefix = readStoredCookie(storagePrefix);
if (fromPrefix) { if (fromPrefix) {
if (AUTH_COOKIE_DEBUG) { debugAuthCookie("using stored cookie", storagePrefix, {
console.info("[auth-cookie] using stored cookie", { length: fromPrefix.length,
storagePrefix, names: cookieNames(fromPrefix),
length: fromPrefix.length, });
names: cookieNames(fromPrefix),
});
}
return fromPrefix; return fromPrefix;
} }
@@ -82,18 +88,15 @@ export function getAuthCookie(
storagePrefix === GUEST_AUTH_STORAGE_PREFIX storagePrefix === GUEST_AUTH_STORAGE_PREFIX
? null ? null
: readStoredCookie(GUEST_AUTH_STORAGE_PREFIX); : readStoredCookie(GUEST_AUTH_STORAGE_PREFIX);
if (AUTH_COOKIE_DEBUG) { debugAuthCookie("resolved tRPC cookie", storagePrefix, {
console.info("[auth-cookie] resolved tRPC cookie", { fallbackPrefix:
storagePrefix, fromGuest && storagePrefix !== GUEST_AUTH_STORAGE_PREFIX
fallbackPrefix: ? GUEST_AUTH_STORAGE_PREFIX
fromGuest && storagePrefix !== GUEST_AUTH_STORAGE_PREFIX : null,
? GUEST_AUTH_STORAGE_PREFIX hasCookie: Boolean(fromGuest),
: null, length: fromGuest?.length ?? 0,
hasCookie: Boolean(fromGuest), names: fromGuest ? cookieNames(fromGuest) : [],
length: fromGuest?.length ?? 0, });
names: fromGuest ? cookieNames(fromGuest) : [],
});
}
return fromGuest; return fromGuest;
} }
@@ -103,21 +106,16 @@ export function getAuthCookieHeaders(
): Record<string, string> { ): Record<string, string> {
const cookie = getAuthCookie(authClient, storagePrefix); const cookie = getAuthCookie(authClient, storagePrefix);
if (!cookie) { if (!cookie) {
if (AUTH_COOKIE_DEBUG) { debugAuthCookie("no tRPC auth cookie", storagePrefix, {});
console.info("[auth-cookie] no tRPC auth cookie", { storagePrefix });
}
return {}; return {};
} }
const sessionToken = cookie.match(SESSION_TOKEN_COOKIE_PART)?.[1]; const sessionToken = cookie.match(SESSION_TOKEN_COOKIE_PART)?.[1];
if (AUTH_COOKIE_DEBUG) { debugAuthCookie("sending tRPC auth headers", storagePrefix, {
console.info("[auth-cookie] sending tRPC auth headers", { cookieLength: cookie.length,
storagePrefix, cookieNames: cookieNames(cookie),
cookieLength: cookie.length, hasSessionTokenHeader: Boolean(sessionToken),
cookieNames: cookieNames(cookie), });
hasSessionTokenHeader: Boolean(sessionToken),
});
}
return { return {
cookie, cookie,
Cookie: cookie, Cookie: cookie,
+8 -3
View File
@@ -1,7 +1,12 @@
/** Matches web invoice-form default numbering. */ /** Matches web invoice-form default numbering. */
export function generateInvoiceNumber(): string { export function generateInvoiceNumber(now = new Date()): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); const date = [
return `INV-${date}-${String(Date.now()).slice(-6)}`; now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
].join("");
return `INV-${date}-${String(now.getTime()).slice(-6)}`;
} }
export function defaultDueDate(issueDate: Date): Date { export function defaultDueDate(issueDate: Date): Date {
+1 -4
View File
@@ -6,9 +6,6 @@ import { spacing } from "@/constants/theme";
/** Standard UITabBar content height (home indicator is separate). */ /** Standard UITabBar content height (home indicator is separate). */
const IOS_TAB_BAR_HEIGHT = 49; const IOS_TAB_BAR_HEIGHT = 49;
/** Trim extra inset so scroll content sits closer to the tab bar. */
const TAB_BAR_PADDING_TRIM = spacing.lg;
/** /**
* Pixels between the bottom of the safe-area layout frame and the window bottom. * Pixels between the bottom of the safe-area layout frame and the window bottom.
*/ */
@@ -39,7 +36,7 @@ export function useTabBarScrollPadding(): number {
const tabBar = useNativeTabBarHeight(); const tabBar = useNativeTabBarHeight();
const clearance = tabBar + homeIndicator; const clearance = tabBar + homeIndicator;
return Math.max(spacing.xs, clearance - TAB_BAR_PADDING_TRIM); return clearance + spacing.sm;
} }
/** Bottom offset for floating action buttons above the tab bar. */ /** Bottom offset for floating action buttons above the tab bar. */
@@ -10,7 +10,9 @@ import {
import { fetchAuthCapabilities } from "../lib/auth-capabilities"; import { fetchAuthCapabilities } from "../lib/auth-capabilities";
import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories"; import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
import { getInvoiceStatus } from "../lib/invoice-status"; import { getInvoiceStatus } from "../lib/invoice-status";
import { generateInvoiceNumber as generateMobileInvoiceNumber } from "../lib/invoice-number";
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock"; import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice";
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url"; import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
import { import {
normalizeOptionalId, normalizeOptionalId,
@@ -47,6 +49,13 @@ describe("auth contract smoke checks", () => {
}); });
describe("invoice parity", () => { describe("invoice parity", () => {
test("web and mobile use the device-local date in invoice numbers", () => {
const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123);
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
});
test("web and mobile agree on draft, paid, sent, and overdue states", () => { test("web and mobile agree on draft, paid, sent, and overdue states", () => {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
+19
View File
@@ -0,0 +1,19 @@
/// <reference types="bun" />
import { describe, expect, test } from "bun:test";
import { resolveActionForeground } from "../lib/action-contrast";
describe("swipe action contrast", () => {
test("uses dark content on bright dark-mode action colors", () => {
expect(resolveActionForeground("#FAFAFA", "#FFFFFF")).toBe("#18181B");
expect(resolveActionForeground("#A1A1AA", "#FFFFFF")).toBe("#18181B");
expect(resolveActionForeground("#4ADE80", "#FFFFFF")).toBe("#18181B");
expect(resolveActionForeground("#FBBF24", "#FFFFFF")).toBe("#18181B");
expect(resolveActionForeground("#F87171", "#FFFFFF")).toBe("#18181B");
});
test("keeps white content on the light-mode primary action", () => {
expect(resolveActionForeground("#18181B", "#FFFFFF")).toBe("#FFFFFF");
});
});
@@ -42,6 +42,7 @@ import {
Mail, Mail,
} from "lucide-react"; } from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency"; import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { generateInvoiceNumber } from "~/lib/draft-invoice";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
DropdownMenu, DropdownMenu,
@@ -108,7 +109,7 @@ function plainTextToHtml(value: string) {
function createDefaultInvoiceFormData(): InvoiceFormData { function createDefaultInvoiceFormData(): InvoiceFormData {
return { return {
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`, invoiceNumber: generateInvoiceNumber(),
invoicePrefix: "#", invoicePrefix: "#",
businessId: "", businessId: "",
clientId: "", clientId: "",
@@ -3,7 +3,14 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
@@ -11,6 +18,7 @@ import { Label } from "~/components/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
@@ -20,7 +28,7 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "~/components/ui/collapsible"; } from "~/components/ui/collapsible";
import { ChevronDown, Clock, Play, Square } from "lucide-react"; import { ChevronDown, Play, Square } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { import {
@@ -39,8 +47,6 @@ import { invoiceLabel } from "~/lib/time-entry-display";
import { TimeEntryList } from "~/components/time-clock/time-entry-list"; import { TimeEntryList } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog"; import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
const FEATURED_CLIENT_COUNT = 4;
type StartMode = "now" | "pick" | "ago"; type StartMode = "now" | "pick" | "ago";
function toDatetimeLocalValue(value: Date | string) { function toDatetimeLocalValue(value: Date | string) {
@@ -67,21 +73,25 @@ function RunningTextFields({
return ( return (
<> <>
<div className="space-y-2"> <div className="flex flex-col gap-2">
<Label htmlFor="clock-running-title">What are you working on?</Label> <Label htmlFor="clock-running-title">What are you working on?</Label>
<Input <Input
id="clock-running-title" id="clock-running-title"
name="description"
autoComplete="off"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
onBlur={() => onDescriptionCommit(title)} onBlur={() => onDescriptionCommit(title)}
placeholder="What are you working on?" placeholder="e.g. Client kickoff…"
/> />
</div> </div>
<div className="space-y-2"> <div className="flex flex-col gap-2">
<Label htmlFor="clock-running-start">Started at</Label> <Label htmlFor="clock-running-start">Started at</Label>
<Input <Input
id="clock-running-start" id="clock-running-start"
name="startedAt"
autoComplete="off"
type="datetime-local" type="datetime-local"
value={runningStartedAt} value={runningStartedAt}
onChange={(e) => { onChange={(e) => {
@@ -105,31 +115,6 @@ export type TimeClockPanelProps = {
compact?: boolean; compact?: boolean;
}; };
function ClientChip({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
{label}
</button>
);
}
export function TimeClockPanel({ export function TimeClockPanel({
defaultClientId = "", defaultClientId = "",
defaultInvoiceId = "", defaultInvoiceId = "",
@@ -161,7 +146,6 @@ export function TimeClockPanel({
const [stopNote, setStopNote] = useState(""); const [stopNote, setStopNote] = useState("");
const [rate, setRate] = useState(0); const [rate, setRate] = useState(0);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const [showAllClients, setShowAllClients] = useState(false);
const [optionsOpen, setOptionsOpen] = useState(false); const [optionsOpen, setOptionsOpen] = useState(false);
const [startMode, setStartMode] = useState<StartMode>("now"); const [startMode, setStartMode] = useState<StartMode>("now");
const [pickedStart, setPickedStart] = useState(""); const [pickedStart, setPickedStart] = useState("");
@@ -180,40 +164,6 @@ export function TimeClockPanel({
[clients, clientId], [clients, clientId],
); );
const featuredClientIds = useMemo(() => {
const ids: string[] = [];
const last = getLastTimeClockClientId();
if (last) ids.push(last);
if (running?.clientId && !ids.includes(running.clientId)) {
ids.unshift(running.clientId);
}
for (const entry of todayEntries ?? []) {
if (entry.clientId && !ids.includes(entry.clientId)) {
ids.push(entry.clientId);
}
}
for (const client of clients ?? []) {
if (!ids.includes(client.id)) ids.push(client.id);
if (ids.length >= FEATURED_CLIENT_COUNT) break;
}
return ids;
}, [clients, todayEntries, running]);
const visibleClients = useMemo(() => {
if (!clients?.length) return [];
if (showAllClients) return clients;
const featured = featuredClientIds
.map((id) => clients.find((c) => c.id === id))
.filter((c): c is NonNullable<typeof c> => Boolean(c));
return featured.length > 0 ? featured : clients.slice(0, FEATURED_CLIENT_COUNT);
}, [clients, featuredClientIds, showAllClients]);
const hiddenClientCount = Math.max(0, (clients?.length ?? 0) - visibleClients.length);
useEffect(() => { useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current); if (intervalRef.current) clearInterval(intervalRef.current);
if (!running) return; if (!running) return;
@@ -373,292 +323,63 @@ export function TimeClockPanel({
const runningTitle = formatRunningTimerLabel(running?.description); const runningTitle = formatRunningTimerLabel(running?.description);
const activeClientId = running ? (running.clientId ?? "") : clientId; const activeClientId = running ? (running.clientId ?? "") : clientId;
const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId; const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId;
const completedToday = todayEntries?.filter((entry) => entry.endedAt) ?? [];
const todayHours = completedToday.reduce(
(total, entry) => total + Number(entry.hours ?? 0),
0,
);
return ( return (
<div className={compact ? "space-y-4" : "space-y-6"}> <div className={cn("flex flex-col gap-6", !compact && "xl:grid xl:grid-cols-[minmax(0,1fr)_22rem]")}>
{running ? ( <Card className="min-w-0 overflow-hidden">
<div className="border-primary/20 bg-primary/5 rounded-2xl border p-6 text-center shadow-sm"> <CardHeader className="gap-3">
<div className="mb-3 flex items-center justify-center gap-2"> <div className="flex flex-wrap items-start justify-between gap-4">
<span className="relative flex h-2.5 w-2.5"> <div className="flex min-w-0 flex-col gap-1.5">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" /> <p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" /> {running ? "In progress" : "Ready to start"}
</span> </p>
<span className="text-primary text-sm font-medium">Timer running</span> <CardTitle className="text-pretty text-2xl">
{running ? runningTitle : "What are you working on?"}
</CardTitle>
<CardDescription className="text-pretty">
{running
? [
running.client?.name ?? "No client",
running.invoice ? invoiceLabel(running.invoice) : null,
displayRate ? `$${displayRate}/hr` : null,
]
.filter(Boolean)
.join(" · ")
: "Add the details you know now. You can update the rest while the timer runs."}
</CardDescription>
</div>
{running ? (
<p
aria-label={`${elapsed} elapsed seconds`}
className="font-mono text-4xl font-semibold tracking-tight tabular-nums sm:text-5xl"
>
{formatElapsedSeconds(elapsed)}
</p>
) : null}
</div> </div>
<p className="text-primary font-mono text-5xl font-bold tracking-tight tabular-nums sm:text-6xl">
{formatElapsedSeconds(elapsed)}
</p>
<p className="mt-3 text-lg font-medium">{runningTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">
{running.client?.name ?? "No client"}
{running.invoice ? ` · ${invoiceLabel(running.invoice)}` : ""}
{displayRate ? ` · $${displayRate}/hr` : ""}
</p>
</div>
) : null}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
{!running ? <Clock className="h-4 w-4" /> : null}
{running ? "Update & stop" : "Clock in"}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="flex flex-col gap-4">
{!running ? ( {!running ? (
<> <>
<div className="space-y-2"> <div className="flex flex-col gap-1.5">
<Label htmlFor="clock-title" className="sr-only"> <Label htmlFor="clock-title">Description</Label>
What are you working on?
</Label>
<Input <Input
id="clock-title" id="clock-title"
name="description"
autoComplete="off"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="What are you working on?" placeholder="e.g. Client kickoff…"
className="h-12 border-0 bg-transparent px-0 text-lg font-medium shadow-none focus-visible:ring-0" className="h-11"
/>
</div>
<div className="space-y-2">
<Label>Client</Label>
<div className="flex flex-wrap gap-2">
{visibleClients.map((client) => (
<ClientChip
key={client.id}
label={client.name}
active={activeClientId === client.id}
onClick={() => handleClientChange(client.id)}
/>
))}
{!showAllClients && hiddenClientCount > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full"
onClick={() => setShowAllClients(true)}
>
+{hiddenClientCount} more
</Button>
) : null}
</div>
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
<Select value={clientId || undefined} onValueChange={handleClientChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{clients?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label>Invoice</Label>
<Select
value={invoiceId || "__none__"}
onValueChange={handleInvoiceChange}
disabled={!clientId}
>
<SelectTrigger>
<SelectValue
placeholder={
clientId ? "Draft invoice (optional)" : "Choose a client first"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No invoice save entry only</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Collapsible open={optionsOpen} onOpenChange={setOptionsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="text-muted-foreground h-auto w-full justify-between px-0 py-1 font-normal hover:bg-transparent"
>
Rate & start time
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 transition-transform",
optionsOpen && "rotate-180",
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
<div className="space-y-2">
<Label>Hourly rate</Label>
<NumberInput
value={rate}
onChange={setRate}
min={0}
step={0.01}
placeholder="0.00"
/>
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
<p className="text-muted-foreground text-xs">
Client default: ${selectedClient.defaultHourlyRate}/hr (used when left at zero).
</p>
) : null}
</div>
<div className="space-y-2">
<Label>When to start</Label>
<div className="flex flex-wrap gap-2">
{(
[
["now", "Now"],
["pick", "Pick time"],
["ago", "Time ago"],
] as const
).map(([mode, label]) => (
<Button
key={mode}
type="button"
size="sm"
variant={startMode === mode ? "default" : "outline"}
className="rounded-full"
onClick={() => selectStartMode(mode)}
>
{label}
</Button>
))}
</div>
{startMode === "pick" ? (
<Input
type="datetime-local"
value={pickedStart}
onChange={(e) => setPickedStart(e.target.value)}
className="mt-2"
/>
) : null}
{startMode === "ago" ? (
<div className="mt-2 flex items-center gap-2">
<Input
type="number"
min={1}
max={1440}
value={minutesAgo}
onChange={(e) => setMinutesAgo(e.target.value)}
className="w-24"
/>
<span className="text-muted-foreground text-sm">minutes ago</span>
</div>
) : null}
</div>
</CollapsibleContent>
</Collapsible>
</>
) : (
<>
<RunningTextFields
key={running.id}
running={running}
updateRunningPending={updateRunning.isPending}
onDescriptionCommit={handleRunningDescriptionCommit}
onStartedAtCommit={handleRunningStartedAtCommit}
/>
<div className="space-y-2">
<Label>Client</Label>
<div className="flex flex-wrap gap-2">
{visibleClients.map((client) => (
<ClientChip
key={client.id}
label={client.name}
active={activeClientId === client.id}
onClick={() => handleClientChange(client.id)}
/>
))}
{!showAllClients && hiddenClientCount > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full"
onClick={() => setShowAllClients(true)}
>
+{hiddenClientCount} more
</Button>
) : null}
</div>
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
<Select
value={activeClientId || undefined}
onValueChange={handleClientChange}
disabled={updateRunning.isPending}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{clients?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label>Invoice</Label>
<Select
value={activeInvoiceId || "__none__"}
onValueChange={handleInvoiceChange}
disabled={!activeClientId || updateRunning.isPending}
>
<SelectTrigger>
<SelectValue
placeholder={
activeClientId
? "Draft invoice (optional)"
: "Choose a client first"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No invoice save entry only</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
<Input
id="clock-stop-note"
value={stopNote}
onChange={(e) => setStopNote(e.target.value)}
placeholder={
running?.description?.trim()
? running.description
: "Update description when you stop"
}
/> />
</div> </div>
</> </>
)} ) : null}
{running ? ( {running ? (
<Button <Button
@@ -672,49 +393,246 @@ export function TimeClockPanel({
} }
disabled={clockOut.isPending} disabled={clockOut.isPending}
> >
<Square className="mr-2 h-4 w-4" /> <Square data-icon="inline-start" aria-hidden="true" />
{clockOut.isPending ? "Stopping…" : "Stop & save"} {clockOut.isPending ? "Stopping…" : "Stop & save entry"}
</Button> </Button>
) : ( ) : null}
<div className="grid gap-5 md:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="clock-client">Client</Label>
<Select
value={activeClientId || "__none__"}
onValueChange={(value) => handleClientChange(value === "__none__" ? "" : value)}
disabled={Boolean(running && updateRunning.isPending)}
>
<SelectTrigger id="clock-client" aria-label="Client" className="h-11 w-full">
<SelectValue placeholder="Select client…" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="__none__">No client</SelectItem>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="clock-invoice">Invoice</Label>
<Select
value={activeInvoiceId || "__none__"}
onValueChange={handleInvoiceChange}
disabled={!activeClientId || Boolean(running && updateRunning.isPending)}
>
<SelectTrigger id="clock-invoice" aria-label="Invoice" className="h-11 w-full">
<SelectValue
placeholder={activeClientId ? "Select invoice…" : "Choose a client first"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="__none__">Entry only no invoice</SelectItem>
{billableInvoices?.map((invoice) => (
<SelectItem key={invoice.id} value={invoice.id}>
{invoiceLabel(invoice)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs text-pretty">
{activeClientId
? "Linking an invoice adds the completed time as a billable line item."
: "Choose a client to see billable invoices."}
</p>
</div>
</div>
<Collapsible open={optionsOpen} onOpenChange={setOptionsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-between rounded-none border-t px-0 pt-4 pb-0 font-normal"
>
<span className="flex min-w-0 flex-col items-start gap-0.5 text-left">
<span className="font-medium">
{running ? "Edit timer details" : "Rate & start time"}
</span>
<span className="text-muted-foreground truncate text-xs">
{running
? `Started ${new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(running.startedAt))}`
: `${displayRate ? `$${displayRate}/hr` : "No rate"} · ${
startMode === "now" ? "Starting now" : "Custom start"
}`}
</span>
</span>
<ChevronDown
data-icon="inline-end"
aria-hidden="true"
className={cn(
"shrink-0 transition-transform",
optionsOpen && "rotate-180",
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<div className="grid gap-5 md:grid-cols-2">
{running ? (
<RunningTextFields
key={running.id}
running={running}
updateRunningPending={updateRunning.isPending}
onDescriptionCommit={handleRunningDescriptionCommit}
onStartedAtCommit={handleRunningStartedAtCommit}
/>
) : (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="clock-rate">Hourly rate</Label>
<NumberInput
id="clock-rate"
value={rate}
onChange={setRate}
min={0}
step={0.01}
placeholder="0.00"
/>
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
<p className="text-muted-foreground text-xs">
{`Uses ${selectedClient.defaultHourlyRate}/hr from ${selectedClient.name}.`}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label>When to start</Label>
<div className="flex flex-wrap gap-2">
{(
[
["now", "Now"],
["pick", "Pick time"],
["ago", "Time ago"],
] as const
).map(([mode, label]) => (
<Button
key={mode}
type="button"
size="sm"
variant={startMode === mode ? "default" : "outline"}
className="rounded-full"
aria-pressed={startMode === mode}
onClick={() => selectStartMode(mode)}
>
{label}
</Button>
))}
</div>
{startMode === "pick" ? (
<Input
aria-label="Start date and time"
name="startedAt"
autoComplete="off"
type="datetime-local"
value={pickedStart}
onChange={(event) => setPickedStart(event.target.value)}
/>
) : null}
{startMode === "ago" ? (
<div className="flex items-center gap-2">
<Input
aria-label="Minutes ago"
name="minutesAgo"
autoComplete="off"
type="number"
min={1}
max={1440}
value={minutesAgo}
onChange={(event) => setMinutesAgo(event.target.value)}
className="w-24"
/>
<span className="text-muted-foreground text-sm">minutes ago</span>
</div>
) : null}
</div>
</>
)}
</div>
{running ? (
<div className="mt-5 flex flex-col gap-2">
<Label htmlFor="clock-stop-note">Note on stop</Label>
<Input
id="clock-stop-note"
name="stopNote"
autoComplete="off"
value={stopNote}
onChange={(event) => setStopNote(event.target.value)}
placeholder="Add a final note…"
/>
</div>
) : null}
</CollapsibleContent>
</Collapsible>
</CardContent>
{!running ? (
<CardFooter className="pt-0">
<Button <Button
size="lg" size="lg"
className="w-full" className="w-full"
onClick={handleStart} onClick={handleStart}
disabled={clockIn.isPending} disabled={clockIn.isPending}
> >
<Play className="mr-2 h-4 w-4" /> <Play data-icon="inline-start" aria-hidden="true" />
{clockIn.isPending ? "Starting…" : "Start timer"} {clockIn.isPending ? "Starting…" : "Start timer"}
</Button> </Button>
)} </CardFooter>
</CardContent> ) : null}
</Card> </Card>
{!compact ? ( {!compact ? (
<Card> <Card className="h-fit min-w-0">
<CardHeader className="flex flex-row items-center justify-between space-y-0"> <CardHeader>
<CardTitle className="text-base">Today&apos;s entries</CardTitle> <div className="flex items-start justify-between gap-3">
<Button variant="ghost" size="sm" className="h-8" asChild> <div className="flex min-w-0 flex-col gap-1">
<Link href="/dashboard/time-clock/entries">View all entries</Link> <CardTitle>Today</CardTitle>
</Button> <CardDescription>
{completedToday.length === 1
? "1 completed entry"
: `${completedToday.length} completed entries`}
</CardDescription>
</div>
<p className="font-mono text-xl font-semibold tabular-nums">
{todayHours.toFixed(2)}h
</p>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{todayEntries?.some((e) => e.endedAt) ? ( {completedToday.length > 0 ? (
<TimeEntryList <TimeEntryList entries={completedToday} onEdit={(entry) => setEditEntryId(entry.id)} />
entries={todayEntries}
onEdit={(entry) => setEditEntryId(entry.id)}
/>
) : ( ) : (
<p className="text-muted-foreground py-4 text-center text-sm"> <div className="flex flex-col gap-1 py-6 text-center">
No entries today.{" "} <p className="font-medium">No time logged yet</p>
<Link <p className="text-muted-foreground text-sm text-pretty">
href="/dashboard/time-clock/entries" Start your first timer or open history to add an entry manually.
className="text-primary hover:underline" </p>
> </div>
View history
</Link>
</p>
)} )}
</CardContent> </CardContent>
<CardFooter>
<Button variant="outline" className="w-full" asChild>
<Link href="/dashboard/time-clock/entries">View time history</Link>
</Button>
</CardFooter>
</Card> </Card>
) : null} ) : null}
+6 -1
View File
@@ -1,6 +1,11 @@
/** Default invoice number format (matches web/mobile create forms). */ /** Default invoice number format (matches web/mobile create forms). */
export function generateInvoiceNumber(now = new Date()): string { export function generateInvoiceNumber(now = new Date()): string {
const date = now.toISOString().slice(0, 10).replace(/-/g, ""); const date = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
].join("");
return `INV-${date}-${String(now.getTime()).slice(-6)}`; return `INV-${date}-${String(now.getTime()).slice(-6)}`;
} }