update
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"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) ───────────────────
|
||||
var prodRows []models.ProductRow
|
||||
gdb.Raw(`
|
||||
@@ -127,6 +176,8 @@ func GetAdminStats(c *gin.Context) {
|
||||
},
|
||||
"by_weekday": byWeekday,
|
||||
"by_day_30": byDay,
|
||||
"by_day_revenue": byDayRevenue,
|
||||
"by_hour": byHour,
|
||||
"top_products": topProducts,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,3 +19,14 @@ type ProductRow struct {
|
||||
OrderCount int `gorm:"column:order_count"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -69,6 +69,17 @@ export interface DayStat {
|
||||
label: string;
|
||||
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 {
|
||||
product_id: number;
|
||||
name: string;
|
||||
@@ -81,6 +92,8 @@ export interface AdminStats {
|
||||
summary: StatsSummary;
|
||||
by_weekday: WeekdayStat[];
|
||||
by_day_30: DayStat[];
|
||||
by_day_revenue: DayRevenueStat[];
|
||||
by_hour: HourStat[];
|
||||
top_products: ProductStat[];
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
import { shadows } from "../../theme/shadows";
|
||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
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 ────────────────────────────────────────────────────────
|
||||
const CHART_ACCENT = "#6366f1";
|
||||
@@ -60,7 +60,7 @@ const hBarStyles = StyleSheet.create({
|
||||
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 }) {
|
||||
const { colors } = useTheme();
|
||||
if (!data.length) return null;
|
||||
@@ -82,7 +82,36 @@ function SparkLine({ data, color }: { data: DayStat[]; color: string }) {
|
||||
/>
|
||||
))}
|
||||
</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 }}>
|
||||
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[0]?.label}</Text>
|
||||
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[data.length - 1]?.label}</Text>
|
||||
@@ -230,8 +259,8 @@ export default function StatsScreen() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ── Évolution 30 jours ── */}
|
||||
<Section title="30 derniers jours" icon="bar-chart-outline">
|
||||
{/* ── Évolution 30 jours (commandes) ── */}
|
||||
<Section title="30 derniers jours — commandes" icon="bar-chart-outline">
|
||||
{stats?.by_day_30?.length ? (
|
||||
<SparkLine data={stats.by_day_30} color={CHART_ACCENT} />
|
||||
) : (
|
||||
@@ -239,6 +268,73 @@ export default function StatsScreen() {
|
||||
)}
|
||||
</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 ── */}
|
||||
<Section title="Jours d'affluence" icon="calendar-outline">
|
||||
{(stats?.by_weekday ?? []).map((w: WeekdayStat) => (
|
||||
|
||||
Reference in New Issue
Block a user