chore: build
This commit is contained in:
@@ -104,6 +104,36 @@ export interface ProductQuantityBreakdown {
|
||||
quantities: QuantityStat[];
|
||||
}
|
||||
|
||||
export interface DailyProductItem {
|
||||
product_id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
order_count: number;
|
||||
revenue: number;
|
||||
}
|
||||
export interface DailyCategoryDetail {
|
||||
category: string;
|
||||
category_color: string;
|
||||
total_quantity: number;
|
||||
total_revenue: number;
|
||||
products: DailyProductItem[];
|
||||
}
|
||||
export interface DailyDetail {
|
||||
date: string;
|
||||
total_orders: number;
|
||||
total_quantity: number;
|
||||
total_revenue: number;
|
||||
categories: DailyCategoryDetail[];
|
||||
}
|
||||
|
||||
export type StatSection =
|
||||
| "commandes"
|
||||
| "revenus"
|
||||
| "produits"
|
||||
| "heures"
|
||||
| "jours"
|
||||
| "doses";
|
||||
|
||||
export interface AdminStats {
|
||||
summary: StatsSummary;
|
||||
by_weekday: WeekdayStat[];
|
||||
@@ -112,6 +142,13 @@ export interface AdminStats {
|
||||
by_hour: HourStat[];
|
||||
top_products: ProductStat[];
|
||||
by_quantity: ProductQuantityBreakdown[];
|
||||
daily_detail?: DailyDetail;
|
||||
reset_at_commandes?: string;
|
||||
reset_at_revenus?: string;
|
||||
reset_at_produits?: string;
|
||||
reset_at_heures?: string;
|
||||
reset_at_jours?: string;
|
||||
reset_at_doses?: string;
|
||||
}
|
||||
|
||||
export const getAdminStats = async (): Promise<AdminStats> => {
|
||||
@@ -119,6 +156,55 @@ export const getAdminStats = async (): Promise<AdminStats> => {
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getAdminDailyDetail = async (
|
||||
date: string,
|
||||
): Promise<DailyDetail> => {
|
||||
const { data } = await apiClient.get(`${V2}/admin/protected/stats/daily`, {
|
||||
params: { date },
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const resetAdminStats = async (
|
||||
section: StatSection,
|
||||
): Promise<{ success: boolean; reset_at: string }> => {
|
||||
const { data } = await apiClient.post(
|
||||
`${V2}/admin/protected/stats/reset/${section}`,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
// ============================================
|
||||
// STATISTIQUES MENSUELLES (jour par jour)
|
||||
// ============================================
|
||||
export interface MonthlyDayStat {
|
||||
day: string; // "2026-06-05"
|
||||
label: string; // "05/06"
|
||||
count: number;
|
||||
revenue: number;
|
||||
quantity: number;
|
||||
}
|
||||
export interface MonthlyStatsSummary {
|
||||
total_orders: number;
|
||||
total_revenue: number;
|
||||
total_quantity: number;
|
||||
}
|
||||
export interface MonthlyStats {
|
||||
month: string; // "2026-06"
|
||||
summary: MonthlyStatsSummary;
|
||||
by_day: MonthlyDayStat[];
|
||||
}
|
||||
|
||||
export const getAdminStatsByMonth = async (
|
||||
month?: string,
|
||||
): Promise<MonthlyStats> => {
|
||||
const { data } = await apiClient.get(
|
||||
`${V2}/admin/protected/stats/monthly`,
|
||||
{
|
||||
params: month ? { month } : undefined,
|
||||
},
|
||||
);
|
||||
return data;
|
||||
};
|
||||
export const getAllClients = async (): Promise<ClientResponse[]> => {
|
||||
const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`);
|
||||
return data.clients || [];
|
||||
@@ -237,9 +323,17 @@ export const validateCommand = async (commandId: number) => {
|
||||
`${V2}/admin/protected/orders/${commandId}/force-validate`,
|
||||
{ command_id: commandId },
|
||||
);
|
||||
const validated = (data.validated ?? []) as { command_id: number; points_awarded: number }[];
|
||||
const points = validated.find((v) => v.command_id === commandId)?.points_awarded ?? 0;
|
||||
return { success: true, points_awarded: points, validated_count: data.validated_count ?? 0 };
|
||||
const validated = (data.validated ?? []) as {
|
||||
command_id: number;
|
||||
points_awarded: number;
|
||||
}[];
|
||||
const points =
|
||||
validated.find((v) => v.command_id === commandId)?.points_awarded ?? 0;
|
||||
return {
|
||||
success: true,
|
||||
points_awarded: points,
|
||||
validated_count: data.validated_count ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const proposeAddressChangeAdmin = async (
|
||||
@@ -264,7 +358,6 @@ export const notifyClientToDescend = async (commandId: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// ============================================
|
||||
|
||||
export const getAvailableDeliveryPersons = async () => {
|
||||
@@ -299,6 +392,74 @@ export const getDeliveryPersonDetails = async (username: string) => {
|
||||
return data.deliveryman || data;
|
||||
};
|
||||
|
||||
export const getLivreurRatings = async (
|
||||
username: string,
|
||||
): Promise<{
|
||||
ratings: {
|
||||
id: number;
|
||||
order_id: number;
|
||||
client_username: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
}[];
|
||||
average: number;
|
||||
count: number;
|
||||
}> => {
|
||||
try {
|
||||
const { data } = await apiClient.get(
|
||||
`${V2}/admin/protected/delivery-persons/${username}/ratings`,
|
||||
);
|
||||
return data;
|
||||
} catch {
|
||||
return { ratings: [], average: 0, count: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
export interface LoginHistoryEntry {
|
||||
id: number;
|
||||
username: string;
|
||||
created_at: string;
|
||||
}
|
||||
export interface LoginHistoryWeek {
|
||||
week: number;
|
||||
entries: LoginHistoryEntry[];
|
||||
}
|
||||
|
||||
export const getLivreurLoginHistory = async (
|
||||
username: string,
|
||||
year?: number,
|
||||
month?: number,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
year: number;
|
||||
month: number;
|
||||
weeks: LoginHistoryWeek[];
|
||||
count: number;
|
||||
}> => {
|
||||
try {
|
||||
const { data } = await apiClient.get(
|
||||
`${V2}/admin/protected/delivery-persons/${username}/login-history`,
|
||||
{ params: { year, month } },
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
year: data.year,
|
||||
month: data.month,
|
||||
weeks: data.weeks || [],
|
||||
count: data.count || 0,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
year: year ?? new Date().getFullYear(),
|
||||
month: month ?? new Date().getMonth() + 1,
|
||||
weeks: [],
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const parseStatus = (status: any): "available" | "busy" | "offline" => {
|
||||
if (!status) return "offline";
|
||||
if (status === "available" || status === "busy" || status === "offline")
|
||||
@@ -446,10 +607,11 @@ export const createProductAdmin = async (formData: FormData) => {
|
||||
`${V2}/admin/protected/products`,
|
||||
formData,
|
||||
{
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 120000,
|
||||
},
|
||||
);
|
||||
return { success: true, data: data.product, message: data.message };
|
||||
return { success: true, data: data.data, message: data.message };
|
||||
};
|
||||
|
||||
export const updateProductAdmin = async (
|
||||
@@ -544,6 +706,18 @@ export const setClientParrain = async (
|
||||
return { success: false, error: e.response?.data?.error || "Erreur" };
|
||||
}
|
||||
};
|
||||
export const getClientParrain = async (
|
||||
username: string,
|
||||
): Promise<{ success: boolean; parrain?: string | null; error?: string }> => {
|
||||
try {
|
||||
const { data } = await apiClient.get(
|
||||
`${V2}/admin/protected/client/${username}/parrain`,
|
||||
);
|
||||
return { success: true, parrain: data.parrain ?? null };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.response?.data?.error || "Erreur" };
|
||||
}
|
||||
};
|
||||
|
||||
export const creditClientReferral = async (
|
||||
username: string,
|
||||
@@ -748,7 +922,7 @@ export const uploadProductMediaAdmin = async (
|
||||
const { data } = await apiClient.post(
|
||||
`${V2}/admin/protected/products/${productId}/media`,
|
||||
fd,
|
||||
{ timeout: 120000 },
|
||||
{ headers: { "Content-Type": "multipart/form-data" }, timeout: 120000 },
|
||||
);
|
||||
return { success: true, media: data.media, message: data.message };
|
||||
};
|
||||
@@ -841,6 +1015,7 @@ export interface Category {
|
||||
name: string;
|
||||
color: string;
|
||||
is_coming_soon: boolean;
|
||||
position: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -906,6 +1081,22 @@ export const deleteCategoryAdmin = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const reorderCategoriesAdmin = async (
|
||||
ids: number[],
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await apiClient.put(`${V2}/admin/protected/categories/reorder`, {
|
||||
ids,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || "Erreur",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PARAMÈTRES GLOBAUX
|
||||
// ============================================
|
||||
@@ -1050,6 +1241,18 @@ export interface AppSettings {
|
||||
delivery_mode: DeliveryModeConfig;
|
||||
shop_name: string;
|
||||
contact_telegram: string;
|
||||
admin_color_primary: string;
|
||||
admin_color_secondary: string;
|
||||
admin_color_success: string;
|
||||
admin_color_danger: string;
|
||||
admin_color_warning: string;
|
||||
client_color_primary: string;
|
||||
client_color_secondary: string;
|
||||
client_color_success: string;
|
||||
client_color_danger: string;
|
||||
client_color_warning: string;
|
||||
client_title_gradient_from: string;
|
||||
client_title_gradient_to: string;
|
||||
}
|
||||
|
||||
export const getSettings = async (): Promise<{
|
||||
|
||||
@@ -379,6 +379,8 @@ export const getMyStats = async (): Promise<{
|
||||
by_day?: StatPoint[];
|
||||
by_week?: StatPoint[];
|
||||
by_month?: StatPoint[];
|
||||
today_count?: number;
|
||||
today_revenue?: number;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
@@ -388,12 +390,39 @@ export const getMyStats = async (): Promise<{
|
||||
by_day: data.by_day || [],
|
||||
by_week: data.by_week || [],
|
||||
by_month: data.by_month || [],
|
||||
today_count: data.today_count || 0,
|
||||
today_revenue: data.today_revenue || 0,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.response?.data?.error || "Erreur réseau" };
|
||||
}
|
||||
};
|
||||
|
||||
export interface LivreurRating {
|
||||
id: number;
|
||||
order_id: number;
|
||||
livreur_username: string;
|
||||
client_username: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const getMyRatings = async (): Promise<{
|
||||
success: boolean;
|
||||
ratings: LivreurRating[];
|
||||
average: number;
|
||||
count: number;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
const { data } = await apiClient.get(`${API}/ratings`);
|
||||
return { success: true, ratings: data.ratings || [], average: data.average || 0, count: data.count || 0 };
|
||||
} catch (e: any) {
|
||||
return { success: false, ratings: [], average: 0, count: 0, error: e?.response?.data?.error || "Erreur" };
|
||||
}
|
||||
};
|
||||
|
||||
export const reportDeliveryIssue = async (
|
||||
deliveryId: number,
|
||||
issueType: IssueType,
|
||||
|
||||
@@ -70,6 +70,7 @@ export const maneuverIcons: Record<string, string> = {
|
||||
DEFAULT: "arrow-up",
|
||||
};
|
||||
|
||||
// ---- Helpers ----
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters < 1000) return `${Math.round(meters)} m`;
|
||||
return `${(meters / 1000).toFixed(1)} km`;
|
||||
|
||||
+124
-114
@@ -1,166 +1,176 @@
|
||||
// Shared types for admin panel
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
user?: AdminUser;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
user?: AdminUser;
|
||||
}
|
||||
|
||||
export interface ClientResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
telephone: string;
|
||||
adresse?: string;
|
||||
role?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
command: number;
|
||||
points_extra: Record<string, number>;
|
||||
amende: number;
|
||||
cancellations_count: number;
|
||||
last_penalty_reason?: string;
|
||||
referral_balance?: number;
|
||||
id: number;
|
||||
username: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
telephone: string;
|
||||
adresse?: string;
|
||||
role?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
command: number;
|
||||
points_extra: Record<string, number>;
|
||||
amende: number;
|
||||
cancellations_count: number;
|
||||
last_penalty_reason?: string;
|
||||
referral_balance?: number;
|
||||
}
|
||||
|
||||
export interface CommandResponse {
|
||||
id: number;
|
||||
client_order_number?: number;
|
||||
username: string;
|
||||
status: string;
|
||||
adresse: string;
|
||||
total_prix: number;
|
||||
livreur_assign?: string | null;
|
||||
proposed_address?: string | null;
|
||||
address_proposal_status?: string;
|
||||
referral_used?: number;
|
||||
cancel_reason?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: number;
|
||||
client_order_number?: number;
|
||||
username: string;
|
||||
status: string;
|
||||
adresse: string;
|
||||
total_prix: number;
|
||||
livreur_assign?: string | null;
|
||||
proposed_address?: string | null;
|
||||
address_proposal_status?: string;
|
||||
referral_used?: number;
|
||||
cancel_reason?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: number;
|
||||
command_id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
id: number;
|
||||
command_id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: number;
|
||||
username: string;
|
||||
status: string;
|
||||
message: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: number;
|
||||
username: string;
|
||||
status: string;
|
||||
message: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DeliveryPerson {
|
||||
id: number;
|
||||
username: string;
|
||||
status: 'available' | 'busy' | 'offline';
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
last_update: string;
|
||||
is_recent: boolean;
|
||||
};
|
||||
stats: {
|
||||
total_deliveries: number;
|
||||
completed_today: number;
|
||||
queue_size: number;
|
||||
current_command: number | null;
|
||||
};
|
||||
id: number;
|
||||
username: string;
|
||||
status: "available" | "busy" | "offline";
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
last_update: string;
|
||||
is_recent: boolean;
|
||||
};
|
||||
stats: {
|
||||
total_deliveries: number;
|
||||
completed_today: number;
|
||||
queue_size: number;
|
||||
current_command: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeliveryPersonsStats {
|
||||
total: number;
|
||||
available: number;
|
||||
busy: number;
|
||||
offline: number;
|
||||
active_deliveries: number;
|
||||
total: number;
|
||||
available: number;
|
||||
busy: number;
|
||||
offline: number;
|
||||
active_deliveries: number;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
stock: number;
|
||||
unit: string;
|
||||
prices?: Array<{ id?: number; quantity: number; price: number; active_price?: boolean }>;
|
||||
media?: Array<{ url: string; type: string; id?: number; created_at?: string }>;
|
||||
coming_soon?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
stock: number;
|
||||
unit: string;
|
||||
prices?: Array<{
|
||||
id?: number;
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
}>;
|
||||
media?: Array<{
|
||||
url: string;
|
||||
type: string;
|
||||
id?: number;
|
||||
created_at?: string;
|
||||
}>;
|
||||
coming_soon?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface DeliveryStatus {
|
||||
status: 'available' | 'busy' | 'offline';
|
||||
current_command?: number;
|
||||
last_update?: number;
|
||||
status: "available" | "busy" | "offline";
|
||||
current_command?: number;
|
||||
last_update?: number;
|
||||
}
|
||||
|
||||
export interface QueueInfo {
|
||||
queue_size: number;
|
||||
commands: any[];
|
||||
queue_size: number;
|
||||
commands: any[];
|
||||
}
|
||||
|
||||
export interface DeliveryItemProduct {
|
||||
produit: string;
|
||||
quantite: number;
|
||||
prix: number;
|
||||
produit: string;
|
||||
quantite: number;
|
||||
prix: number;
|
||||
}
|
||||
|
||||
export interface DeliveryItem {
|
||||
id: number;
|
||||
status: string;
|
||||
adresse: string;
|
||||
total_prix: number;
|
||||
referral_used?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
eta?: string;
|
||||
items?: DeliveryItemProduct[];
|
||||
items_count?: number;
|
||||
id: number;
|
||||
status: string;
|
||||
adresse: string;
|
||||
total_prix: number;
|
||||
referral_used?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
eta?: string;
|
||||
items?: DeliveryItemProduct[];
|
||||
items_count?: number;
|
||||
}
|
||||
|
||||
export interface ClientInfo {
|
||||
username: string;
|
||||
nom?: string;
|
||||
prenom?: string;
|
||||
telephone?: string;
|
||||
username: string;
|
||||
nom?: string;
|
||||
prenom?: string;
|
||||
telephone?: string;
|
||||
}
|
||||
|
||||
export interface DeliveryDetails {
|
||||
delivery: DeliveryItem;
|
||||
client_info: ClientInfo;
|
||||
delivery: DeliveryItem;
|
||||
client_info: ClientInfo;
|
||||
}
|
||||
|
||||
export interface PenaltyInfo {
|
||||
client_username: string;
|
||||
current_amende: number;
|
||||
cancellations_count: number;
|
||||
next_penalty: number;
|
||||
client_username: string;
|
||||
current_amende: number;
|
||||
cancellations_count: number;
|
||||
next_penalty: number;
|
||||
}
|
||||
|
||||
export interface MapLinks {
|
||||
google_maps: string;
|
||||
waze: string;
|
||||
apple_maps: string;
|
||||
openstreetmap: string;
|
||||
google_maps: string;
|
||||
waze: string;
|
||||
apple_maps: string;
|
||||
openstreetmap: string;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const TOKEN_KEY = "token";
|
||||
const ADMIN_TOKEN_KEY = "admin_token";
|
||||
const USERNAME_KEY = "username";
|
||||
const ADMIN_USERNAME_KEY = "admin_username";
|
||||
const ROLE_KEY = "user_role";
|
||||
const TOKEN_KEY = 'token';
|
||||
const ADMIN_TOKEN_KEY = 'admin_token';
|
||||
const USERNAME_KEY = 'username';
|
||||
const ADMIN_USERNAME_KEY = 'admin_username';
|
||||
const ROLE_KEY = 'user_role';
|
||||
|
||||
// Token Client
|
||||
// Client token
|
||||
export const getToken = () => AsyncStorage.getItem(TOKEN_KEY);
|
||||
export const setToken = (token: string) =>
|
||||
AsyncStorage.setItem(TOKEN_KEY, token);
|
||||
export const setToken = (token: string) => AsyncStorage.setItem(TOKEN_KEY, token);
|
||||
export const removeToken = () => AsyncStorage.removeItem(TOKEN_KEY);
|
||||
|
||||
// Admin/Cabine/Livreur token
|
||||
export const getAdminToken = () => AsyncStorage.getItem(ADMIN_TOKEN_KEY);
|
||||
export const setAdminToken = (token: string) =>
|
||||
AsyncStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||
export const setAdminToken = (token: string) => AsyncStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||
export const removeAdminToken = () => AsyncStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
|
||||
// Username
|
||||
export const getUsername = () => AsyncStorage.getItem(USERNAME_KEY);
|
||||
export const setUsername = (username: string) =>
|
||||
AsyncStorage.setItem(USERNAME_KEY, username);
|
||||
export const setUsername = (username: string) => AsyncStorage.setItem(USERNAME_KEY, username);
|
||||
export const removeUsername = () => AsyncStorage.removeItem(USERNAME_KEY);
|
||||
|
||||
// Admin username
|
||||
export const getAdminUsername = () => AsyncStorage.getItem(ADMIN_USERNAME_KEY);
|
||||
export const setAdminUsername = (username: string) =>
|
||||
AsyncStorage.setItem(ADMIN_USERNAME_KEY, username);
|
||||
export const removeAdminUsername = () =>
|
||||
AsyncStorage.removeItem(ADMIN_USERNAME_KEY);
|
||||
export const setAdminUsername = (username: string) => AsyncStorage.setItem(ADMIN_USERNAME_KEY, username);
|
||||
export const removeAdminUsername = () => AsyncStorage.removeItem(ADMIN_USERNAME_KEY);
|
||||
|
||||
// Role
|
||||
export const getRole = () => AsyncStorage.getItem(ROLE_KEY);
|
||||
@@ -38,11 +33,11 @@ export const removeRole = () => AsyncStorage.removeItem(ROLE_KEY);
|
||||
|
||||
// Clear all auth data
|
||||
export const clearAllAuth = async () => {
|
||||
await AsyncStorage.multiRemove([
|
||||
TOKEN_KEY,
|
||||
ADMIN_TOKEN_KEY,
|
||||
USERNAME_KEY,
|
||||
ADMIN_USERNAME_KEY,
|
||||
ROLE_KEY,
|
||||
]);
|
||||
await AsyncStorage.multiRemove([
|
||||
TOKEN_KEY,
|
||||
ADMIN_TOKEN_KEY,
|
||||
USERNAME_KEY,
|
||||
ADMIN_USERNAME_KEY,
|
||||
ROLE_KEY,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { createContext, useContext, useState, useEffect, type ReactNode } from "react";
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { darkColors, lightColors, type Colors } from "../theme/colors";
|
||||
import { getSettings } from "../api/api_admin";
|
||||
|
||||
type ThemeMode = "dark" | "light";
|
||||
|
||||
@@ -9,19 +11,53 @@ interface ThemeContextType {
|
||||
mode: ThemeMode;
|
||||
toggleTheme: () => void;
|
||||
isDark: boolean;
|
||||
refreshColors: () => Promise<void>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "@theme_mode";
|
||||
const COLORS_CACHE_KEY = "@admin_colors_cache";
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setMode] = useState<ThemeMode>("dark");
|
||||
const [colorOverrides, setColorOverrides] = useState<Partial<Colors>>({});
|
||||
|
||||
const fetchAdminColors = useCallback(async () => {
|
||||
try {
|
||||
const result = await getSettings();
|
||||
if (result.success && result.settings) {
|
||||
const s = result.settings;
|
||||
const overrides: Partial<Colors> = {
|
||||
...(s.admin_color_primary && { accent: s.admin_color_primary }),
|
||||
...(s.admin_color_secondary && { secondary: s.admin_color_secondary }),
|
||||
...(s.admin_color_success && { success: s.admin_color_success }),
|
||||
...(s.admin_color_danger && { danger: s.admin_color_danger }),
|
||||
...(s.admin_color_warning && { warning: s.admin_color_warning }),
|
||||
};
|
||||
setColorOverrides(overrides);
|
||||
AsyncStorage.setItem(COLORS_CACHE_KEY, JSON.stringify(overrides)).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem(STORAGE_KEY).then((val) => {
|
||||
if (val === "light" || val === "dark") setMode(val);
|
||||
});
|
||||
}, []);
|
||||
AsyncStorage.getItem(COLORS_CACHE_KEY).then((val) => {
|
||||
if (val) {
|
||||
try { setColorOverrides(JSON.parse(val)); } catch {}
|
||||
}
|
||||
});
|
||||
fetchAdminColors();
|
||||
}, [fetchAdminColors]);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener("change", (state) => {
|
||||
if (state === "active") fetchAdminColors();
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [fetchAdminColors]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = mode === "dark" ? "light" : "dark";
|
||||
@@ -29,11 +65,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
AsyncStorage.setItem(STORAGE_KEY, next);
|
||||
};
|
||||
|
||||
const baseColors = mode === "dark" ? darkColors : lightColors;
|
||||
const colors: Colors = { ...baseColors, ...colorOverrides };
|
||||
|
||||
const value: ThemeContextType = {
|
||||
colors: mode === "dark" ? darkColors : lightColors,
|
||||
colors,
|
||||
mode,
|
||||
toggleTheme,
|
||||
isDark: mode === "dark",
|
||||
refreshColors: fetchAdminColors,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -93,7 +93,7 @@ function AdminTabs() {
|
||||
<>
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||
headerStyle: { backgroundColor: isDark ? colors.secondary : "#ffffff" },
|
||||
headerTintColor: colors.textWhite,
|
||||
headerRight: () => (
|
||||
<View
|
||||
@@ -157,7 +157,7 @@ function AdminTabs() {
|
||||
</View>
|
||||
),
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.bgSecondary,
|
||||
backgroundColor: isDark ? colors.secondary : "#ffffff",
|
||||
borderTopColor: colors.border,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
@@ -339,12 +339,12 @@ function AdminTabs() {
|
||||
}
|
||||
|
||||
export default function AdminNavigator() {
|
||||
const { colors } = useTheme();
|
||||
const { colors, isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgSecondary },
|
||||
headerStyle: { backgroundColor: isDark ? colors.secondary : "#ffffff" },
|
||||
headerTintColor: colors.textWhite,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { DeliveryTabParamList } from "./types";
|
||||
import DashboardScreen from "../screens/delivery/DashboardScreen";
|
||||
import StatsScreen from "../screens/delivery/StatsScreen";
|
||||
import AlertsScreen from "../screens/delivery/AlertsScreen";
|
||||
import RatingsScreen from "../screens/delivery/RatingsScreen";
|
||||
|
||||
const Tab = createBottomTabNavigator<DeliveryTabParamList>();
|
||||
|
||||
@@ -201,6 +202,20 @@ export default function DeliveryNavigator() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Ratings"
|
||||
component={RatingsScreen}
|
||||
options={{
|
||||
title: "Mes avis",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons
|
||||
name="star-outline"
|
||||
size={size}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
|
||||
{/* Modal notifications */}
|
||||
|
||||
@@ -36,4 +36,5 @@ export type DeliveryTabParamList = {
|
||||
Dashboard: undefined;
|
||||
Stats: undefined;
|
||||
Alerts: undefined;
|
||||
Ratings: undefined;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
createCategoryAdmin,
|
||||
updateCategoryAdmin,
|
||||
deleteCategoryAdmin,
|
||||
reorderCategoriesAdmin,
|
||||
} from "../../api/api_admin";
|
||||
import type { Category } from "../../api/api_admin";
|
||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
@@ -47,6 +48,7 @@ export default function CategoriesScreen() {
|
||||
const [hexInput, setHexInput] = useState("#7c3aed");
|
||||
const [isComingSoon, setIsComingSoon] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reordering, setReordering] = useState(false);
|
||||
const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -144,6 +146,17 @@ export default function CategoriesScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleMove = async (index: number, direction: "up" | "down") => {
|
||||
const newList = [...categories];
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= newList.length) return;
|
||||
[newList[index], newList[swapIndex]] = [newList[swapIndex], newList[index]];
|
||||
setCategories(newList);
|
||||
setReordering(true);
|
||||
await reorderCategoriesAdmin(newList.map((c) => c.id));
|
||||
setReordering(false);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bgPrimary },
|
||||
header: {
|
||||
@@ -189,6 +202,8 @@ export default function CategoriesScreen() {
|
||||
paddingVertical: 2,
|
||||
},
|
||||
comingSoonBadgeText: { fontSize: fontSize.xs, color: colors.warning, fontWeight: "600" },
|
||||
orderBtns: { flexDirection: "column", alignItems: "center", marginRight: spacing.s },
|
||||
orderBtn: { padding: 2 },
|
||||
actions: { flexDirection: "row", gap: spacing.s },
|
||||
actionBtn: { padding: 8 },
|
||||
overlay: {
|
||||
@@ -297,9 +312,33 @@ export default function CategoriesScreen() {
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.empty}>Aucune catégorie. Créez-en une !</Text>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
renderItem={({ item, index }) => (
|
||||
<Card>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.orderBtns}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleMove(index, "up")}
|
||||
disabled={index === 0 || reordering}
|
||||
style={styles.orderBtn}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-up"
|
||||
size={18}
|
||||
color={index === 0 ? colors.textMuted : colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleMove(index, "down")}
|
||||
disabled={index === categories.length - 1 || reordering}
|
||||
style={styles.orderBtn}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={18}
|
||||
color={index === categories.length - 1 ? colors.textMuted : colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.catLeft}>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
Modal,
|
||||
StatusBar,
|
||||
useWindowDimensions,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
@@ -22,7 +24,10 @@ import { useTheme } from "../../context/ThemeContext";
|
||||
import {
|
||||
getAllDeliveryPersonsWithDetails,
|
||||
getCommandByID,
|
||||
getLivreurRatings,
|
||||
getLivreurLoginHistory,
|
||||
} from "../../api/api_admin";
|
||||
import type { LoginHistoryWeek } from "../../api/api_admin";
|
||||
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
|
||||
import type { RouteInfo } from "../../api/tomtom";
|
||||
import type { DeliveryPerson } from "../../api/types";
|
||||
@@ -61,6 +66,76 @@ export default function DeliveryScreen() {
|
||||
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
|
||||
// Avis livreur
|
||||
const [ratingsModal, setRatingsModal] = useState<{
|
||||
username: string;
|
||||
ratings: { id: number; order_id: number; client_username: string; rating: number; comment: string; created_at: string }[];
|
||||
average: number;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
const [ratingsLoading, setRatingsLoading] = useState(false);
|
||||
|
||||
const openRatings = async (username: string) => {
|
||||
setRatingsLoading(true);
|
||||
const data = await getLivreurRatings(username);
|
||||
setRatingsModal({ username, ...data });
|
||||
setRatingsLoading(false);
|
||||
};
|
||||
|
||||
// Historique de connexion livreur
|
||||
const [loginHistoryModal, setLoginHistoryModal] = useState<{
|
||||
username: string;
|
||||
year: number;
|
||||
month: number;
|
||||
weeks: LoginHistoryWeek[];
|
||||
} | null>(null);
|
||||
const [loginHistoryLoading, setLoginHistoryLoading] = useState(false);
|
||||
const loginHistoryRequestRef = useRef(0);
|
||||
|
||||
const fetchLoginHistory = async (
|
||||
username: string,
|
||||
year: number,
|
||||
month: number,
|
||||
) => {
|
||||
const requestId = ++loginHistoryRequestRef.current;
|
||||
setLoginHistoryLoading(true);
|
||||
const res = await getLivreurLoginHistory(username, year, month);
|
||||
if (requestId !== loginHistoryRequestRef.current) return;
|
||||
setLoginHistoryModal({
|
||||
username,
|
||||
year: res.year,
|
||||
month: res.month,
|
||||
weeks: res.weeks,
|
||||
});
|
||||
setLoginHistoryLoading(false);
|
||||
};
|
||||
|
||||
const openLoginHistory = (username: string) => {
|
||||
const now = new Date();
|
||||
fetchLoginHistory(username, now.getFullYear(), now.getMonth() + 1);
|
||||
};
|
||||
|
||||
const changeLoginHistoryMonth = (delta: number) => {
|
||||
if (!loginHistoryModal || loginHistoryLoading) return;
|
||||
let year = loginHistoryModal.year;
|
||||
let month = loginHistoryModal.month + delta;
|
||||
if (month < 1) {
|
||||
month = 12;
|
||||
year -= 1;
|
||||
} else if (month > 12) {
|
||||
month = 1;
|
||||
year += 1;
|
||||
}
|
||||
const now = new Date();
|
||||
if (
|
||||
year > now.getFullYear() ||
|
||||
(year === now.getFullYear() && month > now.getMonth() + 1)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
fetchLoginHistory(loginHistoryModal.username, year, month);
|
||||
};
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const result = await getAllDeliveryPersonsWithDetails();
|
||||
@@ -338,6 +413,66 @@ export default function DeliveryScreen() {
|
||||
fontWeight: "500",
|
||||
},
|
||||
|
||||
ratingsBtn: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.s,
|
||||
marginTop: spacing.m,
|
||||
paddingVertical: spacing.s,
|
||||
paddingHorizontal: spacing.m,
|
||||
borderRadius: borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: "#f59e0b",
|
||||
},
|
||||
ratingsBtnText: {
|
||||
color: "#f59e0b",
|
||||
fontSize: fontSize.sm,
|
||||
fontWeight: "600",
|
||||
},
|
||||
|
||||
historyBtn: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.s,
|
||||
marginTop: spacing.m,
|
||||
paddingVertical: spacing.s,
|
||||
paddingHorizontal: spacing.m,
|
||||
borderRadius: borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
historyBtnText: {
|
||||
color: colors.accent,
|
||||
fontSize: fontSize.sm,
|
||||
fontWeight: "600",
|
||||
},
|
||||
|
||||
historyWeekLabel: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.xs,
|
||||
fontWeight: "700",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: spacing.xs,
|
||||
marginTop: spacing.m,
|
||||
},
|
||||
historyEntryRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: spacing.xs,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderLight,
|
||||
},
|
||||
historyEntryDate: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: fontSize.sm,
|
||||
},
|
||||
historyEntryTime: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.sm,
|
||||
},
|
||||
|
||||
trackBtn: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -356,6 +491,80 @@ export default function DeliveryScreen() {
|
||||
fontWeight: "600",
|
||||
},
|
||||
|
||||
ratingsOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
ratingsSheet: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderTopLeftRadius: borderRadius.xl,
|
||||
borderTopRightRadius: borderRadius.xl,
|
||||
padding: spacing.l,
|
||||
maxHeight: "80%",
|
||||
},
|
||||
ratingsHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
ratingsTitle: {
|
||||
color: colors.textWhite,
|
||||
fontSize: fontSize.lg,
|
||||
fontWeight: "700",
|
||||
},
|
||||
ratingsAvg: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
marginBottom: spacing.l,
|
||||
},
|
||||
ratingsAvgText: {
|
||||
color: "#f59e0b",
|
||||
fontSize: fontSize.md,
|
||||
fontWeight: "700",
|
||||
},
|
||||
ratingsCount: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.sm,
|
||||
},
|
||||
ratingItem: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderLight,
|
||||
paddingVertical: spacing.m,
|
||||
},
|
||||
ratingItemHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
ratingItemClient: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: fontSize.sm,
|
||||
fontWeight: "600",
|
||||
},
|
||||
ratingItemDate: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.xs,
|
||||
},
|
||||
ratingStarsRow: {
|
||||
flexDirection: "row",
|
||||
gap: 2,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
ratingItemComment: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fontSize.sm,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
ratingsEmpty: {
|
||||
color: colors.textMuted,
|
||||
textAlign: "center",
|
||||
paddingVertical: spacing.xl,
|
||||
},
|
||||
|
||||
empty: {
|
||||
color: colors.textMuted,
|
||||
textAlign: "center",
|
||||
@@ -523,6 +732,30 @@ export default function DeliveryScreen() {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.ratingsBtn}
|
||||
onPress={() => openRatings(item.username)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="star-outline" size={14} color="#f59e0b" />
|
||||
<Text style={styles.ratingsBtnText}>Voir les avis</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.historyBtn}
|
||||
onPress={() => openLoginHistory(item.username)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name="time-outline"
|
||||
size={14}
|
||||
color={colors.accent}
|
||||
/>
|
||||
<Text style={styles.historyBtnText}>
|
||||
Historique de connexion
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{hasGPS && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
@@ -835,6 +1068,252 @@ export default function DeliveryScreen() {
|
||||
<Text style={styles.empty}>Aucun livreur</Text>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Modal avis livreur ── */}
|
||||
<Modal
|
||||
visible={ratingsModal !== null}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setRatingsModal(null)}
|
||||
>
|
||||
<Pressable style={styles.ratingsOverlay} onPress={() => setRatingsModal(null)}>
|
||||
<Pressable onPress={() => {}}>
|
||||
<View style={styles.ratingsSheet}>
|
||||
<View style={styles.ratingsHeader}>
|
||||
<Text style={styles.ratingsTitle}>
|
||||
Avis — {ratingsModal?.username}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setRatingsModal(null)}>
|
||||
<Ionicons name="close" size={22} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{ratingsLoading ? (
|
||||
<Text style={styles.ratingsEmpty}>Chargement...</Text>
|
||||
) : ratingsModal && ratingsModal.count > 0 ? (
|
||||
<>
|
||||
<View style={styles.ratingsAvg}>
|
||||
{[1,2,3,4,5].map((s) => (
|
||||
<Ionicons
|
||||
key={s}
|
||||
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
|
||||
size={20}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
))}
|
||||
<Text style={styles.ratingsAvgText}>
|
||||
{ratingsModal.average.toFixed(1)}
|
||||
</Text>
|
||||
<Text style={styles.ratingsCount}>
|
||||
({ratingsModal.count} avis)
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{ratingsModal.ratings.map((r) => (
|
||||
<View key={r.id} style={styles.ratingItem}>
|
||||
<View style={styles.ratingItemHeader}>
|
||||
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
|
||||
<Text style={styles.ratingItemDate}>
|
||||
{new Date(r.created_at).toLocaleDateString("fr-FR")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.ratingStarsRow}>
|
||||
{[1,2,3,4,5].map((s) => (
|
||||
<Ionicons
|
||||
key={s}
|
||||
name={s <= r.rating ? "star" : "star-outline"}
|
||||
size={14}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{r.comment !== "" && (
|
||||
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
{/* ── Modal historique de connexion livreur ── */}
|
||||
<Modal
|
||||
visible={loginHistoryModal !== null}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setLoginHistoryModal(null)}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.ratingsOverlay}
|
||||
onPress={() => setLoginHistoryModal(null)}
|
||||
>
|
||||
<Pressable onPress={() => {}}>
|
||||
<View style={styles.ratingsSheet}>
|
||||
<View style={styles.ratingsHeader}>
|
||||
<Text style={styles.ratingsTitle}>
|
||||
Connexions — {loginHistoryModal?.username}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setLoginHistoryModal(null)}
|
||||
>
|
||||
<Ionicons
|
||||
name="close"
|
||||
size={22}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const isCurrentMonth =
|
||||
!!loginHistoryModal &&
|
||||
loginHistoryModal.year ===
|
||||
now.getFullYear() &&
|
||||
loginHistoryModal.month ===
|
||||
now.getMonth() + 1;
|
||||
const canGoBack = !loginHistoryLoading;
|
||||
const canGoForward =
|
||||
!loginHistoryLoading && !isCurrentMonth;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: spacing.m,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
disabled={!canGoBack}
|
||||
onPress={() =>
|
||||
changeLoginHistoryMonth(-1)
|
||||
}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={20}
|
||||
color={
|
||||
canGoBack
|
||||
? colors.textPrimary
|
||||
: colors.textMuted
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
style={{
|
||||
color: colors.textWhite,
|
||||
fontWeight: "700",
|
||||
fontSize: fontSize.md,
|
||||
}}
|
||||
>
|
||||
{loginHistoryModal &&
|
||||
new Date(
|
||||
loginHistoryModal.year,
|
||||
loginHistoryModal.month -
|
||||
1,
|
||||
1,
|
||||
)
|
||||
.toLocaleDateString(
|
||||
"fr-FR",
|
||||
{
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
},
|
||||
)
|
||||
.replace(/^./, (c) =>
|
||||
c.toUpperCase(),
|
||||
)}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
disabled={!canGoForward}
|
||||
onPress={() =>
|
||||
changeLoginHistoryMonth(1)
|
||||
}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={
|
||||
canGoForward
|
||||
? colors.textPrimary
|
||||
: colors.textMuted
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
|
||||
{loginHistoryLoading ? (
|
||||
<Text style={styles.ratingsEmpty}>
|
||||
Chargement...
|
||||
</Text>
|
||||
) : loginHistoryModal &&
|
||||
loginHistoryModal.weeks.length > 0 ? (
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{loginHistoryModal.weeks.map((week) => (
|
||||
<View key={week.week}>
|
||||
<Text style={styles.historyWeekLabel}>
|
||||
Semaine {week.week}
|
||||
</Text>
|
||||
{week.entries.map((entry) => (
|
||||
<View
|
||||
key={entry.id}
|
||||
style={styles.historyEntryRow}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
styles.historyEntryDate
|
||||
}
|
||||
>
|
||||
{new Date(
|
||||
entry.created_at,
|
||||
).toLocaleDateString(
|
||||
"fr-FR",
|
||||
{
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
style={
|
||||
styles.historyEntryTime
|
||||
}
|
||||
>
|
||||
{new Date(
|
||||
entry.created_at,
|
||||
).toLocaleTimeString(
|
||||
"fr-FR",
|
||||
{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<Text style={styles.ratingsEmpty}>
|
||||
Aucune connexion ce mois-ci
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -782,7 +782,7 @@ export default function OrdersScreen() {
|
||||
setOpenMenuId(null);
|
||||
handleForceValidate(item.id);
|
||||
},
|
||||
condition: !isDone && item.status !== "livre",
|
||||
condition: !isDone,
|
||||
},
|
||||
{
|
||||
label: "Supprimer",
|
||||
|
||||
@@ -62,12 +62,12 @@ interface PendingMedia {
|
||||
// Les catégories sont chargées dynamiquement depuis l'API
|
||||
|
||||
const UNITS = [
|
||||
{ value: "u", label: "u" },
|
||||
{ value: "kg", label: "kg" },
|
||||
{ value: "g", label: "g" },
|
||||
{ value: "u", label: "u" },
|
||||
{ value: "kg", label: "kg" },
|
||||
{ value: "g", label: "g" },
|
||||
{ value: "bag", label: "bag" },
|
||||
{ value: "l", label: "l" },
|
||||
{ value: "cl", label: "cl" },
|
||||
{ value: "l", label: "l" },
|
||||
{ value: "cl", label: "cl" },
|
||||
{ value: "pcs", label: "pcs" },
|
||||
];
|
||||
|
||||
@@ -247,7 +247,11 @@ export default function ProductsScreen() {
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: type === "image" ? ["images"] : ["videos"],
|
||||
mediaTypes:
|
||||
type === "image"
|
||||
? ImagePicker.MediaTypeOptions.Images
|
||||
: ImagePicker.MediaTypeOptions.Videos,
|
||||
quality: 0.8,
|
||||
allowsMultipleSelection: true,
|
||||
});
|
||||
|
||||
@@ -354,10 +358,7 @@ export default function ProductsScreen() {
|
||||
prices.forEach((p, i) => {
|
||||
fd.append(`prices[${i}][quantity]`, String(p.quantity));
|
||||
fd.append(`prices[${i}][price]`, String(p.price));
|
||||
fd.append(
|
||||
`prices[${i}][active_price]`,
|
||||
p.active_price ? "true" : "false",
|
||||
);
|
||||
fd.append(`prices[${i}][active_price]`, p.active_price ? "true" : "false");
|
||||
});
|
||||
|
||||
// Attacher les médias en attente
|
||||
@@ -387,18 +388,13 @@ export default function ProductsScreen() {
|
||||
m.mediaType,
|
||||
);
|
||||
} catch (uploadErr: any) {
|
||||
const msg =
|
||||
uploadErr?.response?.data?.error ||
|
||||
uploadErr?.message ||
|
||||
`Erreur upload ${m.mediaType}`;
|
||||
const msg = uploadErr?.response?.data?.error || uploadErr?.message || `Erreur upload ${m.mediaType}`;
|
||||
uploadErrors.push(msg);
|
||||
}
|
||||
}
|
||||
setUploadingMedia(false);
|
||||
if (uploadErrors.length > 0) {
|
||||
setFormError(
|
||||
`Erreur upload média: ${uploadErrors.join(", ")}`,
|
||||
);
|
||||
setFormError(`Erreur upload média: ${uploadErrors.join(", ")}`);
|
||||
await loadData();
|
||||
return;
|
||||
}
|
||||
@@ -649,14 +645,8 @@ export default function ProductsScreen() {
|
||||
borderColor: "#22c55e",
|
||||
backgroundColor: "#22c55e20",
|
||||
},
|
||||
comingSoonBtnText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.sm,
|
||||
},
|
||||
comingSoonBtnTextActive: {
|
||||
color: "#22c55e",
|
||||
fontWeight: "700",
|
||||
},
|
||||
comingSoonBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
|
||||
comingSoonBtnTextActive: { color: "#22c55e", fontWeight: "700" },
|
||||
|
||||
// Prices
|
||||
sectionHeader: {
|
||||
@@ -818,10 +808,7 @@ export default function ProductsScreen() {
|
||||
<Text style={styles.name}>{item.name}</Text>
|
||||
<Badge
|
||||
label={item.category}
|
||||
color={
|
||||
categories.find((c) => c.name === item.category)
|
||||
?.color || colors.accent
|
||||
}
|
||||
color={categories.find(c => c.name === item.category)?.color || colors.accent}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -856,18 +843,9 @@ export default function ProductsScreen() {
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.info}>
|
||||
Stock: {item.stock} {item.unit || "u"}
|
||||
</Text>
|
||||
<Text style={styles.info}>Stock: {item.stock} {item.unit || "u"}</Text>
|
||||
{item.prices && item.prices.length > 0 && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, marginTop: 2 }}>
|
||||
{item.prices.map((p, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
@@ -880,8 +858,7 @@ export default function ProductsScreen() {
|
||||
},
|
||||
]}
|
||||
>
|
||||
{p.quantity}
|
||||
{item.unit || "u"} = {p.price}€
|
||||
{p.quantity}{item.unit || "u"} = {p.price}€
|
||||
{i < item.prices!.length - 1 ? " |" : ""}
|
||||
</Text>
|
||||
))}
|
||||
@@ -1047,8 +1024,7 @@ export default function ProductsScreen() {
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.comingSoonBtn,
|
||||
form.comingSoon &&
|
||||
styles.comingSoonBtnActive,
|
||||
form.comingSoon && styles.comingSoonBtnActive,
|
||||
]}
|
||||
onPress={() =>
|
||||
setForm((f) => {
|
||||
@@ -1056,24 +1032,16 @@ export default function ProductsScreen() {
|
||||
return {
|
||||
...f,
|
||||
comingSoon: next,
|
||||
prices: f.prices.map((p) => ({
|
||||
...p,
|
||||
active: !next,
|
||||
})),
|
||||
prices: f.prices.map((p) => ({ ...p, active: !next })),
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.comingSoonBtnText,
|
||||
form.comingSoon &&
|
||||
styles.comingSoonBtnTextActive,
|
||||
]}
|
||||
>
|
||||
{form.comingSoon
|
||||
? "À venir (activé)"
|
||||
: "Marquer comme «À venir»"}
|
||||
<Text style={[
|
||||
styles.comingSoonBtnText,
|
||||
form.comingSoon && styles.comingSoonBtnTextActive,
|
||||
]}>
|
||||
{form.comingSoon ? "À venir (activé)" : "Marquer comme «À venir»"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1172,18 +1140,9 @@ export default function ProductsScreen() {
|
||||
onPress={() => togglePriceActive(idx)}
|
||||
>
|
||||
<Ionicons
|
||||
name={
|
||||
p.active
|
||||
? "checkmark-circle"
|
||||
: "close-circle"
|
||||
}
|
||||
name={p.active ? "checkmark-circle" : "close-circle"}
|
||||
size={22}
|
||||
color={
|
||||
p.active
|
||||
? colors.success ||
|
||||
"#22c55e"
|
||||
: colors.danger
|
||||
}
|
||||
color={p.active ? colors.success || "#22c55e" : colors.danger}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{form.prices.length > 1 && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -1155,9 +1156,25 @@ function CentralRewardSection({
|
||||
);
|
||||
}
|
||||
|
||||
// Palette violette d'origine de l'application (thème par défaut historique)
|
||||
const ORIGINAL_THEME_COLORS = {
|
||||
admin_color_primary: "#7c3aed",
|
||||
admin_color_secondary: "#000000",
|
||||
admin_color_success: "#4ade80",
|
||||
admin_color_danger: "#ef4444",
|
||||
admin_color_warning: "#f59e0b",
|
||||
client_color_primary: "#7c3aed",
|
||||
client_color_secondary: "#000000",
|
||||
client_color_success: "#4ade80",
|
||||
client_color_danger: "#ef4444",
|
||||
client_color_warning: "#f59e0b",
|
||||
client_title_gradient_from: "#a78bfa",
|
||||
client_title_gradient_to: "#22d3ee",
|
||||
} as const;
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
||||
const { colors, refreshColors } = useTheme();
|
||||
const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert();
|
||||
const navigation = useNavigation();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const inputMd = screenWidth < 380 ? 64 : 80;
|
||||
@@ -1194,6 +1211,18 @@ export default function SettingsScreen() {
|
||||
shop_name: "Milieu-Nantais",
|
||||
contact_telegram: "",
|
||||
points_reward: null,
|
||||
admin_color_primary: "#7c3aed",
|
||||
admin_color_secondary: "#22d3ee",
|
||||
admin_color_success: "#4ade80",
|
||||
admin_color_danger: "#ef4444",
|
||||
admin_color_warning: "#f59e0b",
|
||||
client_color_primary: "#7c3aed",
|
||||
client_color_secondary: "#22d3ee",
|
||||
client_color_success: "#4ade80",
|
||||
client_color_danger: "#ef4444",
|
||||
client_color_warning: "#f59e0b",
|
||||
client_title_gradient_from: "#a78bfa",
|
||||
client_title_gradient_to: "#22d3ee",
|
||||
});
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showIpnSecret, setShowIpnSecret] = useState(false);
|
||||
@@ -1280,9 +1309,13 @@ export default function SettingsScreen() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!isDirty.current) {
|
||||
loadData();
|
||||
}
|
||||
}, [loadData])
|
||||
);
|
||||
|
||||
// Retourne l'index du pool auquel la catégorie est assignée, ou -1 si aucun
|
||||
const getPoolIndexFor = (catName: string): number => {
|
||||
@@ -1356,6 +1389,8 @@ export default function SettingsScreen() {
|
||||
setSaving(false);
|
||||
if (res.success) {
|
||||
isDirty.current = false;
|
||||
await loadData();
|
||||
await refreshColors();
|
||||
showSuccess("Succès", "Paramètres sauvegardés");
|
||||
} else {
|
||||
showError("Erreur", res.error || "Erreur lors de la sauvegarde");
|
||||
@@ -1484,6 +1519,7 @@ export default function SettingsScreen() {
|
||||
<ScrollView contentContainerStyle={s.content}>
|
||||
{/* Personnalisation */}
|
||||
<AccordionSection title="Personnalisation" colors={colors} s={s}>
|
||||
{/* Nom du shop */}
|
||||
<View style={[s.row, s.rowFirst]}>
|
||||
<View style={s.rowLeft}>
|
||||
<Text style={s.rowLabel}>Nom du shop</Text>
|
||||
@@ -1500,6 +1536,67 @@ export default function SettingsScreen() {
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Dégradé titre boutique */}
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingVertical: spacing.m, gap: spacing.m }}>
|
||||
<Text style={s.rowLabel}>Dégradé du titre boutique</Text>
|
||||
<Text style={s.rowDesc}>Couleurs du nom de la boutique dans le header du site client.</Text>
|
||||
|
||||
{/* Aperçu du dégradé */}
|
||||
<View style={{ height: 36, borderRadius: borderRadius.sm, overflow: "hidden" }}>
|
||||
<View style={{
|
||||
flex: 1,
|
||||
backgroundColor: settings.client_title_gradient_from,
|
||||
// gradient simulé : deux moitiés de couleur
|
||||
}}>
|
||||
<View style={{
|
||||
position: "absolute", right: 0, top: 0, bottom: 0,
|
||||
width: "50%",
|
||||
backgroundColor: settings.client_title_gradient_to,
|
||||
}} />
|
||||
<View style={{
|
||||
position: "absolute", left: "25%", right: "25%", top: 0, bottom: 0,
|
||||
backgroundColor: `${settings.client_title_gradient_from}00`,
|
||||
}} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Couleur de départ */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
|
||||
<View style={{ width: 32, height: 32, borderRadius: borderRadius.sm, backgroundColor: settings.client_title_gradient_from, borderWidth: 1, borderColor: colors.border }} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[s.rowDesc, { marginBottom: 4 }]}>Couleur de départ</Text>
|
||||
<TextInput
|
||||
style={[s.input, { fontFamily: "monospace" }]}
|
||||
value={settings.client_title_gradient_from}
|
||||
onChangeText={(v) => setSettings((p) => ({ ...p, client_title_gradient_from: v }))}
|
||||
placeholder="#a78bfa"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
maxLength={7}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Couleur de fin */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
|
||||
<View style={{ width: 32, height: 32, borderRadius: borderRadius.sm, backgroundColor: settings.client_title_gradient_to, borderWidth: 1, borderColor: colors.border }} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[s.rowDesc, { marginBottom: 4 }]}>Couleur de fin</Text>
|
||||
<TextInput
|
||||
style={[s.input, { fontFamily: "monospace" }]}
|
||||
value={settings.client_title_gradient_to}
|
||||
onChangeText={(v) => setSettings((p) => ({ ...p, client_title_gradient_to: v }))}
|
||||
placeholder="#22d3ee"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
maxLength={7}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Amendes */}
|
||||
@@ -2182,6 +2279,97 @@ export default function SettingsScreen() {
|
||||
)}
|
||||
</AccordionSection>
|
||||
|
||||
{/* ============================================ */}
|
||||
{/* 🎨 COULEURS DE L'INTERFACE */}
|
||||
{/* ============================================ */}
|
||||
<AccordionSection title="Couleurs de l'interface" colors={colors} s={s}>
|
||||
<Text style={[s.hint, { paddingTop: spacing.s, paddingHorizontal: spacing.l }]}>
|
||||
Personnalisez les couleurs de l'espace admin et de l'app client / site web.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => showConfirm(
|
||||
"Restaurer le thème d'origine",
|
||||
"Toutes les couleurs personnalisées seront remplacées par le violet de base des premières versions de l'application. Continuer ?",
|
||||
() => {
|
||||
Keyboard.dismiss();
|
||||
setSettings((p) => ({ ...p, ...ORIGINAL_THEME_COLORS }));
|
||||
},
|
||||
"Restaurer",
|
||||
"Annuler",
|
||||
)}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
marginHorizontal: spacing.l,
|
||||
marginTop: spacing.m,
|
||||
paddingVertical: spacing.s,
|
||||
paddingHorizontal: spacing.m,
|
||||
borderRadius: borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: "#7c3aed",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="color-palette-outline" size={16} color="#7c3aed" />
|
||||
<Text style={{ fontSize: fontSize.sm, color: "#7c3aed", fontWeight: "600" }}>
|
||||
Restaurer les couleurs de base
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{(["admin", "client"] as const).map((scope) => (
|
||||
<View key={scope}>
|
||||
<Text style={[s.rowLabel, { paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.xs }]}>
|
||||
{scope === "admin" ? "Espace admin" : "App client & site web"}
|
||||
</Text>
|
||||
{([
|
||||
{ label: "Principale", key: `${scope}_color_primary` as const },
|
||||
{ label: "Secondaire", key: `${scope}_color_secondary` as const },
|
||||
{ label: "Succès", key: `${scope}_color_success` as const },
|
||||
{ label: "Danger", key: `${scope}_color_danger` as const },
|
||||
{ label: "Avertissement", key: `${scope}_color_warning` as const },
|
||||
] as { label: string; key: keyof typeof settings }[]).map(({ label, key }) => (
|
||||
<View key={key as string} style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.m }}>
|
||||
<Text style={[s.rowDesc, { marginBottom: spacing.xs }]}>{label}</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
|
||||
<View style={{
|
||||
width: 36, height: 36,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: (settings[key] as string) || "#7c3aed",
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
}} />
|
||||
<TextInput
|
||||
style={[s.input, { flex: 1 }]}
|
||||
value={settings[key] as string}
|
||||
onChangeText={(v) => setSettings((p) => ({ ...p, [key]: v }))}
|
||||
placeholder="#7c3aed"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flexDirection: "row", gap: spacing.xs, marginTop: spacing.xs, flexWrap: "wrap" }}>
|
||||
{["#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706", "#dc2626", "#db2777", "#f59e0b", "#4ade80", "#ef4444", "#22d3ee", "#0f172a"].map((color) => (
|
||||
<TouchableOpacity
|
||||
key={color}
|
||||
onPress={() => setSettings((p) => ({ ...p, [key]: color }))}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: 13,
|
||||
backgroundColor: color,
|
||||
borderWidth: (settings[key] as string) === color ? 2.5 : 1,
|
||||
borderColor: (settings[key] as string) === color ? colors.textPrimary : colors.border,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</AccordionSection>
|
||||
|
||||
<TouchableOpacity
|
||||
style={s.saveButton}
|
||||
onPress={handleSave}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { spacing, fontSize } from "../../theme";
|
||||
import { getMyRatings } from "../../api/api_delivery";
|
||||
import type { LivreurRating } from "../../api/api_delivery";
|
||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
|
||||
const STAR_COLOR = "#f59e0b";
|
||||
const STAR_EMPTY = "#374151";
|
||||
|
||||
function Stars({ value }: { value: number }) {
|
||||
return (
|
||||
<View style={{ flexDirection: "row", gap: 2 }}>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Ionicons
|
||||
key={i}
|
||||
name={i <= value ? "star" : "star-outline"}
|
||||
size={14}
|
||||
color={i <= value ? STAR_COLOR : STAR_EMPTY}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function RatingsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const [ratings, setRatings] = useState<LivreurRating[]>([]);
|
||||
const [average, setAverage] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const load = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
const res = await getMyRatings();
|
||||
if (res.success) {
|
||||
setRatings(res.ratings);
|
||||
setAverage(res.average);
|
||||
}
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}, []);
|
||||
|
||||
useFocusEffect(useCallback(() => { load(); }, [load]));
|
||||
|
||||
const onRefresh = () => { setRefreshing(true); load(true); };
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bgPrimary },
|
||||
content: { padding: spacing.l, paddingBottom: spacing.xxxl },
|
||||
headerCard: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: 14,
|
||||
padding: spacing.l,
|
||||
marginBottom: spacing.l,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderLight,
|
||||
},
|
||||
avgNumber: { fontSize: 48, fontWeight: "800", color: STAR_COLOR, lineHeight: 56 },
|
||||
avgLabel: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: spacing.xs },
|
||||
countLabel: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 4 },
|
||||
starsRow: { flexDirection: "row", gap: 4, marginTop: spacing.s },
|
||||
card: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: 12,
|
||||
padding: spacing.l,
|
||||
marginBottom: spacing.m,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderLight,
|
||||
},
|
||||
cardHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: spacing.s },
|
||||
client: { fontSize: fontSize.sm, fontWeight: "600", color: colors.textPrimary },
|
||||
date: { fontSize: fontSize.xs, color: colors.textMuted },
|
||||
orderRef: { fontSize: fontSize.xs, color: colors.textMuted, marginBottom: spacing.s },
|
||||
comment: { fontSize: fontSize.sm, color: colors.textSecondary, fontStyle: "italic", marginTop: spacing.s, lineHeight: 20 },
|
||||
emptyWrap: { alignItems: "center", paddingVertical: spacing.xxxl },
|
||||
emptyText: { color: colors.textMuted, fontSize: fontSize.md, marginTop: spacing.m, textAlign: "center" },
|
||||
});
|
||||
|
||||
if (loading) return <LoadingSpinner message="Chargement des avis..." />;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={STAR_COLOR} />}
|
||||
>
|
||||
{/* Résumé */}
|
||||
<View style={styles.headerCard}>
|
||||
<Text style={styles.avgNumber}>
|
||||
{average > 0 ? average.toFixed(1) : "—"}
|
||||
</Text>
|
||||
<View style={styles.starsRow}>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Ionicons
|
||||
key={i}
|
||||
name={i <= Math.round(average) ? "star" : "star-outline"}
|
||||
size={22}
|
||||
color={i <= Math.round(average) ? STAR_COLOR : STAR_EMPTY}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.avgLabel}>Note moyenne</Text>
|
||||
<Text style={styles.countLabel}>{ratings.length} avis client{ratings.length > 1 ? "s" : ""}</Text>
|
||||
</View>
|
||||
|
||||
{/* Liste */}
|
||||
{ratings.length === 0 ? (
|
||||
<View style={styles.emptyWrap}>
|
||||
<Ionicons name="chatbubble-ellipses-outline" size={48} color={colors.textMuted} />
|
||||
<Text style={styles.emptyText}>Aucun avis reçu pour l'instant</Text>
|
||||
</View>
|
||||
) : (
|
||||
ratings.map((r) => (
|
||||
<View key={r.id} style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.client}>{r.client_username}</Text>
|
||||
<Text style={styles.date}>{formatDate(r.created_at)}</Text>
|
||||
</View>
|
||||
<Text style={styles.orderRef}>Commande #{r.order_id}</Text>
|
||||
<Stars value={r.rating} />
|
||||
{r.comment ? (
|
||||
<Text style={styles.comment}>"{r.comment}"</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +89,7 @@ export default function StatsScreen() {
|
||||
const [byDay, setByDay] = useState<StatPoint[]>([]);
|
||||
const [byWeek, setByWeek] = useState<StatPoint[]>([]);
|
||||
const [byMonth, setByMonth] = useState<StatPoint[]>([]);
|
||||
const [todayCount, setTodayCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [period, setPeriod] = useState<Period>("week");
|
||||
@@ -104,6 +105,7 @@ export default function StatsScreen() {
|
||||
setByDay(statsRes.by_day ?? []);
|
||||
setByWeek(statsRes.by_week ?? []);
|
||||
setByMonth(statsRes.by_month ?? []);
|
||||
setTodayCount(statsRes.today_count ?? 0);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -205,6 +207,7 @@ export default function StatsScreen() {
|
||||
);
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Livraisons du jour", value: todayCount.toString(), icon: "today-outline" as const, color: colors.accent },
|
||||
{ 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 },
|
||||
|
||||
@@ -16,6 +16,7 @@ export const darkColors = {
|
||||
accent: "#7c3aed",
|
||||
accentDark: "#6d28d9",
|
||||
accentLight: "#8b5cf6",
|
||||
secondary: "#22d3ee",
|
||||
|
||||
// Status
|
||||
success: "#4ade80",
|
||||
@@ -62,6 +63,7 @@ export const lightColors: Colors = {
|
||||
accent: "#7c3aed",
|
||||
accentDark: "#6d28d9",
|
||||
accentLight: "#8b5cf6",
|
||||
secondary: "#0891b2",
|
||||
|
||||
// Status
|
||||
success: "#16a34a",
|
||||
|
||||
Reference in New Issue
Block a user