feat: group order items by product with category totals

This commit is contained in:
2026-05-14 21:10:54 +02:00
parent 4ad80fa57d
commit 775b9200f5
3 changed files with 213 additions and 75 deletions
+4 -1
View File
@@ -211,6 +211,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
LivreurAssign *string `gorm:"column:livreur_assign"` LivreurAssign *string `gorm:"column:livreur_assign"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"` CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
Category string `gorm:"column:category"` Category string `gorm:"column:category"`
Unit string `gorm:"column:unit"`
ClientOrderNumber int `gorm:"column:client_order_number"` ClientOrderNumber int `gorm:"column:client_order_number"`
} }
@@ -236,7 +237,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
c.referral_used, c.referral_used,
c.livreur_assign, c.livreur_assign,
c.created_at as command_created_at, c.created_at as command_created_at,
p.category, COALESCE(p.category, '') as category,
COALESCE(p.unit, '') as unit,
c.client_order_id as client_order_number c.client_order_id as client_order_number
FROM command_items ci FROM command_items ci
LEFT JOIN commandes c ON ci.command_id = c.id LEFT JOIN commandes c ON ci.command_id = c.id
@@ -283,6 +285,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
"livreur_assign": ptrStr(row.LivreurAssign), "livreur_assign": ptrStr(row.LivreurAssign),
"command_created_at": commandCreatedAt, "command_created_at": commandCreatedAt,
"category": row.Category, "category": row.Category,
"unit": row.Unit,
"client_order_number": row.ClientOrderNumber, "client_order_number": row.ClientOrderNumber,
} }
items = append(items, item) items = append(items, item)
@@ -409,6 +409,81 @@ export default function OrderDetailScreen() {
marginTop: spacing.xs, marginTop: spacing.xs,
}, },
// Grouped items
subItemRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: spacing.xs,
paddingLeft: spacing.m,
borderLeftWidth: 2,
borderLeftColor: colors.border,
marginLeft: spacing.xs,
marginBottom: 2,
},
subItemText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
flex: 1,
},
groupTotalRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: spacing.s,
paddingTop: spacing.s,
borderTopWidth: 1,
borderTopColor: colors.border,
},
groupTotalQty: {
color: colors.textWhite,
fontSize: fontSize.sm,
fontWeight: "700",
},
groupTotalPrice: {
color: colors.accent,
fontSize: fontSize.md,
fontWeight: "700",
},
categoryBadge: {
fontSize: fontSize.xs,
color: colors.accent,
fontWeight: "600",
marginRight: spacing.s,
textTransform: "uppercase",
},
// Category summary
categorySummaryCard: {
marginTop: spacing.s,
},
categoryRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: spacing.s,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
categoryName: {
color: colors.textSecondary,
fontSize: fontSize.sm,
fontWeight: "600",
flex: 1,
},
categoryQty: {
color: colors.textWhite,
fontSize: fontSize.sm,
fontWeight: "700",
marginRight: spacing.l,
},
categoryTotal: {
color: colors.accent,
fontSize: fontSize.sm,
fontWeight: "700",
minWidth: 70,
textAlign: "right",
},
// Items modal // Items modal
itemsModalOverlay: { itemsModalOverlay: {
flex: 1, flex: 1,
@@ -476,6 +551,25 @@ export default function OrderDetailScreen() {
[colors, MAP_HEIGHT], [colors, MAP_HEIGHT],
); );
// Groupement des items par product_id
const productGroups = items.reduce<Record<string, any[]>>((acc, item) => {
const key = String(item.product_id || item.produit);
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {});
const groupedList = Object.values(productGroups);
// Récapitulatif par catégorie
const categoryTotals = items.reduce<Record<string, { qty: number; total: number }>>((acc, item) => {
const cat = item.category || "Autre";
if (!acc[cat]) acc[cat] = { qty: 0, total: 0 };
acc[cat].qty += item.quantite ?? 0;
acc[cat].total += item.prix ?? 0;
return acc;
}, {});
const categoryEntries = Object.entries(categoryTotals);
if (loading) return <LoadingSpinner message="Chargement..." />; if (loading) return <LoadingSpinner message="Chargement..." />;
if (!command) if (!command)
return ( return (
@@ -742,40 +836,66 @@ export default function OrderDetailScreen() {
</> </>
)} )}
{/* Items */} {/* Items groupés par produit */}
<Text style={styles.sectionTitle}>Articles ({items.length})</Text> <Text style={styles.sectionTitle}>Articles ({items.length})</Text>
{items.map((item: any) => ( {groupedList.map((group, gi) => {
<Card key={item.id} style={{ marginBottom: spacing.s }}> const rep = group[0];
<View style={styles.row}> const name = rep.produit ?? rep.product_name;
<Text style={[styles.itemName, { flex: 1 }]}> const unit = rep.unit || "";
{item.produit ?? item.product_name} const totalQty = group.reduce((s: number, it: any) => s + (it.quantite ?? 0), 0);
</Text> const totalPrice = group.reduce((s: number, it: any) => s + (it.prix ?? 0), 0);
<TouchableOpacity const isMultiple = group.length > 1;
style={styles.itemDeleteBtn} return (
onPress={() => <Card key={`${rep.product_id}-${gi}`} style={{ marginBottom: spacing.s }}>
handleDeleteItem( <View style={styles.row}>
item.id, <Text style={[styles.itemName, { flex: 1 }]}>{name}</Text>
item.produit ?? item.product_name, {rep.category ? (
) <Text style={styles.categoryBadge}>{rep.category}</Text>
} ) : null}
> <TouchableOpacity
<Ionicons style={styles.itemDeleteBtn}
name="trash-outline" onPress={() => handleDeleteItem(rep.id, name)}
size={18} >
color={colors.danger} <Ionicons name="trash-outline" size={18} color={colors.danger} />
/> </TouchableOpacity>
</TouchableOpacity> </View>
</View> {isMultiple && group.map((item: any, i: number) => (
<View style={styles.itemDetails}> <View key={item.id} style={styles.subItemRow}>
<Text style={styles.info}> <Text style={styles.subItemText}>
Quantité: {item.quantite} {item.quantite}{unit} {item.prix?.toFixed(2)}
</Text> </Text>
<Text style={styles.info}> <TouchableOpacity
Prix: {item.prix?.toFixed(2)} onPress={() => handleDeleteItem(item.id, name)}
</Text> >
</View> <Ionicons name="remove-circle-outline" size={16} color={colors.danger} />
</Card> </TouchableOpacity>
))} </View>
))}
<View style={styles.groupTotalRow}>
<Text style={styles.groupTotalQty}>
{isMultiple ? `Total: ${totalQty}${unit}` : `${totalQty}${unit}`}
</Text>
<Text style={styles.groupTotalPrice}>{totalPrice.toFixed(2)} </Text>
</View>
</Card>
);
})}
{/* Récapitulatif par catégorie */}
{categoryEntries.length > 0 && (
<>
<Text style={styles.sectionTitle}>Par catégorie</Text>
<Card style={styles.categorySummaryCard}>
{categoryEntries.map(([cat, data]) => (
<View key={cat} style={styles.categoryRow}>
<Text style={styles.categoryName}>{cat}</Text>
<Text style={styles.categoryQty}>{data.qty.toFixed(data.qty % 1 === 0 ? 0 : 2)}</Text>
<Text style={styles.categoryTotal}>{data.total.toFixed(2)} </Text>
</View>
))}
</Card>
</>
)}
{/* Actions */} {/* Actions */}
<Text style={styles.sectionTitle}>Actions</Text> <Text style={styles.sectionTitle}>Actions</Text>
@@ -865,48 +985,61 @@ export default function OrderDetailScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView showsVerticalScrollIndicator={false}> <ScrollView showsVerticalScrollIndicator={false}>
{items.map((item: any) => ( {groupedList.map((group, gi) => {
<View const rep = group[0];
key={item.id} const name = rep.produit ?? rep.product_name;
style={styles.itemsModalCard} const unit = rep.unit || "";
> const totalQty = group.reduce((s: number, it: any) => s + (it.quantite ?? 0), 0);
<View style={styles.row}> const totalPrice = group.reduce((s: number, it: any) => s + (it.prix ?? 0), 0);
<Text const isMultiple = group.length > 1;
style={[ return (
styles.itemName, <View key={`modal-${rep.product_id}-${gi}`} style={styles.itemsModalCard}>
{ flex: 1 }, <View style={styles.row}>
]} <Text style={[styles.itemName, { flex: 1 }]}>{name}</Text>
> {rep.category ? (
{item.produit ?? item.product_name} <Text style={styles.categoryBadge}>{rep.category}</Text>
</Text> ) : null}
<TouchableOpacity <TouchableOpacity
style={styles.itemDeleteBtn} style={styles.itemDeleteBtn}
onPress={() => { onPress={() => {
setShowItemsModal(false); setShowItemsModal(false);
handleDeleteItem( handleDeleteItem(rep.id, name);
item.id, }}
item.produit ?? >
item.product_name, <Ionicons name="trash-outline" size={18} color={colors.danger} />
); </TouchableOpacity>
}} </View>
> {isMultiple && group.map((item: any) => (
<Ionicons <View key={item.id} style={styles.subItemRow}>
name="trash-outline" <Text style={styles.subItemText}>
size={18} {item.quantite}{unit} {item.prix?.toFixed(2)}
color={colors.danger} </Text>
/> </View>
</TouchableOpacity> ))}
</View> <View style={styles.groupTotalRow}>
<View style={styles.itemDetails}> <Text style={styles.groupTotalQty}>
<Text style={styles.info}> {isMultiple ? `Total: ${totalQty}${unit}` : `${totalQty}${unit}`}
Quantité: {item.quantite} </Text>
</Text> <Text style={styles.groupTotalPrice}>{totalPrice.toFixed(2)} </Text>
<Text style={styles.info}> </View>
Prix: {item.prix?.toFixed(2)}
</Text>
</View> </View>
);
})}
{/* Récapitulatif catégories dans le modal */}
{categoryEntries.length > 0 && (
<View style={{ marginTop: spacing.m, borderTopWidth: 1, borderTopColor: colors.border, paddingTop: spacing.m }}>
<Text style={[styles.itemsModalTitle, { fontSize: fontSize.md, marginBottom: spacing.s }]}>
Par catégorie
</Text>
{categoryEntries.map(([cat, data]) => (
<View key={cat} style={styles.categoryRow}>
<Text style={styles.categoryName}>{cat}</Text>
<Text style={styles.categoryQty}>{data.qty.toFixed(data.qty % 1 === 0 ? 0 : 2)}</Text>
<Text style={styles.categoryTotal}>{data.total.toFixed(2)} </Text>
</View>
))}
</View> </View>
))} )}
{items.length === 0 && ( {items.length === 0 && (
<Text style={styles.empty}>Aucun article</Text> <Text style={styles.empty}>Aucun article</Text>
)} )}
@@ -60,7 +60,9 @@ function OrderDetails() {
const location = useLocation(); const location = useLocation();
// commandId = ID global pour l'API (passé en state depuis l'historique) // commandId = ID global pour l'API (passé en state depuis l'historique)
// fallback sur orderId si navigation directe via URL // fallback sur orderId si navigation directe via URL
const commandId = (location.state as { commandId?: number } | null)?.commandId ?? parseInt(orderId ?? "0"); const commandId =
(location.state as { commandId?: number } | null)?.commandId ??
parseInt(orderId ?? "0");
const [order, setOrder] = useState<OrderDetailsData | null>(null); const [order, setOrder] = useState<OrderDetailsData | null>(null);
const [enrichedProducts, setEnrichedProducts] = useState< const [enrichedProducts, setEnrichedProducts] = useState<