This commit is contained in:
2026-05-15 21:44:46 +02:00
parent cefc7e9f32
commit afbe310dd7
4 changed files with 179 additions and 8 deletions
+54 -3
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"fmt"
"gestion/db" "gestion/db"
"gestion/models" "gestion/models"
"net/http" "net/http"
@@ -60,6 +61,54 @@ func GetAdminStats(c *gin.Context) {
} }
} }
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────
var dayRevRows []models.DayRevenueRow
gdb.Raw(`
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix), 0) AS revenue
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND status = 'approved'
GROUP BY DATE(created_at)
ORDER BY day
`).Scan(&dayRevRows)
byDayRevenue := make([]gin.H, len(dayRevRows))
for i, r := range dayRevRows {
byDayRevenue[i] = gin.H{
"day": r.Day.Format("2006-01-02"),
"label": r.Day.Format("02/01"),
"revenue": r.Revenue,
}
}
// ── Commandes & revenus par heure (all time, non annulées) ───────────────
var hourRows []models.HourRow
gdb.Raw(`
SELECT
EXTRACT(HOUR FROM created_at)::int AS hour,
COUNT(*) AS count,
COALESCE(SUM(total_prix), 0) AS revenue
FROM commandes
WHERE status != 'cancelled'
GROUP BY hour
ORDER BY hour
`).Scan(&hourRows)
hourMap := make(map[int]models.HourRow, len(hourRows))
for _, r := range hourRows {
hourMap[r.Hour] = r
}
byHour := make([]gin.H, 24)
for h := 0; h < 24; h++ {
r := hourMap[h]
byHour[h] = gin.H{
"hour": h,
"label": fmt.Sprintf("%02dh", h),
"count": r.Count,
"revenue": r.Revenue,
}
}
// ── Top produits (quantité vendue, commandes terminées) ─────────────────── // ── Top produits (quantité vendue, commandes terminées) ───────────────────
var prodRows []models.ProductRow var prodRows []models.ProductRow
gdb.Raw(` gdb.Raw(`
@@ -125,8 +174,10 @@ func GetAdminStats(c *gin.Context) {
"top_product": topProductName, "top_product": topProductName,
"avg_per_day": avgPerDay, "avg_per_day": avgPerDay,
}, },
"by_weekday": byWeekday, "by_weekday": byWeekday,
"by_day_30": byDay, "by_day_30": byDay,
"top_products": topProducts, "by_day_revenue": byDayRevenue,
"by_hour": byHour,
"top_products": topProducts,
}) })
} }
+11
View File
@@ -19,3 +19,14 @@ type ProductRow struct {
OrderCount int `gorm:"column:order_count"` OrderCount int `gorm:"column:order_count"`
Revenue float64 `gorm:"column:revenue"` Revenue float64 `gorm:"column:revenue"`
} }
type HourRow struct {
Hour int `gorm:"column:hour"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type DayRevenueRow struct {
Day time.Time `gorm:"column:day"`
Revenue float64 `gorm:"column:revenue"`
}
+13
View File
@@ -69,6 +69,17 @@ export interface DayStat {
label: string; label: string;
count: number; count: number;
} }
export interface DayRevenueStat {
day: string;
label: string;
revenue: number;
}
export interface HourStat {
hour: number;
label: string;
count: number;
revenue: number;
}
export interface ProductStat { export interface ProductStat {
product_id: number; product_id: number;
name: string; name: string;
@@ -81,6 +92,8 @@ export interface AdminStats {
summary: StatsSummary; summary: StatsSummary;
by_weekday: WeekdayStat[]; by_weekday: WeekdayStat[];
by_day_30: DayStat[]; by_day_30: DayStat[];
by_day_revenue: DayRevenueStat[];
by_hour: HourStat[];
top_products: ProductStat[]; top_products: ProductStat[];
} }
@@ -14,7 +14,7 @@ import { spacing, fontSize, borderRadius } from "../../theme";
import { shadows } from "../../theme/shadows"; import { shadows } from "../../theme/shadows";
import LoadingSpinner from "../../components/ui/LoadingSpinner"; import LoadingSpinner from "../../components/ui/LoadingSpinner";
import { getAdminStats } from "../../api/api_admin"; import { getAdminStats } from "../../api/api_admin";
import type { AdminStats, WeekdayStat, DayStat, ProductStat } from "../../api/api_admin"; import type { AdminStats, WeekdayStat, DayStat, DayRevenueStat, HourStat, ProductStat } from "../../api/api_admin";
// ── Palette graphiques ──────────────────────────────────────────────────────── // ── Palette graphiques ────────────────────────────────────────────────────────
const CHART_ACCENT = "#6366f1"; const CHART_ACCENT = "#6366f1";
@@ -60,7 +60,7 @@ const hBarStyles = StyleSheet.create({
value: { width: 46, fontSize: fontSize.xs, textAlign: "right" }, value: { width: 46, fontSize: fontSize.xs, textAlign: "right" },
}); });
// ── Barres verticales (sparkline 30 jours) ──────────────────────────────────── // ── Barres verticales (sparkline 30 jours — commandes) ───────────────────────
function SparkLine({ data, color }: { data: DayStat[]; color: string }) { function SparkLine({ data, color }: { data: DayStat[]; color: string }) {
const { colors } = useTheme(); const { colors } = useTheme();
if (!data.length) return null; if (!data.length) return null;
@@ -82,7 +82,36 @@ function SparkLine({ data, color }: { data: DayStat[]; color: string }) {
/> />
))} ))}
</View> </View>
{/* Labels début / fin */} <View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 4 }}>
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[0]?.label}</Text>
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[data.length - 1]?.label}</Text>
</View>
</View>
);
}
// ── Sparkline revenus 30 jours ────────────────────────────────────────────────
function SparkLineRevenue({ data, color }: { data: DayRevenueStat[]; color: string }) {
const { colors } = useTheme();
if (!data.length) return null;
const max = Math.max(...data.map((d) => d.revenue), 1);
const BAR_H = 56;
return (
<View>
<View style={{ flexDirection: "row", alignItems: "flex-end", height: BAR_H, gap: 2 }}>
{data.map((d, i) => (
<View
key={i}
style={{
flex: 1,
height: max > 0 ? Math.max((d.revenue / max) * BAR_H, d.revenue > 0 ? 3 : 0) : 0,
backgroundColor: color,
borderRadius: 2,
opacity: 0.85,
}}
/>
))}
</View>
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 4 }}> <View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 4 }}>
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[0]?.label}</Text> <Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[0]?.label}</Text>
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[data.length - 1]?.label}</Text> <Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[data.length - 1]?.label}</Text>
@@ -230,8 +259,8 @@ export default function StatsScreen() {
/> />
</View> </View>
{/* ── Évolution 30 jours ── */} {/* ── Évolution 30 jours (commandes) ── */}
<Section title="30 derniers jours" icon="bar-chart-outline"> <Section title="30 derniers jours — commandes" icon="bar-chart-outline">
{stats?.by_day_30?.length ? ( {stats?.by_day_30?.length ? (
<SparkLine data={stats.by_day_30} color={CHART_ACCENT} /> <SparkLine data={stats.by_day_30} color={CHART_ACCENT} />
) : ( ) : (
@@ -239,6 +268,73 @@ export default function StatsScreen() {
)} )}
</Section> </Section>
{/* ── Revenus par jour (30 jours) ── */}
<Section title="Revenus par jour (30j)" icon="trending-up-outline">
{stats?.by_day_revenue?.length ? (
<>
<SparkLineRevenue data={stats.by_day_revenue} color={CHART_GREEN} />
{(() => {
const best = [...(stats.by_day_revenue ?? [])].sort((a, b) => b.revenue - a.revenue)[0];
if (!best) return null;
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.s }}>
<Ionicons name="star-outline" size={13} color={CHART_GREEN} />
<Text style={{ color: colors.textMuted, fontSize: fontSize.xs }}>
Meilleure journée :{" "}
<Text style={{ color: CHART_GREEN, fontWeight: "600" }}>
{best.label} · {fmtEuro(best.revenue)}
</Text>
</Text>
</View>
);
})()}
</>
) : (
<Text style={styles.emptyText}>Aucune donnée de revenu</Text>
)}
</Section>
{/* ── Heures d'affluence ── */}
{(() => {
const hourData: HourStat[] = stats?.by_hour ?? [];
const peakHour = hourData.reduce<HourStat | null>(
(best, h) => (!best || h.count > best.count ? h : best),
null,
);
const hourMax = maxOf(hourData.map((h) => h.count));
return (
<Section title="Heures d'affluence" icon="time-outline">
{hourData.some((h) => h.count > 0) ? (
<>
{hourData.map((h) => (
<HBar
key={h.hour}
label={h.label}
value={h.count}
max={hourMax}
color={h.count === hourMax && hourMax > 0 ? CHART_AMBER : CHART_BLUE}
right={h.count > 0 ? String(h.count) : "—"}
/>
))}
{peakHour && peakHour.count > 0 && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.s }}>
<Ionicons name="flame-outline" size={13} color={CHART_AMBER} />
<Text style={{ color: colors.textMuted, fontSize: fontSize.xs }}>
Heure de pointe :{" "}
<Text style={{ color: CHART_AMBER, fontWeight: "600" }}>
{peakHour.label} · {peakHour.count} cmd · {fmtEuro(peakHour.revenue)}
</Text>
</Text>
</View>
)}
</>
) : (
<Text style={styles.emptyText}>Aucune donnée horaire</Text>
)}
</Section>
);
})()}
{/* ── Commandes par jour de la semaine ── */} {/* ── Commandes par jour de la semaine ── */}
<Section title="Jours d'affluence" icon="calendar-outline"> <Section title="Jours d'affluence" icon="calendar-outline">
{(stats?.by_weekday ?? []).map((w: WeekdayStat) => ( {(stats?.by_weekday ?? []).map((w: WeekdayStat) => (