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"`
|
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];
|
||||||
|
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}>
|
<View style={styles.row}>
|
||||||
<Text style={[styles.itemName, { flex: 1 }]}>
|
<Text style={[styles.itemName, { flex: 1 }]}>{name}</Text>
|
||||||
{item.produit ?? item.product_name}
|
{rep.category ? (
|
||||||
</Text>
|
<Text style={styles.categoryBadge}>{rep.category}</Text>
|
||||||
|
) : null}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.itemDeleteBtn}
|
style={styles.itemDeleteBtn}
|
||||||
onPress={() =>
|
onPress={() => handleDeleteItem(rep.id, name)}
|
||||||
handleDeleteItem(
|
|
||||||
item.id,
|
|
||||||
item.produit ?? item.product_name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name="trash-outline" size={18} color={colors.danger} />
|
||||||
name="trash-outline"
|
|
||||||
size={18}
|
|
||||||
color={colors.danger}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.itemDetails}>
|
{isMultiple && group.map((item: any, i: number) => (
|
||||||
<Text style={styles.info}>
|
<View key={item.id} style={styles.subItemRow}>
|
||||||
Quantité: {item.quantite}
|
<Text style={styles.subItemText}>
|
||||||
|
{item.quantite}{unit} — {item.prix?.toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.info}>
|
<TouchableOpacity
|
||||||
Prix: {item.prix?.toFixed(2)} €
|
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>
|
||||||
|
<Text style={styles.groupTotalPrice}>{totalPrice.toFixed(2)} €</Text>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</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);
|
||||||
|
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}>
|
<View style={styles.row}>
|
||||||
<Text
|
<Text style={[styles.itemName, { flex: 1 }]}>{name}</Text>
|
||||||
style={[
|
{rep.category ? (
|
||||||
styles.itemName,
|
<Text style={styles.categoryBadge}>{rep.category}</Text>
|
||||||
{ flex: 1 },
|
) : null}
|
||||||
]}
|
|
||||||
>
|
|
||||||
{item.produit ?? item.product_name}
|
|
||||||
</Text>
|
|
||||||
<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
|
<Ionicons name="trash-outline" size={18} color={colors.danger} />
|
||||||
name="trash-outline"
|
|
||||||
size={18}
|
|
||||||
color={colors.danger}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.itemDetails}>
|
{isMultiple && group.map((item: any) => (
|
||||||
<Text style={styles.info}>
|
<View key={item.id} style={styles.subItemRow}>
|
||||||
Quantité: {item.quantite}
|
<Text style={styles.subItemText}>
|
||||||
|
{item.quantite}{unit} — {item.prix?.toFixed(2)} €
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.info}>
|
|
||||||
Prix: {item.prix?.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</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 && (
|
{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<
|
||||||
|
|||||||
Reference in New Issue
Block a user