187 lines
6.0 KiB
TypeScript
187 lines
6.0 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { StyleSheet, Text, View } from "react-native";
|
|
|
|
import { AppBackground } from "@/components/AppBackground";
|
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
|
import { PageHeader } from "@/components/PageHeader";
|
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
|
import { TabPage } from "@/components/TabPage";
|
|
import { TabScrollView } from "@/components/TabScrollView";
|
|
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { fonts, spacing } from "@/constants/theme";
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import { formatRunningTimerLabel } from "@/lib/time-clock";
|
|
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
|
|
import { api } from "@/lib/trpc";
|
|
import type { AppRouter } from "beenvoice/server/api/root";
|
|
import type { inferRouterOutputs } from "@trpc/server";
|
|
import {
|
|
DEFAULT_TIME_ZONE,
|
|
getZonedDateTimeParts,
|
|
} from "@beenvoice/domain/time-zone";
|
|
|
|
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
|
|
|
|
function groupByDate(entries: TimeEntry[], timeZone: string) {
|
|
const groups = new Map<string, typeof entries>();
|
|
for (const entry of entries) {
|
|
const d = new Date(entry.startedAt);
|
|
const parts = getZonedDateTimeParts(d, timeZone);
|
|
const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
|
|
const list = groups.get(dateKey) ?? [];
|
|
list.push(entry);
|
|
groups.set(dateKey, list);
|
|
}
|
|
return Array.from(groups.entries()).map(
|
|
([, groupedEntries]) =>
|
|
[
|
|
new Date(groupedEntries[0]!.startedAt).toLocaleDateString(undefined, {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
timeZone,
|
|
}),
|
|
groupedEntries,
|
|
] as const,
|
|
);
|
|
}
|
|
|
|
export default function TimeEntriesScreen() {
|
|
const { colors } = useAppTheme();
|
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
|
const entriesQuery = api.timeEntries.getAll.useQuery();
|
|
const profileQuery = api.settings.getProfile.useQuery();
|
|
|
|
const completed = useMemo(
|
|
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
|
|
[entriesQuery.data],
|
|
);
|
|
const grouped = useMemo(
|
|
() =>
|
|
groupByDate(completed, profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE),
|
|
[completed, profileQuery.data?.timeZone],
|
|
);
|
|
|
|
if (entriesQuery.isLoading) {
|
|
return <LoadingScreen message="Loading time entries…" />;
|
|
}
|
|
|
|
if (entriesQuery.error) {
|
|
return (
|
|
<AppBackground>
|
|
<TabPage showMoreBack>
|
|
<View style={styles.errorBox}>
|
|
<PageHeader
|
|
title="Time entries"
|
|
subtitle="Completed work history"
|
|
/>
|
|
<Text style={{ color: colors.mutedForeground }}>
|
|
{formatTrpcErrorMessage(entriesQuery.error)}
|
|
</Text>
|
|
</View>
|
|
</TabPage>
|
|
</AppBackground>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppBackground>
|
|
<TabPage showMoreBack>
|
|
<TabScrollView
|
|
header={
|
|
<PageHeader
|
|
title="Time entries"
|
|
subtitle={`${completed.length} completed entries`}
|
|
/>
|
|
}
|
|
refreshControl={
|
|
<PullToRefresh
|
|
onRefresh={() => entriesQuery.refetch()}
|
|
tintColor={colors.primary}
|
|
/>
|
|
}
|
|
>
|
|
{grouped.length === 0 ? (
|
|
<Text
|
|
style={{ color: colors.mutedForeground, fontFamily: fonts.body }}
|
|
>
|
|
No completed entries yet. Start the timer from the Timer tab.
|
|
</Text>
|
|
) : (
|
|
grouped.map(([label, entries]) => (
|
|
<Card key={label} title={label}>
|
|
{entries.map((entry) => (
|
|
<SwipeableRow
|
|
key={entry.id}
|
|
actions={[
|
|
{
|
|
key: "edit",
|
|
label: "Edit",
|
|
icon: "create-outline",
|
|
color: "#fff",
|
|
backgroundColor: colors.primary,
|
|
onPress: () => setEditEntryId(entry.id),
|
|
},
|
|
]}
|
|
>
|
|
<View style={styles.row}>
|
|
<View style={{ flex: 1, gap: 2 }}>
|
|
<Text
|
|
style={[styles.title, { color: colors.foreground }]}
|
|
>
|
|
{formatRunningTimerLabel(entry.description)}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
color: colors.mutedForeground,
|
|
fontFamily: fonts.body,
|
|
}}
|
|
>
|
|
{entry.client?.name ?? "No client"}
|
|
{entry.invoice
|
|
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
|
: " · not billed"}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
style={[styles.title, { color: colors.foreground }]}
|
|
>
|
|
{entry.hours ?? "—"}h
|
|
</Text>
|
|
</View>
|
|
</SwipeableRow>
|
|
))}
|
|
</Card>
|
|
))
|
|
)}
|
|
</TabScrollView>
|
|
</TabPage>
|
|
|
|
<TimeEntryEditSheet
|
|
entryId={editEntryId}
|
|
visible={editEntryId != null}
|
|
onClose={() => setEditEntryId(null)}
|
|
/>
|
|
</AppBackground>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
row: {
|
|
flexDirection: "row",
|
|
gap: spacing.md,
|
|
padding: spacing.md,
|
|
},
|
|
title: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 14,
|
|
},
|
|
errorBox: {
|
|
padding: spacing.lg,
|
|
gap: spacing.md,
|
|
},
|
|
});
|