chore: add new features for cabine
This commit is contained in:
@@ -446,6 +446,35 @@ func (d *Database) CheckClientCanOrder(username string) (bool, float64, error) {
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientPoint(username string, resetCancellationsPoint bool) error {
|
||||
var query string
|
||||
if resetCancellationsPoint {
|
||||
query = `UPDATE clients SET point = 0, point_zipette = 0, updated_at = CURRENT_TIMESTAMP WHERE username = $1`
|
||||
} else {
|
||||
query = `UPDATE clients SET point = 0, updated_at = CURRENT_TIMESTAMP WHERE username = $1`
|
||||
}
|
||||
|
||||
result, err := d.Exec(query, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE: %v", err)
|
||||
return fmt.Errorf("erreur reset points: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
// Invalider le cache Redis du client
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientPenalties(username string, resetCancellationsCount bool) error {
|
||||
log.Printf("🔄 [ResetClientPenalties] Reset pour %s (reset_count=%v)", username, resetCancellationsCount)
|
||||
|
||||
@@ -477,8 +506,6 @@ func (d *Database) ResetClientPenalties(username string, resetCancellationsCount
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [ResetClientPenalties] Reset effectué pour %s", username)
|
||||
|
||||
// Invalider le cache Redis du client
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
@@ -935,9 +935,41 @@ func GetPenaltiesStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func ResetClientPointAdmin(c *gin.Context) {
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ResetCancellationsPoints bool `json:"reset_cancellations_points"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.ResetCancellationsPoints = false
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
err := database.ResetClientPoint(username, req.ResetCancellationsPoints)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la réinitialisation",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Points réinitialisés"})
|
||||
}
|
||||
|
||||
// ResetClientPenaltiesAdmin réinitialise les pénalités d'un client (Admin seulement)
|
||||
// POST /api/v2/admin/protected/client/:username/penalties/reset
|
||||
// Body: {"reset_cancellations_count": false}
|
||||
func ResetClientPenaltiesAdmin(c *gin.Context) {
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
|
||||
@@ -225,9 +225,9 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
cabineGroupV1.DELETE("/commands/:id", handlers.DeleteCommandByCabine)
|
||||
// ⭐ NOUVEAU - ANNULATION PAR CABINE
|
||||
cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders)
|
||||
cabineGroupV1.POST("/penalty", handlers.ApplyClientPenalty) // Appliquer pénalité
|
||||
cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
|
||||
cabineGroupV1.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités
|
||||
cabineGroupV1.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin)
|
||||
cabineGroupV1.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
|
||||
cabineGroupV1.GET("/penalties/stats", handlers.GetPenaltiesStats)
|
||||
|
||||
|
||||
@@ -62,6 +62,17 @@ export const resetClientPenalties = async (clientUsername: string) => {
|
||||
return { success: true, message: data.message };
|
||||
};
|
||||
|
||||
export const resetClientPoints = async (
|
||||
clientUsername: string,
|
||||
resetCancellationsPoints: boolean = false,
|
||||
) => {
|
||||
const { data } = await apiClient.post(
|
||||
`${API}/client/${clientUsername}/point/reset`,
|
||||
{ reset_cancellations_points: resetCancellationsPoints },
|
||||
);
|
||||
return { success: true, message: data.message };
|
||||
};
|
||||
|
||||
export const getAllClientsWithPenalties = async () => {
|
||||
const { data } = await apiClient.get(`${API}/penalties/all`);
|
||||
return {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Dimensions,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import MapView, { Marker, Polyline, PROVIDER_DEFAULT } from "react-native-maps";
|
||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import {
|
||||
@@ -31,6 +30,10 @@ import { STATUS_LABELS, getStatusColors } from "../../utils/constants";
|
||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
import Card from "../../components/ui/Card";
|
||||
import Badge from "../../components/ui/Badge";
|
||||
import TomTomMap, {
|
||||
TomTomMapRef,
|
||||
TomTomMarker,
|
||||
} from "../../components/TomTomMap";
|
||||
|
||||
const MAP_HEIGHT = 280;
|
||||
|
||||
@@ -48,17 +51,14 @@ export default function DeliveryScreen() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Map
|
||||
const mapRef = useRef<MapView | null>(null);
|
||||
const fullscreenMapRef = useRef<MapView | null>(null);
|
||||
const mapRef = useRef<TomTomMapRef | null>(null);
|
||||
const fullscreenMapRef = useRef<TomTomMapRef | null>(null);
|
||||
const [mapFullscreen, setMapFullscreen] = useState(false);
|
||||
|
||||
// Selected livreur route
|
||||
const [selectedLivreur, setSelectedLivreur] =
|
||||
useState<DeliveryPerson | null>(null);
|
||||
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
|
||||
const [destinationCoords, setDestinationCoords] = useState<LatLng | null>(
|
||||
null,
|
||||
);
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -93,7 +93,6 @@ export default function DeliveryScreen() {
|
||||
async (livreur: DeliveryPerson) => {
|
||||
setSelectedLivreur(livreur);
|
||||
setRouteInfo(null);
|
||||
setDestinationCoords(null);
|
||||
|
||||
if (!livreur.stats.current_command) return;
|
||||
|
||||
@@ -110,44 +109,26 @@ export default function DeliveryScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const origin: LatLng = {
|
||||
latitude: livreur.location.latitude,
|
||||
longitude: livreur.location.longitude,
|
||||
};
|
||||
|
||||
const dest = await geocodeAddress(cmdAddress);
|
||||
if (!dest) {
|
||||
setRouteLoading(false);
|
||||
return;
|
||||
}
|
||||
setDestinationCoords(dest);
|
||||
|
||||
const origin: LatLng = {
|
||||
latitude: livreur.location.latitude,
|
||||
longitude: livreur.location.longitude,
|
||||
};
|
||||
const result = await calculateRoute(origin, dest);
|
||||
if (result) {
|
||||
setRouteInfo(result.route);
|
||||
const ref = mapFullscreen ? fullscreenMapRef : mapRef;
|
||||
if (ref.current) {
|
||||
ref.current.fitToCoordinates(
|
||||
[
|
||||
{
|
||||
latitude: origin.latitude,
|
||||
longitude: origin.longitude,
|
||||
},
|
||||
{
|
||||
latitude: dest.latitude,
|
||||
longitude: dest.longitude,
|
||||
},
|
||||
],
|
||||
{
|
||||
edgePadding: {
|
||||
top: 80,
|
||||
right: 60,
|
||||
bottom: 80,
|
||||
left: 60,
|
||||
},
|
||||
animated: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
ref.current?.fitToCoordinates([origin, dest]);
|
||||
ref.current?.calcRoute(origin, dest);
|
||||
} else {
|
||||
const ref = mapFullscreen ? fullscreenMapRef : mapRef;
|
||||
ref.current?.calcRoute(origin, dest);
|
||||
}
|
||||
} catch {
|
||||
/* silent */
|
||||
@@ -160,88 +141,26 @@ export default function DeliveryScreen() {
|
||||
const clearRoute = () => {
|
||||
setSelectedLivreur(null);
|
||||
setRouteInfo(null);
|
||||
setDestinationCoords(null);
|
||||
mapRef.current?.clearRoute();
|
||||
fullscreenMapRef.current?.clearRoute();
|
||||
};
|
||||
|
||||
const fitAllMarkers = (ref: React.RefObject<MapView | null>) => {
|
||||
if (ref.current && livreursWithGPS.length > 0) {
|
||||
ref.current.fitToCoordinates(
|
||||
const livreurMarkers = useMemo<TomTomMarker[]>(
|
||||
() =>
|
||||
livreursWithGPS.map((l) => ({
|
||||
id: l.username,
|
||||
latitude: l.location.latitude,
|
||||
longitude: l.location.longitude,
|
||||
color:
|
||||
selectedLivreur?.username === l.username
|
||||
? "#2196F3"
|
||||
: statusColors[l.status] || "#888",
|
||||
label: l.username,
|
||||
description: `${STATUS_LABELS[l.status] || l.status}${l.stats.current_command ? ` · Cmd #${l.stats.current_command}` : ""}`,
|
||||
isSelected: selectedLivreur?.username === l.username,
|
||||
})),
|
||||
{
|
||||
edgePadding: { top: 60, right: 60, bottom: 60, left: 60 },
|
||||
animated: true,
|
||||
},
|
||||
[livreursWithGPS, selectedLivreur, statusColors],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Render map markers – point bleu pour chaque livreur
|
||||
const renderMapMarkers = () =>
|
||||
livreursWithGPS.map((l) => {
|
||||
const isSelected = selectedLivreur?.username === l.username;
|
||||
return (
|
||||
<Marker
|
||||
key={l.username}
|
||||
coordinate={{
|
||||
latitude: l.location.latitude,
|
||||
longitude: l.location.longitude,
|
||||
}}
|
||||
title={l.username}
|
||||
description={`${STATUS_LABELS[l.status] || l.status}${l.stats.current_command ? ` · Cmd #${l.stats.current_command}` : ""}`}
|
||||
onPress={() => trackLivreur(l)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.markerOuter,
|
||||
isSelected && styles.markerSelected,
|
||||
]}
|
||||
>
|
||||
<View style={styles.markerInner}>
|
||||
<Ionicons
|
||||
name="bicycle"
|
||||
size={14}
|
||||
color={colors.white}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Marker>
|
||||
);
|
||||
});
|
||||
|
||||
const renderRouteOverlay = () => {
|
||||
if (!destinationCoords) return null;
|
||||
return (
|
||||
<>
|
||||
<Marker
|
||||
coordinate={{
|
||||
latitude: destinationCoords.latitude,
|
||||
longitude: destinationCoords.longitude,
|
||||
}}
|
||||
title="Destination"
|
||||
>
|
||||
<View style={styles.destMarkerOuter}>
|
||||
<View style={styles.destMarkerInner}>
|
||||
<Ionicons
|
||||
name="flag"
|
||||
size={12}
|
||||
color={colors.white}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Marker>
|
||||
{routeInfo && routeInfo.coordinates.length > 0 && (
|
||||
<Polyline
|
||||
coordinates={routeInfo.coordinates}
|
||||
strokeColor="#4285F4"
|
||||
strokeWidth={4}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLivreur = ({ item }: { item: DeliveryPerson }) => {
|
||||
const isSelected = selectedLivreur?.username === item.username;
|
||||
@@ -334,22 +253,28 @@ export default function DeliveryScreen() {
|
||||
<View>
|
||||
{livreursWithGPS.length > 0 && (
|
||||
<View style={styles.mapContainer}>
|
||||
<MapView
|
||||
<TomTomMap
|
||||
ref={mapRef}
|
||||
provider={PROVIDER_DEFAULT}
|
||||
style={styles.map}
|
||||
initialRegion={{
|
||||
markers={livreurMarkers}
|
||||
initialCenter={{
|
||||
latitude: livreursWithGPS[0].location.latitude,
|
||||
longitude: livreursWithGPS[0].location.longitude,
|
||||
latitudeDelta: 0.05,
|
||||
longitudeDelta: 0.05,
|
||||
}}
|
||||
onMapReady={() => fitAllMarkers(mapRef)}
|
||||
showsUserLocation={false}
|
||||
>
|
||||
{renderMapMarkers()}
|
||||
{renderRouteOverlay()}
|
||||
</MapView>
|
||||
initialZoom={13}
|
||||
onMarkerPress={(id) => {
|
||||
const livreur = livreursWithGPS.find(
|
||||
(l) => l.username === id,
|
||||
);
|
||||
if (livreur) {
|
||||
if (selectedLivreur?.username === id) {
|
||||
clearRoute();
|
||||
} else {
|
||||
trackLivreur(livreur);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{routeInfo && selectedLivreur && (
|
||||
<View style={styles.routeOverlay}>
|
||||
@@ -392,7 +317,7 @@ export default function DeliveryScreen() {
|
||||
<View style={styles.mapBtns}>
|
||||
<TouchableOpacity
|
||||
style={styles.mapBtn}
|
||||
onPress={() => fitAllMarkers(mapRef)}
|
||||
onPress={() => mapRef.current?.fitAllMarkers()}
|
||||
>
|
||||
<Ionicons
|
||||
name="locate-outline"
|
||||
@@ -469,47 +394,6 @@ export default function DeliveryScreen() {
|
||||
},
|
||||
map: { width: "100%", height: MAP_HEIGHT },
|
||||
|
||||
// Point bleu pour les livreurs
|
||||
markerOuter: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
backgroundColor: "rgba(33, 150, 243, 0.3)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
markerSelected: {
|
||||
borderWidth: 2,
|
||||
borderColor: colors.info,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
},
|
||||
markerInner: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
backgroundColor: "#2196F3",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
destMarkerOuter: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
backgroundColor: colors.danger + "40",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
destMarkerInner: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.danger,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
routeOverlay: {
|
||||
position: "absolute",
|
||||
top: spacing.s,
|
||||
@@ -746,25 +630,30 @@ export default function DeliveryScreen() {
|
||||
<StatusBar hidden={mapFullscreen} />
|
||||
<View style={styles.fullscreenContainer}>
|
||||
{livreursWithGPS.length > 0 && (
|
||||
<MapView
|
||||
<TomTomMap
|
||||
ref={fullscreenMapRef}
|
||||
provider={PROVIDER_DEFAULT}
|
||||
style={styles.fullscreenMap}
|
||||
initialRegion={{
|
||||
latitude: livreursWithGPS[0].location.latitude,
|
||||
markers={livreurMarkers}
|
||||
initialCenter={{
|
||||
latitude:
|
||||
livreursWithGPS[0].location.latitude,
|
||||
longitude:
|
||||
livreursWithGPS[0].location.longitude,
|
||||
latitudeDelta: 0.05,
|
||||
longitudeDelta: 0.05,
|
||||
}}
|
||||
onMapReady={() => fitAllMarkers(fullscreenMapRef)}
|
||||
showsUserLocation={false}
|
||||
showsCompass
|
||||
showsScale
|
||||
>
|
||||
{renderMapMarkers()}
|
||||
{renderRouteOverlay()}
|
||||
</MapView>
|
||||
initialZoom={13}
|
||||
onMarkerPress={(id) => {
|
||||
const livreur = livreursWithGPS.find(
|
||||
(l) => l.username === id,
|
||||
);
|
||||
if (livreur) {
|
||||
if (selectedLivreur?.username === id) {
|
||||
clearRoute();
|
||||
} else {
|
||||
trackLivreur(livreur);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={styles.fullscreenTopBar}>
|
||||
@@ -783,7 +672,9 @@ export default function DeliveryScreen() {
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.closeBtn}
|
||||
onPress={() => fitAllMarkers(fullscreenMapRef)}
|
||||
onPress={() =>
|
||||
fullscreenMapRef.current?.fitAllMarkers()
|
||||
}
|
||||
>
|
||||
<Ionicons
|
||||
name="locate-outline"
|
||||
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getAllCommands } from "../../api/api_admin";
|
||||
import {
|
||||
getCommandItems,
|
||||
updateItemStatus,
|
||||
deleteCommand,
|
||||
} from "../../api/api_cabine";
|
||||
import type { CommandResponse } from "../../api/types";
|
||||
@@ -24,7 +24,23 @@ import Card from "../../components/ui/Card";
|
||||
import AlertModal from "../../components/ui/AlertModal";
|
||||
import { useAlert } from "../../hooks/useAlert";
|
||||
|
||||
const ITEM_STATUSES = ["pending", "preparing", "ready"];
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: "#F59E0B",
|
||||
preparing: "#3B82F6",
|
||||
ready: "#10B981",
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
pending: "time-outline",
|
||||
preparing: "construct-outline",
|
||||
ready: "checkmark-circle-outline",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: "En attente",
|
||||
preparing: "En préparation",
|
||||
ready: "Prêt",
|
||||
};
|
||||
|
||||
export default function OrdersScreen() {
|
||||
const { colors } = useTheme();
|
||||
@@ -36,7 +52,15 @@ export default function OrdersScreen() {
|
||||
visible: boolean;
|
||||
commandId: number | null;
|
||||
items: any[];
|
||||
}>({ visible: false, commandId: null, items: [] });
|
||||
commandInfo: any;
|
||||
clientInfo: any;
|
||||
}>({
|
||||
visible: false,
|
||||
commandId: null,
|
||||
items: [],
|
||||
commandInfo: null,
|
||||
clientInfo: null,
|
||||
});
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
@@ -56,6 +80,7 @@ export default function OrdersScreen() {
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
@@ -65,20 +90,18 @@ export default function OrdersScreen() {
|
||||
const openItems = async (commandId: number) => {
|
||||
try {
|
||||
const result = await getCommandItems(commandId);
|
||||
setItemsModal({ visible: true, commandId, items: result.items });
|
||||
setItemsModal({
|
||||
visible: true,
|
||||
commandId,
|
||||
items: result.items,
|
||||
commandInfo: result.command_info,
|
||||
clientInfo: result.client_info,
|
||||
});
|
||||
} catch (e: any) {
|
||||
showError("Erreur", e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemStatus = async (itemId: number, status: string) => {
|
||||
try {
|
||||
await updateItemStatus(itemId, status);
|
||||
if (itemsModal.commandId) await openItems(itemsModal.commandId);
|
||||
} catch (e: any) {
|
||||
showError("Erreur", e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (commandId: number) => {
|
||||
showConfirm(
|
||||
@@ -96,6 +119,15 @@ export default function OrdersScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const closeModal = () =>
|
||||
setItemsModal({
|
||||
visible: false,
|
||||
commandId: null,
|
||||
items: [],
|
||||
commandInfo: null,
|
||||
clientInfo: null,
|
||||
});
|
||||
|
||||
const styles = useMemo(
|
||||
() =>
|
||||
StyleSheet.create({
|
||||
@@ -121,24 +153,124 @@ export default function OrdersScreen() {
|
||||
gap: spacing.s,
|
||||
marginTop: spacing.m,
|
||||
},
|
||||
itemRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: spacing.m,
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
itemName: {
|
||||
color: colors.textWhite,
|
||||
fontSize: fontSize.md,
|
||||
fontWeight: "600",
|
||||
},
|
||||
empty: {
|
||||
color: colors.textMuted,
|
||||
textAlign: "center",
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
|
||||
// Modal summary
|
||||
modalSummary: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: borderRadius.sm,
|
||||
padding: spacing.m,
|
||||
marginBottom: spacing.m,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
modalSummaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.s,
|
||||
},
|
||||
modalSummaryText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: fontSize.sm,
|
||||
flex: 1,
|
||||
},
|
||||
modalTotal: {
|
||||
color: colors.accent,
|
||||
fontSize: fontSize.md,
|
||||
fontWeight: "700",
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
|
||||
// Modal progress bar
|
||||
progressContainer: {
|
||||
marginBottom: spacing.m,
|
||||
},
|
||||
progressLabel: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.xs,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
progressBar: {
|
||||
height: 4,
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressFill: {
|
||||
height: "100%",
|
||||
borderRadius: 2,
|
||||
},
|
||||
|
||||
// Item card
|
||||
itemCard: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginBottom: spacing.s,
|
||||
overflow: "hidden",
|
||||
},
|
||||
itemCardAccent: {
|
||||
height: 3,
|
||||
},
|
||||
itemCardBody: {
|
||||
padding: spacing.m,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: spacing.m,
|
||||
},
|
||||
itemIndex: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.bgPrimary,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginTop: 2,
|
||||
},
|
||||
itemIndexText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.xs,
|
||||
fontWeight: "700",
|
||||
},
|
||||
itemInfo: { flex: 1 },
|
||||
itemName: {
|
||||
color: colors.textWhite,
|
||||
fontSize: fontSize.md,
|
||||
fontWeight: "600",
|
||||
marginBottom: 2,
|
||||
},
|
||||
itemMeta: {
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.xs,
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
itemStatusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
},
|
||||
itemStatusText: {
|
||||
fontSize: fontSize.xs,
|
||||
fontWeight: "600",
|
||||
},
|
||||
itemActions: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
statusBtn: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.s,
|
||||
paddingVertical: 5,
|
||||
borderRadius: borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
},
|
||||
statusBtnText: {
|
||||
fontSize: fontSize.xs,
|
||||
fontWeight: "600",
|
||||
},
|
||||
}),
|
||||
[colors],
|
||||
);
|
||||
@@ -171,6 +303,12 @@ export default function OrdersScreen() {
|
||||
</Card>
|
||||
);
|
||||
|
||||
const readyCount = itemsModal.items.filter(
|
||||
(i) => i.status === "ready",
|
||||
).length;
|
||||
const totalCount = itemsModal.items.length;
|
||||
const progress = totalCount > 0 ? readyCount / totalCount : 0;
|
||||
|
||||
if (loading) return <LoadingSpinner message="Chargement..." />;
|
||||
|
||||
return (
|
||||
@@ -191,51 +329,138 @@ export default function OrdersScreen() {
|
||||
<Text style={styles.empty}>Aucune commande active</Text>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={itemsModal.visible}
|
||||
onClose={() =>
|
||||
setItemsModal({
|
||||
visible: false,
|
||||
commandId: null,
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
title={`Items #${itemsModal.commandId}`}
|
||||
icon="list-outline"
|
||||
onClose={closeModal}
|
||||
title={`Commande #${itemsModal.commandId}`}
|
||||
icon="receipt-outline"
|
||||
>
|
||||
{itemsModal.items.map((item: any) => (
|
||||
<View key={item.id} style={styles.itemRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.product_name}
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={false}
|
||||
>
|
||||
{/* Résumé commande / client */}
|
||||
{(itemsModal.commandInfo || itemsModal.clientInfo) && (
|
||||
<View style={styles.modalSummary}>
|
||||
{itemsModal.clientInfo?.username && (
|
||||
<View style={styles.modalSummaryRow}>
|
||||
<Ionicons
|
||||
name="person-outline"
|
||||
size={14}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
<Text style={styles.modalSummaryText}>
|
||||
{itemsModal.clientInfo.prenom}{" "}
|
||||
{itemsModal.clientInfo.nom} ·{" "}
|
||||
{itemsModal.clientInfo.username}
|
||||
</Text>
|
||||
<Text style={styles.info}>
|
||||
Qté: {item.quantity} | {item.price?.toFixed(2)}{" "}
|
||||
</View>
|
||||
)}
|
||||
{(itemsModal.commandInfo?.address || itemsModal.commandInfo?.adresse) && (
|
||||
<View style={styles.modalSummaryRow}>
|
||||
<Ionicons
|
||||
name="location-outline"
|
||||
size={14}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
<Text style={styles.modalSummaryText}>
|
||||
{itemsModal.commandInfo.address || itemsModal.commandInfo.adresse}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{itemsModal.commandInfo?.total_prix != null && (
|
||||
<Text style={styles.modalTotal}>
|
||||
{Number(
|
||||
itemsModal.commandInfo.total_prix,
|
||||
).toFixed(2)}{" "}
|
||||
€
|
||||
</Text>
|
||||
<StatusBadge status={item.status} />
|
||||
</View>
|
||||
<View style={{ gap: spacing.xs }}>
|
||||
{ITEM_STATUSES.filter((s) => s !== item.status).map(
|
||||
(s) => (
|
||||
<Button
|
||||
key={s}
|
||||
title={s}
|
||||
onPress={() =>
|
||||
handleItemStatus(item.id, s)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Barre de progression */}
|
||||
{totalCount > 0 && (
|
||||
<View style={styles.progressContainer}>
|
||||
<Text style={styles.progressLabel}>
|
||||
{readyCount}/{totalCount} prêts
|
||||
</Text>
|
||||
<View style={styles.progressBar}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor:
|
||||
progress === 1
|
||||
? STATUS_COLORS.ready
|
||||
: colors.info,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Liste des items */}
|
||||
{itemsModal.items.map((item: any, index: number) => {
|
||||
const statusColor =
|
||||
STATUS_COLORS[item.status] || colors.textMuted;
|
||||
return (
|
||||
<View key={item.id} style={styles.itemCard}>
|
||||
<View
|
||||
style={[
|
||||
styles.itemCardAccent,
|
||||
{ backgroundColor: statusColor },
|
||||
]}
|
||||
/>
|
||||
<View style={styles.itemCardBody}>
|
||||
<View style={styles.itemIndex}>
|
||||
<Text style={styles.itemIndexText}>
|
||||
{index + 1}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.itemInfo}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.produit ?? item.product_name}
|
||||
</Text>
|
||||
<Text style={styles.itemMeta}>
|
||||
Qté: {item.quantite ?? item.quantity} ·{" "}
|
||||
{(item.prix ?? item.price)?.toFixed(2)} €
|
||||
</Text>
|
||||
<View style={styles.itemStatusRow}>
|
||||
<Ionicons
|
||||
name={
|
||||
STATUS_ICONS[
|
||||
item.status
|
||||
] || "ellipse-outline"
|
||||
}
|
||||
size={13}
|
||||
color={statusColor}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.itemStatusText,
|
||||
{ color: statusColor },
|
||||
]}
|
||||
>
|
||||
{STATUS_LABELS[item.status] ||
|
||||
item.status}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{itemsModal.items.length === 0 && (
|
||||
<Text style={styles.empty}>Aucun item</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Modal>
|
||||
|
||||
<AlertModal
|
||||
visible={alert.visible}
|
||||
type={alert.type}
|
||||
|
||||
@@ -3,13 +3,11 @@ import { View, Text, StyleSheet, FlatList, RefreshControl } from "react-native";
|
||||
import { spacing, fontSize } from "../../theme";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getAllClients } from "../../api/api_admin";
|
||||
import { applyClientPenalty, resetClientPenalties } from "../../api/api_cabine";
|
||||
import { applyClientPenalty, resetClientPenalties, resetClientPoints } from "../../api/api_cabine";
|
||||
import type { ClientResponse } from "../../api/types";
|
||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||
import Card from "../../components/ui/Card";
|
||||
import Button from "../../components/ui/Button";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import TextInput from "../../components/ui/TextInput";
|
||||
import AlertModal from "../../components/ui/AlertModal";
|
||||
import { useAlert } from "../../hooks/useAlert";
|
||||
|
||||
@@ -22,8 +20,6 @@ export default function UsersScreen() {
|
||||
visible: boolean;
|
||||
username: string;
|
||||
}>({ visible: false, username: "" });
|
||||
const [reason, setReason] = useState("");
|
||||
const [penaltyAmount, setPenaltyAmount] = useState("");
|
||||
const { alert, showError, showSuccess, showConfirm, hideAlert } =
|
||||
useAlert();
|
||||
|
||||
@@ -45,31 +41,9 @@ export default function UsersScreen() {
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleApplyPenalty = async () => {
|
||||
if (!reason.trim()) {
|
||||
showError("Erreur", "Raison requise");
|
||||
return;
|
||||
}
|
||||
const amount = parseInt(penaltyAmount, 10);
|
||||
if (!penaltyAmount.trim() || isNaN(amount) || amount <= 0) {
|
||||
showError("Erreur", "Nombre de points invalide");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await applyClientPenalty(penaltyModal.username, reason, amount);
|
||||
setPenaltyModal({ visible: false, username: "" });
|
||||
setReason("");
|
||||
setPenaltyAmount("");
|
||||
await loadData();
|
||||
showSuccess("Succès", "Pénalité appliquée");
|
||||
} catch (e: any) {
|
||||
showError("Erreur", e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = (username: string) => {
|
||||
showConfirm(
|
||||
"Reset",
|
||||
"Reset pénalités",
|
||||
`Réinitialiser les pénalités de ${username} ?`,
|
||||
async () => {
|
||||
try {
|
||||
@@ -83,6 +57,22 @@ export default function UsersScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleResetPoints = (username: string) => {
|
||||
showConfirm(
|
||||
"Reset points",
|
||||
`Réinitialiser les points de ${username} ?`,
|
||||
async () => {
|
||||
try {
|
||||
await resetClientPoints(username);
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
showError("Erreur", e.message);
|
||||
}
|
||||
},
|
||||
"Reset",
|
||||
);
|
||||
};
|
||||
|
||||
const styles = useMemo(
|
||||
() =>
|
||||
StyleSheet.create({
|
||||
@@ -153,21 +143,14 @@ export default function UsersScreen() {
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title="Pénalité"
|
||||
onPress={() => {
|
||||
setPenaltyModal({
|
||||
visible: true,
|
||||
username: item.username,
|
||||
});
|
||||
setReason("");
|
||||
setPenaltyAmount("");
|
||||
}}
|
||||
title="Reset pénalités"
|
||||
onPress={() => handleReset(item.username)}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
variant="outline"
|
||||
/>
|
||||
<Button
|
||||
title="Reset"
|
||||
onPress={() => handleReset(item.username)}
|
||||
title="Reset points"
|
||||
onPress={() => handleResetPoints(item.username)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
/>
|
||||
@@ -195,35 +178,7 @@ export default function UsersScreen() {
|
||||
<Text style={styles.empty}>Aucun client</Text>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
visible={penaltyModal.visible}
|
||||
onClose={() =>
|
||||
setPenaltyModal({ visible: false, username: "" })
|
||||
}
|
||||
title={`Pénalité - ${penaltyModal.username}`}
|
||||
icon="warning-outline"
|
||||
iconColor={colors.danger}
|
||||
>
|
||||
<TextInput
|
||||
label="Nombre de points"
|
||||
value={penaltyAmount}
|
||||
onChangeText={setPenaltyAmount}
|
||||
placeholder="Ex: 5"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<TextInput
|
||||
label="Raison"
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
placeholder="Raison de la pénalité"
|
||||
/>
|
||||
<Button
|
||||
title="Appliquer"
|
||||
onPress={handleApplyPenalty}
|
||||
variant="danger"
|
||||
fullWidth
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<AlertModal
|
||||
visible={alert.visible}
|
||||
type={alert.type}
|
||||
|
||||
@@ -434,7 +434,10 @@ export default function DashboardScreen() {
|
||||
|
||||
const openNavigation = (address: string) => {
|
||||
const encoded = encodeURIComponent(address);
|
||||
const url = `https://www.google.com/maps/dir/?api=1&destination=${encoded}&travelmode=driving`;
|
||||
let url = `https://waze.com/ul?q=${encoded}&navigate=yes`;
|
||||
if (lastCoords) {
|
||||
url += `&ll=${lastCoords.lat},${lastCoords.lng}`;
|
||||
}
|
||||
Linking.openURL(url);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user