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"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
Category string `gorm:"column:category"`
Unit string `gorm:"column:unit"`
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.livreur_assign,
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
FROM command_items ci
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),
"command_created_at": commandCreatedAt,
"category": row.Category,
"unit": row.Unit,
"client_order_number": row.ClientOrderNumber,
}
items = append(items, item)
@@ -409,6 +409,81 @@ export default function OrderDetailScreen() {
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
itemsModalOverlay: {
flex: 1,
@@ -476,6 +551,25 @@ export default function OrderDetailScreen() {
[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 (!command)
return (
@@ -742,40 +836,66 @@ export default function OrderDetailScreen() {
</>
)}
{/* Items */}
{/* Items groupés par produit */}
<Text style={styles.sectionTitle}>Articles ({items.length})</Text>
{items.map((item: any) => (
<Card key={item.id} style={{ marginBottom: spacing.s }}>
<View style={styles.row}>
<Text style={[styles.itemName, { flex: 1 }]}>
{item.produit ?? item.product_name}
</Text>
<TouchableOpacity
style={styles.itemDeleteBtn}
onPress={() =>
handleDeleteItem(
item.id,
item.produit ?? item.product_name,
)
}
>
<Ionicons
name="trash-outline"
size={18}
color={colors.danger}
/>
</TouchableOpacity>
</View>
<View style={styles.itemDetails}>
<Text style={styles.info}>
Quantité: {item.quantite}
</Text>
<Text style={styles.info}>
Prix: {item.prix?.toFixed(2)}
</Text>
</View>
</Card>
))}
{groupedList.map((group, gi) => {
const rep = group[0];
const name = rep.produit ?? rep.product_name;
const unit = rep.unit || "";
const totalQty = group.reduce((s: number, it: any) => s + (it.quantite ?? 0), 0);
const totalPrice = group.reduce((s: number, it: any) => s + (it.prix ?? 0), 0);
const isMultiple = group.length > 1;
return (
<Card key={`${rep.product_id}-${gi}`} style={{ marginBottom: spacing.s }}>
<View style={styles.row}>
<Text style={[styles.itemName, { flex: 1 }]}>{name}</Text>
{rep.category ? (
<Text style={styles.categoryBadge}>{rep.category}</Text>
) : null}
<TouchableOpacity
style={styles.itemDeleteBtn}
onPress={() => handleDeleteItem(rep.id, name)}
>
<Ionicons name="trash-outline" size={18} color={colors.danger} />
</TouchableOpacity>
</View>
{isMultiple && group.map((item: any, i: number) => (
<View key={item.id} style={styles.subItemRow}>
<Text style={styles.subItemText}>
{item.quantite}{unit} {item.prix?.toFixed(2)}
</Text>
<TouchableOpacity
onPress={() => handleDeleteItem(item.id, name)}
>
<Ionicons name="remove-circle-outline" size={16} color={colors.danger} />
</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 */}
<Text style={styles.sectionTitle}>Actions</Text>
@@ -865,48 +985,61 @@ export default function OrderDetailScreen() {
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{items.map((item: any) => (
<View
key={item.id}
style={styles.itemsModalCard}
>
<View style={styles.row}>
<Text
style={[
styles.itemName,
{ flex: 1 },
]}
>
{item.produit ?? item.product_name}
</Text>
<TouchableOpacity
style={styles.itemDeleteBtn}
onPress={() => {
setShowItemsModal(false);
handleDeleteItem(
item.id,
item.produit ??
item.product_name,
);
}}
>
<Ionicons
name="trash-outline"
size={18}
color={colors.danger}
/>
</TouchableOpacity>
</View>
<View style={styles.itemDetails}>
<Text style={styles.info}>
Quantité: {item.quantite}
</Text>
<Text style={styles.info}>
Prix: {item.prix?.toFixed(2)}
</Text>
{groupedList.map((group, gi) => {
const rep = group[0];
const name = rep.produit ?? rep.product_name;
const unit = rep.unit || "";
const totalQty = group.reduce((s: number, it: any) => s + (it.quantite ?? 0), 0);
const totalPrice = group.reduce((s: number, it: any) => s + (it.prix ?? 0), 0);
const isMultiple = group.length > 1;
return (
<View key={`modal-${rep.product_id}-${gi}`} style={styles.itemsModalCard}>
<View style={styles.row}>
<Text style={[styles.itemName, { flex: 1 }]}>{name}</Text>
{rep.category ? (
<Text style={styles.categoryBadge}>{rep.category}</Text>
) : null}
<TouchableOpacity
style={styles.itemDeleteBtn}
onPress={() => {
setShowItemsModal(false);
handleDeleteItem(rep.id, name);
}}
>
<Ionicons name="trash-outline" size={18} color={colors.danger} />
</TouchableOpacity>
</View>
{isMultiple && group.map((item: any) => (
<View key={item.id} style={styles.subItemRow}>
<Text style={styles.subItemText}>
{item.quantite}{unit} {item.prix?.toFixed(2)}
</Text>
</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>
</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>
))}
)}
{items.length === 0 && (
<Text style={styles.empty}>Aucun article</Text>
)}
@@ -60,7 +60,9 @@ function OrderDetails() {
const location = useLocation();
// commandId = ID global pour l'API (passé en state depuis l'historique)
// 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 [enrichedProducts, setEnrichedProducts] = useState<