matiru

Blockchain-Based Supply Chain Transparency for Agricultural Produce
commit eeca2211851b288129b638fb5252b42a466a4568
parent 8e01d04de7f2ee3c3f2c9394129fa634d5cfa378
Author: maydayv7 <maydayv7@gmail.com>
Date:   Sun,  5 Oct 2025 17:57:31 +0530

feat: Add inventory

Diffstat:
Mchaincode/index.js | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mfrontend/App.js | 2++
Afrontend/components/DetailRow.js | 22++++++++++++++++++++++
Afrontend/components/ProduceCard.js | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfrontend/screens/Distributor.js | 14++++++++++++++
Mfrontend/screens/Farmer.js | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mfrontend/screens/Home.js | 2+-
Mfrontend/screens/Inspector.js | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Afrontend/screens/Inventory.js | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfrontend/screens/Retailer.js | 14++++++++++++++
Mfrontend/screens/Search.js | 155+++++++++++++------------------------------------------------------------------
Mfrontend/styles.js | 17+++++++++++++++++
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, + }, });