chore: update
This commit is contained in:
@@ -372,6 +372,28 @@ export const ISSUE_LABELS: Record<IssueType, string> = {
|
||||
other: "Autre",
|
||||
};
|
||||
|
||||
export type StatPoint = { label: string; count: number; revenue: number };
|
||||
|
||||
export const getMyStats = async (): Promise<{
|
||||
success: boolean;
|
||||
by_day?: StatPoint[];
|
||||
by_week?: StatPoint[];
|
||||
by_month?: StatPoint[];
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
const { data } = await apiClient.get(`${API}/stats`);
|
||||
return {
|
||||
success: true,
|
||||
by_day: data.by_day || [],
|
||||
by_week: data.by_week || [],
|
||||
by_month: data.by_month || [],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.response?.data?.error || "Erreur réseau" };
|
||||
}
|
||||
};
|
||||
|
||||
export const reportDeliveryIssue = async (
|
||||
deliveryId: number,
|
||||
issueType: IssueType,
|
||||
|
||||
@@ -672,7 +672,7 @@ export default function OrdersScreen() {
|
||||
</Text>
|
||||
<Text style={styles.itemMeta}>
|
||||
Qté:{" "}
|
||||
{item.quantite ?? item.quantity} ·{" "}
|
||||
{item.quantite ?? item.quantity}{item.unit || ""} ·{" "}
|
||||
{(item.prix ?? item.price)?.toFixed(
|
||||
2,
|
||||
)}{" "}
|
||||
|
||||
@@ -75,7 +75,7 @@ interface EnrichedDelivery extends DeliveryItem {
|
||||
clientUsername?: string;
|
||||
clientNom?: string;
|
||||
clientPrenom?: string;
|
||||
items?: Array<{ produit: string; quantite: number; prix: number }>;
|
||||
items?: Array<{ produit: string; quantite: number; prix: number; unit?: string }>;
|
||||
}
|
||||
|
||||
export default function DashboardScreen() {
|
||||
@@ -706,7 +706,7 @@ export default function DashboardScreen() {
|
||||
{prod.produit}
|
||||
</Text>
|
||||
<Text style={styles.itemQty}>
|
||||
Quantité: {prod.quantite}
|
||||
Quantité: {prod.quantite}{prod.unit || ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.itemPrice}>
|
||||
@@ -1974,7 +1974,7 @@ export default function DashboardScreen() {
|
||||
{prod.produit}
|
||||
</Text>
|
||||
<Text style={styles.detailProductQty}>
|
||||
Quantité : {prod.quantite}
|
||||
Quantité : {prod.quantite}{prod.unit || ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.detailProductPrice}>
|
||||
|
||||
@@ -5,34 +5,128 @@ import {
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getMyDeliveries, getMyStatus } from "../../api/api_delivery";
|
||||
import type { DeliveryItem } from "../../api/types";
|
||||
import { getMyDeliveries, getMyStats } from "../../api/api_delivery";
|
||||
import type { DeliveryItem, } from "../../api/types";
|
||||
import type { StatPoint } from "../../api/api_delivery";
|
||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
import Card from "../../components/ui/Card";
|
||||
|
||||
type Period = "day" | "week" | "month";
|
||||
type Metric = "count" | "revenue";
|
||||
|
||||
const BAR_MAX_HEIGHT = 110;
|
||||
const BAR_WIDTH = 36;
|
||||
const BAR_GAP = 8;
|
||||
|
||||
function BarChart({
|
||||
data,
|
||||
metric,
|
||||
colors,
|
||||
}: {
|
||||
data: StatPoint[];
|
||||
metric: Metric;
|
||||
colors: any;
|
||||
}) {
|
||||
const values = data.map((d) => (metric === "count" ? d.count : d.revenue));
|
||||
const maxVal = Math.max(...values, 1);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<View style={{ alignItems: "center", paddingVertical: spacing.xl }}>
|
||||
<Ionicons name="bar-chart-outline" size={36} color={colors.textMuted} />
|
||||
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, marginTop: spacing.s }}>
|
||||
Aucune donnée sur cette période
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginTop: spacing.m }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "flex-end", paddingBottom: spacing.s, paddingHorizontal: 4 }}>
|
||||
{data.map((point, i) => {
|
||||
const val = metric === "count" ? point.count : point.revenue;
|
||||
const barH = Math.max(4, (val / maxVal) * BAR_MAX_HEIGHT);
|
||||
const isLast = i === data.length - 1;
|
||||
return (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
alignItems: "center",
|
||||
marginRight: isLast ? 0 : BAR_GAP,
|
||||
width: BAR_WIDTH,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.textMuted, fontSize: 9, marginBottom: 3 }}>
|
||||
{metric === "count"
|
||||
? val > 0 ? String(val) : ""
|
||||
: val > 0 ? (val >= 1000 ? `${(val / 1000).toFixed(1)}k` : `${Math.round(val)}`) : ""}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
width: BAR_WIDTH - 6,
|
||||
height: barH,
|
||||
backgroundColor: val > 0 ? colors.accent : colors.border,
|
||||
borderRadius: 5,
|
||||
opacity: val > 0 ? 1 : 0.3,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: colors.textMuted,
|
||||
fontSize: 9,
|
||||
marginTop: 4,
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{point.label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const [deliveries, setDeliveries] = useState<DeliveryItem[]>([]);
|
||||
const [byDay, setByDay] = useState<StatPoint[]>([]);
|
||||
const [byWeek, setByWeek] = useState<StatPoint[]>([]);
|
||||
const [byMonth, setByMonth] = useState<StatPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [period, setPeriod] = useState<Period>("week");
|
||||
const [metric, setMetric] = useState<Metric>("count");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const res = await getMyDeliveries();
|
||||
if (res.success && res.deliveries) setDeliveries(res.deliveries);
|
||||
const [delivRes, statsRes] = await Promise.all([
|
||||
getMyDeliveries(),
|
||||
getMyStats(),
|
||||
]);
|
||||
if (delivRes.success && delivRes.deliveries) setDeliveries(delivRes.deliveries);
|
||||
if (statsRes.success) {
|
||||
setByDay(statsRes.by_day ?? []);
|
||||
setByWeek(statsRes.by_week ?? []);
|
||||
setByMonth(statsRes.by_month ?? []);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
@@ -40,39 +134,27 @@ export default function StatsScreen() {
|
||||
};
|
||||
|
||||
const total = deliveries.length;
|
||||
const completed = deliveries.filter((d) => d.status === "livre").length;
|
||||
const completed = deliveries.filter((d) => d.status === "livre" || d.status === "approved").length;
|
||||
const inProgress = deliveries.filter((d) => d.status === "en_route").length;
|
||||
const pending = deliveries.filter((d) => d.status === "assigned").length;
|
||||
const totalRevenue = deliveries
|
||||
.filter((d) => d.status === "livre")
|
||||
.filter((d) => d.status === "livre" || d.status === "approved")
|
||||
.reduce((s, d) => s + d.total_prix, 0);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: "Total livraisons",
|
||||
value: total.toString(),
|
||||
icon: "cube-outline" as const,
|
||||
color: colors.accent,
|
||||
},
|
||||
{
|
||||
label: "Complétées",
|
||||
value: completed.toString(),
|
||||
icon: "checkmark-circle-outline" as const,
|
||||
color: colors.success,
|
||||
},
|
||||
{
|
||||
label: "En cours",
|
||||
value: inProgress.toString(),
|
||||
icon: "time-outline" as const,
|
||||
color: colors.warning,
|
||||
},
|
||||
{
|
||||
label: "En attente",
|
||||
value: pending.toString(),
|
||||
icon: "hourglass-outline" as const,
|
||||
color: colors.info,
|
||||
},
|
||||
];
|
||||
const chartData = period === "day" ? byDay : period === "week" ? byWeek : byMonth;
|
||||
|
||||
const periodTotal = useMemo(() => {
|
||||
return chartData.reduce(
|
||||
(acc, p) => ({ count: acc.count + p.count, revenue: acc.revenue + p.revenue }),
|
||||
{ count: 0, revenue: 0 },
|
||||
);
|
||||
}, [chartData]);
|
||||
|
||||
const periodLabels: Record<Period, string> = {
|
||||
day: "30 derniers jours",
|
||||
week: "12 dernières semaines",
|
||||
month: "12 derniers mois",
|
||||
};
|
||||
|
||||
const styles = useMemo(
|
||||
() =>
|
||||
@@ -84,57 +166,195 @@ export default function StatsScreen() {
|
||||
fontWeight: "700",
|
||||
marginBottom: spacing.l,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.m,
|
||||
},
|
||||
statCard: {
|
||||
width: "47%",
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.l,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: fontSize.xxl,
|
||||
fontWeight: "700",
|
||||
marginTop: spacing.s,
|
||||
},
|
||||
grid: { flexDirection: "row", flexWrap: "wrap", gap: spacing.m },
|
||||
statCard: { width: "47%", alignItems: "center", paddingVertical: spacing.l },
|
||||
statValue: { fontSize: fontSize.xxl, fontWeight: "700", marginTop: spacing.s },
|
||||
statLabel: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.xs,
|
||||
marginTop: spacing.xs,
|
||||
textAlign: "center",
|
||||
},
|
||||
revenueCard: {
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.l,
|
||||
marginTop: spacing.m,
|
||||
},
|
||||
revenueValue: {
|
||||
fontSize: 28,
|
||||
fontWeight: "800",
|
||||
color: colors.success,
|
||||
marginTop: spacing.s,
|
||||
},
|
||||
revenueLabel: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.sm,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.textWhite,
|
||||
fontSize: fontSize.lg,
|
||||
fontWeight: "700",
|
||||
marginBottom: spacing.m,
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
periodRow: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.s,
|
||||
marginBottom: spacing.m,
|
||||
},
|
||||
periodBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.s,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
periodBtnActive: {
|
||||
backgroundColor: colors.accent + "22",
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
periodBtnText: {
|
||||
fontSize: fontSize.sm,
|
||||
fontWeight: "600",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
periodBtnTextActive: { color: colors.accent },
|
||||
metricRow: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.s,
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
metricBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 6,
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
metricBtnActive: {
|
||||
backgroundColor: colors.success + "22",
|
||||
borderColor: colors.success,
|
||||
},
|
||||
metricBtnText: { fontSize: fontSize.xs, fontWeight: "600", color: colors.textMuted },
|
||||
metricBtnTextActive: { color: colors.success },
|
||||
chartCard: { paddingBottom: spacing.s },
|
||||
summaryRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginTop: spacing.s,
|
||||
paddingTop: spacing.s,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
summaryItem: { alignItems: "center", flex: 1 },
|
||||
summaryValue: { color: colors.textWhite, fontSize: fontSize.lg, fontWeight: "700" },
|
||||
summaryLabel: { color: colors.textMuted, fontSize: fontSize.xs, marginTop: 2 },
|
||||
periodHint: { color: colors.textMuted, fontSize: fontSize.xs, marginBottom: spacing.s },
|
||||
}),
|
||||
[colors],
|
||||
);
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Total livraisons", value: total.toString(), icon: "cube-outline" as const, color: colors.accent },
|
||||
{ label: "Complétées", value: completed.toString(), icon: "checkmark-circle-outline" as const, color: colors.success },
|
||||
{ label: "En cours", value: inProgress.toString(), icon: "time-outline" as const, color: colors.warning },
|
||||
{ label: "En attente", value: pending.toString(), icon: "hourglass-outline" as const, color: colors.info },
|
||||
];
|
||||
|
||||
if (loading) return <LoadingSpinner message="Chargement stats..." />;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={{ padding: spacing.l }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colors.success}
|
||||
/>
|
||||
}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.success} />}
|
||||
>
|
||||
<Text style={styles.title}>Mes performances</Text>
|
||||
|
||||
{/* Cartes résumé */}
|
||||
<View style={styles.grid}>
|
||||
{stats.map((s, i) => (
|
||||
{summaryCards.map((s, i) => (
|
||||
<Card key={i} style={styles.statCard}>
|
||||
<Ionicons name={s.icon} size={28} color={s.color} />
|
||||
<Text style={[styles.statValue, { color: s.color }]}>
|
||||
{s.value}
|
||||
</Text>
|
||||
<Text style={[styles.statValue, { color: s.color }]}>{s.value}</Text>
|
||||
<Text style={styles.statLabel}>{s.label}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Revenu total all-time */}
|
||||
<Card style={styles.revenueCard}>
|
||||
<Ionicons name="cash-outline" size={28} color={colors.success} />
|
||||
<Text style={styles.revenueValue}>
|
||||
{totalRevenue.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €
|
||||
</Text>
|
||||
<Text style={styles.revenueLabel}>Revenu total généré</Text>
|
||||
</Card>
|
||||
|
||||
{/* Section graphiques */}
|
||||
<Text style={styles.sectionTitle}>Évolution</Text>
|
||||
|
||||
{/* Sélecteur période */}
|
||||
<View style={styles.periodRow}>
|
||||
{(["day", "week", "month"] as Period[]).map((p) => (
|
||||
<TouchableOpacity
|
||||
key={p}
|
||||
style={[styles.periodBtn, period === p && styles.periodBtnActive]}
|
||||
onPress={() => setPeriod(p)}
|
||||
>
|
||||
<Text style={[styles.periodBtnText, period === p && styles.periodBtnTextActive]}>
|
||||
{p === "day" ? "Jour" : p === "week" ? "Semaine" : "Mois"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Sélecteur métrique */}
|
||||
<View style={styles.metricRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.metricBtn, metric === "count" && styles.metricBtnActive]}
|
||||
onPress={() => setMetric("count")}
|
||||
>
|
||||
<Text style={[styles.metricBtnText, metric === "count" && styles.metricBtnTextActive]}>
|
||||
Commandes
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.metricBtn, metric === "revenue" && styles.metricBtnActive]}
|
||||
onPress={() => setMetric("revenue")}
|
||||
>
|
||||
<Text style={[styles.metricBtnText, metric === "revenue" && styles.metricBtnTextActive]}>
|
||||
Revenus (€)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Card style={styles.chartCard}>
|
||||
<Text style={styles.periodHint}>{periodLabels[period]}</Text>
|
||||
|
||||
<BarChart data={chartData} metric={metric} colors={colors} />
|
||||
|
||||
{/* Totaux de la période */}
|
||||
{chartData.length > 0 && (
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={styles.summaryValue}>{periodTotal.count}</Text>
|
||||
<Text style={styles.summaryLabel}>livraisons</Text>
|
||||
</View>
|
||||
<View style={[styles.summaryItem, { borderLeftWidth: 1, borderLeftColor: colors.border }]}>
|
||||
<Text style={[styles.summaryValue, { color: colors.success }]}>
|
||||
{periodTotal.revenue.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €
|
||||
</Text>
|
||||
<Text style={styles.summaryLabel}>revenus</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -922,6 +922,7 @@ export type RewardCategoryConfig = {
|
||||
category: string;
|
||||
all_products: boolean;
|
||||
product_ids: number[];
|
||||
product_names: string[];
|
||||
amount: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -493,13 +493,19 @@ export default function OrderHistoryScreen() {
|
||||
</View>
|
||||
{pool.eligible_configs.length > 0 && (
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, marginBottom: spacing.xs }}>
|
||||
{pool.eligible_configs.map((cfg) => (
|
||||
<View key={cfg.category} style={styles.rewardAmountBadge}>
|
||||
{pool.eligible_configs.flatMap((cfg) =>
|
||||
cfg.all_products
|
||||
? [<View key={cfg.category} style={styles.rewardAmountBadge}>
|
||||
<Text style={styles.rewardAmountText}>
|
||||
{cfg.category}{cfg.amount > 0 ? ` — ${cfg.amount}€` : ""}
|
||||
</Text>
|
||||
</View>]
|
||||
: (cfg.product_names ?? []).map((name) => (
|
||||
<View key={`${cfg.category}-${name}`} style={styles.rewardAmountBadge}>
|
||||
<Text style={styles.rewardAmountText}>{name}</Text>
|
||||
</View>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.progressBarBg}>
|
||||
|
||||
Reference in New Issue
Block a user