From 1fdfe88e5139c01ed082ae329a4ba1df5ef7e6e2 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Sun, 30 Aug 2026 16:04:29 -0500 Subject: [PATCH] feat: persistencia total Appwrite, inteligencia de mercado y estrategias de precios --- apply_sneakers_columns.py | 82 ++ deploy_dripzone_vps.py | 13 +- dripzone-app/docker-compose.prod.yml | 2 + dripzone-app/src/app/admin/ciudades/page.tsx | 600 ++++++---- .../src/app/admin/feed-whatsapp/page.tsx | 1030 +++++++++++------ .../src/app/admin/metodos-pago/page.tsx | 486 ++++++-- dripzone-app/src/app/api/cities/route.ts | 68 +- .../src/app/api/market-intelligence/route.ts | 22 + dripzone-app/src/app/api/orders/route.ts | 34 +- .../src/app/api/payment-methods/route.ts | 66 +- dripzone-app/src/app/api/settings/route.ts | 18 +- dripzone-app/src/app/api/suppliers/route.ts | 42 +- .../src/app/api/whatsapp-feed/route.ts | 121 +- dripzone-app/src/lib/data-service.ts | 21 + dripzone-app/src/lib/market-intelligence.ts | 127 ++ dripzone-app/src/lib/pricing-engine.ts | 92 ++ dripzone-app/src/lib/server-appwrite.ts | 665 +++++++++++ dripzone-app/src/types/index.ts | 14 +- patch_appwrite_db.py | 54 + scripts/send_apk_to_whatsapp.js | 2 +- seed_appwrite_initial.js | 290 +++++ setup_sneakers_schema.js | 210 ++++ ssh_exec.py | 25 + sync_collection_attributes.py | 86 ++ vps_sql.py | 37 + vps_sql_runner.py | 36 + wait_and_seed.js | 47 + 27 files changed, 3530 insertions(+), 760 deletions(-) create mode 100644 apply_sneakers_columns.py create mode 100644 dripzone-app/src/app/api/market-intelligence/route.ts create mode 100644 dripzone-app/src/lib/market-intelligence.ts create mode 100644 dripzone-app/src/lib/server-appwrite.ts create mode 100644 patch_appwrite_db.py create mode 100644 seed_appwrite_initial.js create mode 100644 setup_sneakers_schema.js create mode 100644 ssh_exec.py create mode 100644 sync_collection_attributes.py create mode 100644 vps_sql.py create mode 100644 vps_sql_runner.py create mode 100644 wait_and_seed.js diff --git a/apply_sneakers_columns.py b/apply_sneakers_columns.py new file mode 100644 index 0000000..8325586 --- /dev/null +++ b/apply_sneakers_columns.py @@ -0,0 +1,82 @@ +import paramiko +import sys +import io + +if sys.platform == 'win32': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + +HOST = "161.97.93.156" +USER = "carlos" +PASSWORD = "Innova1978+*?" +MARIADB_CONTAINER = "appwrite-mariadb-sss6crntvrcs54qbhvhy5728" +DB_PASS = "sn9YkZqoLV06CQxAy2qsKi36S7POVShT" +DB_USER = "sYoHWwl0DGvEyt3s" + +client = paramiko.SSHClient() +client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30) + +# 1. Get collections mapping for DB 2 +def get_query(q): + sftp = client.open_sftp() + with sftp.file('/tmp/q.sql', 'w') as f: + f.write(q) + sftp.close() + cmd = f'docker exec -i {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} appwrite -N -B < /tmp/q.sql' + stdin, stdout, stderr = client.exec_command(cmd) + lines = [line.strip().split('\t') for line in stdout if line.strip()] + return lines + +cols = get_query("SELECT _id, _uid FROM _1_database_2;") +print("Collections in DB 2:", cols) +col_map = {row[1]: f"_1_database_2_collection_{row[0]}" for row in cols} + +# 2. Get attributes for DB 2 +attrs = get_query("SELECT collectionId, `key`, type, size, required FROM _1_attributes WHERE databaseInternalId = 2;") +print(f"Total attributes in DB 2: {len(attrs)}") + +sql_commands = [] +for col_uid, key, attr_type, size, req in attrs: + tbl = col_map.get(col_uid) + if not tbl: + continue + + col_def = "VARCHAR(255) DEFAULT NULL" + if attr_type == 'string': + s = int(size) if size and size != 'NULL' else 255 + if s > 65535: + col_def = "MEDIUMTEXT DEFAULT NULL" + elif s > 2000: + col_def = "TEXT DEFAULT NULL" + else: + col_def = f"VARCHAR({min(s, 4000)}) DEFAULT NULL" + elif attr_type == 'integer': + col_def = "BIGINT DEFAULT NULL" + elif attr_type == 'boolean': + col_def = "TINYINT(1) DEFAULT NULL" + elif attr_type == 'float' or attr_type == 'double': + col_def = "DOUBLE DEFAULT NULL" + + sql_commands.append(f"ALTER TABLE {tbl} ADD COLUMN `{key}` {col_def};") + +sql_commands.append("UPDATE _1_attributes SET status = 'available', error = '' WHERE databaseInternalId = 2;") + +full_sql = "\n".join([f"{cmd} -- error ignored" for cmd in sql_commands]) + +sftp = client.open_sftp() +with sftp.file('/tmp/apply_attrs.sql', 'w') as f: + f.write("\n".join(sql_commands)) +sftp.close() + +print("Applying attribute migrations to MariaDB...") +cmd = f'docker exec -i {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} -f appwrite < /tmp/apply_attrs.sql' +stdin, stdout, stderr = client.exec_command(cmd) + +for line in stdout: + print(line.strip()) +for line in stderr: + print("[LOG]:", line.strip()) + +client.close() +print("Done!") diff --git a/deploy_dripzone_vps.py b/deploy_dripzone_vps.py index 65138bb..d6b1b17 100644 --- a/deploy_dripzone_vps.py +++ b/deploy_dripzone_vps.py @@ -40,10 +40,19 @@ def deploy(): client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30) sftp = client.open_sftp() - print("[3/4] Subiendo archivo al servidor...") + print("[3/4] Subiendo archivo comprimido y APK al servidor...") sftp.put(ARCHIVE_NAME, REMOTE_TAR) + apk_local = r"c:\Users\Carlos\dev\almeira-web\dripzone-app\public\DripZone.apk" + if os.path.exists(apk_local): + print(" -> Subiendo DripZone.apk directamente a public/...") + try: + sftp.mkdir(f"{REMOTE_DIR}/public") + except: + pass + sftp.put(apk_local, f"{REMOTE_DIR}/public/DripZone.apk") + print(" [OK] DripZone.apk subido al VPS.") sftp.close() - print("[OK] Archivo subido exitosamente.") + print("[OK] Archivos subidos exitosamente.") commands = f""" mkdir -p {REMOTE_DIR} diff --git a/dripzone-app/docker-compose.prod.yml b/dripzone-app/docker-compose.prod.yml index cb8e58c..c1534a4 100644 --- a/dripzone-app/docker-compose.prod.yml +++ b/dripzone-app/docker-compose.prod.yml @@ -19,6 +19,8 @@ services: - EVOLUTION_API_KEY=EE49029EEB85-4568-8F81-C9FDFC6E2943 - EVOLUTION_INSTANCE_NAME=AutomataAlmeira - GEMINI_API_KEY=AIzaSyC7JMkREYxl4qoEUGzd4hzbJ6RFg_YMxx0 + volumes: + - ./public/DripZone.apk:/app/public/DripZone.apk:ro networks: - coolify labels: diff --git a/dripzone-app/src/app/admin/ciudades/page.tsx b/dripzone-app/src/app/admin/ciudades/page.tsx index 53756a2..07d84c1 100644 --- a/dripzone-app/src/app/admin/ciudades/page.tsx +++ b/dripzone-app/src/app/admin/ciudades/page.tsx @@ -1,249 +1,431 @@ 'use client'; import React, { useState, useEffect } from 'react'; -import { MapPin, Plus, Save, Trash2, Check, X, Truck, ShieldCheck } from 'lucide-react'; +import { MapPin, Plus, Trash2, Edit3, ShieldCheck, Truck, Check, X, AlertTriangle, Save } from 'lucide-react'; import { City } from '@/types'; import { INITIAL_CITIES } from '@/lib/data-service'; import { formatCOP } from '@/lib/pricing-engine'; export default function AdminCitiesPage() { const [cities, setCities] = useState(INITIAL_CITIES); - const [isSaving, setIsSaving] = useState(false); - const [message, setMessage] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - // New city form - const [newName, setNewName] = useState(''); - const [newDepartment, setNewDepartment] = useState(''); - const [newShippingCost, setNewShippingCost] = useState('0'); - const [newDeliveryTime, setNewDeliveryTime] = useState('Entrega en 24 Horas'); - const [newAllowCod, setNewAllowCod] = useState(true); - const [newFirstExchangeFree, setNewFirstExchangeFree] = useState(true); + // Modal para Crear / Editar + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editingCity, setEditingCity] = useState | null>(null); + + // Modal para Confirmar Eliminación + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [cityToDelete, setCityToDelete] = useState(null); useEffect(() => { - fetch('/api/cities') - .then((r) => r.json()) - .then((d) => d.success && setCities(d.cities)) - .catch(() => {}); + loadCities(); }, []); - const handleToggleExchangeFree = async (city: City) => { - const updated = { ...city, firstExchangeFree: !city.firstExchangeFree }; - setCities((prev) => prev.map((c) => (c.id === city.id ? updated : c))); - - await fetch('/api/cities', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: city.id, updates: { firstExchangeFree: updated.firstExchangeFree } }), - }); - setMessage(`Política de 1er cambio gratis para "${city.name}" ${updated.firstExchangeFree ? 'ACTIVADA' : 'DESACTIVADA'}.`); - }; - - const handleUpdateShipping = async (cityId: string, cost: number) => { - setCities((prev) => prev.map((c) => (c.id === cityId ? { ...c, shippingCost: cost } : c))); - await fetch('/api/cities', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: cityId, updates: { shippingCost: cost } }), - }); - setMessage('Tarifa de envío actualizada.'); - }; - - const handleAddCity = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newName) return; - - const payload = { - name: newName, - department: newDepartment || 'Colombia', - shippingCost: parseInt(newShippingCost, 10) || 0, - deliveryTime: newDeliveryTime, - allowsContraentrega: newAllowCod, - firstExchangeFree: newFirstExchangeFree, - exchangePolicyNote: newFirstExchangeFree - ? `1er Cambio de Talla 100% GRATIS en ${newName} (posteriores con flete estándar)` - : `Cambio de talla garantizado (flete logístico asumido por el cliente)`, - }; - - const res = await fetch('/api/cities', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const data = await res.json(); - if (data.success) { - setCities((prev) => [...prev, data.city]); - setNewName(''); - setNewDepartment(''); - setMessage(`Ciudad "${data.city.name}" agregada con éxito.`); + const loadCities = async () => { + setIsLoading(true); + try { + const res = await fetch('/api/cities'); + const data = await res.json(); + if (data.success && Array.isArray(data.cities)) { + setCities(data.cities); + } + } catch (err) { + console.warn('Error cargando ciudades:', err); + } finally { + setIsLoading(false); } }; + const handleOpenCreateModal = () => { + setEditingCity({ + name: '', + department: 'Colombia', + shippingCost: 0, + deliveryTime: 'Entrega en 24 Horas', + allowsContraentrega: true, + firstExchangeFree: true, + exchangePolicyNote: '1er Cambio de Talla 100% GRATIS', + isActive: true, + }); + setIsEditModalOpen(true); + }; + + const handleOpenEditModal = (city: City) => { + setEditingCity({ ...city }); + setIsEditModalOpen(true); + }; + + const handleSaveCity = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingCity || !editingCity.name?.trim()) return; + + try { + if (editingCity.id) { + // Actualizar ciudad existente + const res = await fetch('/api/cities', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: editingCity.id, updates: editingCity }), + }); + const data = await res.json(); + if (data.success) { + setCities((prev) => prev.map((c) => (c.id === editingCity.id ? { ...c, ...editingCity } as City : c))); + setMessage({ type: 'success', text: `Ciudad "${editingCity.name}" actualizada con éxito en Appwrite.` }); + } else { + setMessage({ type: 'error', text: data.error || 'Error al actualizar ciudad' }); + } + } else { + // Crear nueva ciudad + const res = await fetch('/api/cities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(editingCity), + }); + const data = await res.json(); + if (data.success) { + setCities((prev) => [...prev, data.city]); + setMessage({ type: 'success', text: `Ciudad "${data.city.name}" registrada con éxito en Appwrite.` }); + } else { + setMessage({ type: 'error', text: data.error || 'Error al crear ciudad' }); + } + } + setIsEditModalOpen(false); + setEditingCity(null); + } catch (err: any) { + setMessage({ type: 'error', text: err.message || 'Error de conexión' }); + } + }; + + const handleOpenDeleteModal = (city: City) => { + setCityToDelete(city); + setIsDeleteModalOpen(true); + }; + + const handleConfirmDelete = async () => { + if (!cityToDelete) return; + try { + const res = await fetch(`/api/cities?id=${cityToDelete.id}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + setCities((prev) => prev.filter((c) => c.id !== cityToDelete.id)); + setMessage({ type: 'success', text: `Ciudad "${cityToDelete.name}" eliminada correctamente.` }); + } else { + setMessage({ type: 'error', text: data.error || 'No se pudo eliminar la ciudad' }); + } + } catch (err: any) { + setMessage({ type: 'error', text: err.message || 'Error de conexión' }); + } finally { + setIsDeleteModalOpen(false); + setCityToDelete(null); + } + }; + + const handleToggleExchangeFree = async (city: City) => { + const updatedVal = !city.firstExchangeFree; + setCities((prev) => prev.map((c) => (c.id === city.id ? { ...c, firstExchangeFree: updatedVal } : c))); + + await fetch('/api/cities', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: city.id, updates: { firstExchangeFree: updatedVal } }), + }); + setMessage({ + type: 'success', + text: `Garantía 1er cambio gratis para "${city.name}" ${updatedVal ? 'ACTIVADA' : 'DESACTIVADA'}.`, + }); + }; + return (
{/* Header */} -
-

- - Configuración de Ciudades, Fletes y Garantía de Cambio -

-

- Administra las tarifas de envío, tiempos de entrega y configura si el 1er cambio de talla es gratis o si el cliente asume el flete para cada ciudad. -

+
+
+

+ + Gestión de Ciudades, Fletes & Garantías +

+

+ Configura tarifas de flete, tiempos de entrega, cobro contraentrega y la política de 1er cambio gratis por ciudad con persistencia en Appwrite. +

+
+ +
+ {/* Notification Message */} {message && ( -
- {message} -
)} - {/* Add New City Card */} -
-

- - Registrar Nueva Ciudad o Municipio -

- -
-
- - setNewName(e.target.value)} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - /> -
- -
- - setNewDepartment(e.target.value)} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - /> -
- -
- - setNewShippingCost(e.target.value)} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - /> -
- -
- - setNewDeliveryTime(e.target.value)} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - /> -
-
- -
-
- - - -
- - -
-
- {/* Cities List */} -
-

Ciudades y Zonas Registradas ({cities.length})

+
+
+

Ciudades y Zonas Registradas ({cities.length})

+ Sincronizado con Appwrite +
-
- {cities.map((city) => ( -
-
-
- {city.name} - ({city.department}) - {city.allowsContraentrega ? ( - - Contraentrega Activo - - ) : ( - - Solo Despacho Nacional - + {isLoading ? ( +
Cargando ciudades desde la base de datos...
+ ) : cities.length === 0 ? ( +
No hay ciudades registradas. Haz clic en "Registrar Nueva Ciudad".
+ ) : ( +
+ {cities.map((city) => ( +
+
+
+ {city.name} + ({city.department}) + {city.allowsContraentrega ? ( + + Contraentrega Activo + + ) : ( + + Solo Despacho Prepagado + + )} + {city.shippingCost === 0 ? ( + + Envío Gratis + + ) : ( + + Flete: {formatCOP(city.shippingCost)} + + )} +
+

+ + {city.deliveryTime || 'Entrega en 24 Horas'} +

+ {city.exchangePolicyNote && ( +

+ Nota: {city.exchangePolicyNote} +

)}
-

- - {city.deliveryTime} -

-
- {/* Actions & Policy Toggle */} -
- {/* 1st Exchange Free Toggle Button */} - + {/* Actions & Policy Toggle */} +
+ {/* 1st Exchange Free Toggle Button */} + - {/* Shipping cost edit */} -
- Flete: - handleUpdateShipping(city.id, parseInt(e.target.value, 10) || 0)} - className="w-20 bg-transparent text-xs font-bold text-white outline-hidden text-right" - /> - COP + {/* Edit button */} + + + {/* Delete button */} +
-
- ))} -
+ ))} +
+ )}
+ + {/* Modal para Crear / Editar Ciudad */} + {isEditModalOpen && editingCity && ( +
+
+
+

+ + {editingCity.id ? 'Editar Ciudad / Zona' : 'Registrar Nueva Ciudad'} +

+ +
+ +
+
+
+ + setEditingCity({ ...editingCity, name: e.target.value })} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" + /> +
+ +
+ + setEditingCity({ ...editingCity, department: e.target.value })} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" + /> +
+ +
+ + setEditingCity({ ...editingCity, shippingCost: parseInt(e.target.value, 10) || 0 })} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" + /> +
+ +
+ + setEditingCity({ ...editingCity, deliveryTime: e.target.value })} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" + /> +
+
+ +
+ + setEditingCity({ ...editingCity, exchangePolicyNote: e.target.value })} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" + /> +
+ +
+ + + + + +
+ +
+ + +
+
+
+
+ )} + + {/* Modal para Confirmar Eliminación */} + {isDeleteModalOpen && cityToDelete && ( +
+
+
+ +
+ +
+

¿Eliminar Ciudad?

+

+ ¿Estás seguro de eliminar "{cityToDelete.name}"? Esta acción se sincronizará permanentemente en Appwrite. +

+
+ +
+ + +
+
+
+ )}
); } diff --git a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx index a8bde4a..b655755 100644 --- a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx +++ b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx @@ -24,18 +24,22 @@ import { Trash2, ArrowUpRight, Eye, + Globe, + TrendingUp, + Store, + DollarSign, + HelpCircle, } from 'lucide-react'; -import { SupplierPost, Product, MonitoredWhatsAppGroup } from '@/types'; -import { formatCOP } from '@/lib/pricing-engine'; +import { SupplierPost, Product, MonitoredWhatsAppGroup, ExtractedProductData } from '@/types'; +import { formatCOP, calculateMarketPricingStrategies } from '@/lib/pricing-engine'; import { BRANDS, BrandId } from '@/config/brands'; -import { normalizeModelKey } from '@/lib/model-grouper'; export default function SupplierFeedPage() { const [posts, setPosts] = useState([]); const [monitoredGroups, setMonitoredGroups] = useState([]); const [existingProducts, setExistingProducts] = useState([]); const [selectedGroupFilter, setSelectedGroupFilter] = useState('all'); - const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded'>('pending'); + const [tabStatus, setTabStatus] = useState<'all' | 'pending' | 'published' | 'discarded'>('all'); const [isLoading, setIsLoading] = useState(false); const [isSyncingLive, setIsSyncingLive] = useState(false); const [actionMessage, setActionMessage] = useState(null); @@ -43,14 +47,23 @@ export default function SupplierFeedPage() { // Multi-selection state const [selectedPostIds, setSelectedPostIds] = useState>(new Set()); + // Market research loading per post + const [researchingPostId, setResearchingPostId] = useState(null); + // Edit Modal State const [editingPost, setEditingPost] = useState(null); const [editName, setEditName] = useState(''); const [editBrand, setEditBrand] = useState(''); + const [editModelName, setEditModelName] = useState(''); const [editColorName, setEditColorName] = useState(''); - const [editPrice, setEditPrice] = useState(120000); + const [editStyleCode, setEditStyleCode] = useState(''); + const [editPrice, setEditPrice] = useState(125000); + const [editSupplierPrice, setEditSupplierPrice] = useState(85000); + const [editMarketPriceCOP, setEditMarketPriceCOP] = useState(450000); + const [editMarketPriceSource, setEditMarketPriceSource] = useState('Nike Store Oficial Colombia'); + const [editMarketPriceSourceUrl, setEditMarketPriceSourceUrl] = useState('https://www.nike.com/co/'); + const [editMarketSummary, setEditMarketSummary] = useState(''); const [editSizes, setEditSizes] = useState(''); - const [mergeWithExisting, setMergeWithExisting] = useState(true); // Target brand selector for publishing const [targetBrand, setTargetBrand] = useState('almeira'); @@ -73,8 +86,8 @@ export default function SupplierFeedPage() { setExistingProducts(prodData.products); } - // 3. Posts en vivo desde los grupos de WhatsApp - const feedRes = await fetch('/api/whatsapp-feed?fetchLive=true&limit=50'); + // 3. Posts en vivo desde los grupos de WhatsApp (todos los mensajes: leídos y sin leer) + const feedRes = await fetch('/api/whatsapp-feed?fetchLive=true&limit=60'); const feedData = await feedRes.json(); if (feedData.success && Array.isArray(feedData.posts)) { setPosts(feedData.posts); @@ -99,14 +112,12 @@ export default function SupplierFeedPage() { const url = groupJid !== 'all' ? `/api/whatsapp-feed?fetchLive=true&groupJid=${encodeURIComponent(groupJid)}` - : '/api/whatsapp-feed?fetchLive=true&limit=50'; + : '/api/whatsapp-feed?fetchLive=true&limit=60'; const res = await fetch(url); const data = await res.json(); if (data.success && Array.isArray(data.posts)) { setPosts(data.posts); - setActionMessage( - `✅ Se cargaron ${data.totalFound || data.posts.length} mensajes del grupo.` - ); + setActionMessage(`✅ Se cargaron ${data.totalFound || data.posts.length} mensajes (leídos y sin leer) del grupo.`); } } catch (err) { console.error(err); @@ -124,14 +135,14 @@ export default function SupplierFeedPage() { const url = selectedGroupFilter !== 'all' ? `/api/whatsapp-feed?fetchLive=true&groupJid=${encodeURIComponent(selectedGroupFilter)}` - : '/api/whatsapp-feed?fetchLive=true&limit=50'; + : '/api/whatsapp-feed?fetchLive=true&limit=60'; const res = await fetch(url); const data = await res.json(); if (data.success && Array.isArray(data.posts)) { setPosts(data.posts); setActionMessage( - `✅ ¡Sincronización completa! Se escanearon ${data.totalFound || data.posts.length} mensajes recientes de WhatsApp.` + `✅ ¡Sincronización completa! Se escanearon ${data.totalFound || data.posts.length} mensajes de WhatsApp (leídos y sin leer) y se sincronizaron con Appwrite.` ); } else { setActionMessage('⚠️ No se encontraron mensajes nuevos en los grupos seleccionados.'); @@ -144,10 +155,60 @@ export default function SupplierFeedPage() { } }; + // Ejecutar Búsqueda Visual e Inteligencia de Mercado en Internet + const handleMarketResearch = async (post: SupplierPost) => { + setResearchingPostId(post.id); + setActionMessage(null); + try { + const res = await fetch('/api/whatsapp-feed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'market-research', + post, + }), + }); + const data = await res.json(); + if (data.success && data.extractedProduct) { + setPosts((prev) => + prev.map((p) => + p.id === post.id + ? { + ...p, + extractedProduct: { ...p.extractedProduct, ...data.extractedProduct }, + marketResearchDone: true, + } + : p + ) + ); + setActionMessage( + `🔍 ¡Inteligencia de mercado obtenida! ${data.extractedProduct.name} • Precio en otras tiendas: ${formatCOP( + data.extractedProduct.marketPriceCOP + )} (${data.extractedProduct.marketPriceSource})` + ); + } else { + setActionMessage('⚠️ No se pudo completar el análisis visual de mercado.'); + } + } catch (err) { + console.error(err); + setActionMessage('❌ Error realizando búsqueda en internet.'); + } finally { + setResearchingPostId(null); + } + }; + // Publicar un producto a la tienda const handlePublishPost = async ( post: SupplierPost, - customData?: { name?: string; brand?: string; colorName?: string; retailPrice?: number; sizes?: string[] } + customData?: { + name?: string; + brand?: string; + modelName?: string; + colorName?: string; + retailPrice?: number; + sizes?: string[]; + marketIntelligence?: any; + } ) => { try { const res = await fetch('/api/whatsapp-feed', { @@ -161,6 +222,7 @@ export default function SupplierFeedPage() { customColorName: customData?.colorName, customRetailPrice: customData?.retailPrice, customSizes: customData?.sizes, + marketIntelligence: customData?.marketIntelligence, }), }); @@ -169,7 +231,7 @@ export default function SupplierFeedPage() { setPosts((prev) => prev.map((p) => (p.id === post.id ? { ...p, status: 'published' } : p)) ); - setActionMessage(`🚀 ¡"${data.product.name}" ha sido publicado en la App Móvil y Web!`); + setActionMessage(`🚀 ¡"${data.product.name}" ha sido publicado y guardado en Appwrite, App Móvil y Web!`); if (editingPost?.id === post.id) setEditingPost(null); } } catch (e) { @@ -202,7 +264,7 @@ export default function SupplierFeedPage() { ); setSelectedPostIds(new Set()); setActionMessage( - `🎉 ¡Éxito! Se han publicado ${data.products?.length || selectedPosts.length} zapatillas en la App Móvil y Web.` + `🎉 ¡Éxito! Se han publicado ${data.products?.length || selectedPosts.length} zapatillas en Appwrite, App Móvil y Web.` ); } } catch (e) { @@ -247,8 +309,7 @@ export default function SupplierFeedPage() { // Filtrado de posts por estado y grupo const filteredPosts = posts.filter((p) => { // Estado - if (tabStatus === 'pending' && p.status === 'published') return false; - if (tabStatus === 'pending' && p.status === 'discarded') return false; + if (tabStatus === 'pending' && (p.status === 'published' || p.status === 'discarded')) return false; if (tabStatus === 'published' && p.status !== 'published') return false; if (tabStatus === 'discarded' && p.status !== 'discarded') return false; @@ -259,6 +320,7 @@ export default function SupplierFeedPage() { return true; }); + const totalAllCount = posts.length; const pendingCount = posts.filter((p) => p.status !== 'published' && p.status !== 'discarded').length; const publishedCount = posts.filter((p) => p.status === 'published').length; const discardedCount = posts.filter((p) => p.status === 'discarded').length; @@ -269,13 +331,22 @@ export default function SupplierFeedPage() { const prodBrand = post.extractedProduct?.brand || 'Nike'; setEditName(prodName); setEditBrand(prodBrand); + setEditModelName(post.extractedProduct?.modelName || prodName); setEditColorName(post.extractedProduct?.colorName || ''); + setEditStyleCode(post.extractedProduct?.styleCode || ''); + setEditSupplierPrice(post.extractedProduct?.supplierPrice || 85000); setEditPrice( post.extractedProduct?.suggestedRetailPrice || (post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000) ); - setEditSizes(post.extractedProduct?.sizes?.join(', ') || '38, 39, 40, 41, 42, 43'); - setMergeWithExisting(true); + setEditMarketPriceCOP(post.extractedProduct?.marketPriceCOP || 450000); + setEditMarketPriceSource(post.extractedProduct?.marketPriceSource || 'Nike Store Oficial Colombia'); + setEditMarketPriceSourceUrl(post.extractedProduct?.marketPriceSourceUrl || 'https://www.nike.com/co/'); + setEditMarketSummary( + post.extractedProduct?.marketComparisonSummary || + `En tiendas oficiales se vende a ${formatCOP(post.extractedProduct?.marketPriceCOP || 450000)}. Gran margen y ahorro para el cliente.` + ); + setEditSizes(post.extractedProduct?.sizes?.join(', ') || '37, 38, 39, 40, 41, 42, 43'); }; return ( @@ -284,13 +355,13 @@ export default function SupplierFeedPage() {
-
+

Feed de Mensajes de WhatsApp

- Visualiza los mensajes de tus grupos de proveedores y selecciona los calzados que deseas publicar en la app móvil. + Visualiza todos los mensajes (leídos y sin leer) de los grupos de proveedores mayoristas, busca imágenes en internet para comparar precios de mercado colombiano y publica con un clic.

@@ -300,7 +371,7 @@ export default function SupplierFeedPage() {
Gestionar Grupos @@ -309,7 +380,7 @@ export default function SupplierFeedPage() { +
- {/* Group Selector Filter */} + {/* Group Filter Dropdown */}
setMergeWithExisting(true)} - className="accent-purple-600" - /> - Agregar como nueva variante de color a "{matchingProduct.name}" - - -
-
- )} - -
-
- + {/* Form fields */} +
+
+ setEditName(e.target.value)} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500" + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" />
- {matchingProduct && mergeWithExisting && ( -
- - setEditColorName(e.target.value)} - placeholder="Ej: Blanco / Negro, Gum Edition, Rosa Pastel" - className="w-full bg-neutral-950 border border-purple-500/50 rounded-xl px-3 py-2 text-white font-semibold outline-hidden focus:border-purple-500" - /> -
- )} -
- + setEditBrand(e.target.value)} - placeholder="Ej: Adidas, New Balance, Nike, On Running..." - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500 mb-1.5" + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" /> -
- {[ - 'Adidas', - 'Nike', - 'Jordan', - 'New Balance', - 'On Running', - 'Puma', - 'Asics', - 'Vans', - 'Converse', - 'Salomon', - 'Hoka', - 'Balenciaga', - 'Alexander McQueen', - 'Golden Goose', - 'Crocs', - 'Lacoste', - 'Timberland', - 'Diesel', - 'Oakley', - 'Veja', - 'Autry', - 'Urbano / Variado', - ].map((b) => ( - - ))} -
+
+ + setEditColorName(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" + /> +
- + + setEditSupplierPrice(parseInt(e.target.value, 10) || 0)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" + /> +
+ +
+ setEditPrice(Number(e.target.value))} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-emerald-300 font-black font-mono outline-hidden focus:border-emerald-500" + onChange={(e) => setEditPrice(parseInt(e.target.value, 10) || 0)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-emerald-400 font-bold outline-hidden focus:border-emerald-500" />
+
+ + setEditMarketPriceCOP(parseInt(e.target.value, 10) || 0)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-purple-300 font-bold outline-hidden focus:border-purple-500" + /> +
+ + {/* Estrategias Inteligentes de Precio Sugerido */} +
+
+ + + Estrategias Inteligentes de Precio Sugerido (Toma de Decisión): + + Clic para aplicar +
+ + {(() => { + const strat = calculateMarketPricingStrategies(editSupplierPrice, editMarketPriceCOP); + const isComp = editPrice === strat.competitive.price; + const isBal = editPrice === strat.balanced.price; + const isPrem = editPrice === strat.premium.price; + + return ( +
+ {/* 1. Alta Rotación */} + + + {/* 2. Recomendado */} + + + {/* 3. Máxima Ganancia */} + +
+ ); + })()} + + {/* Resumen de Margen Actual Seleccionado */} +
+ + Margen Neto Seleccionado:{' '} + +{formatCOP(Math.max(0, editPrice - editSupplierPrice))} + + + Ahorro Cliente vs Oficial:{' '} + + {editMarketPriceCOP > editPrice + ? `${Math.round(((editMarketPriceCOP - editPrice) / editMarketPriceCOP) * 100)}% (${formatCOP( + editMarketPriceCOP - editPrice + )})` + : 'Precio Exclusivo'} + + +
+
- + setEditSizes(e.target.value)} - placeholder="38, 39, 40, 41, 42, 43" - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500" + value={editMarketPriceSource} + onChange={(e) => setEditMarketPriceSource(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" />
-
+
+ + setEditSizes(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" + placeholder="37, 38, 39, 40, 41, 42, 43" + /> +
+ +
+ + setEditMarketSummary(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" + /> +
+ +
-
- ); - })()} +
+ )}
); } diff --git a/dripzone-app/src/app/admin/metodos-pago/page.tsx b/dripzone-app/src/app/admin/metodos-pago/page.tsx index d2b0467..5cc7690 100644 --- a/dripzone-app/src/app/admin/metodos-pago/page.tsx +++ b/dripzone-app/src/app/admin/metodos-pago/page.tsx @@ -1,164 +1,454 @@ 'use client'; import React, { useState, useEffect } from 'react'; -import { CreditCard, Zap, Save, Check, ShieldCheck, QrCode } from 'lucide-react'; +import { CreditCard, Zap, Plus, Trash2, Edit3, Save, Check, X, AlertTriangle, QrCode, ShieldCheck, DollarSign } from 'lucide-react'; import { PaymentMethod } from '@/types'; import { INITIAL_PAYMENT_METHODS } from '@/lib/data-service'; import { formatCOP } from '@/lib/pricing-engine'; export default function AdminPaymentMethodsPage() { const [methods, setMethods] = useState(INITIAL_PAYMENT_METHODS); - const [message, setMessage] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // Modal Crear / Editar + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editingMethod, setEditingMethod] = useState | null>(null); + + // Modal Confirmar Eliminación + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [methodToDelete, setMethodToDelete] = useState(null); useEffect(() => { - fetch('/api/payment-methods') - .then((r) => r.json()) - .then((d) => d.success && setMethods(d.paymentMethods)) - .catch(() => {}); + loadPaymentMethods(); }, []); + const loadPaymentMethods = async () => { + setIsLoading(true); + try { + const res = await fetch('/api/payment-methods'); + const data = await res.json(); + if (data.success && Array.isArray(data.paymentMethods)) { + setMethods(data.paymentMethods); + } + } catch (err) { + console.warn('Error cargando métodos de pago:', err); + } finally { + setIsLoading(false); + } + }; + + const handleOpenCreateModal = () => { + setEditingMethod({ + name: '', + type: 'contraentrega', + description: 'Paga al recibir en tu puerta', + requiresDeposit: false, + depositAmount: 10000, + accountInfo: '', + boldPaymentLink: '', + boldQrUrl: '', + isActive: true, + }); + setIsEditModalOpen(true); + }; + + const handleOpenEditModal = (method: PaymentMethod) => { + setEditingMethod({ ...method }); + setIsEditModalOpen(true); + }; + + const handleSaveMethod = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingMethod || !editingMethod.name?.trim()) return; + + try { + if (editingMethod.id) { + // Actualizar existente + const res = await fetch('/api/payment-methods', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: editingMethod.id, updates: editingMethod }), + }); + const data = await res.json(); + if (data.success) { + setMethods((prev) => prev.map((m) => (m.id === editingMethod.id ? { ...m, ...editingMethod } as PaymentMethod : m))); + setMessage({ type: 'success', text: `Método "${editingMethod.name}" actualizado en Appwrite.` }); + } else { + setMessage({ type: 'error', text: data.error || 'Error al actualizar método de pago' }); + } + } else { + // Crear nuevo + const res = await fetch('/api/payment-methods', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(editingMethod), + }); + const data = await res.json(); + if (data.success) { + setMethods((prev) => [...prev, data.paymentMethod]); + setMessage({ type: 'success', text: `Método "${data.paymentMethod.name}" creado con éxito en Appwrite.` }); + } else { + setMessage({ type: 'error', text: data.error || 'Error al crear método de pago' }); + } + } + setIsEditModalOpen(false); + setEditingMethod(null); + } catch (err: any) { + setMessage({ type: 'error', text: err.message || 'Error de conexión' }); + } + }; + const handleToggleActive = async (method: PaymentMethod) => { - const updated = { ...method, isActive: !method.isActive }; - setMethods((prev) => prev.map((m) => (m.id === method.id ? updated : m))); + const updatedVal = !method.isActive; + setMethods((prev) => prev.map((m) => (m.id === method.id ? { ...m, isActive: updatedVal } : m))); await fetch('/api/payment-methods', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: method.id, updates: { isActive: updated.isActive } }), + body: JSON.stringify({ id: method.id, updates: { isActive: updatedVal } }), + }); + setMessage({ + type: 'success', + text: `Método "${method.name}" ${updatedVal ? 'HABILITADO' : 'DESHABILITADO'}.`, }); - setMessage(`Método "${method.name}" ${updated.isActive ? 'activado' : 'desactivado'}.`); }; - const handleUpdateField = (id: string, field: keyof PaymentMethod, val: any) => { - setMethods((prev) => - prev.map((m) => (m.id === id ? { ...m, [field]: val } : m)) - ); + const handleOpenDeleteModal = (method: PaymentMethod) => { + setMethodToDelete(method); + setIsDeleteModalOpen(true); }; - const handleSaveMethod = async (method: PaymentMethod) => { - await fetch('/api/payment-methods', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: method.id, updates: method }), - }); - setMessage(`✅ Cambios guardados para "${method.name}".`); + const handleConfirmDelete = async () => { + if (!methodToDelete) return; + try { + const res = await fetch(`/api/payment-methods?id=${methodToDelete.id}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + setMethods((prev) => prev.filter((m) => m.id !== methodToDelete.id)); + setMessage({ type: 'success', text: `Método "${methodToDelete.name}" eliminado correctamente.` }); + } else { + setMessage({ type: 'error', text: data.error || 'No se pudo eliminar el método de pago' }); + } + } catch (err: any) { + setMessage({ type: 'error', text: err.message || 'Error de conexión' }); + } finally { + setIsDeleteModalOpen(false); + setMethodToDelete(null); + } }; return (
{/* Header */} -
-

- - Configuración de Medios de Pago & Anticipos -

-

- Habilita métodos de pago, configura el valor de separación de $10.000 COP y personaliza los datos de Nequi / Daviplata. -

+
+
+

+ + Configuración de Medios de Pago & Anticipos +

+

+ Administra pasarelas de pago, anticipos de $10.000 COP para contraentrega, transferencias y enlaces Bold con persistencia en Appwrite. +

+
+ +
+ {/* Notification Message */} {message && ( -
- {message} -
)} - {/* Methods Cards */} + {/* Methods Grid / Cards */}
- {methods.map((method) => ( -
-
-
-
- {method.type === 'contraentrega' ? : } + {isLoading ? ( +
Cargando métodos de pago desde Appwrite...
+ ) : methods.length === 0 ? ( +
No hay métodos de pago registrados. Haz clic en "Nuevo Método de Pago".
+ ) : ( + methods.map((method) => ( +
+
+
+
+ {method.type === 'contraentrega' ? ( + + ) : method.type === 'bold' ? ( + + ) : ( + + )} +
+
+
+

{method.name}

+ {method.requiresDeposit && ( + + Anticipo {formatCOP(method.depositAmount)} + + )} +
+ Tipo: {method.type} +
-
-

{method.name}

- Tipo: {method.type} + +
+ + + + +
-
- +
+
+ Descripción para el Cliente: +

{method.description || 'Sin descripción'}

+
+ + {method.accountInfo && ( +
+ Cuentas / Datos Bancarios: +

{method.accountInfo}

+
+ )} + + {method.boldPaymentLink && ( +
+ Link de Pago Bold: + + {method.boldPaymentLink} + +
+ )}
+ )) + )} +
-
-
- - handleUpdateField(method.id, 'name', e.target.value)} - className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - /> -
+ {/* Modal para Crear / Editar Método de Pago */} + {isEditModalOpen && editingMethod && ( +
+
+
+

+ + {editingMethod.id ? 'Editar Método de Pago' : 'Nuevo Método de Pago'} +

+ +
-
- - handleUpdateField(method.id, 'description', e.target.value)} - className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - /> -
+
+
+
+ + setEditingMethod({ ...editingMethod, name: e.target.value })} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" + /> +
- {method.requiresDeposit && (
- + + +
+ +
+ handleUpdateField(method.id, 'depositAmount', parseInt(e.target.value, 10) || 0)} - className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden font-bold" + value={editingMethod.depositAmount !== undefined ? editingMethod.depositAmount : 0} + onChange={(e) => + setEditingMethod({ ...editingMethod, depositAmount: parseInt(e.target.value, 10) || 0 }) + } + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-500" /> - - (El saldo restante se cobra contraentrega al recibir) -
- )} +
- - handleUpdateField(method.id, 'accountInfo', e.target.value)} - className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - placeholder="Ej: Nequi / Daviplata: 3XX XXX XXXX" + +