feat: group order items by product with category totals
This commit is contained in:
@@ -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<
|
||||
|
||||
Reference in New Issue
Block a user