update
This commit is contained in:
@@ -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(`
|
||||||
@@ -127,6 +176,8 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"by_weekday": byWeekday,
|
"by_weekday": byWeekday,
|
||||||
"by_day_30": byDay,
|
"by_day_30": byDay,
|
||||||
|
"by_day_revenue": byDayRevenue,
|
||||||
|
"by_hour": byHour,
|
||||||
"top_products": topProducts,
|
"top_products": topProducts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user