chore: features category

This commit is contained in:
2026-03-07 15:10:18 +01:00
parent f66efee59c
commit dd31473ca1
13 changed files with 433 additions and 62 deletions
+3 -3
View File
@@ -15,7 +15,7 @@
"simulator": true
},
"env": {
"API_URL": "https://uber-stup.club"
"API_URL": "http://5.181.0.112"
}
},
"preview": {
@@ -24,7 +24,7 @@
"buildType": "apk"
},
"env": {
"API_URL": "https://uber-stup.club"
"API_URL": "http://5.181.0.112"
}
},
"production": {
@@ -34,7 +34,7 @@
"buildType": "apk"
},
"env": {
"API_URL": "https://uber-stup.club"
"API_URL": "http://5.181.0.112"
}
}
}
+72 -2
View File
@@ -9,8 +9,8 @@ import type {
Alert,
} from "./types";
const V2 = "https://uber-stup.club/api/v2";
const CABINE_URL = "https://uber-stup.club/api/v1/cabine";
const V2 = "http://5.181.0.112/api/v2";
const CABINE_URL = "http://5.181.0.112/api/v1/cabine";
// ============================================
// AUTH
@@ -580,3 +580,73 @@ export const getCommandCountByStatus = async (
return 0;
}
};
// ============================================
// CATÉGORIES
// ============================================
const V1_PUBLIC = "https://uber-stup.club/api/v1";
export interface Category {
id: number;
name: string;
created_at: string;
}
export const getCategories = async (): Promise<Category[]> => {
try {
const { data } = await apiClient.get(`${V1_PUBLIC}/categories`);
return data.categories || [];
} catch {
return [];
}
};
export const createCategoryAdmin = async (
name: string,
): Promise<{ success: boolean; category?: Category; error?: string }> => {
try {
const { data } = await apiClient.post(
`${V2}/admin/protected/categories`,
{ name },
);
return { success: true, category: data.category };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur",
};
}
};
export const updateCategoryAdmin = async (
id: number,
name: string,
): Promise<{ success: boolean; category?: Category; error?: string }> => {
try {
const { data } = await apiClient.put(
`${V2}/admin/protected/categories/${id}`,
{ name },
);
return { success: true, category: data.category };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur",
};
}
};
export const deleteCategoryAdmin = async (
id: number,
): Promise<{ success: boolean; error?: string }> => {
try {
await apiClient.delete(`${V2}/admin/protected/categories/${id}`);
return { success: true };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur",
};
}
};
+2 -2
View File
@@ -6,8 +6,8 @@ import type {
Alert,
} from "./types";
const API = "https://uber-stup.club/api/v1/cabine";
const V2 = "https://uber-stup.club/api/v2";
const API = "http://5.181.0.112/api/v1/cabine";
const V2 = "http://5.181.0.112/api/v2";
// ============================================
// ITEMS
+1 -1
View File
@@ -7,7 +7,7 @@ import type {
Alert,
} from "./types";
const API = "https://uber-stup.club/api/v1/livreur";
const API = "http://5.181.0.112/api/v1/livreur";
// ============================================
// STATUT
+1 -1
View File
@@ -2,7 +2,7 @@ import axios from "axios";
import { getToken, getAdminToken } from "../auth/tokenStorage";
// Change this to your server IP/domain
export const API_BASE_URL = "https://uber-stup.club";
export const API_BASE_URL = "http://5.181.0.112";
const apiClient = axios.create({
baseURL: API_BASE_URL,
@@ -14,6 +14,7 @@ import OrdersScreen from "../screens/admin/OrdersScreen";
import OrderDetailScreen from "../screens/admin/OrderDetailScreen";
import UsersScreen from "../screens/admin/UsersScreen";
import ProductsScreen from "../screens/admin/ProductsScreen";
import CategoriesScreen from "../screens/admin/CategoriesScreen";
import DeliveryScreen from "../screens/admin/DeliveryScreen";
import AlertsScreen from "../screens/admin/AlertsScreen";
import AddressScreen from "../screens/admin/AddressScreen";
@@ -128,6 +129,20 @@ function AdminTabs() {
),
}}
/>
<Tab.Screen
name="Categories"
component={CategoriesScreen}
options={{
title: "Catégories",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="pricetag-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Delivery"
component={DeliveryScreen}
+1
View File
@@ -10,6 +10,7 @@ export type AdminTabParamList = {
Orders: undefined;
Users: undefined;
Products: undefined;
Categories: undefined;
Delivery: undefined;
Alerts: undefined;
Addresses: undefined;
@@ -0,0 +1,280 @@
import React, { useState, useEffect, useCallback } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
TextInput,
TouchableOpacity,
Modal,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getCategories,
createCategoryAdmin,
updateCategoryAdmin,
deleteCategoryAdmin,
} from "../../api/api_admin";
import type { Category } from "../../api/api_admin";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
export default function CategoriesScreen() {
const { colors } = useTheme();
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [name, setName] = useState("");
const [saving, setSaving] = useState(false);
const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert();
const load = useCallback(async () => {
setLoading(true);
const data = await getCategories();
setCategories(data);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
const openCreate = () => {
setEditingCategory(null);
setName("");
setModalVisible(true);
};
const openEdit = (cat: Category) => {
setEditingCategory(cat);
setName(cat.name);
setModalVisible(true);
};
const closeModal = () => {
setModalVisible(false);
setName("");
setEditingCategory(null);
};
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) {
showError("Erreur", "Le nom est requis");
return;
}
setSaving(true);
if (editingCategory) {
const res = await updateCategoryAdmin(editingCategory.id, trimmed);
if (res.success) {
showSuccess("Succès", "Catégorie modifiée");
closeModal();
load();
} else {
showError("Erreur", res.error || "Erreur");
}
} else {
const res = await createCategoryAdmin(trimmed);
if (res.success) {
showSuccess("Succès", "Catégorie créée");
closeModal();
load();
} else {
showError("Erreur", res.error || "Erreur");
}
}
setSaving(false);
};
const handleDelete = (cat: Category) => {
showConfirm(
"Supprimer",
`Supprimer la catégorie "${cat.name}" ?`,
async () => {
const res = await deleteCategoryAdmin(cat.id);
if (res.success) {
showSuccess("Succès", "Catégorie supprimée");
load();
} else {
showError("Erreur", res.error || "Erreur");
}
},
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: spacing.m,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
title: { fontSize: fontSize.xl, fontWeight: "700", color: colors.text },
addBtn: {
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: colors.accent,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
borderRadius: borderRadius.m,
},
addBtnText: { color: "#fff", fontWeight: "600", fontSize: fontSize.s },
list: { padding: spacing.m, gap: spacing.s },
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
catName: { fontSize: fontSize.m, color: colors.text, fontWeight: "600", textTransform: "capitalize" },
catDate: { fontSize: fontSize.xs, color: colors.textSecondary, marginTop: 2 },
actions: { flexDirection: "row", gap: spacing.s },
actionBtn: { padding: 8 },
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "center",
padding: spacing.l,
},
modal: {
backgroundColor: colors.surface,
borderRadius: borderRadius.l,
padding: spacing.l,
gap: spacing.m,
},
modalTitle: { fontSize: fontSize.l, fontWeight: "700", color: colors.text },
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.m,
padding: spacing.m,
color: colors.text,
fontSize: fontSize.m,
backgroundColor: colors.background,
},
modalBtns: { flexDirection: "row", gap: spacing.s },
cancelBtn: {
flex: 1,
padding: spacing.m,
borderRadius: borderRadius.m,
borderWidth: 1,
borderColor: colors.border,
alignItems: "center",
},
cancelBtnText: { color: colors.text, fontWeight: "600" },
saveBtn: {
flex: 1,
padding: spacing.m,
borderRadius: borderRadius.m,
backgroundColor: colors.accent,
alignItems: "center",
},
saveBtnText: { color: "#fff", fontWeight: "600" },
empty: { textAlign: "center", color: colors.textSecondary, marginTop: spacing.xl },
});
if (loading) return <LoadingSpinner />;
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Catégories</Text>
<TouchableOpacity style={styles.addBtn} onPress={openCreate}>
<Ionicons name="add" size={18} color="#fff" />
<Text style={styles.addBtnText}>Ajouter</Text>
</TouchableOpacity>
</View>
<FlatList
data={categories}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
ListEmptyComponent={
<Text style={styles.empty}>Aucune catégorie. Créez-en une !</Text>
}
renderItem={({ item }) => (
<Card>
<View style={styles.row}>
<View>
<Text style={styles.catName}>{item.name}</Text>
<Text style={styles.catDate}>
Créée le {new Date(item.created_at).toLocaleDateString("fr-FR")}
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity
style={styles.actionBtn}
onPress={() => openEdit(item)}
>
<Ionicons name="pencil-outline" size={20} color={colors.info} />
</TouchableOpacity>
<TouchableOpacity
style={styles.actionBtn}
onPress={() => handleDelete(item)}
>
<Ionicons name="trash-outline" size={20} color={colors.danger} />
</TouchableOpacity>
</View>
</View>
</Card>
)}
/>
<Modal visible={modalVisible} transparent animationType="fade">
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.overlay}
>
<View style={styles.modal}>
<Text style={styles.modalTitle}>
{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"}
</Text>
<TextInput
style={styles.input}
placeholder="Nom de la catégorie"
placeholderTextColor={colors.textSecondary}
value={name}
onChangeText={setName}
autoCapitalize="none"
/>
<View style={styles.modalBtns}>
<TouchableOpacity style={styles.cancelBtn} onPress={closeModal}>
<Text style={styles.cancelBtnText}>Annuler</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.saveBtn}
onPress={handleSave}
disabled={saving}
>
<Text style={styles.saveBtnText}>
{saving ? "..." : "Enregistrer"}
</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onConfirm={alert.onConfirm || hideAlert}
onCancel={alert.type === "confirm" ? hideAlert : undefined}
confirmText={alert.type === "confirm" ? "Confirmer" : "OK"}
cancelText="Annuler"
/>
</View>
);
}
@@ -25,7 +25,9 @@ import {
deleteProductAdmin,
uploadProductMediaAdmin,
deleteProductMediaAdmin,
getCategories,
} from "../../api/api_admin";
import type { Category } from "../../api/api_admin";
import type { Product } from "../../api/types";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
@@ -54,11 +56,7 @@ interface PendingMedia {
mediaType: "image" | "video";
}
const CATEGORIES = [
{ value: "weed&hash", label: "Weed & Hash" },
{ value: "zipette&co", label: "Zipette & Co" },
{ value: "gros&semi", label: "Gros & Semi" },
];
// Les catégories sont chargées dynamiquement depuis l'API
const UNITS = [
{ value: "u", label: "u" },
@@ -79,9 +77,9 @@ interface FormState {
prices: PriceRow[];
}
const emptyForm = (): FormState => ({
const emptyForm = (firstCategory = ""): FormState => ({
name: "",
category: "weed&hash",
category: firstCategory,
description: "",
stock: "",
unit: "u",
@@ -94,6 +92,7 @@ const emptyForm = (): FormState => ({
export default function ProductsScreen() {
const { colors } = useTheme();
const [products, setProducts] = useState<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -117,8 +116,12 @@ export default function ProductsScreen() {
// --------------------------------------------------
const loadData = useCallback(async () => {
try {
const result = await getAllProductsAdmin();
const [result, cats] = await Promise.all([
getAllProductsAdmin(),
getCategories(),
]);
setProducts(result.data);
setCategories(cats);
} catch {
/* ignore */
}
@@ -139,7 +142,7 @@ export default function ProductsScreen() {
// --------------------------------------------------
const openCreate = () => {
setEditingProduct(null);
setForm(emptyForm());
setForm(emptyForm(categories[0]?.name || ""));
setExistingMedia([]);
setMediaToDelete([]);
setPendingMedia([]);
@@ -896,29 +899,29 @@ export default function ProductsScreen() {
{/* Catégorie */}
<Text style={styles.label}>Catégorie *</Text>
<View style={styles.catRow}>
{CATEGORIES.map((c) => (
{categories.map((c) => (
<TouchableOpacity
key={c.value}
key={c.id}
style={[
styles.catBtn,
form.category === c.value &&
form.category === c.name &&
styles.catBtnActive,
]}
onPress={() =>
setForm((f) => ({
...f,
category: c.value,
category: c.name,
}))
}
>
<Text
style={[
styles.catBtnText,
form.category === c.value &&
form.category === c.name &&
styles.catBtnTextActive,
]}
>
{c.label}
{c.name}
</Text>
</TouchableOpacity>
))}