commit eeca2211851b288129b638fb5252b42a466a4568
parent 8e01d04de7f2ee3c3f2c9394129fa634d5cfa378
Author: maydayv7 <maydayv7@gmail.com>
Date: Sun, 5 Oct 2025 17:57:31 +0530
feat: Add inventory
Diffstat:
12 files changed, 747 insertions(+), 160 deletions(-)
diff --git a/chaincode/index.js b/chaincode/index.js
@@ -40,6 +40,43 @@ class ProduceContract extends Contract {
};
}
+ // Find user state by ID
+ async _findUserKeyById(ctx, id) {
+ if (!id) return null;
+ const roles = ["FARMER", "DISTRIBUTOR", "RETAILER", "INSPECTOR"];
+ for (const r of roles) {
+ const key = `${r}-${id}`;
+ const data = await ctx.stub.getState(key);
+ if (data && data.length > 0) return key;
+ }
+ return null;
+ }
+
+ async _getUserById(ctx, id) {
+ const key = await this._findUserKeyById(ctx, id);
+ if (!key) return null;
+ const data = await ctx.stub.getState(key);
+ if (!data || data.length === 0) return null;
+ return { key, user: JSON.parse(data.toString()) };
+ }
+
+ async _addOwnedProduceToUser(ctx, userKey, userObj, produceId) {
+ userObj.ownedProduce = userObj.ownedProduce || [];
+ if (!userObj.ownedProduce.includes(produceId)) {
+ userObj.ownedProduce.push(produceId);
+ await this._putState(ctx, userKey, userObj);
+ }
+ }
+
+ async _removeOwnedProduceFromUser(ctx, userKey, userObj, produceId) {
+ userObj.ownedProduce = userObj.ownedProduce || [];
+ const idx = userObj.ownedProduce.indexOf(produceId);
+ if (idx >= 0) {
+ userObj.ownedProduce.splice(idx, 1);
+ await this._putState(ctx, userKey, userObj);
+ }
+ }
+
// Initialize ledger
async initLedger(ctx) {
console.info("Ledger initialized");
@@ -50,9 +87,8 @@ class ProduceContract extends Contract {
async registerProduce(ctx, farmerId, detailsStr) {
const farmerKey = `FARMER-${farmerId}`;
const farmerState = await ctx.stub.getState(farmerKey);
- if (!farmerState || farmerState.length === 0) {
+ if (!farmerState || farmerState.length === 0)
throw new Error(`Farmer ${farmerId} is not registered`);
- }
const farmer = JSON.parse(farmerState.toString());
const details = JSON.parse(detailsStr || "{}");
@@ -78,7 +114,7 @@ class ProduceContract extends Contract {
expiryDate: details.expiryDate || null,
storageConditions: details.storageConditions || [],
imageUrl: details.imageUrl || null,
- certification: farmer.certification || [],
+ certification: details.certification || farmer.certification || [],
status: "Harvested",
isAvailable: true,
notAvailableReason: null,
@@ -91,9 +127,13 @@ class ProduceContract extends Contract {
);
await this._putState(ctx, id, produce);
- farmer.registeredProduce = farmer.registeredProduce || [];
- farmer.registeredProduce.push(id);
- await this._putState(ctx, farmerKey, farmer);
+ const farmerObj = farmer;
+ farmerObj.registeredProduce = farmerObj.registeredProduce || [];
+ if (!farmerObj.registeredProduce.includes(id))
+ farmerObj.registeredProduce.push(id);
+ farmerObj.ownedProduce = farmerObj.ownedProduce || [];
+ if (!farmerObj.ownedProduce.includes(id)) farmerObj.ownedProduce.push(id);
+ await this._putState(ctx, farmerKey, farmerObj);
return produce;
}
@@ -163,6 +203,18 @@ class ProduceContract extends Contract {
}
await this._putState(ctx, produceId, produce);
+
+ const inspectorKey = `INSPECTOR-${inspectorId}`;
+ const inspectorState = await ctx.stub.getState(inspectorKey);
+ if (inspectorState && inspectorState.length > 0) {
+ const inspectorObj = JSON.parse(inspectorState.toString());
+ inspectorObj.inspectedProduce = inspectorObj.inspectedProduce || [];
+ if (!inspectorObj.inspectedProduce.includes(produceId)) {
+ inspectorObj.inspectedProduce.push(produceId);
+ await this._putState(ctx, inspectorKey, inspectorObj);
+ }
+ }
+
return produce;
}
@@ -171,9 +223,8 @@ class ProduceContract extends Contract {
const details = JSON.parse(detailsStr || "{}");
const produce = await this._getState(ctx, produceId);
- if (produce.currentOwner !== actorId) {
+ if (produce.currentOwner !== actorId)
throw new Error("Only current owner can update details");
- }
if (details.pricePerUnit !== undefined)
produce.pricePerUnit = details.pricePerUnit;
@@ -235,6 +286,16 @@ class ProduceContract extends Contract {
await this._putState(ctx, produce.id, produce);
await this._putState(ctx, childId, child);
+ const ownerRes = await this._getUserById(ctx, ownerId);
+ if (ownerRes) {
+ await this._addOwnedProduceToUser(
+ ctx,
+ ownerRes.key,
+ ownerRes.user,
+ childId
+ );
+ }
+
return { parent: produce, child };
}
@@ -251,6 +312,7 @@ class ProduceContract extends Contract {
let resultAssetId = produceId;
if (qty < produce.qty) {
+ // Partial Transfer
const splitRes = await this.splitProduce(
ctx,
produceId,
@@ -276,7 +338,28 @@ class ProduceContract extends Contract {
});
await this._putState(ctx, child.id, child);
resultAssetId = child.id;
+
+ const prevOwnerRes = await this._getUserById(ctx, currentOwner);
+ if (prevOwnerRes) {
+ await this._removeOwnedProduceFromUser(
+ ctx,
+ prevOwnerRes.key,
+ prevOwnerRes.user,
+ child.id
+ );
+ }
+
+ const newOwnerRes = await this._getUserById(ctx, newOwnerId);
+ if (newOwnerRes) {
+ await this._addOwnedProduceToUser(
+ ctx,
+ newOwnerRes.key,
+ newOwnerRes.user,
+ child.id
+ );
+ }
} else {
+ // Full Transfer
produce.currentOwner = newOwnerId;
produce.actionHistory.push(
this._actionItem(ctx, "SALE", produce.currentLocation, newOwnerId, {
@@ -295,6 +378,26 @@ class ProduceContract extends Contract {
});
await this._putState(ctx, produceId, produce);
resultAssetId = produceId;
+
+ const prevOwnerRes = await this._getUserById(ctx, currentOwner);
+ if (prevOwnerRes) {
+ await this._removeOwnedProduceFromUser(
+ ctx,
+ prevOwnerRes.key,
+ prevOwnerRes.user,
+ produceId
+ );
+ }
+
+ const newOwnerRes = await this._getUserById(ctx, newOwnerId);
+ if (newOwnerRes) {
+ await this._addOwnedProduceToUser(
+ ctx,
+ newOwnerRes.key,
+ newOwnerRes.user,
+ produceId
+ );
+ }
}
return { newAssetId: resultAssetId };
@@ -382,19 +485,33 @@ class ProduceContract extends Contract {
const id = details.id || `USER-${ctx.stub.getTxID()}`;
const key = `${role.toUpperCase()}-${id}`;
- const user = {
+ const base = {
role,
id,
name: details.name || "",
location: details.location || "",
walletId: details.walletId || "",
- registeredProduce: [],
- ownedProduce: [],
- inventory: [],
};
- if (role.toUpperCase() === "FARMER") {
- user.certification = details.certification || [];
+ let user = null;
+ const roleUpper = role.toUpperCase();
+ if (roleUpper === "FARMER") {
+ user = {
+ ...base,
+ registeredProduce: details.registeredProduce || [],
+ ownedProduce: details.ownedProduce || [],
+ certification: details.certification || [],
+ };
+ } else if (roleUpper === "DISTRIBUTOR" || roleUpper === "RETAILER") {
+ user = {
+ ...base,
+ ownedProduce: details.ownedProduce || [],
+ };
+ } else if (roleUpper === "INSPECTOR") {
+ user = {
+ ...base,
+ inspectedProduce: details.inspectedProduce || [],
+ };
}
await this._putState(ctx, key, user);
diff --git a/frontend/App.js b/frontend/App.js
@@ -5,6 +5,7 @@ import { AuthProvider } from "./AuthContext";
import HomeScreen from "./screens/Home";
import LoginScreen from "./screens/Login";
+import InventoryScreen from "./screens/Inventory";
import FarmerScreen from "./screens/Farmer";
import DistributorScreen from "./screens/Distributor";
import RetailerScreen from "./screens/Retailer";
@@ -27,6 +28,7 @@ function App() {
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Login" component={LoginScreen} />
+ <Stack.Screen name="Inventory" component={InventoryScreen} />
<Stack.Screen name="Farmer" component={FarmerScreen} />
<Stack.Screen name="Distributor" component={DistributorScreen} />
<Stack.Screen name="Retailer" component={RetailerScreen} />
diff --git a/frontend/components/DetailRow.js b/frontend/components/DetailRow.js
@@ -0,0 +1,22 @@
+import { View, Text } from "react-native";
+import { MaterialCommunityIcons } from "@expo/vector-icons";
+import { colors } from "../styles";
+
+export default function DetailRow({ icon, label, value }) {
+ return (
+ <View
+ style={{ flexDirection: "row", alignItems: "center", marginVertical: 6 }}
+ >
+ <MaterialCommunityIcons
+ name={icon}
+ size={20}
+ color={colors.darkGreen}
+ style={{ marginRight: 12 }}
+ />
+ <View style={{ flex: 1 }}>
+ <Text style={{ fontSize: 12, color: colors.gray }}>{label}</Text>
+ <Text style={{ fontSize: 15, fontWeight: "600" }}>{value}</Text>
+ </View>
+ </View>
+ );
+}
diff --git a/frontend/components/ProduceCard.js b/frontend/components/ProduceCard.js
@@ -0,0 +1,244 @@
+import { View, Text, Image } from "react-native";
+import { MaterialCommunityIcons } from "@expo/vector-icons";
+import { colors } from "../styles";
+import DetailRow from "./DetailRow";
+
+export default function ProduceCard({ produce }) {
+ const status = (produce.status || "").toLowerCase();
+ const statusStyle = getStatusStyle(status);
+
+ return (
+ <View style={local.card}>
+ <View style={local.header}>
+ <View style={local.headerLeft}>
+ <Text style={local.refNoText}> Ref. No. </Text>
+ </View>
+ <View style={local.headerRight}>
+ <Text style={local.refNoId}>{produce.id}</Text>
+ </View>
+ </View>
+
+ <View style={local.splitRow}>
+ <View style={{ flex: 1, marginRight: 8 }}>
+ <Text style={local.detailLabel}>Crop Type</Text>
+ <Text style={local.title}>{produce.cropType || "N/A"}</Text>
+ </View>
+ <View style={{ flex: 1, marginLeft: 8 }}>
+ <Text style={local.detailLabel}>Quality</Text>
+ <Text style={local.title}>{produce.quality || "N/A"}</Text>
+ </View>
+ </View>
+
+ <View style={local.mainContentRow}>
+ {produce.imageUrl ? (
+ <Image
+ source={{ uri: produce.imageUrl }}
+ style={local.produceImage}
+ resizeMode="cover"
+ />
+ ) : (
+ <View style={[local.produceImage, local.imagePlaceholder]}>
+ <MaterialCommunityIcons
+ name="image-off-outline"
+ size={40}
+ color={colors.midGreen}
+ />
+ </View>
+ )}
+
+ <View style={local.detailsContainer}>
+ <DetailRow
+ icon="weight-kilogram"
+ label="Quantity"
+ value={`${produce.qty} ${produce.qtyUnit}`}
+ />
+ <DetailRow
+ icon="cash"
+ label="Price"
+ value={`₹${produce.pricePerUnit} / ${produce.qtyUnit}`}
+ />
+ <DetailRow
+ icon="account-outline"
+ label="Current Owner"
+ value={produce.currentOwner}
+ />
+ </View>
+ </View>
+
+ <View style={local.splitRow}>
+ <View style={{ flex: 1, marginRight: 8 }}>
+ <DetailRow
+ icon="calendar-arrow-left"
+ label="Harvest Date"
+ value={
+ produce.harvestDate
+ ? new Date(produce.harvestDate).toLocaleDateString()
+ : "N/A"
+ }
+ />
+ </View>
+ <View style={{ flex: 1, marginLeft: 8 }}>
+ <DetailRow
+ icon="calendar-arrow-right"
+ label="Expiry Date"
+ value={
+ produce.expiryDate
+ ? new Date(produce.expiryDate).toLocaleDateString()
+ : "N/A"
+ }
+ />
+ </View>
+ </View>
+
+ <View style={local.badgesRow}>
+ <View style={local.badgesLeft}>
+ {produce.certification?.length > 0 ? (
+ produce.certification.map((cert, idx) => (
+ <View key={idx} style={local.certPill}>
+ <MaterialCommunityIcons
+ name="shield-check"
+ size={16}
+ color={colors.darkGreen}
+ />
+ <Text style={local.certText}>{cert}</Text>
+ </View>
+ ))
+ ) : (
+ <Text style={{ color: colors.gray }}> No certification </Text>
+ )}
+ </View>
+
+ <View style={[local.statusContainer, statusStyle.container]}>
+ <Text style={[local.statusText, statusStyle.text]}>
+ {produce.status || "Unknown"}
+ </Text>
+ </View>
+ </View>
+ </View>
+ );
+}
+
+function getStatusStyle(status) {
+ switch (status) {
+ case "harvested":
+ return {
+ container: { backgroundColor: "#e4f8e4" },
+ text: { color: "#228b22" },
+ };
+ case "in transit":
+ return {
+ container: { backgroundColor: "#fff7d6" },
+ text: { color: "#c28c00" },
+ };
+ case "retail":
+ return {
+ container: { backgroundColor: "#e2f0ff" },
+ text: { color: "#005fb8" },
+ };
+ case "failed inspection":
+ case "removed":
+ case "missing":
+ return {
+ container: { backgroundColor: "#fde7e7" },
+ text: { color: "#d22" },
+ };
+ case "sold":
+ return {
+ container: { backgroundColor: "#f3e6ff" },
+ text: { color: "#7a3fc9" },
+ };
+ default:
+ return {
+ container: { backgroundColor: "#efefef" },
+ text: { color: colors.gray },
+ };
+ }
+}
+
+const local = {
+ card: {
+ backgroundColor: "white",
+ padding: 12,
+ borderRadius: 12,
+ marginBottom: 12,
+ shadowColor: "#000",
+ shadowOpacity: 0.05,
+ shadowRadius: 8,
+ elevation: 2,
+ },
+ header: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "flex-start",
+ },
+ headerLeft: { flexShrink: 0 },
+ headerRight: { flex: 1, marginLeft: 8 },
+ refNoText: { color: colors.gray, fontSize: 12 },
+ refNoId: {
+ fontSize: 12,
+ fontWeight: "700",
+ textAlign: "right",
+ flexWrap: "wrap",
+ },
+ splitRow: { flexDirection: "row", marginTop: 8 },
+ detailLabel: { fontSize: 12, color: colors.gray },
+ title: { fontSize: 16, fontWeight: "700" },
+ mainContentRow: { flexDirection: "row", marginTop: 12 },
+ produceImage: {
+ width: 120,
+ height: 120,
+ borderRadius: 8,
+ marginRight: 16,
+ },
+ imagePlaceholder: {
+ backgroundColor: "#f4f7f4",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ detailsContainer: { flex: 1 },
+ badgesRow: {
+ position: "relative",
+ marginTop: 12,
+ flexWrap: "wrap",
+ paddingRight: 100,
+ },
+ badgesLeft: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ alignItems: "center",
+ gap: 8,
+ },
+ certPill: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: "#f0f9f0",
+ borderRadius: 16,
+ paddingVertical: 4,
+ paddingHorizontal: 10,
+ marginRight: 8,
+ marginBottom: 6,
+ maxWidth: "100%",
+ },
+ certText: {
+ marginLeft: 6,
+ color: colors.gray,
+ flexShrink: 1,
+ flexWrap: "wrap",
+ fontSize: 13,
+ },
+ statusContainer: {
+ position: "absolute",
+ top: 0,
+ right: 0,
+ paddingVertical: 4,
+ paddingHorizontal: 10,
+ borderRadius: 6,
+ flexShrink: 0,
+ alignSelf: "flex-start",
+ },
+ statusText: {
+ fontWeight: "700",
+ fontSize: 13,
+ textTransform: "capitalize",
+ },
+};
diff --git a/frontend/screens/Distributor.js b/frontend/screens/Distributor.js
@@ -261,6 +261,20 @@ export default function DistributorScreen({ navigation, route }) {
</View>
)}
</ScrollView>
+
+ <View style={{ padding: 12 }}>
+ <TouchableOpacity
+ style={styles.secondaryButton}
+ onPress={() =>
+ navigation.navigate("Inventory", {
+ userId,
+ role: "Distributor",
+ })
+ }
+ >
+ <Text style={styles.secondaryButtonText}>View Inventory</Text>
+ </TouchableOpacity>
+ </View>
</SafeAreaView>
);
}
diff --git a/frontend/screens/Farmer.js b/frontend/screens/Farmer.js
@@ -44,6 +44,11 @@ export default function FarmerScreen({ navigation, route }) {
// splitProduce
const [splitQty, setSplitQty] = useState("");
+ // transferOwnership
+ const [newOwnerId, setNewOwnerId] = useState("");
+ const [salePrice, setSalePrice] = useState("");
+ const [transferQty, setTransferQty] = useState("");
+
// QR Modal
const [qrVisible, setQrVisible] = useState(false);
const [lastProduceId, setLastProduceId] = useState(null);
@@ -132,13 +137,13 @@ export default function FarmerScreen({ navigation, route }) {
produceId,
actorId: userId,
details: {
- pricePerUnit: parseFloat(pricePerUnit),
+ pricePerUnit: pricePerUnit ? parseFloat(pricePerUnit) : undefined,
storageConditions: storageConditions
? storageConditions.split(",").map((c) => c.trim())
- : [],
+ : undefined,
certification: certification
? certification.split(",").map((c) => c.trim())
- : [],
+ : undefined,
},
}),
});
@@ -214,6 +219,40 @@ export default function FarmerScreen({ navigation, route }) {
}
};
+ const transferOwnership = async () => {
+ if (!produceId || !newOwnerId || !transferQty) {
+ Alert.alert(
+ "Error",
+ "Please enter Produce ID, New Owner ID and Quantity"
+ );
+ return;
+ }
+ try {
+ const res = await fetch(`${API_BASE}/transferOwnership`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ produceId,
+ newOwnerId,
+ qty: parseFloat(transferQty),
+ salePrice: salePrice ? parseFloat(salePrice) : 0,
+ }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || "Error");
+ Alert.alert("Transferred", "Ownership transferred successfully");
+ resetCommon();
+ setNewOwnerId("");
+ setTransferQty("");
+ setSalePrice("");
+ } catch (err) {
+ Alert.alert("Error", err.message);
+ }
+ };
+
return (
<SafeAreaView style={styles.container}>
<ScreenHeader
@@ -243,6 +282,11 @@ export default function FarmerScreen({ navigation, route }) {
text="Update Location"
onPress={() => setActive("location")}
/>
+ <ActionButton
+ icon="cash"
+ text="Transfer Ownership"
+ onPress={() => setActive("transfer")}
+ />
</View>
{active === "register" && (
@@ -376,6 +420,38 @@ export default function FarmerScreen({ navigation, route }) {
</TouchableOpacity>
</View>
)}
+
+ {active === "transfer" && (
+ <View>
+ <Scanner value={produceId} onChange={setProduceId} />
+ <TextInput
+ style={styles.input}
+ placeholder="New Owner ID"
+ value={newOwnerId}
+ onChangeText={setNewOwnerId}
+ />
+ <TextInput
+ style={styles.input}
+ placeholder="Quantity"
+ keyboardType="numeric"
+ value={transferQty}
+ onChangeText={setTransferQty}
+ />
+ <TextInput
+ style={styles.input}
+ placeholder="Sale Price"
+ keyboardType="numeric"
+ value={salePrice}
+ onChangeText={setSalePrice}
+ />
+ <TouchableOpacity
+ style={styles.primaryButton}
+ onPress={transferOwnership}
+ >
+ <Text style={styles.buttonText}>Transfer Ownership</Text>
+ </TouchableOpacity>
+ </View>
+ )}
</ScrollView>
<QRModal
@@ -383,6 +459,17 @@ export default function FarmerScreen({ navigation, route }) {
onClose={() => setQrVisible(false)}
value={lastProduceId || ""}
/>
+
+ <View style={{ padding: 12 }}>
+ <TouchableOpacity
+ style={styles.secondaryButton}
+ onPress={() =>
+ navigation.navigate("Inventory", { userId: userId, role: "Farmer" })
+ }
+ >
+ <Text style={styles.secondaryButtonText}>View Inventory</Text>
+ </TouchableOpacity>
+ </View>
</SafeAreaView>
);
}
diff --git a/frontend/screens/Home.js b/frontend/screens/Home.js
@@ -32,7 +32,7 @@ export default function HomeScreen({ navigation }) {
<TouchableOpacity
style={[
styles.bigButton,
- { backgroundColor: "#444", marginBottom: 30 },
+ { backgroundColor: colors.gray, marginBottom: 30 },
]}
onPress={() => navigation.navigate("Search")}
>
diff --git a/frontend/screens/Inspector.js b/frontend/screens/Inspector.js
@@ -2,6 +2,7 @@ import { useContext, useState } from "react";
import {
Alert,
ScrollView,
+ Switch,
Text,
TextInput,
TouchableOpacity,
@@ -12,7 +13,7 @@ import ScreenHeader from "../components/ScreenHeader";
import ActionButton from "../components/ActionButton";
import Scanner from "../components/Scanner";
import { API_BASE } from "../config";
-import styles from "../styles";
+import styles, { colors } from "../styles";
import { AuthContext } from "../AuthContext";
export default function InspectorScreen({ navigation, route }) {
@@ -24,31 +25,54 @@ export default function InspectorScreen({ navigation, route }) {
const [produceId, setProduceId] = useState("");
const [quality, setQuality] = useState("");
const [expiryDate, setExpiryDate] = useState("");
+ const [markFailed, setMarkFailed] = useState(false);
+ const [reason, setReason] = useState("");
const resetCommon = () => {
setProduceId("");
+ setQuality("");
+ setExpiryDate("");
+ setMarkFailed(false);
+ setReason("");
};
const inspectProduce = async () => {
+ if (!produceId) {
+ Alert.alert("Error", "Enter a Produce ID");
+ return;
+ }
+
try {
+ const body = {
+ produceId,
+ inspectorId: userId,
+ qualityUpdate: {
+ quality,
+ expiryDate,
+ failed: markFailed,
+ reason: markFailed ? reason || "Failed Inspection" : undefined,
+ },
+ };
+
const res = await fetch(`${API_BASE}/inspectProduce`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
- body: JSON.stringify({
- produceId,
- inspectorId: userId,
- qualityUpdate: { quality, expiryDate },
- }),
+ body: JSON.stringify(body),
});
+
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Error");
- Alert.alert("Inspected", "Inspection recorded successfully");
+
+ Alert.alert(
+ markFailed ? "Marked as Failed" : "Inspection Recorded",
+ markFailed
+ ? "Produce marked as failed."
+ : "Inspection data submitted successfully."
+ );
resetCommon();
- setQuality("");
- setExpiryDate("");
} catch (err) {
Alert.alert("Error", err.message);
}
@@ -75,7 +99,7 @@ export default function InspectorScreen({ navigation, route }) {
<Scanner value={produceId} onChange={setProduceId} />
<TextInput
style={styles.input}
- placeholder="Quality"
+ placeholder="Quality (e.g. A+, Good)"
value={quality}
onChangeText={setQuality}
/>
@@ -85,15 +109,58 @@ export default function InspectorScreen({ navigation, route }) {
value={expiryDate}
onChangeText={setExpiryDate}
/>
+ <View
+ style={{
+ flexDirection: "row",
+ alignItems: "center",
+ marginTop: 10,
+ }}
+ >
+ <Text style={{ flex: 1, fontWeight: "600", color: colors.gray }}>
+ Mark as Failed
+ </Text>
+ <Switch
+ value={markFailed}
+ onValueChange={setMarkFailed}
+ thumbColor={markFailed ? colors.danger : colors.gray}
+ />
+ </View>
+ {markFailed && (
+ <TextInput
+ style={styles.input}
+ placeholder="Failure Reason"
+ value={reason}
+ onChangeText={setReason}
+ />
+ )}
<TouchableOpacity
- style={styles.primaryButton}
+ style={[
+ styles.primaryButton,
+ markFailed && { backgroundColor: colors.danger },
+ ]}
onPress={inspectProduce}
>
- <Text style={styles.buttonText}>Submit Inspection</Text>
+ <Text style={styles.buttonText}>
+ {markFailed ? "Mark as Failed" : "Submit Inspection"}
+ </Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
+
+ <View style={{ padding: 12 }}>
+ <TouchableOpacity
+ style={styles.secondaryButton}
+ onPress={() =>
+ navigation.navigate("Inventory", {
+ userId: userId,
+ role: "Inspector",
+ })
+ }
+ >
+ <Text style={styles.secondaryButtonText}>View Inspected Produce</Text>
+ </TouchableOpacity>
+ </View>
</SafeAreaView>
);
}
diff --git a/frontend/screens/Inventory.js b/frontend/screens/Inventory.js
@@ -0,0 +1,108 @@
+import { useContext, useEffect, useState } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ ScrollView,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import ScreenHeader from "../components/ScreenHeader";
+import ProduceCard from "../components/ProduceCard";
+import { AuthContext } from "../AuthContext";
+import { API_BASE } from "../config";
+import styles, { colors } from "../styles";
+
+export default function InventoryScreen({ navigation, route }) {
+ const { user } = useContext(AuthContext);
+ const routeUserId = route.params?.userId || user?.id;
+ const role = route.params?.role || user?.role;
+ const [loading, setLoading] = useState(false);
+ const [produces, setProduces] = useState([]);
+
+ useEffect(() => {
+ fetchInventory();
+ }, []);
+
+ const fetchUserDetails = async (userKey) => {
+ try {
+ const res = await fetch(`${API_BASE}/getUser/${userKey}`);
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || "Error fetching user");
+ return data.user;
+ } catch (err) {
+ throw err;
+ }
+ };
+
+ const fetchInventory = async () => {
+ try {
+ setLoading(true);
+ setProduces([]);
+
+ if (role.toUpperCase() === "INSPECTOR") {
+ const userKey = `INSPECTOR-${routeUserId}`;
+ const userDetails = await fetchUserDetails(userKey);
+ const inspected = userDetails.inspectedProduce || [];
+ const items = [];
+ for (const pid of inspected) {
+ try {
+ const resp = await fetch(`${API_BASE}/getProduce/${pid}`);
+ const data = await resp.json();
+ if (resp.ok && data.produce) items.push(data.produce);
+ } catch (e) {
+ // Ignore failure for single produce
+ }
+ }
+ setProduces(items);
+ } else {
+ const res = await fetch(`${API_BASE}/getProduceByOwner/${routeUserId}`);
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || "Error fetching inventory");
+ setProduces(data.produces || []);
+ }
+ } catch (err) {
+ Alert.alert("Error", err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <SafeAreaView style={styles.container}>
+ <ScreenHeader
+ title="My Inventory"
+ navigation={navigation}
+ role={role}
+ hideSearchButton={true}
+ showBack={true}
+ />
+ <ScrollView contentContainerStyle={{ padding: 12 }}>
+ {loading ? (
+ <View style={{ alignItems: "center", padding: 20 }}>
+ <ActivityIndicator size="large" />
+ <Text>Loading...</Text>
+ </View>
+ ) : (
+ <>
+ {produces.length === 0 ? (
+ <View style={{ marginTop: 20, alignItems: "center" }}>
+ <Text style={{ color: colors.gray }}>No produce found</Text>
+ </View>
+ ) : (
+ produces.map((p) => <ProduceCard key={p.id} produce={p} />)
+ )}
+
+ <TouchableOpacity
+ style={styles.secondaryButton}
+ onPress={fetchInventory}
+ >
+ <Text style={styles.secondaryButtonText}>Refresh</Text>
+ </TouchableOpacity>
+ </>
+ )}
+ </ScrollView>
+ </SafeAreaView>
+ );
+}
diff --git a/frontend/screens/Retailer.js b/frontend/screens/Retailer.js
@@ -212,6 +212,20 @@ export default function RetailerScreen({ navigation, route }) {
</View>
)}
</ScrollView>
+
+ <View style={{ padding: 12 }}>
+ <TouchableOpacity
+ style={styles.secondaryButton}
+ onPress={() =>
+ navigation.navigate("Inventory", {
+ userId,
+ role: "Retailer",
+ })
+ }
+ >
+ <Text style={styles.secondaryButtonText}>View Inventory</Text>
+ </TouchableOpacity>
+ </View>
</SafeAreaView>
);
}
diff --git a/frontend/screens/Search.js b/frontend/screens/Search.js
@@ -2,7 +2,6 @@ import { useState } from "react";
import {
ActivityIndicator,
Alert,
- Image,
ScrollView,
StyleSheet,
Text,
@@ -18,24 +17,11 @@ import RNPickerSelect from "react-native-picker-select";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import ScreenHeader from "../components/ScreenHeader";
import Scanner from "../components/Scanner";
+import ProduceCard from "../components/ProduceCard";
+import DetailRow from "../components/DetailRow";
import { API_BASE } from "../config";
import styles, { colors } from "../styles";
-const DetailRow = ({ icon, label, value }) => (
- <View style={local.detailItem}>
- <MaterialCommunityIcons
- name={icon}
- size={20}
- color={colors.darkGreen}
- style={{ marginRight: 12 }}
- />
- <View style={{ flex: 1 }}>
- <Text style={local.detailLabel}>{label}</Text>
- <Text style={local.detailValue}>{value}</Text>
- </View>
- </View>
-);
-
export default function SearchScreen({ navigation }) {
const insets = useSafeAreaInsets();
@@ -50,18 +36,16 @@ export default function SearchScreen({ navigation }) {
const fetchResult = async () => {
const isProduceSearch = tab === "Produce";
const searchInput = isProduceSearch ? produceId : userId;
- if (!searchInput.trim()) {
- return Alert.alert("Invalid Input", "Please enter a valid ID to search.");
- }
+ if (!searchInput.trim())
+ return Alert.alert("Invalid Input", "Please enter a valid ID to search");
try {
setLoading(true);
setResult(null);
let url = "";
- if (isProduceSearch) {
- url = `${API_BASE}/getProduce/${produceId.trim()}`;
- } else {
+ if (isProduceSearch) url = `${API_BASE}/getProduce/${produceId.trim()}`;
+ else {
const userKey = `${userRole}-${userId.trim()}`;
url = `${API_BASE}/getUser/${userKey}`;
}
@@ -69,7 +53,7 @@ export default function SearchScreen({ navigation }) {
const res = await fetch(url);
const data = await res.json();
if (!res.ok)
- throw new Error(data.error || `Server returned status ${res.status}`);
+ throw new Error(data.error || `Server returned ${res.status}`);
setResult(data);
} catch (err) {
Alert.alert("Error", err.message);
@@ -125,97 +109,6 @@ export default function SearchScreen({ navigation }) {
</View>
);
- const renderProduce = (produce) => (
- <View style={local.card}>
- <View style={local.header}>
- <Text style={local.refNoText}>Ref. No.</Text>
- <Text style={local.refNoId}>{produce.id}</Text>
- </View>
- <View style={local.splitRow}>
- <View style={{ flex: 1, marginRight: 8 }}>
- <Text style={local.detailLabel}>Name</Text>
- <Text style={local.title}>{produce.cropType}</Text>
- </View>
- <View style={{ flex: 1, marginLeft: 8 }}>
- <Text style={local.detailLabel}>Type / Quality</Text>
- <Text style={local.title}>{produce.quality || "N/A"}</Text>
- </View>
- </View>
- <View style={local.mainContentRow}>
- {produce.imageUrl ? (
- <Image
- source={{ uri: produce.imageUrl }}
- style={local.produceImage}
- resizeMode="cover"
- />
- ) : (
- <View style={[local.produceImage, local.imagePlaceholder]}>
- <MaterialCommunityIcons
- name="image-off"
- size={40}
- color={colors.midGreen}
- />
- </View>
- )}
- <View style={local.detailsContainer}>
- <DetailRow
- icon="weight-kilogram"
- label="Quantity"
- value={`${produce.qty} ${produce.qtyUnit}`}
- />
- <DetailRow
- icon="cash"
- label="Price"
- value={`₹${produce.pricePerUnit} / ${produce.qtyUnit}`}
- />
- <DetailRow
- icon="account-circle-outline"
- label="Current Owner"
- value={produce.currentOwner}
- />
- </View>
- </View>
- <View style={local.splitRow}>
- <View style={{ flex: 1, marginRight: 8 }}>
- <DetailRow
- icon="calendar-arrow-left"
- label="Date of Harvest"
- value={new Date(produce.harvestDate).toLocaleDateString()}
- />
- </View>
- <View style={{ flex: 1, marginLeft: 8 }}>
- <DetailRow
- icon="calendar-arrow-right"
- label="Date of Expiry"
- value={new Date(produce.expiryDate).toLocaleDateString()}
- />
- </View>
- </View>
- <View style={local.badgesRow}>
- <View style={{ flexDirection: "row", flexWrap: "wrap" }}>
- {produce.certification?.length > 0 ? (
- produce.certification.map((cert, idx) => (
- <View key={idx} style={local.certContainer}>
- <MaterialCommunityIcons
- name="shield-check"
- size={20}
- color={colors.darkGreen}
- />
- <Text style={local.certText}>{cert}</Text>
- </View>
- ))
- ) : (
- <Text style={{ color: "#666" }}>No certifications</Text>
- )}
- </View>
- <View style={local.statusContainer}>
- <Text style={local.statusText}>{produce.status}</Text>
- </View>
- </View>
- {renderTimeline(produce)}
- </View>
- );
-
const renderUser = (user) => (
<View style={local.card}>
<View style={local.userHeader}>
@@ -261,9 +154,6 @@ export default function SearchScreen({ navigation }) {
paddingHorizontal: 4,
}}
keyboardShouldPersistTaps="handled"
- nestedScrollEnabled={true}
- showsVerticalScrollIndicator={true}
- scrollIndicatorInsets={{ bottom: insets.bottom }}
>
<ScreenHeader
title="Global Search"
@@ -376,19 +266,24 @@ export default function SearchScreen({ navigation }) {
/>
)}
- {!loading &&
- result &&
- (tab === "Produce" && result.produce ? (
- renderProduce(result.produce)
- ) : tab === "User" && result.user ? (
- renderUser(result.user)
- ) : (
- <View style={local.card}>
- <Text style={{ textAlign: "center", fontWeight: "500" }}>
- No results found for provided ID
- </Text>
- </View>
- ))}
+ {!loading && result && (
+ <>
+ {tab === "Produce" && result.produce ? (
+ <View style={{ marginTop: 16 }}>
+ <ProduceCard produce={result.produce} />
+ {renderTimeline(result.produce)}
+ </View>
+ ) : tab === "User" && result.user ? (
+ <View style={{ marginTop: 16 }}>{renderUser(result.user)}</View>
+ ) : (
+ <View style={local.card}>
+ <Text style={{ textAlign: "center", fontWeight: "500" }}>
+ No results found for provided ID
+ </Text>
+ </View>
+ )}
+ </>
+ )}
</ScrollView>
</SafeAreaView>
);
diff --git a/frontend/styles.js b/frontend/styles.js
@@ -9,6 +9,7 @@ export const colors = {
lightGreen: "#DCEFCF",
accent: "#BEE7A9",
danger: "#C04A4A",
+ gray: "#444",
};
export default StyleSheet.create({
@@ -141,4 +142,20 @@ export default StyleSheet.create({
fontSize: 18,
fontWeight: "600",
},
+ secondaryButton: {
+ backgroundColor: colors.lightGreen,
+ borderWidth: 1,
+ borderColor: colors.midGreen,
+ paddingVertical: 12,
+ paddingHorizontal: 20,
+ borderRadius: 12,
+ alignItems: "center",
+ justifyContent: "center",
+ marginTop: 10,
+ },
+ secondaryButtonText: {
+ color: colors.midGreen,
+ fontWeight: "600",
+ fontSize: 16,
+ },
});