- {t('"${fd_name}" will be permanently deleted. This action cannot be undone.').replace(
- "${fd_name}",
- confirmDeleteFeed.fd_name ?? ""
+ {/* N7: name the country too — the same feed name can exist in
+ another country, and deleting removes it for that country. */}
+ {fillText(
+ confirmDeleteFeed.fd_country_name
+ ? t(
+ '"${fd_name}" (${country}) will be permanently deleted. This action cannot be undone.'
+ )
+ : t('"${fd_name}" will be permanently deleted. This action cannot be undone.'),
+ {
+ fd_name: confirmDeleteFeed.fd_name ?? "",
+ country: confirmDeleteFeed.fd_country_name ?? "",
+ }
)}
diff --git a/frontend/src/components/admin/feeds/FeedsTab.tsx b/frontend/src/components/admin/feeds/FeedsTab.tsx
index 4e3d422a..40ebc2be 100644
--- a/frontend/src/components/admin/feeds/FeedsTab.tsx
+++ b/frontend/src/components/admin/feeds/FeedsTab.tsx
@@ -6,9 +6,13 @@
import type { Dispatch, SetStateAction } from "react";
import { COLORS } from "@/lib/colors";
+import CustomSelect from "@/components/CustomSelect";
import { SkeletonCard, type AdminFeed } from "./shared";
export interface FeedsTabProps {
+ countryFilter: string;
+ countryNames: string[];
+ setCountryFilter: (country: string) => void;
deletingFeedId: string | null;
filteredFeeds: AdminFeed[];
isLoading: boolean;
@@ -21,6 +25,9 @@ export interface FeedsTabProps {
}
export default function FeedsTab({
+ countryFilter,
+ countryNames,
+ setCountryFilter,
deletingFeedId,
filteredFeeds,
isLoading,
@@ -33,8 +40,29 @@ export default function FeedsTab({
}: FeedsTabProps) {
return (
<>
- {/* Search by feed name — matches Android til_search_feed */}
+ {/* Country filter (N7) — same chrome as Admin › Users' country filter.
+ The same feed name can exist in two countries, so the list can be
+ narrowed to one before editing or deleting. */}
+
+ {/* Search by feed name — matches Android til_search_feed */}
+
- {searchQuery ? t("No feeds match your search") : t("No feeds found")}
+ {searchQuery || countryFilter ? t("No feeds match your search") : t("No feeds found")}
) : (
@@ -107,6 +135,14 @@ export default function FeedsTab({
>
{f.fd_type || t("N/A")}
+ {/* N7: which country's feed this is — two countries can
+ have a feed with the same name. */}
+
+ {f.fd_country_name || t("N/A")}
+
{/* Edit: go_green_15 bg + dark_aquamarine_green tint,
diff --git a/frontend/src/components/cattle-info/CountryChangeDialog.tsx b/frontend/src/components/cattle-info/CountryChangeDialog.tsx
new file mode 100644
index 00000000..aa042e41
--- /dev/null
+++ b/frontend/src/components/cattle-info/CountryChangeDialog.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+// N8: Cattle Info → Continue with a different country while feed cards are
+// filled. Those feeds belong to the old country, so they are cleared — but
+// only after the user agrees. Cancel keeps everything and saves nothing.
+import { COLUMN_LEFT, COLUMN_WIDTH } from "@/lib/column";
+import { COLORS } from "@/lib/colors";
+import { fillText } from "@/lib/i18n-ui";
+import { useEscapeKey } from "@/lib/use-escape";
+
+export interface CountryChangeDialogProps {
+ count: number;
+ onCancel: () => void;
+ onConfirm: () => void;
+ t: (source: string) => string;
+}
+
+export default function CountryChangeDialog({
+ count,
+ onCancel,
+ onConfirm,
+ t,
+}: CountryChangeDialogProps) {
+ useEscapeKey(true, onCancel);
+ return (
+
+
+
+ {t("Change country?")}
+
+
+ {fillText(t("Changing the country clears the ${n} selected feeds. Continue?"), {
+ n: String(count),
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/cattle-info/useCountryChange.tsx b/frontend/src/components/cattle-info/useCountryChange.tsx
new file mode 100644
index 00000000..fe110fa3
--- /dev/null
+++ b/frontend/src/components/cattle-info/useCountryChange.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useState } from "react";
+import type { CattleInfo } from "@/lib/api";
+import { useStore } from "@/lib/store";
+import { filledFeedCount } from "@/lib/feed-selection-rules";
+import CountryChangeDialog from "./CountryChangeDialog";
+
+/**
+ * N8: Cattle Info → Continue. The filled feed cards belong to the SAVED
+ * country, so switching to another one asks first and then clears them.
+ * Compared with the saved cattleInfo, not the form's last pick, so
+ * Kenya → India → Kenya asks nothing; a restore saves its own country, so
+ * it never trips this either. `proceed` is what Continue does today.
+ */
+export function useCountryChange(proceed: (info: CattleInfo) => void, t: (s: string) => string) {
+ const [pending, setPending] = useState<{ info: CattleInfo; count: number } | null>(null);
+
+ const continueWith = (info: CattleInfo) => {
+ const { cattleInfo, feedSelections } = useStore.getState();
+ const saved = cattleInfo?.country_id ? String(cattleInfo.country_id) : "";
+ const filled = filledFeedCount(feedSelections);
+ if (saved && saved !== String(info.country_id) && filled > 0) {
+ setPending({ info, count: filled });
+ return;
+ }
+ proceed(info);
+ };
+
+ const dialog = pending && (
+ setPending(null)}
+ onConfirm={() => {
+ setPending(null);
+ // Every card goes: feed, price, quantity, inclusion limits.
+ useStore.getState().setFeedSelections([]);
+ proceed(pending.info);
+ }}
+ t={t}
+ />
+ );
+
+ return { continueWith, dialog };
+}
diff --git a/frontend/src/components/feed-row/useFeedCascade.ts b/frontend/src/components/feed-row/useFeedCascade.ts
index d293b39a..66ec6d6f 100644
--- a/frontend/src/components/feed-row/useFeedCascade.ts
+++ b/frontend/src/components/feed-row/useFeedCascade.ts
@@ -458,11 +458,11 @@ export function useFeedCascade({
: null;
})
.filter((s): s is FeedSubCategoryItem => s !== null);
- const match = list.find(
- (s) =>
- (item.feed_uuid && s.feed_uuid === item.feed_uuid) ||
- s.feed_name === item.sub_category_name
- );
+ // By id when the card has one; by name only for a card that has
+ // none (names are unique per country, not across the library).
+ const match = item.feed_uuid
+ ? list.find((s) => s.feed_uuid === item.feed_uuid)
+ : list.find((s) => s.feed_name === item.sub_category_name);
// If the row has a picked feed_uuid but it's not in the new
// list (e.g. missing Hindi translation on backend), inject a
// synthetic entry using the stored data so the dropdown can
diff --git a/frontend/src/lib/__tests__/feed-selection-rules.test.ts b/frontend/src/lib/__tests__/feed-selection-rules.test.ts
new file mode 100644
index 00000000..629bf281
--- /dev/null
+++ b/frontend/src/lib/__tests__/feed-selection-rules.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from "vitest";
+import { filledFeedCount, isFeedOnOtherCard } from "@/lib/feed-selection-rules";
+
+const cards = [
+ { id: "a", feed_uuid: "napier", sub_category_name: "Napier" },
+ { id: "b", feed_uuid: null, sub_category_name: "" },
+ { id: "c", feed_uuid: null, sub_category_name: "Restored by name" },
+];
+
+describe("feed-selection rules", () => {
+ it("counts the cards that have a feed (N8)", () => {
+ expect(filledFeedCount(cards)).toBe(2);
+ expect(filledFeedCount([])).toBe(0);
+ expect(filledFeedCount(null)).toBe(0);
+ });
+
+ it("finds a feed held by ANOTHER card only", () => {
+ expect(isFeedOnOtherCard(cards, "napier", "b")).toBe(true);
+ expect(isFeedOnOtherCard(cards, "napier", "a")).toBe(false);
+ expect(isFeedOnOtherCard(cards, "maize", "b")).toBe(false);
+ expect(isFeedOnOtherCard(cards, null, "b")).toBe(false);
+ });
+});
diff --git a/frontend/src/lib/data/ui-labels.xlsx b/frontend/src/lib/data/ui-labels.xlsx
index 01ff9ca5..87f3fdec 100644
Binary files a/frontend/src/lib/data/ui-labels.xlsx and b/frontend/src/lib/data/ui-labels.xlsx differ
diff --git a/frontend/src/lib/feed-selection-rules.ts b/frontend/src/lib/feed-selection-rules.ts
new file mode 100644
index 00000000..6b21176d
--- /dev/null
+++ b/frontend/src/lib/feed-selection-rules.ts
@@ -0,0 +1,20 @@
+import type { FeedItem } from "@/lib/api-client/models";
+
+type Card = Pick;
+
+/** Feed cards that have a feed picked (N8: what a country change would clear). */
+export function filledFeedCount(items: readonly Card[] | null | undefined): number {
+ return (items ?? []).filter((r) => !!r.feed_uuid || r.sub_category_name.trim() !== "").length;
+}
+
+/**
+ * True when another card already holds this feed. One feed may be on only
+ * one card: the optimizer would treat two cards of it as two feeds.
+ */
+export function isFeedOnOtherCard(
+ items: readonly Card[],
+ feedUuid: string | null | undefined,
+ rowId: string | null | undefined
+): boolean {
+ return !!feedUuid && items.some((r) => r.id !== rowId && r.feed_uuid === feedUuid);
+}