206 lines
6.1 KiB
TypeScript
206 lines
6.1 KiB
TypeScript
import { useState } from "react";
|
|
import { Alert, Pressable, Text, View } from "react-native";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "@/lib/api";
|
|
import { t } from "@/lib/i18n";
|
|
import { formatMinor } from "@/lib/money";
|
|
import {
|
|
Body,
|
|
Button,
|
|
Card,
|
|
EmptyState,
|
|
ErrorView,
|
|
Heading,
|
|
Input,
|
|
LoadingView,
|
|
Row,
|
|
Screen,
|
|
Small,
|
|
Spacer,
|
|
} from "@/components/ui";
|
|
import { colors, spacing } from "@/lib/theme";
|
|
import { formatQuantity } from "@/lib/units";
|
|
|
|
/** Inköpslista (spec §27): delad, avdelningssorterad, uppdaterar lagret vid avslut. */
|
|
|
|
interface ShoppingItem {
|
|
id: string;
|
|
displayName: string;
|
|
quantity: number;
|
|
unit: string;
|
|
storeSection: string;
|
|
estimatedPriceMinor: number | null;
|
|
checked: boolean;
|
|
}
|
|
interface ListResponse {
|
|
list: { id: string; name: string };
|
|
items: ShoppingItem[];
|
|
estimatedTotalMinor: number;
|
|
currency: string;
|
|
}
|
|
|
|
/** Butikssektionernas etiketter bor i i18n-katalogen: shopping.section.<id> (12 språk). */
|
|
const SECTION_IDS = new Set([
|
|
"frukt_gront",
|
|
"brod",
|
|
"mejeri",
|
|
"kott_fagel",
|
|
"fisk",
|
|
"chark",
|
|
"frys",
|
|
"skafferi",
|
|
"konserver",
|
|
"kryddor_bak",
|
|
"dryck",
|
|
"snacks",
|
|
"hygien_ovrigt",
|
|
]);
|
|
const sectionLabel = (id: string): string =>
|
|
SECTION_IDS.has(id) ? t(`shopping.section.${id}`) : id;
|
|
|
|
export default function ShoppingScreen() {
|
|
const queryClient = useQueryClient();
|
|
const [newItem, setNewItem] = useState("");
|
|
|
|
const lists = useQuery({
|
|
queryKey: ["shopping-lists"],
|
|
queryFn: async () => {
|
|
const result = await api<{ lists: Array<{ id: string }> }>("/v1/shopping-lists");
|
|
if (result.lists.length === 0) {
|
|
const created = await api<{ list: { id: string } }>("/v1/shopping-lists", {
|
|
method: "POST",
|
|
body: { name: t("shopping.title") },
|
|
});
|
|
return [created.list];
|
|
}
|
|
return result.lists;
|
|
},
|
|
});
|
|
const listId = lists.data?.[0]?.id;
|
|
|
|
const list = useQuery({
|
|
queryKey: ["shopping-list", listId],
|
|
queryFn: () => api<ListResponse>(`/v1/shopping-lists/${listId}`),
|
|
enabled: Boolean(listId),
|
|
});
|
|
|
|
const invalidate = () => {
|
|
void queryClient.invalidateQueries({ queryKey: ["shopping-list", listId] });
|
|
void queryClient.invalidateQueries({ queryKey: ["shopping-lists"] });
|
|
};
|
|
|
|
const addItem = useMutation({
|
|
mutationFn: (displayName: string) =>
|
|
api(`/v1/shopping-lists/${listId}/items`, { method: "POST", body: { displayName } }),
|
|
onSuccess: () => {
|
|
setNewItem("");
|
|
invalidate();
|
|
},
|
|
});
|
|
|
|
const toggle = useMutation({
|
|
mutationFn: (item: ShoppingItem) =>
|
|
api(`/v1/shopping-lists/${listId}/items/${item.id}`, {
|
|
method: "PATCH",
|
|
body: { checked: !item.checked },
|
|
}),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const complete = useMutation({
|
|
mutationFn: () =>
|
|
api(`/v1/shopping-lists/${listId}/complete`, {
|
|
method: "POST",
|
|
body: { addToInventory: true },
|
|
}),
|
|
onSuccess: async (result) => {
|
|
const added = (result as { itemsAddedToInventory?: number }).itemsAddedToInventory ?? 0;
|
|
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
|
invalidate();
|
|
Alert.alert(t("shopping.completedTitle"), t("shopping.completedBody", { count: added }));
|
|
},
|
|
});
|
|
|
|
if (lists.isLoading || list.isLoading) return <LoadingView />;
|
|
if (list.isError || !list.data) return <ErrorView onRetry={() => void list.refetch()} />;
|
|
|
|
const grouped = new Map<string, ShoppingItem[]>();
|
|
for (const item of list.data.items) {
|
|
const arr = grouped.get(item.storeSection) ?? [];
|
|
arr.push(item);
|
|
grouped.set(item.storeSection, arr);
|
|
}
|
|
const checkedCount = list.data.items.filter((i) => i.checked).length;
|
|
|
|
return (
|
|
<Screen>
|
|
<Row>
|
|
<View style={{ flex: 1 }}>
|
|
<Input
|
|
placeholder={t("shopping.addPlaceholder")}
|
|
value={newItem}
|
|
onChangeText={setNewItem}
|
|
onSubmitEditing={() => newItem.trim() && addItem.mutate(newItem.trim())}
|
|
returnKeyType="done"
|
|
/>
|
|
</View>
|
|
<Button label="+" onPress={() => newItem.trim() && addItem.mutate(newItem.trim())} />
|
|
</Row>
|
|
|
|
{list.data.items.length === 0 && <EmptyState text={t("shopping.empty")} />}
|
|
|
|
{[...grouped.entries()].map(([section, items]) => (
|
|
<Card key={section}>
|
|
<Heading>{sectionLabel(section)}</Heading>
|
|
{items.map((item) => (
|
|
<Pressable key={item.id} onPress={() => toggle.mutate(item)}>
|
|
<Row style={{ justifyContent: "space-between", paddingVertical: 6 }}>
|
|
<Body>
|
|
<Text style={{ color: item.checked ? colors.primary : colors.textMuted }}>
|
|
{item.checked ? "☑" : "☐"}
|
|
</Text>{" "}
|
|
<Text
|
|
style={
|
|
item.checked
|
|
? { textDecorationLine: "line-through", color: colors.textMuted }
|
|
: undefined
|
|
}
|
|
>
|
|
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
|
|
</Text>
|
|
</Body>
|
|
{item.estimatedPriceMinor != null && (
|
|
<Small>
|
|
{t("shopping.estimated", {
|
|
amount: formatMinor(item.estimatedPriceMinor, list.data.currency),
|
|
})}
|
|
</Small>
|
|
)}
|
|
</Row>
|
|
</Pressable>
|
|
))}
|
|
</Card>
|
|
))}
|
|
|
|
{list.data.estimatedTotalMinor > 0 && (
|
|
<Small>
|
|
{t("shopping.estimatedTotal", {
|
|
amount: formatMinor(list.data.estimatedTotalMinor, list.data.currency),
|
|
})}
|
|
</Small>
|
|
)}
|
|
<Spacer size={spacing.sm} />
|
|
{checkedCount > 0 && (
|
|
<>
|
|
<Button
|
|
label={`${t("shopping.complete")} (${checkedCount})`}
|
|
onPress={() => complete.mutate()}
|
|
loading={complete.isPending}
|
|
/>
|
|
<Small>{t("shopping.completeNote")}</Small>
|
|
</>
|
|
)}
|
|
</Screen>
|
|
);
|
|
}
|