diff --git a/.husky/pre-commit b/.husky/pre-commit index 2601a3b..d0a7784 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npm run precommit \ No newline at end of file +npx lint-staged \ No newline at end of file diff --git a/.lintstagedrc b/.lintstagedrc index 9458d0b..4c57188 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,10 +1,9 @@ { "*/**/*.{js,jsx,ts,tsx}": [ - "prettier --write", - "eslint --fix", - "eslint" + "npm run format", + "npm run lint:fix" ], "*/**/*.{json,css,md}": [ - "prettier --write" + "npm run format" ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9a17168..99710f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "dashboard", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dashboard", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@heroicons/react": "^2.2.0", - "@pharmatech/sdk": "^0.4.16", + "@pharmatech/sdk": "^0.4.21", + "@react-google-maps/api": "^2.20.6", "@react-pdf/renderer": "^4.3.0", "blob-stream": "^0.1.3", "cloudinary": "^2.6.0", @@ -1096,6 +1097,22 @@ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, + "node_modules/@googlemaps/js-api-loader": { + "version": "1.16.8", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz", + "integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==", + "license": "Apache-2.0" + }, + "node_modules/@googlemaps/markerclusterer": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz", + "integrity": "sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==", + "license": "Apache-2.0", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "supercluster": "^8.0.1" + } + }, "node_modules/@heroicons/react": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", @@ -1809,9 +1826,10 @@ } }, "node_modules/@pharmatech/sdk": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/@pharmatech/sdk/-/sdk-0.4.16.tgz", - "integrity": "sha512-J8ycNdl+x7h7HH0PDJVXUzKyT9n0WfykHyg8gJTw37nziuQJcY5GVpo6gCmlG4bj78ucbUICQGJqc2zVE6Q8UQ==", + "version": "0.4.21", + "resolved": "https://registry.npmjs.org/@pharmatech/sdk/-/sdk-0.4.21.tgz", + "integrity": "sha512-fxyXlgKN3qLxuGVg6bmRS5DhqfLOLsCYazffax5x0iYFFBIQHQBVRLltm5h84jvOEM0oA7ipD08VVXWMyBj/vQ==", + "license": "MIT", "dependencies": { "axios": "^1.8.1" } @@ -1826,6 +1844,36 @@ "node": ">=14" } }, + "node_modules/@react-google-maps/api": { + "version": "2.20.6", + "resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.6.tgz", + "integrity": "sha512-frxkSHWbd36ayyxrEVopSCDSgJUT1tVKXvQld2IyzU3UnDuqqNA3AZE4/fCdqQb2/zBQx3nrWnZB1wBXDcrjcw==", + "license": "MIT", + "dependencies": { + "@googlemaps/js-api-loader": "1.16.8", + "@googlemaps/markerclusterer": "2.5.3", + "@react-google-maps/infobox": "2.20.0", + "@react-google-maps/marker-clusterer": "2.20.0", + "@types/google.maps": "3.58.1", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19", + "react-dom": "^16.8 || ^17 || ^18 || ^19" + } + }, + "node_modules/@react-google-maps/infobox": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-2.20.0.tgz", + "integrity": "sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==", + "license": "MIT" + }, + "node_modules/@react-google-maps/marker-clusterer": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz", + "integrity": "sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==", + "license": "MIT" + }, "node_modules/@react-pdf/fns": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz", @@ -2454,6 +2502,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3435,9 +3489,10 @@ } }, "node_modules/axios": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz", - "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -5241,6 +5296,7 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -5821,6 +5877,15 @@ "node": ">=12" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6433,6 +6498,12 @@ "node": ">=18" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7662,7 +7733,8 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" }, "node_modules/punycode": { "version": "2.3.1", @@ -8860,6 +8932,15 @@ "node": ">= 6" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index d0e55e5..0ad9c7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dashboard", - "version": "0.4.0", + "version": "1.0.0", "private": true, "scripts": { "dev": "next dev", @@ -17,7 +17,8 @@ }, "dependencies": { "@heroicons/react": "^2.2.0", - "@pharmatech/sdk": "^0.4.16", + "@pharmatech/sdk": "^0.4.21", + "@react-google-maps/api": "^2.20.6", "@react-pdf/renderer": "^4.3.0", "blob-stream": "^0.1.3", "cloudinary": "^2.6.0", diff --git a/src/app/(dashboard)/branches/[id]/edit/page.tsx b/src/app/(dashboard)/branches/[id]/edit/page.tsx index 889adaa..e726129 100644 --- a/src/app/(dashboard)/branches/[id]/edit/page.tsx +++ b/src/app/(dashboard)/branches/[id]/edit/page.tsx @@ -203,7 +203,6 @@ export default function EditBranchPage() { helperText={errors.name} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" onChange={(e) => setName(e.target.value)} /> @@ -249,7 +248,6 @@ export default function EditBranchPage() { helperText={errors.address} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" onChange={(e) => setAddress(e.target.value)} /> @@ -263,7 +261,6 @@ export default function EditBranchPage() { helperText={errors.latitude} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" onChange={(e) => setLatitude(e.target.value)} /> @@ -275,7 +272,6 @@ export default function EditBranchPage() { helperText={errors.longitude} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" onChange={(e) => setLongitude(e.target.value)} /> diff --git a/src/app/(dashboard)/branches/[id]/page.tsx b/src/app/(dashboard)/branches/[id]/page.tsx index afffbca..112c83e 100644 --- a/src/app/(dashboard)/branches/[id]/page.tsx +++ b/src/app/(dashboard)/branches/[id]/page.tsx @@ -12,6 +12,7 @@ import { REDIRECTION_TIMEOUT } from '@/lib/utils/contants'; import { useAuth } from '@/context/AuthContext'; import { BranchResponse } from '@pharmatech/sdk'; import Input from '@/components/Input/Input'; +import Loader from '@/components/Loader'; export default function BranchDetailsPage() { const params = useParams(); @@ -165,7 +166,7 @@ export default function BranchDetailsPage() { ) : ( -

Cargando datos de la sucursal...

+ )} ); diff --git a/src/app/(dashboard)/branches/new/page.tsx b/src/app/(dashboard)/branches/new/page.tsx index cb090d9..460e991 100644 --- a/src/app/(dashboard)/branches/new/page.tsx +++ b/src/app/(dashboard)/branches/new/page.tsx @@ -10,6 +10,8 @@ import Dropdown from '@/components/Dropdown'; import { toast } from 'react-toastify'; import { StateResponse, CityResponse } from '@pharmatech/sdk'; import Input from '@/components/Input/Input'; +import GoogleMaps from '@/components/GoogleMap/GoogleMap'; +//import { BranchMarker } from '@/components/GoogleMap/GoogleMap'; /// This is a constant that represents the ID of Venezuela. const COUNTRY_ID = '1238bc2a-45a5-47e4-9cc1-68d573089ca1'; @@ -29,6 +31,12 @@ export default function NewBranchPage() { const [cityId, setCityId] = useState(''); const [errors, setErrors] = useState>({}); + // No branches data available, so markers will be empty + + const mapCenter = { + lat: parseFloat(latitude || '10.0653'), + lng: parseFloat(longitude || '-69.3235'), + }; const fetchStates = async () => { try { @@ -188,7 +196,6 @@ export default function NewBranchPage() { helperText={errors.name} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" value={name} onChange={(e) => setName(e.target.value)} /> @@ -233,7 +240,6 @@ export default function NewBranchPage() { helperText={errors.address} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" onChange={(e) => setAddress(e.target.value)} /> @@ -247,7 +253,6 @@ export default function NewBranchPage() { helperText={errors.latitude} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" onChange={(e) => setLatitude(e.target.value)} /> @@ -259,10 +264,21 @@ export default function NewBranchPage() { helperText={errors.longitude} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" onChange={(e) => setLongitude(e.target.value)} /> +
+ { + setLatitude(lat.toFixed(6).toString()); + setLongitude(lng.toFixed(6).toString()); + }} + mapWidth="200%" + mapHeight="220px" + /> +
diff --git a/src/app/(dashboard)/branches/page.tsx b/src/app/(dashboard)/branches/page.tsx index bc72cfa..1d70f03 100644 --- a/src/app/(dashboard)/branches/page.tsx +++ b/src/app/(dashboard)/branches/page.tsx @@ -163,11 +163,8 @@ export default function BranchesPage() { }, itemsPerPageOptions: [5, 10, 15, 20], }} + isLoading={isLoading} /> - - {isLoading && ( -
Cargando sucursales...
- )} ); } diff --git a/src/app/(dashboard)/categories/[id]/edit/page.tsx b/src/app/(dashboard)/categories/[id]/edit/page.tsx index a85e717..0f45cfe 100644 --- a/src/app/(dashboard)/categories/[id]/edit/page.tsx +++ b/src/app/(dashboard)/categories/[id]/edit/page.tsx @@ -152,7 +152,6 @@ export default function EditCategoryPage() { helperText={errors.name} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#d1d5db" /> @@ -130,7 +129,6 @@ export default function NewCategoryPage() { }} helperText={errors.description} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" type="text" borderSize="1px" isTextArea diff --git a/src/app/(dashboard)/categories/page.tsx b/src/app/(dashboard)/categories/page.tsx index aac3cad..d363d17 100644 --- a/src/app/(dashboard)/categories/page.tsx +++ b/src/app/(dashboard)/categories/page.tsx @@ -113,11 +113,8 @@ export default function CategoriesPage() { }, itemsPerPageOptions: [5, 10, 15, 20], }} + isLoading={isLoading} /> - - {isLoading && ( -
Cargando categorías...
- )} ); } diff --git a/src/app/(dashboard)/coupons/[code]/edit/page.tsx b/src/app/(dashboard)/coupons/[code]/edit/page.tsx index 0ac5adf..9a4c0a6 100644 --- a/src/app/(dashboard)/coupons/[code]/edit/page.tsx +++ b/src/app/(dashboard)/coupons/[code]/edit/page.tsx @@ -10,6 +10,7 @@ import { api } from '@/lib/sdkConfig'; import { toast } from 'react-toastify'; import { couponSchema } from '@/lib/validations/couponsSchema'; import { useAuth } from '@/context/AuthContext'; +import { formatPrice } from '@/lib/utils/priceFormatter'; export default function EditCouponPage() { const params = useParams(); @@ -55,7 +56,7 @@ export default function EditCouponPage() { setTempCode(response.code); setTempDiscount(response.discount); - setTempMinPurchase(response.minPurchase); + setTempMinPurchase(Number(formatPrice(response.minPurchase))); setTempMaxUses(response.maxUses); setTempExpirationDate(expirationDate.toISOString()); } catch (error) { @@ -134,7 +135,8 @@ export default function EditCouponPage() { { code: tempCode.trim(), discount: Number(tempDiscount), - minPurchase: Number(tempMinPurchase), + // Convert to cents + minPurchase: Number((Number(tempMinPurchase) * 100).toFixed(0)), maxUses: Number(tempMaxUses), expirationDate: new Date(tempExpirationDate), }, diff --git a/src/app/(dashboard)/coupons/[code]/page.tsx b/src/app/(dashboard)/coupons/[code]/page.tsx index be0e08a..1104e51 100644 --- a/src/app/(dashboard)/coupons/[code]/page.tsx +++ b/src/app/(dashboard)/coupons/[code]/page.tsx @@ -8,11 +8,12 @@ import ModalConfirm from '@/components/ModalConfirm'; import { Colors } from '@/styles/styles'; import { api } from '@/lib/sdkConfig'; import { toast } from 'react-toastify'; -import { format } from 'date-fns'; import { CouponResponse } from '@pharmatech/sdk/types'; import { useAuth } from '@/context/AuthContext'; import Loading from '../../loading'; import Input from '@/components/Input/Input'; +import { formatDateSafe } from '@/lib/utils/useFormatDate'; +import { formatPrice } from '@/lib/utils/priceFormatter'; export default function CouponDetailsPage() { const params = useParams(); @@ -67,14 +68,6 @@ export default function CouponDetailsPage() { } }; - const formatDate = (date: Date) => { - try { - return format(date, 'dd/MM/yyyy'); - } catch { - return date.toString(); - } - }; - if (!coupon) { return ; } @@ -146,7 +139,7 @@ export default function CouponDetailsPage() {
@@ -175,7 +168,7 @@ export default function CouponDetailsPage() {
diff --git a/src/app/(dashboard)/coupons/new/page.tsx b/src/app/(dashboard)/coupons/new/page.tsx index 5578949..0981f2f 100644 --- a/src/app/(dashboard)/coupons/new/page.tsx +++ b/src/app/(dashboard)/coupons/new/page.tsx @@ -64,7 +64,8 @@ export default function NewCouponPage() { } const payload = validationResult.data; - + // Converts minPurchase to cents + payload.minPurchase = Number((payload.minPurchase * 100).toFixed(0)); await api.coupon.create(payload, token); toast.success('Cupón creado exitosamente'); setTimeout(() => router.push('/coupons'), 1500); @@ -136,7 +137,6 @@ export default function NewCouponPage() { helperText={errors.code} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" />
@@ -161,7 +161,6 @@ export default function NewCouponPage() { helperText={errors.maxUses} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" type="number" />
@@ -174,7 +173,6 @@ export default function NewCouponPage() { helperText={errors.discount} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#E7E7E6" type="number" />
diff --git a/src/app/(dashboard)/coupons/page.tsx b/src/app/(dashboard)/coupons/page.tsx index 0418629..0d9a040 100644 --- a/src/app/(dashboard)/coupons/page.tsx +++ b/src/app/(dashboard)/coupons/page.tsx @@ -9,6 +9,8 @@ import { api } from '@/lib/sdkConfig'; import { Pagination, CouponResponse } from '@pharmatech/sdk'; import { useAuth } from '@/context/AuthContext'; import Badge from '@/components/Badge'; +import { toast } from 'react-toastify'; +import { formatPrice } from '@/lib/utils/priceFormatter'; // Presets de rango de expiración que el backend soporta via expirationBetween const expirationTranslations: Record = { @@ -123,7 +125,7 @@ export default function CouponsPage() { { key: 'minPurchase', label: 'Compra min.', - render: (c: CouponResponse) => `$${c.minPurchase.toFixed(2)}`, + render: (c: CouponResponse) => `$${formatPrice(c.minPurchase)}`, }, { key: 'maxUses', @@ -154,6 +156,64 @@ export default function CouponsPage() { }, ]; + const handleSetExpiredAtToday = async (coupons: CouponResponse[]) => { + api.promo + .bulkUpdate( + { + ids: coupons.map((p) => p.id), + expiredAt: new Date(), + }, + token!, + ) + .then(() => { + toast.success('Cupones expirados'); + fetchCoupons( + currentPage, + itemsPerPage, + searchQuery, + selectedExpirationPeriod, + ); + }) + .catch((err) => { + console.error('Error al expirar los cupones:', err); + toast.error('Error al expirar los cupones'); + }); + }; + + const handleDeleteCoupon = async (coupons: CouponResponse[]) => { + api.promo + .bulkDelete( + { + ids: coupons.map((p) => p.id), + }, + token!, + ) + .then(() => { + toast.success('Cupones eliminados'); + fetchCoupons( + currentPage, + itemsPerPage, + searchQuery, + selectedExpirationPeriod, + ); + }) + .catch((err) => { + console.error('Error al eliminar los cupones:', err); + toast.error('Error al eliminar los cupones'); + }); + }; + + const actions = [ + { + label: 'Expirar hoy', + onClick: handleSetExpiredAtToday, + }, + { + label: 'Eliminar', + onClick: handleDeleteCoupon, + }, + ]; + return (
router.push('/coupons/new')} addButtonText="Agregar Cupón" onSearch={handleSearch} + actions={actions} dropdownComponent={ - - {isLoading &&
Cargando cupones...
}
); } diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 1515f78..e25a547 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -17,6 +17,8 @@ import { Bar, XAxis, YAxis, + LineChart, + Line, } from 'recharts'; import Calendar from '@/components/Calendar'; import { Colors, FontSizes } from '@/styles/styles'; @@ -27,6 +29,9 @@ export default function DashboardPage() { const [stats, setStats] = useState(null); const [prevStats, setPrevStats] = useState(null); + const [sales, setSales] = useState< + { date: string; total: number; predictedTotal: number }[] + >([]); const pieData = [ { name: 'Órdenes Abiertas', value: stats?.openOrders ?? 0 }, @@ -64,9 +69,46 @@ export default function DashboardPage() { } }, [token, fromDate, toDate]); + const fetchSales = useCallback(async () => { + if (!token) return; + + try { + const params: ReportQueryParams = { + startDate: fromDate || '', + endDate: toDate || '', + }; + + const response = await api.report.getSalesReport(params, token); + setSales( + response.items.map((item) => ({ + date: item.date.split('T')[0], + total: item.total, + predictedTotal: 0, + })), + ); + const prediction = await api.salesPrediction.getPredictedSales( + { days: 5 }, + token, + ); + setSales((prevSales) => [ + ...prevSales, + ...prediction.map((item) => ({ + date: item.date.split('T')[0], + predictedTotal: Math.round(item.predictedTotal / 1000), + total: 0, + })), + ]); + } catch (error) { + console.error('Error fetching dashboard stats:', error); + toast.error('Error al cargar las ventas'); + } finally { + } + }, [token, fromDate, toDate]); + useEffect(() => { fetchDashboardStats(); - }, [fetchDashboardStats]); + fetchSales(); + }, [fetchDashboardStats, fetchSales]); return (
@@ -271,6 +313,26 @@ export default function DashboardPage() {
+
+

+ Predicción de Ventas +

+ + + + + + + + + + +
); } diff --git a/src/app/(dashboard)/inventories/page.tsx b/src/app/(dashboard)/inventories/page.tsx new file mode 100644 index 0000000..b4be985 --- /dev/null +++ b/src/app/(dashboard)/inventories/page.tsx @@ -0,0 +1,183 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import TableContainer from '@/components/TableContainer'; +import Dropdown from '@/components/Dropdown'; +import { api } from '@/lib/sdkConfig'; +import { useAuth } from '@/context/AuthContext'; +import { + Pagination, + BranchResponse, + UserRole, + InventoryResponse, +} from '@pharmatech/sdk'; +import { toast } from 'react-toastify'; +import { formatDateSafe } from '@/lib/utils/useFormatDate'; +import InventoryStock from '@/components/Input/InventoryStock'; +import { formatPrice } from '@/lib/utils/priceFormatter'; + +export default function InventoryListPage() { + const { token, user } = useAuth(); + + const [inventories, setInventories] = useState([]); + const [branches, setBranches] = useState([]); + const [selectedBranchId, setSelectedBranchId] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + const [totalItems, setTotalItems] = useState(0); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const branchOptions = ['Todas', ...branches.map((branch) => branch.name)]; + const handleBranchChange = (label: string) => { + const branch = branches.find((branch) => branch.name === label); + setSelectedBranchId(branch?.id ?? ''); + setCurrentPage(1); + }; + + const fetchBranches = useCallback(async () => { + try { + const resp = await api.branch.findAll({ page: 1, limit: 100 }); + setBranches(resp.results); + } catch (err: unknown) { + console.error('Error fetching Branches:', err); + toast.error('No se pudieron cargar las sucursales'); + } + }, []); + + useEffect(() => { + if (token && user?.sub && user.role == UserRole.ADMIN) fetchBranches(); + }, [fetchBranches, token, user?.sub]); + + const fetchInventories = useCallback(async () => { + if (!token || !user?.sub) return; + setIsLoading(true); + setError(null); + const params: Parameters[0] = { + page: currentPage, + limit: itemsPerPage, + }; + if (user.role == UserRole.BRANCH_ADMIN) { + params.branchId = user.branch?.id; + } else { + params.branchId = selectedBranchId ? selectedBranchId : undefined; + } + try { + const response: Pagination = + await api.inventory.findAll(params); + setInventories(response.results); + setTotalItems(response.count); + } catch (err: unknown) { + console.error('Error fetching inventories:', err); + toast.error('Error al cargar los inventarios'); + setError('No se pudieron cargar los inventarios.'); + } finally { + setIsLoading(false); + } + }, [token, user?.sub, currentPage, itemsPerPage, selectedBranchId]); + + useEffect(() => { + fetchInventories(); + }, [fetchInventories]); + + const totalPages = Math.ceil(totalItems / itemsPerPage); + + const getColumns = () => { + const columns = [ + { + key: 'name', + label: 'Producto', + render: (i: InventoryResponse) => i.productPresentation.product.name, + }, + { + key: 'presentation', + label: 'Presentación', + render: (i: InventoryResponse) => + i.productPresentation.presentation.name, + }, + { + key: 'manufacturer', + label: 'Marca', + render: (i: InventoryResponse) => + i.productPresentation.product.manufacturer.name, + }, + { + key: 'updatedAt', + label: 'Actualizado', + render: (i: InventoryResponse) => formatDateSafe(i.updatedAt), + }, + { + key: 'stockQuantity', + label: 'Existencia', + render: (i: InventoryResponse) => ( + + ), + }, + { + key: 'price', + label: 'Precio', + render: (i: InventoryResponse) => + `$${formatPrice(i.productPresentation.price)}`, + }, + { + key: 'total', + label: 'Total', + render: (i: InventoryResponse) => + `$${formatPrice(i.stockQuantity * i.productPresentation.price)}`, + }, + ]; + if (user?.role == UserRole.ADMIN) { + columns.unshift({ + key: 'branch', + label: 'Sucursal', + render: (i: InventoryResponse) => i.branch.name, + }); + } + return columns; + }; + + const renderDropdown = () => { + if (user?.role == UserRole.ADMIN) { + return ( + + ); + } + return <>; + }; + + return ( +
+ {error && ( +
{error}
+ )} + + + title="Inventario" + dropdownComponent={renderDropdown()} + tableData={inventories} + tableColumns={getColumns()} + pagination={{ + currentPage, + totalPages, + totalItems, + itemsPerPage, + onPageChange: setCurrentPage, + onItemsPerPageChange: (val) => { + setItemsPerPage(val); + setCurrentPage(1); + }, + itemsPerPageOptions: [5, 10, 15, 20], + }} + isLoading={isLoading} + /> +
+ ); +} diff --git a/src/app/(dashboard)/lots/page.tsx b/src/app/(dashboard)/lots/page.tsx new file mode 100644 index 0000000..32567e9 --- /dev/null +++ b/src/app/(dashboard)/lots/page.tsx @@ -0,0 +1,178 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import TableContainer from '@/components/TableContainer'; +import Dropdown from '@/components/Dropdown'; +import { api } from '@/lib/sdkConfig'; +import { useAuth } from '@/context/AuthContext'; +import { + Pagination, + BranchResponse, + UserRole, + LotResponse, +} from '@pharmatech/sdk'; +import { toast } from 'react-toastify'; +import { formatDateSafe } from '@/lib/utils/useFormatDate'; +import { formatPrice } from '@/lib/utils/priceFormatter'; + +export default function InventoryListPage() { + const { token, user } = useAuth(); + + const [lots, setLots] = useState([]); + const [branches, setBranches] = useState([]); + const [selectedBranchId, setSelectedBranchId] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + const [totalItems, setTotalItems] = useState(0); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const branchOptions = ['Todas', ...branches.map((branch) => branch.name)]; + const handleBranchChange = (label: string) => { + const branch = branches.find((branch) => branch.name === label); + setSelectedBranchId(branch?.id ?? ''); + setCurrentPage(1); + }; + + const fetchBranches = useCallback(async () => { + try { + const resp = await api.branch.findAll({ page: 1, limit: 100 }); + setBranches(resp.results); + } catch (err: unknown) { + console.error('Error fetching Branches:', err); + toast.error('No se pudieron cargar las sucursales'); + } + }, []); + + useEffect(() => { + if (token && user?.sub && user.role == UserRole.ADMIN) fetchBranches(); + }, [fetchBranches, token, user?.sub]); + + const fetchLots = useCallback(async () => { + if (!token || !user?.sub) return; + setIsLoading(true); + setError(null); + const params: Parameters[0] = { + page: currentPage, + limit: itemsPerPage, + }; + if (user.role == UserRole.BRANCH_ADMIN) { + params.branchId = user.branch?.id; + } else { + params.branchId = selectedBranchId ? selectedBranchId : undefined; + } + try { + const response: Pagination = await api.lot.findAll(params); + setLots(response.results); + setTotalItems(response.count); + } catch (err: unknown) { + console.error('Error fetching lots:', err); + toast.error('Error al cargar los lotes'); + setError('No se pudieron cargar los lotes.'); + } finally { + setIsLoading(false); + } + }, [token, user?.sub, currentPage, itemsPerPage, selectedBranchId]); + + useEffect(() => { + fetchLots(); + }, [fetchLots]); + + const totalPages = Math.ceil(totalItems / itemsPerPage); + + const getColumns = () => { + const columns = [ + { + key: 'name', + label: 'Producto', + render: (i: LotResponse) => i.productPresentation.product.name, + }, + { + key: 'presentation', + label: 'Presentación', + render: (i: LotResponse) => i.productPresentation.presentation.name, + }, + { + key: 'manufacturer', + label: 'Marca', + render: (i: LotResponse) => + i.productPresentation.product.manufacturer.name, + }, + { + key: 'expirationDate', + label: 'Fecha de expiración', + render: (i: LotResponse) => formatDateSafe(i.expirationDate), + }, + { + key: 'stockQuantity', + label: 'Existencia', + render: (i: LotResponse) => i.quantity, + }, + { + key: 'price', + label: 'Precio', + render: (i: LotResponse) => + `$${formatPrice(i.productPresentation.price)}`, + }, + { + key: 'total', + label: 'Total', + render: (i: LotResponse) => + `$${formatPrice(i.quantity * i.productPresentation.price)}`, + }, + ]; + if (user?.role == UserRole.ADMIN) { + columns.unshift({ + key: 'branch', + label: 'Sucursal', + render: (i: LotResponse) => i.branch.name, + }); + } + return columns; + }; + + const renderDropdown = () => { + if (user?.role == UserRole.ADMIN) { + return ( + + ); + } + return <>; + }; + + return ( +
+ {error && ( +
{error}
+ )} + + + title="Lotes" + dropdownComponent={renderDropdown()} + tableData={lots} + tableColumns={getColumns()} + pagination={{ + currentPage, + totalPages, + totalItems, + itemsPerPage, + onPageChange: setCurrentPage, + onItemsPerPageChange: (val) => { + setItemsPerPage(val); + setCurrentPage(1); + }, + itemsPerPageOptions: [5, 10, 15, 20], + }} + isLoading={isLoading} + /> +
+ ); +} diff --git a/src/app/(dashboard)/orders/[id]/edit/page.tsx b/src/app/(dashboard)/orders/[id]/edit/page.tsx index 9e014c1..e391005 100644 --- a/src/app/(dashboard)/orders/[id]/edit/page.tsx +++ b/src/app/(dashboard)/orders/[id]/edit/page.tsx @@ -26,22 +26,13 @@ import { } from '@/lib/utils/orderTranslations'; import { useAuth } from '@/context/AuthContext'; import Loading from '@/app/(dashboard)/loading'; +import { formatPrice } from '@/lib/utils/priceFormatter'; export default function EditOrderStatusPage() { const params = useParams(); const id = typeof params?.id === 'string' ? params.id : ''; const router = useRouter(); - const { token } = useAuth(); - const socket = io(SOCKET_URL, { - transportOptions: { - polling: { - extraHeaders: { - authorization: `Bearer ${token}`, - }, - }, - }, - }); - + const { token, user } = useAuth(); const [order, setOrder] = useState(null); const [orderStatus, setOrderStatus] = useState(); const [deliveryStatus, setDeliveryStatus] = useState(); @@ -51,33 +42,50 @@ export default function EditOrderStatusPage() { string | null >(null); const [loading, setLoading] = useState(true); - const [isConnected, setIsConnected] = useState(false); - type SocketError = { - message: string; - data: { id: string }; - }; + const socket = io(SOCKET_URL, { + transportOptions: { + polling: { + extraHeaders: { + authorization: `Bearer ${token}`, + }, + }, + }, + }); useEffect(() => { - socket.on('connect', () => { - setIsConnected(true); - console.log('Socket connected: ', isConnected); - }); + function onConnect() { + console.log('Connected to socket server'); + } - socket.on('disconnect', () => { - setIsConnected(false); - console.log('Socket connected: ', isConnected); - }); + function onDisconnect() { + console.log('Disconnected from socket server'); + } - socket.on('error', (error: SocketError) => { - console.error('Socket error: ', error); - toast.error('Error de conexión con el servidor'); - }); + async function onDeliveryUpdated(delivery: { + orderDeliveryId: string; + status: OrderDeliveryStatus; + employeeId: string; + }) { + const response = await api.deliveryService.getById( + delivery.orderDeliveryId, + token!, + ); + setDeliveryStatus(response.deliveryStatus); + setDeliveryId(response.id); + setSelectedDeliveryUserId(response.employeeId); + } + + socket.on('connect', onConnect); + socket.on('disconnect', onDisconnect); + socket.on('deliveryUpdated', onDeliveryUpdated); return () => { - socket.disconnect(); + socket.off('connect', onConnect); + socket.off('deliveryUpdated', onDeliveryUpdated); + socket.off('disconnect', onDisconnect); }; - }, [isConnected, socket]); + }, []); const fetchOrderData = useCallback(async () => { if (!token || !id) return; @@ -122,8 +130,16 @@ export default function EditOrderStatusPage() { if (!token || !id) return; try { - socket.emit('updateOrder', { id, status: orderStatus }); - console.log('DeliveryId', selectedDeliveryUserId); + const orderUpdated = await api.order.update( + id, + { status: orderStatus }, + token, + ); + socket.emit('updateOrder', { + id: orderUpdated.id, + status: orderUpdated.status, + }); + if (deliveryId && deliveryStatus) { await api.deliveryService.update( deliveryId, @@ -136,6 +152,7 @@ export default function EditOrderStatusPage() { } toast.success('Orden actualizada correctamente'); + socket.disconnect(); setTimeout(() => router.push('/orders'), 2000); } catch (error) { console.error('Error actualizando la orden:', error); @@ -154,6 +171,16 @@ export default function EditOrderStatusPage() { } const isDelivery = order.type === OrderType.DELIVERY; + if (user?.branch?.id !== order.branch?.id && user?.role !== UserRole.ADMIN) { + return ( +
+

+ No tienes permiso para ver esta orden. +

+
+ ); + } + return ( <>
@@ -241,8 +268,48 @@ export default function EditOrderStatusPage() { )}
+ + {order.paymentConfirmation && ( +
+

Datos del Pago

+
+
+ +

+ {order.paymentConfirmation.reference} +

+
+
+ +

+ {order.paymentConfirmation.bank} +

+
+
+ +

+ {order.paymentConfirmation.phoneNumber} +

+
+
+ +

+ ${formatPrice(order.totalPrice)} +

+
+
+
+ )} - + ); diff --git a/src/app/(dashboard)/orders/[id]/page.tsx b/src/app/(dashboard)/orders/[id]/page.tsx index 274a507..3aad9f2 100644 --- a/src/app/(dashboard)/orders/[id]/page.tsx +++ b/src/app/(dashboard)/orders/[id]/page.tsx @@ -24,12 +24,13 @@ import { orderStatusTranslationMap, orderDeliveryStatusTranslationMap, } from '@/lib/utils/orderTranslations'; +import { formatPrice } from '@/lib/utils/priceFormatter'; export default function ViewOrderStatusPage() { const params = useParams(); const id = typeof params?.id === 'string' ? params.id : ''; const router = useRouter(); - const { token } = useAuth(); + const { token, user } = useAuth(); const [order, setOrder] = useState(null); const [orderStatus, setOrderStatus] = useState(); @@ -90,6 +91,16 @@ export default function ViewOrderStatusPage() { if (loading || !order) return ; const isDelivery = order.type === OrderType.DELIVERY; + if (user?.branch?.id !== order.branch?.id && user?.role !== UserRole.ADMIN) { + return ( +
+

+ No tienes permiso para ver esta orden. +

+
+ ); + } + return ( <>
@@ -170,8 +181,47 @@ export default function ViewOrderStatusPage() { )}
+ {order.paymentConfirmation && ( +
+

Datos del Pago

+
+
+ +

+ {order.paymentConfirmation.reference} +

+
+
+ +

+ {order.paymentConfirmation.bank} +

+
+
+ +

+ {order.paymentConfirmation.phoneNumber} +

+
+
+ +

+ ${formatPrice(order.totalPrice)} +

+
+
+
+ )} - + ); diff --git a/src/app/(dashboard)/orders/page.tsx b/src/app/(dashboard)/orders/page.tsx index 558335e..9733235 100644 --- a/src/app/(dashboard)/orders/page.tsx +++ b/src/app/(dashboard)/orders/page.tsx @@ -12,29 +12,29 @@ import { OrderResponse, OrderStatus, OrderType, + UserRole, } from '@pharmatech/sdk'; import { orderStatusTranslationMap } from '@/lib/utils/orderTranslations'; import Badge from '@/components/Badge'; import { toast } from 'react-toastify'; +import { formatDateSafe } from '@/lib/utils/useFormatDate'; +import { formatPrice } from '@/lib/utils/priceFormatter'; export default function OrdersPage() { - const { token } = useAuth(); + const { token, user } = useAuth(); const router = useRouter(); - // datos y paginación const [orders, setOrders] = useState([]); - const [query, setQuery] = useState(''); // buscador libre (q) + const [query, setQuery] = useState(''); const [selectedStatus, setSelectedStatus] = useState(''); const [selectedType, setSelectedType] = useState(''); const [page, setPage] = useState(1); const [limit, setLimit] = useState(10); const [total, setTotal] = useState(0); - // estados de carga y error const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - // debounce para búsqueda const DEBOUNCE_MS = 500; const debounceRef = useRef(null); const onSearch = (q: string) => { @@ -45,7 +45,6 @@ export default function OrdersPage() { }, DEBOUNCE_MS); }; - // opciones de estado y tipo const statusOptions = [ { value: '', label: 'Todos' }, { value: OrderStatus.REQUESTED, label: 'Solicitado' }, @@ -86,17 +85,8 @@ export default function OrdersPage() { setPage(1); }; - // formatea fechas - const formatDate = (input: string | Date) => - new Date(input).toLocaleDateString('es-ES', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); - - // trae órdenes usando SOLO los filtros que el backend expone (q + status + type) const fetchOrders = useCallback(async () => { - if (!token) return; + if (!token || !user?.sub) return; setIsLoading(true); setError(null); @@ -108,6 +98,9 @@ export default function OrdersPage() { ...(selectedStatus ? { status: selectedStatus } : {}), ...(selectedType ? { type: selectedType } : {}), }; + if (user.role == UserRole.BRANCH_ADMIN) { + params.branchId = user.branch?.id; + } const resp: Pagination = await api.order.findAll( params, @@ -125,7 +118,6 @@ export default function OrdersPage() { } }, [page, limit, query, selectedStatus, selectedType, token]); - // recarga al cambiar filtros o paginación useEffect(() => { fetchOrders(); }, [fetchOrders]); @@ -137,12 +129,12 @@ export default function OrdersPage() { { key: 'createdAt', label: 'Creación', - render: (o) => formatDate(o.createdAt), + render: (o) => formatDateSafe(o.createdAt), }, { key: 'updatedAt', label: 'Actualización', - render: (o) => formatDate(o.updatedAt), + render: (o) => formatDateSafe(o.updatedAt), }, { key: 'status', @@ -163,7 +155,72 @@ export default function OrdersPage() { { key: 'totalPrice', label: 'Precio total', - render: (o) => `$${o.totalPrice.toFixed(2)}`, + render: (o) => `$${formatPrice(o.totalPrice)}`, + }, + ]; + + const handleStatusUpdate = async ( + users: OrderResponse[], + status: OrderStatus, + ) => { + api.order + .bulkUpdate( + { + orders: users.map((u) => u.id), + status: status, + }, + token!, + ) + .then(() => { + toast.success('Órdenes actualizadas'); + fetchOrders(); + }) + .catch((err) => { + console.error('Error al actualizar el status de las órdenes:', err); + toast.error('Error al actualizar el status de las órdenes'); + }); + }; + + const handleStatusUpdateToApproved = async (users: OrderResponse[]) => { + handleStatusUpdate(users, OrderStatus.APPROVED); + }; + + const handleStatusUpdateToCanceled = async (users: OrderResponse[]) => { + handleStatusUpdate(users, OrderStatus.CANCELED); + }; + + const handleStatusUpdateToReadyForPickup = async (users: OrderResponse[]) => { + handleStatusUpdate(users, OrderStatus.READY_FOR_PICKUP); + }; + + const handleStatusUpdateToCompleted = async (users: OrderResponse[]) => { + handleStatusUpdate(users, OrderStatus.COMPLETED); + }; + + const handleStatusUpdateToInProgress = async (users: OrderResponse[]) => { + handleStatusUpdate(users, OrderStatus.IN_PROGRESS); + }; + + const actions = [ + { + label: 'Aprobar', + onClick: handleStatusUpdateToApproved, + }, + { + label: 'Cancelar', + onClick: handleStatusUpdateToCanceled, + }, + { + label: 'Listar para Retiro', + onClick: handleStatusUpdateToReadyForPickup, + }, + { + label: 'Completar', + onClick: handleStatusUpdateToCompleted, + }, + { + label: 'Marcar como En Proceso', + onClick: handleStatusUpdateToInProgress, }, ]; @@ -179,6 +236,7 @@ export default function OrdersPage() { title="Órdenes" onSearch={onSearch} + actions={actions} dropdownComponent={
} - onAddClick={() => router.push('/orders/new')} - addButtonText="Agregar orden" tableData={orders} tableColumns={columns} onView={(o) => router.push(`/orders/${o.id}`)} @@ -219,9 +275,8 @@ export default function OrdersPage() { }, itemsPerPageOptions: [5, 10, 15, 20], }} + isLoading={isLoading} /> - - {isLoading &&
Cargando órdenes...
} ); } diff --git a/src/app/(dashboard)/orders/requests/page.tsx b/src/app/(dashboard)/orders/requests/page.tsx new file mode 100644 index 0000000..6a6e137 --- /dev/null +++ b/src/app/(dashboard)/orders/requests/page.tsx @@ -0,0 +1,226 @@ +'use client'; + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import TableContainer from '@/components/TableContainer'; +import Dropdown from '@/components/Dropdown'; +import { Column } from '@/components/Table'; +import { api } from '@/lib/sdkConfig'; +import { useAuth } from '@/context/AuthContext'; +import { + Pagination, + OrderResponse, + OrderStatus, + OrderType, + UserRole, +} from '@pharmatech/sdk'; +import { toast } from 'react-toastify'; +import { formatDateSafe } from '@/lib/utils/useFormatDate'; +import { formatPrice } from '@/lib/utils/priceFormatter'; + +export default function OrdersPage() { + const { token, user } = useAuth(); + const router = useRouter(); + + const [orders, setOrders] = useState([]); + const [query, setQuery] = useState(''); + const [selectedType, setSelectedType] = useState(''); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(10); + const [total, setTotal] = useState(0); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const DEBOUNCE_MS = 500; + const debounceRef = useRef(null); + const onSearch = (q: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setQuery(q.trim()); + setPage(1); + }, DEBOUNCE_MS); + }; + + const typeOptions = [ + { value: '', label: 'Todos' }, + { value: OrderType.PICKUP, label: 'Pickup' }, + { value: OrderType.DELIVERY, label: 'Delivery' }, + ] as const; + + const handleTypeChange = (label: string) => { + const opt = typeOptions.find((o) => o.label === label); + setSelectedType(opt?.value ?? ''); + setPage(1); + }; + + const fetchOrders = useCallback(async () => { + if (!token || !user?.sub) return; + setIsLoading(true); + setError(null); + + try { + const params: Parameters[0] = { + page, + limit, + status: OrderStatus.REQUESTED, + ...(query ? { q: query } : {}), + ...(selectedType ? { type: selectedType } : {}), + }; + + if (user.role == UserRole.BRANCH_ADMIN) { + params.branchId = user.branch?.id; + } + + const resp: Pagination = await api.order.findAll( + params, + token, + ); + + setOrders(resp.results); + setTotal(resp.count); + } catch (err: unknown) { + console.error('Error fetching orders:', err); + toast.error('Error al cargar órdenes'); + setError('No se pudieron cargar las órdenes.'); + } finally { + setIsLoading(false); + } + }, [page, limit, query, selectedType, token]); + + useEffect(() => { + fetchOrders(); + }, [fetchOrders]); + + const totalPages = Math.ceil(total / limit); + + const columns: Column[] = [ + { key: 'id', label: 'ID', render: (o) => o.id.slice(0, 8) }, + { + key: 'createdAt', + label: 'Creación', + render: (o) => formatDateSafe(o.createdAt), + }, + { + key: 'updatedAt', + label: 'Actualización', + render: (o) => formatDateSafe(o.updatedAt), + }, + { key: 'type', label: 'Tipo', render: (o) => o.type }, + { + key: 'totalPrice', + label: 'Precio total', + render: (o) => `$${formatPrice(o.totalPrice)}`, + }, + ]; + + const handleStatusUpdate = async ( + users: OrderResponse[], + status: OrderStatus, + ) => { + api.order + .bulkUpdate( + { + orders: users.map((u) => u.id), + status: status, + }, + token!, + ) + .then(() => { + toast.success('Órdenes actualizadas'); + fetchOrders(); + }) + .catch((err) => { + console.error('Error al actualizar el status de las órdenes:', err); + toast.error('Error al actualizar el status de las órdenes'); + }); + }; + + const handleStatusUpdateToApproved = async (users: OrderResponse[]) => { + handleStatusUpdate(users, OrderStatus.APPROVED); + }; + + const handleStatusUpdateToCanceled = async (users: OrderResponse[]) => { + handleStatusUpdate(users, OrderStatus.CANCELED); + }; + + const handleStatusUpdateToReadyForPickup = async (users: OrderResponse[]) => { + handleStatusUpdate(users, OrderStatus.READY_FOR_PICKUP); + }; + + const handleStatusUpdateToCompleted = async (users: OrderResponse[]) => { + handleStatusUpdate(users, OrderStatus.COMPLETED); + }; + + const handleStatusUpdateToInProgress = async (users: OrderResponse[]) => { + handleStatusUpdate(users, OrderStatus.IN_PROGRESS); + }; + + const actions = [ + { + label: 'Aprobar', + onClick: handleStatusUpdateToApproved, + }, + { + label: 'Cancelar', + onClick: handleStatusUpdateToCanceled, + }, + { + label: 'Listar para Retiro', + onClick: handleStatusUpdateToReadyForPickup, + }, + { + label: 'Completar', + onClick: handleStatusUpdateToCompleted, + }, + { + label: 'Marcar como En Proceso', + onClick: handleStatusUpdateToInProgress, + }, + ]; + + return ( +
+ {error && ( +
{error}
+ )} + + + title="Órdenes Solicitadas" + onSearch={onSearch} + actions={actions} + dropdownComponent={ + o.label)} + onChange={handleTypeChange} + selected={ + typeOptions.find((o) => o.value === selectedType)?.label || + 'Todos' + } + /> + } + tableData={orders} + tableColumns={columns} + onView={(o) => router.push(`/orders/${o.id}`)} + onEdit={(o) => router.push(`/orders/${o.id}/edit`)} + pagination={{ + currentPage: page, + totalPages, + totalItems: total, + itemsPerPage: limit, + onPageChange: setPage, + onItemsPerPageChange: (val) => { + setLimit(val); + setPage(1); + }, + itemsPerPageOptions: [5, 10, 15, 20], + }} + isLoading={isLoading} + /> +
+ ); +} diff --git a/src/app/(dashboard)/presentations/[id]/edit/page.tsx b/src/app/(dashboard)/presentations/[id]/edit/page.tsx index b50d10d..952467c 100644 --- a/src/app/(dashboard)/presentations/[id]/edit/page.tsx +++ b/src/app/(dashboard)/presentations/[id]/edit/page.tsx @@ -152,7 +152,6 @@ export default function EditPresentationPage() { onChange={(e) => setName(e.target.value)} helperText={errors.name} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" borderSize="1px" /> @@ -181,7 +180,6 @@ export default function EditPresentationPage() { onChange={(e) => setQuantity(e.target.value)} helperText={errors.quantity} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" type="number" borderSize="1px" /> @@ -195,7 +193,6 @@ export default function EditPresentationPage() { onChange={(e) => setDescription(e.target.value)} helperText={errors.description} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" type="text" borderSize="1px" isTextArea diff --git a/src/app/(dashboard)/presentations/new/page.tsx b/src/app/(dashboard)/presentations/new/page.tsx index 63313d0..5472c84 100644 --- a/src/app/(dashboard)/presentations/new/page.tsx +++ b/src/app/(dashboard)/presentations/new/page.tsx @@ -134,7 +134,6 @@ export default function NewPresentationPage() { ) => setName(e.target.value)} helperText={errors.name} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" borderSize="1px" /> @@ -165,7 +164,6 @@ export default function NewPresentationPage() { ) => setQuantity(e.target.value)} helperText={errors.quantity} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" type="number" borderSize="1px" /> @@ -181,7 +179,6 @@ export default function NewPresentationPage() { ) => setDescription(e.target.value)} helperText={errors.description} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" type="text" borderSize="1px" isTextArea diff --git a/src/app/(dashboard)/presentations/page.tsx b/src/app/(dashboard)/presentations/page.tsx index 8c97e0b..9a3607e 100644 --- a/src/app/(dashboard)/presentations/page.tsx +++ b/src/app/(dashboard)/presentations/page.tsx @@ -107,11 +107,8 @@ export default function PresentationListPage() { }, itemsPerPageOptions: [5, 10, 15, 20], }} + isLoading={isLoading} /> - - {isLoading && ( -
Cargando presentaciones...
- )} ); } diff --git a/src/app/(dashboard)/products/[id]/edit/page.tsx b/src/app/(dashboard)/products/[id]/edit/page.tsx index d350183..ac2bcc1 100644 --- a/src/app/(dashboard)/products/[id]/edit/page.tsx +++ b/src/app/(dashboard)/products/[id]/edit/page.tsx @@ -221,7 +221,6 @@ export default function EditProductPage() { onChange={(e) => setGenericName(e.target.value)} helperText={errors.genericName} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" borderSize="1px" /> @@ -251,7 +250,6 @@ export default function EditProductPage() { onChange={(e) => setName(e.target.value)} helperText={errors.name} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" borderSize="1px" /> @@ -263,7 +261,6 @@ export default function EditProductPage() { onChange={(e) => setPriority(e.target.value)} helperText={errors.priority} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" type="number" borderSize="1px" /> @@ -277,7 +274,6 @@ export default function EditProductPage() { onChange={(e) => setDescription(e.target.value)} helperText={errors.description} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" type="text" borderSize="1px" isTextArea diff --git a/src/app/(dashboard)/products/[id]/page.tsx b/src/app/(dashboard)/products/[id]/page.tsx index 224e1c1..1af4e9b 100644 --- a/src/app/(dashboard)/products/[id]/page.tsx +++ b/src/app/(dashboard)/products/[id]/page.tsx @@ -18,6 +18,7 @@ import TableContainer from '@/components/TableContainer'; import { useAuth } from '@/context/AuthContext'; import Loading from '../../loading'; import Input from '@/components/Input/Input'; +import { formatPrice } from '@/lib/utils/priceFormatter'; type ProductPresentationItem = ProductPresentationResponse; export default function GenericProductDetailPage() { @@ -46,7 +47,7 @@ export default function GenericProductDetailPage() { { key: 'price', label: 'Precio', - render: (item) => `$${item.price.toFixed(2)}`, + render: (item) => `$${formatPrice(item.price)}`, }, { key: 'promo', diff --git a/src/app/(dashboard)/products/[id]/presentations/[presentationId]/edit/page.tsx b/src/app/(dashboard)/products/[id]/presentations/[presentationId]/edit/page.tsx index 795a875..4b6acee 100644 --- a/src/app/(dashboard)/products/[id]/presentations/[presentationId]/edit/page.tsx +++ b/src/app/(dashboard)/products/[id]/presentations/[presentationId]/edit/page.tsx @@ -55,6 +55,8 @@ export default function EditProductPresentationPage() { ), api.promo.findAll({ page: 1, limit: 50 }, token), ]); + // Convert price from cents to dollars + presData.price = presData.price / 100; setPresentationData(presData); setPromos(promoResp.results); setPrice(presData.price.toString()); @@ -95,9 +97,9 @@ export default function EditProductPresentationPage() { } const payload = result.data; - + // Convert to cents + payload.price = Number((payload.price * 100).toFixed(0)); try { - console.log('Payload:', payload); await api.productPresentation.update(productId, presentationId, payload); toast.success('Presentación actualizada exitosamente'); setTimeout(() => { @@ -184,7 +186,6 @@ export default function EditProductPresentationPage() { onChange={(e) => setPrice(e.target.value)} helperText={errors.price} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" borderSize="1px" type="number" /> diff --git a/src/app/(dashboard)/products/[id]/presentations/[presentationId]/page.tsx b/src/app/(dashboard)/products/[id]/presentations/[presentationId]/page.tsx index d6102a1..a042f65 100644 --- a/src/app/(dashboard)/products/[id]/presentations/[presentationId]/page.tsx +++ b/src/app/(dashboard)/products/[id]/presentations/[presentationId]/page.tsx @@ -12,6 +12,7 @@ import { toast } from 'react-toastify'; import { REDIRECTION_TIMEOUT } from '@/lib/utils/contants'; import { useAuth } from '@/context/AuthContext'; import Input from '@/components/Input/Input'; +import { formatPrice } from '@/lib/utils/priceFormatter'; export default function ViewProductPresentationPage() { const params = useParams(); @@ -162,7 +163,7 @@ export default function ViewProductPresentationPage() {
diff --git a/src/app/(dashboard)/products/[id]/presentations/new/page.tsx b/src/app/(dashboard)/products/[id]/presentations/new/page.tsx index 68d61fe..6414cf5 100644 --- a/src/app/(dashboard)/products/[id]/presentations/new/page.tsx +++ b/src/app/(dashboard)/products/[id]/presentations/new/page.tsx @@ -72,9 +72,9 @@ export default function AddProductPresentationPage() { } const payload = result.data; - + // Convert to cents + payload.price = Number((payload.price * 100).toFixed(0)); try { - console.log('Payload:', payload); await api.productPresentation.create(productId, payload); toast.success('Presentación añadida al producto'); setTimeout(() => { @@ -177,7 +177,6 @@ export default function AddProductPresentationPage() { onChange={(e) => setPrice(e.target.value)} helperText={errors.price} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" type="number" borderSize="1px" /> diff --git a/src/app/(dashboard)/products/new/page.tsx b/src/app/(dashboard)/products/new/page.tsx index e84a7f6..4c67e01 100644 --- a/src/app/(dashboard)/products/new/page.tsx +++ b/src/app/(dashboard)/products/new/page.tsx @@ -153,7 +153,6 @@ export default function NewGenericProductPage() { helperText={errors.genericName} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#d1d5db" />
@@ -180,7 +179,6 @@ export default function NewGenericProductPage() { helperText={errors.name} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#d1d5db" />
@@ -192,7 +190,6 @@ export default function NewGenericProductPage() { helperText={errors.priority} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#d1d5db" type="number" />
@@ -206,7 +203,6 @@ export default function NewGenericProductPage() { helperText={errors.description} helperTextColor={Colors.semanticDanger} borderSize="1px" - borderColor="#d1d5db" type="text" isTextArea rows={4} diff --git a/src/app/(dashboard)/products/page.tsx b/src/app/(dashboard)/products/page.tsx index 5627e72..bb20895 100644 --- a/src/app/(dashboard)/products/page.tsx +++ b/src/app/(dashboard)/products/page.tsx @@ -165,9 +165,8 @@ export default function GenericProductListPage() { }, itemsPerPageOptions: [5, 10, 15, 20], }} + isLoading={isLoading} /> - - {isLoading &&
Cargando productos…
} ); } diff --git a/src/app/(dashboard)/profile/edit/page.tsx b/src/app/(dashboard)/profile/edit/page.tsx index 7a600af..d31a033 100644 --- a/src/app/(dashboard)/profile/edit/page.tsx +++ b/src/app/(dashboard)/profile/edit/page.tsx @@ -103,7 +103,6 @@ export default function EditProfilePage() { label="Nombre" value={firstName} onChange={(e) => setFirstName(e.target.value)} - borderColor="#E7E7E6" /> {errors.firstName && (

{errors.firstName}

@@ -114,7 +113,6 @@ export default function EditProfilePage() { label="Apellido" value={lastName} onChange={(e) => setLastName(e.target.value)} - borderColor="#E7E7E6" /> {errors.lastName && (

{errors.lastName}

@@ -126,7 +124,6 @@ export default function EditProfilePage() { type="text" helperText={errors.documentId} value={profile.documentId} - borderColor="#E7E7E6" readViewOnly /> @@ -136,7 +133,6 @@ export default function EditProfilePage() { type="text" helperText={errors.documentId} value={profile.email} - borderColor="#E7E7E6" readViewOnly /> @@ -155,7 +151,6 @@ export default function EditProfilePage() { label="Número de Teléfono" value={phone} onChange={(e) => setPhone(e.target.value)} - borderColor="#E7E7E6" /> {errors.phone && (

{errors.phone}

diff --git a/src/app/(dashboard)/profile/layout.tsx b/src/app/(dashboard)/profile/layout.tsx index fb54c53..47dcbcb 100644 --- a/src/app/(dashboard)/profile/layout.tsx +++ b/src/app/(dashboard)/profile/layout.tsx @@ -9,6 +9,7 @@ import { usePathname } from 'next/navigation'; import { UserList } from '@pharmatech/sdk'; import Breadcrumb from '@/components/Breadcrumb'; import { toast } from 'react-toastify'; +import Loader from '@/components/Loader'; interface ProfileLayoutProps { children: ReactNode; @@ -48,7 +49,7 @@ export default function ProfileLayout({ children }: ProfileLayoutProps) { })(); }, [user, token]); - if (!profile) return

Cargando perfil…

; + if (!profile) return ; const handleImageUpload = async (file: File) => { setUploading(true); diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx index 2d6c7c9..1e96a33 100644 --- a/src/app/(dashboard)/profile/page.tsx +++ b/src/app/(dashboard)/profile/page.tsx @@ -6,7 +6,6 @@ import Input from '@/components/Input/Input'; import Button from '@/components/Button'; import { useAuth } from '@/context/AuthContext'; import { api } from '@/lib/sdkConfig'; -import Loader from '@/components/Loader'; import { toast } from 'react-toastify'; import { UserList } from '@pharmatech/sdk'; @@ -14,17 +13,14 @@ export default function ProfilePage() { const { user, token } = useAuth(); const router = useRouter(); const [profile, setProfile] = useState(null); - const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchProfile = useCallback(async () => { if (!user?.sub || !token) { setError('Usuario no autenticado'); - setLoading(false); return; } try { - setLoading(true); const res = await api.user.getProfile(user.sub, token); setProfile(res); setError(null); @@ -33,7 +29,6 @@ export default function ProfilePage() { toast.error('Error cargando perfil'); setError('No se pudo cargar tu perfil'); } finally { - setLoading(false); } }, [user, token]); @@ -41,7 +36,6 @@ export default function ProfilePage() { fetchProfile(); }, [fetchProfile]); - if (loading) return ; if (error) return

{error}

; if (!profile) return null; diff --git a/src/app/(dashboard)/profile/security/page.tsx b/src/app/(dashboard)/profile/security/page.tsx index df97111..de8065d 100644 --- a/src/app/(dashboard)/profile/security/page.tsx +++ b/src/app/(dashboard)/profile/security/page.tsx @@ -57,7 +57,6 @@ export default function SecurityPage() { value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} helperText={errors.currentPassword} - borderColor="#E7E7E6" /> setNewPassword(e.target.value)} helperText={errors.newPassword} - borderColor="#E7E7E6" /> setConfirmPassword(e.target.value)} helperText={errors.confirmPassword} - borderColor="#E7E7E6" />
diff --git a/src/app/(dashboard)/promos/[id]/edit/page.tsx b/src/app/(dashboard)/promos/[id]/edit/page.tsx index 6ae6432..43a4e1f 100644 --- a/src/app/(dashboard)/promos/[id]/edit/page.tsx +++ b/src/app/(dashboard)/promos/[id]/edit/page.tsx @@ -165,7 +165,6 @@ export default function EditPromoPage() { onChange={(e) => setTempName(e.target.value)} helperText={errors.name} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" /> diff --git a/src/app/(dashboard)/promos/[id]/page.tsx b/src/app/(dashboard)/promos/[id]/page.tsx index 7b8f7ab..8b41983 100644 --- a/src/app/(dashboard)/promos/[id]/page.tsx +++ b/src/app/(dashboard)/promos/[id]/page.tsx @@ -8,11 +8,11 @@ import ModalConfirm from '@/components/ModalConfirm'; import { Colors } from '@/styles/styles'; import { api } from '@/lib/sdkConfig'; import { toast } from 'react-toastify'; -import { format } from 'date-fns'; import { PromoResponse } from '@pharmatech/sdk/types'; import { useAuth } from '@/context/AuthContext'; import Loading from '../../loading'; import Input from '@/components/Input/Input'; +import { formatDateSafe } from '@/lib/utils/useFormatDate'; export default function PromoDetailsPage() { const params = useParams(); @@ -76,14 +76,6 @@ export default function PromoDetailsPage() { const handleCancelDelete = () => setShowDeleteModal(false); - const formatDate = (date: Date) => { - try { - return format(date, 'dd/MM/yyyy'); - } catch { - return date.toString(); - } - }; - if (!promo) { return ; } @@ -168,7 +160,7 @@ export default function PromoDetailsPage() {
@@ -176,7 +168,7 @@ export default function PromoDetailsPage() {
diff --git a/src/app/(dashboard)/promos/new/page.tsx b/src/app/(dashboard)/promos/new/page.tsx index 465dd0c..ae67653 100644 --- a/src/app/(dashboard)/promos/new/page.tsx +++ b/src/app/(dashboard)/promos/new/page.tsx @@ -11,6 +11,7 @@ import { api } from '@/lib/sdkConfig'; import { toast } from 'react-toastify'; import { promoSchema } from '@/lib/validations/promoSchema'; import { useAuth } from '@/context/AuthContext'; +import { parseApiDate } from '@/lib/utils/useFormatDate'; export default function NewPromotionPage() { const router = useRouter(); @@ -48,8 +49,8 @@ export default function NewPromotionPage() { const validationData = { name: name.trim(), discount: Number(discount), - startAt, - expiredAt, + startAt: startAt, + expiredAt: expiredAt, }; const validationResult = promoSchema.safeParse(validationData); @@ -141,7 +142,6 @@ export default function NewPromotionPage() { onChange={(e) => setName(e.target.value)} helperText={errors.name} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" />
@@ -154,7 +154,6 @@ export default function NewPromotionPage() { } helperText={errors.discount} helperTextColor={Colors.semanticDanger} - borderColor="#d1d5db" type="number" />
@@ -163,7 +162,7 @@ export default function NewPromotionPage() { - setStartAt(new Date(date))} /> + setStartAt(parseApiDate(date))} /> {errors.startAt && (

{errors.startAt}

)} @@ -172,7 +171,9 @@ export default function NewPromotionPage() { - setExpiredAt(new Date(date))} /> + setExpiredAt(parseApiDate(date))} + /> {errors.expiredAt && (

{errors.expiredAt}

)} diff --git a/src/app/(dashboard)/promos/page.tsx b/src/app/(dashboard)/promos/page.tsx index 84aaf1c..93548fb 100644 --- a/src/app/(dashboard)/promos/page.tsx +++ b/src/app/(dashboard)/promos/page.tsx @@ -10,6 +10,7 @@ import { Pagination, PromoResponse } from '@pharmatech/sdk'; import { useAuth } from '@/context/AuthContext'; import Badge from '@/components/Badge'; import { toast } from 'react-toastify'; +import { formatDateSafe, parseApiDate } from '@/lib/utils/useFormatDate'; // Presets de rango de expiración que el backend acepta via expirationBetween const expirationTranslations: Record = { @@ -94,11 +95,15 @@ export default function PromosPage() { const totalPages = Math.ceil(totalItems / limit); // calcular estado para columna - const calcStatus = (start: Date, end: Date): 'Activa' | 'Finalizada' => { + + const calcStatus = ( + start: string | Date, + end: string | Date, + ): 'Activa' | 'Finalizada' => { const now = new Date(); - return now >= start && now <= end ? 'Activa' : 'Finalizada'; + const parsedEnd = parseApiDate(end); + return now <= parsedEnd ? 'Activa' : 'Finalizada'; }; - // definición de columnas const columns: Column[] = [ { key: 'name', label: 'Nombre', render: (p: PromoResponse) => p.name }, @@ -110,20 +115,18 @@ export default function PromosPage() { { key: 'startAt', label: 'Inicio', - render: (p: PromoResponse) => - new Date(p.startAt).toLocaleDateString('es-ES'), + render: (p: PromoResponse) => formatDateSafe(p.startAt), }, { key: 'expiredAt', label: 'Fin', - render: (p: PromoResponse) => - new Date(p.expiredAt).toLocaleDateString('es-ES'), + render: (p: PromoResponse) => formatDateSafe(p.expiredAt), }, { key: 'status', label: 'Estado', render: (p: PromoResponse) => { - const status = calcStatus(new Date(p.startAt), new Date(p.expiredAt)); + const status = calcStatus(p.startAt, p.expiredAt); return ( { + api.promo + .bulkUpdate( + { + ids: promos.map((p) => p.id), + expiredAt: new Date(), + }, + token!, + ) + .then(() => { + toast.success('Promociones expiradas'); + fetchPromos(); + }) + .catch((err) => { + console.error('Error al expirar las promociones:', err); + toast.error('Error al expirar las promociones'); + }); + }; + + const handleDeletePromos = async (promos: PromoResponse[]) => { + api.promo + .bulkDelete( + { + ids: promos.map((p) => p.id), + }, + token!, + ) + .then(() => { + toast.success('Promociones eliminadas'); + fetchPromos(); + }) + .catch((err) => { + console.error('Error al eliminar las promociones:', err); + toast.error('Error al eliminar las promociones'); + }); + }; + + const actions = [ + { + label: 'Expirar hoy', + onClick: handleSetExpiredAtToday, + }, + { + label: 'Eliminar', + onClick: handleDeletePromos, + }, + ]; + return (
title="Promociones" onSearch={handleSearch} + actions={actions} dropdownComponent={ - - {isLoading && ( -
Cargando promociones...
- )}
); } diff --git a/src/app/(dashboard)/reports/inventory/page.tsx b/src/app/(dashboard)/reports/inventory/page.tsx index 1da2a94..4e745b9 100644 --- a/src/app/(dashboard)/reports/inventory/page.tsx +++ b/src/app/(dashboard)/reports/inventory/page.tsx @@ -8,11 +8,12 @@ import { api } from '@/lib/sdkConfig'; import PDFReportTemplate from '@/components/FileHelper/PDFReportTemplate'; import { - ProductPresentationResponse, - ProductPresentationDetailResponse, StateResponse, CityResponse, + ProductPresentation, } from '@pharmatech/sdk'; +import { formatPrice } from '@/lib/utils/priceFormatter'; +import Button from '@/components/Button'; const COUNTRY_ID = '1238bc2a-45a5-47e4-9cc1-68d573089ca1'; @@ -24,20 +25,12 @@ export default function InventoryReportPreview() { const [cities, setCities] = useState([]); const [selectedState, setSelectedState] = useState(''); const [selectedCity, setSelectedCity] = useState(''); - - const [productData, setProductData] = useState( - [], - ); - const [detailsMap, setDetailsMap] = useState< - Record - >({}); + const [productData, setProductData] = useState([]); useEffect(() => { if (!token || !user?.sub) return; - (async () => { - const profile = await api.user.getProfile(user.sub, token); - setUserName(`${profile.firstName} ${profile.lastName}`); + setUserName(user.name); const stateRes = await api.state.findAll({ page: 1, @@ -65,17 +58,6 @@ export default function InventoryReportPreview() { const fetchData = async () => { const res = await api.product.getProducts({ page: 1, limit: 100 }); setProductData(res.results); - - const detailMap: Record = {}; - for (const prod of res.results) { - const detail = await api.productPresentation.getByPresentationId( - prod.product.id, - prod.presentation.id, - ); - detailMap[prod.presentation.id] = detail; - } - - setDetailsMap(detailMap); }; const columns: { @@ -96,9 +78,8 @@ export default function InventoryReportPreview() { const tableData = useMemo(() => { return productData.map((p) => { - const detail = detailsMap[p.presentation.id]; - const genericName = detail?.product?.genericName || '-'; - const presentationName = detail?.presentation?.name || '-'; + const genericName = p.product.genericName; + const presentationName = p.product.name; const stock = p.stock ?? 0; const price = p.price; @@ -106,17 +87,18 @@ export default function InventoryReportPreview() { genericName, presentationName, stockQuantity: stock, - price, - totalValue: stock * price, + price: formatPrice(price), + totalValue: formatPrice(stock * price), }; }); - }, [productData, detailsMap]); + }, [productData]); const handleDownload = async () => { const printDate = new Date().toLocaleDateString('es-VE'); - const totalValue = tableData - .reduce((acc, row) => acc + row.totalValue, 0) - .toFixed(2); + const totalValue = tableData.reduce( + (acc, row) => acc + Number(row.totalValue) * 100, + 0, + ); const blob = await pdf( , ).toBlob(); @@ -135,14 +120,16 @@ export default function InventoryReportPreview() { }; return ( -
-

+
+

Reporte de Inventario

-
+
- + setSelectedCity(e.target.value)} @@ -172,24 +161,23 @@ export default function InventoryReportPreview() { ))}
+
-
- -
+
+
{tableData.length > 0 && ( - +
+ +
)}
); diff --git a/src/app/(dashboard)/reports/sales/page.tsx b/src/app/(dashboard)/reports/sales/page.tsx index 093fa5f..0322f1f 100644 --- a/src/app/(dashboard)/reports/sales/page.tsx +++ b/src/app/(dashboard)/reports/sales/page.tsx @@ -16,6 +16,8 @@ import { CityResponse, BranchResponse, } from '@pharmatech/sdk'; +import { formatPrice } from '@/lib/utils/priceFormatter'; +import Button from '@/components/Button'; const COUNTRY_ID = '1238bc2a-45a5-47e4-9cc1-68d573089ca1'; @@ -38,8 +40,7 @@ export default function ReportPreviewPage() { (async () => { try { - const profile = await api.user.getProfile(user.sub, token); - setUserName(`${profile.firstName} ${profile.lastName}`); + setUserName(user.name); const stateResponse = await api.state.findAll({ page: 1, @@ -110,8 +111,18 @@ export default function ReportPreviewPage() { { key: 'discount', label: 'Descuento' }, { key: 'total', label: 'Total' }, ]; - - const formatCurrency = (n: number) => `$${n.toFixed(2)}`; + const tableData = useMemo(() => { + return reportData?.items.map((item) => { + return { + ...item, + orderId: `#${String(item.orderId).slice(0, 4)}`, + subtotal: Number(formatPrice(item.subtotal)), + discount: Number(formatPrice(item.discount)), + date: new Date(item.date).toLocaleDateString('es-VE'), + total: Number(formatPrice(item.total)), + }; + }); + }, [reportData]); const handleDownload = async () => { if (!reportData || !startDate || !endDate) return; @@ -121,13 +132,13 @@ export default function ReportPreviewPage() { const totals = [ { label: 'Subtotal General', - value: formatCurrency(reportData.totals.subtotal), + value: formatPrice(reportData.totals.subtotal), }, { label: 'Descuento Total', - value: formatCurrency(reportData.totals.discount), + value: formatPrice(reportData.totals.discount), }, - { label: 'Total Final', value: formatCurrency(reportData.totals.total) }, + { label: 'Total Final', value: formatPrice(reportData.totals.total) }, ]; const blob = await pdf( @@ -137,15 +148,7 @@ export default function ReportPreviewPage() { userName={userName} printDate={printDate} columns={columns} - data={reportData.items.map((item) => { - const dateObj = new Date(item.date); - const formattedDate = dateObj.toLocaleDateString('es-VE'); - return { - ...item, - orderId: `#${String(item.orderId).slice(0, 4)}`, - date: formattedDate, - }; - })} + data={tableData!} totals={totals} />, ).toBlob(); @@ -154,25 +157,31 @@ export default function ReportPreviewPage() { }; return ( -
+

Reporte de Ventas

{/* Filtros de fecha */}
- +
- +
{/* Filtros de ubicación */} -
-
- +
+
+
-
- +
+
-
- +
+ setSearchTerm(e.target.value)} - className="h-full w-[166px] rounded-l-md border border-[#DFE4EA] pl-4 text-sm text-[#666666] placeholder-[#666666] focus:outline-none" - /> + {onSearch && ( +
+ setSearchTerm(e.target.value)} + className="h-full w-[166px] rounded-l-md border border-[#DFE4EA] pl-4 text-sm text-[#666666] placeholder-[#666666] focus:outline-none" + /> + +
+ )} + + {/* Botón "Agregar" */} + {onAddClick && ( -
- - {/* Botón "Agregar" */} - + )}
); diff --git a/src/components/FileHelper/UploadCsv.tsx b/src/components/FileHelper/UploadCsv.tsx index 595498f..28097f1 100644 --- a/src/components/FileHelper/UploadCsv.tsx +++ b/src/components/FileHelper/UploadCsv.tsx @@ -4,6 +4,7 @@ import { useRef } from 'react'; import { useCsvUploader } from '@/lib/utils/useCsvUploader'; import InventoryTable from '@/components/InventoryTable'; import FileUploader from '@/components/FileUploader'; +import Loader from '../Loader'; export default function CsvUploader() { const { csvData, fileName, loading, parseCsv, clearCsv } = useCsvUploader(); @@ -26,15 +27,13 @@ export default function CsvUploader() { return (
-

Importar Inventario CSV

- - {loading &&

Cargando archivo...

} + {loading && } {fileName && (
diff --git a/src/components/GoogleMap/GoogleMap.tsx b/src/components/GoogleMap/GoogleMap.tsx new file mode 100644 index 0000000..fea384c --- /dev/null +++ b/src/components/GoogleMap/GoogleMap.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + GoogleMap, + useJsApiLoader, + Marker, + InfoWindow, +} from '@react-google-maps/api'; +import { GOOGLE_MAPS_OPTIONS } from '@/lib/helpers/googleMapsConfig'; + +export interface BranchMarker { + id: string; + name: string; + latitude: number; + longitude: number; + address: string; +} + +interface GoogleMapsProps { + markers?: BranchMarker[]; + draggable?: boolean; + center?: { lat: number; lng: number }; + onCoordinateChange?: (lat: number, lng: number) => void; + onAddressChange?: (address: string) => void; + mapHeight?: string; + mapWidth?: string; +} + +const GoogleMaps = ({ + markers = [], + draggable = false, + center = { lat: 10.063, lng: -69.323 }, + onCoordinateChange, + onAddressChange, + mapHeight = '500px', + mapWidth = '100%', +}: GoogleMapsProps) => { + const { isLoaded } = useJsApiLoader(GOOGLE_MAPS_OPTIONS); + const [activeMarker, setActiveMarker] = useState(null); + const [selectedPosition, setSelectedPosition] = useState(center); + + useEffect(() => { + const areCoordsDifferent = + center.lat !== selectedPosition.lat || + center.lng !== selectedPosition.lng; + + if (areCoordsDifferent) { + setSelectedPosition(center); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [center]); // solo center, ignoramos selectedPosition + + const handleMapClick = async (e: google.maps.MapMouseEvent) => { + if (!draggable || !e.latLng) return; + const lat = e.latLng.lat(); + const lng = e.latLng.lng(); + setSelectedPosition({ lat, lng }); + onCoordinateChange?.(lat, lng); + reverseGeocode(lat, lng); + }; + + const reverseGeocode = (lat: number, lng: number) => { + if (!window.google?.maps?.Geocoder) return; + + const geocoder = new window.google.maps.Geocoder(); + const latLng = new window.google.maps.LatLng(lat, lng); + + geocoder.geocode({ location: latLng }, (results, status) => { + if (status === 'OK' && results?.length) { + onAddressChange?.(results[0].formatted_address); + } else { + onAddressChange?.('Dirección no encontrada'); + } + }); + }; + + if (!isLoaded) return
Cargando mapa...
; + + return ( + + {/* Pines múltiples para sucursales */} + {markers.map((branch) => ( + setActiveMarker(branch.id)} + onMouseOut={() => setActiveMarker(null)} + icon={{ + url: 'http://maps.google.com/mapfiles/ms/icons/red-dot.png', + scaledSize: new google.maps.Size(40, 40), + }} + > + {activeMarker === branch.id && ( + setActiveMarker(null)} + > +
+ {branch.name} +

{branch.address}

+
+
+ )} +
+ ))} + + {/* Pin único y draggable para selección de ubicación */} + {draggable && ( + { + if (!e.latLng) return; + const lat = e.latLng.lat(); + const lng = e.latLng.lng(); + setSelectedPosition({ lat, lng }); + onCoordinateChange?.(lat, lng); + reverseGeocode(lat, lng); + }} + /> + )} +
+ ); +}; + +export default GoogleMaps; diff --git a/src/components/GoogleMap/UserAddressPoput.tsx b/src/components/GoogleMap/UserAddressPoput.tsx new file mode 100644 index 0000000..dd19842 --- /dev/null +++ b/src/components/GoogleMap/UserAddressPoput.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Button from '@/components/Button'; +import GoogleMaps from '@/components/GoogleMap/GoogleMap'; +import { Colors, FontSizes } from '@/styles/styles'; + +type LocationPopupProps = { + onAdd: ( + location: { lat: number; lng: number }, + address: string, + cityId: string, + ) => void; + onBack: () => void; + setLatitude: React.Dispatch>; + setLongitude: React.Dispatch>; + latitude: number; + longitude: number; +}; + +const LocationPopup: React.FC = ({ + onAdd, + onBack, + setLatitude, + setLongitude, + latitude, + longitude, +}) => { + const [address, setAddress] = useState(''); + const [showGuide, setShowGuide] = useState(true); + const [selectedCityId, setSelectedCityId] = useState(''); + + useEffect(() => { + setSelectedCityId('7ec2b919-b961-458d-8275-4ee957010336'); + }, []); + + const handleCloseModal = (submit: boolean) => { + if (submit) { + onAdd({ lat: latitude, lng: longitude }, address, selectedCityId); + } + onBack(); + }; + + return ( + <> +
+
+
+

+ Ubica la dirección exacta +

+ + {showGuide && ( +
+
+ Mueve el pin hasta tu ubicación exacta + +
+
+ )} + +
+ { + setLatitude(lat); + setLongitude(lng); + const geocoder = new window.google.maps.Geocoder(); + const latLng = new window.google.maps.LatLng(lat, lng); + geocoder.geocode({ location: latLng }, (results, status) => { + if ( + status === google.maps.GeocoderStatus.OK && + results?.length + ) { + setAddress(results[0]?.formatted_address || ''); + } else { + setAddress('Dirección no encontrada'); + } + }); + }} + /> +
+ +
+ + Ubicación seleccionada: {address || 'Obteniendo dirección...'} + +
+ +
+ + +
+
+
+ + ); +}; + +export default LocationPopup; diff --git a/src/components/Image/UploadedImage.tsx b/src/components/Image/UploadedImage.tsx index 4530fee..c069675 100644 --- a/src/components/Image/UploadedImage.tsx +++ b/src/components/Image/UploadedImage.tsx @@ -8,6 +8,7 @@ import { useAuth } from '@/context/AuthContext'; import { api } from '@/lib/sdkConfig'; import { toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; +import Loader from '../Loader'; type UploadedImage = { id: string; @@ -67,7 +68,7 @@ export default function UploadedImages({ productId }: UploadedImagesProps) { fetchImages(); }, [productId, token]); - if (loading) return

Cargando imágenes...

; + if (loading) return ; return (
diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 0a6bb85..fb41a08 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; -import theme from '@/styles/styles'; +import theme, { Colors } from '@/styles/styles'; type IconType = React.FC>; @@ -64,7 +64,7 @@ const Input: React.FC = ({ showPasswordToggle = false, showPasswordToggleIconColor = 'text-gray-500', borderSize = '2px', - borderColor = '#000000', + borderColor = Colors.stroke, readViewOnly = false, isTextArea = false, rows = 3, diff --git a/src/components/Input/InventoryStock.tsx b/src/components/Input/InventoryStock.tsx new file mode 100644 index 0000000..271ca57 --- /dev/null +++ b/src/components/Input/InventoryStock.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import Input from './Input'; +import { api } from '@/lib/sdkConfig'; +import { useAuth } from '@/context/AuthContext'; +import { toast } from 'react-toastify'; +import { CheckCircleIcon } from '@heroicons/react/24/outline'; +import { Colors } from '@/styles/styles'; + +export default function InventoryStock({ + stock, + inventoryId, +}: { + stock: number; + inventoryId: string; +}) { + const { token } = useAuth(); + const [value, setValue] = useState(stock); + const [updated, setUpdated] = useState(false); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + await api.inventory.update(inventoryId, { stockQuantity: value }, token!); + toast.success('Stock actualizado correctamente'); + } catch (error) { + // Optionally handle error + console.error('Error updating stock:', error); + toast.error('Error al actualizar el stock'); + } finally { + setLoading(false); + } + }; + + const handleChange = ( + e: React.ChangeEvent, + ) => { + const newValue = Number(e.target.value); + setValue(newValue); + setUpdated(newValue !== stock); + }; + + return ( +
+
+ + +
+
+ ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 08a3eb1..b8bb6e8 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,12 +1,10 @@ 'use client'; import React, { useEffect, useState } from 'react'; -import { BellIcon, QueueListIcon } from '@heroicons/react/24/outline'; +import { BellIcon } from '@heroicons/react/24/outline'; import Avatar from '@/components/Avatar'; -import SearchBar from '@/components/SearchBar'; -import { Colors } from '@/styles/styles'; import { useAuth } from '@/context/AuthContext'; -import { api } from '@/lib/sdkConfig'; +import Loader from './Loader'; interface AdminProfile { name: string; @@ -20,59 +18,41 @@ export default function AdminNavBar() { const { token, user } = useAuth(); const [userData, setUserData] = useState(null); - const handleSearch = (query: string) => { - console.log('Buscando:', query); - }; - useEffect(() => { if (!token || !user?.sub) { setUserData(null); return; } - (async () => { - try { - const profile = await api.user.getProfile(user.sub, token); - - // Adaptar el perfil a nuestro modelo esperado - const adaptedProfile: AdminProfile = { - name: `${profile.firstName} ${profile.lastName}`, - email: profile.email, - profile: { - profilePicture: profile.profile.profilePicture, - }, - }; - - setUserData(adaptedProfile); - } catch (err) { - console.error('Error al obtener perfil del admin:', err); - setUserData(null); - } + (() => { + const adaptedProfile: AdminProfile = { + name: user.name, + email: user.email, + profile: { + profilePicture: user.profilePicture || '', + }, + }; + setUserData(adaptedProfile); })(); }, [token, user]); - if (!token || !userData) return null; - - return ( -
diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 9b632c0..ebb2e04 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -5,7 +5,6 @@ import Image from 'next/image'; import { Bars3BottomLeftIcon, Bars3BottomRightIcon, - Cog6ToothIcon, ArrowRightOnRectangleIcon, SquaresPlusIcon, Square3Stack3DIcon, @@ -19,9 +18,8 @@ import { import { usePathname, useRouter } from 'next/navigation'; import '@/styles/globals.css'; import theme from '@/styles/styles'; -import Avatar from '@/components/Avatar'; import { useAuth } from '@/context/AuthContext'; -import { api } from '@/lib/sdkConfig'; +import { UserRole } from '@pharmatech/sdk'; interface SubMenuItem { name: string; @@ -39,7 +37,7 @@ interface MenuItem { const Sidebar = () => { const [isOpen, setIsOpen] = useState(true); const [openSubmenu, setOpenSubmenu] = useState(null); - const [profilePicture, setProfilePicture] = useState(); + const [userRole, setUserRole] = useState(UserRole.BRANCH_ADMIN); const router = useRouter(); const pathname = usePathname(); @@ -47,19 +45,12 @@ const Sidebar = () => { const toggleSidebar = () => { setIsOpen(!isOpen); - if (isOpen) setOpenSubmenu(null); }; useEffect(() => { - const fetchProfilePicture = async () => { + const fetchProfilePicture = () => { if (!token || !user?.sub) return; - - try { - const profile = await api.user.getProfile(user.sub, token); - setProfilePicture(profile.profile?.profilePicture || ''); - } catch (err) { - console.error('Error al obtener la imagen de perfil:', err); - } + setUserRole(user.role); }; fetchProfilePicture(); @@ -79,6 +70,8 @@ const Sidebar = () => { { name: 'Productos', route: '/products' }, { name: 'Presentaciones', route: '/presentations' }, { name: 'Categorías', route: '/categories' }, + { name: 'Inventarios', route: '/inventories' }, + { name: 'Lotes', route: '/lots' }, { name: 'Carga de inventario', route: '/upload-files' }, ], }, @@ -87,9 +80,8 @@ const Sidebar = () => { icon: , route: '/orders', subItems: [ - { name: 'Listado', route: '/orders/' }, - { name: 'Reembolsos', route: '/orders/refunds' }, - { name: 'Asignación', route: '/orders/assign' }, + { name: 'Solicitadas', route: '/orders/requests' }, + { name: 'Listado', route: '/orders' }, ], }, { @@ -101,18 +93,23 @@ const Sidebar = () => { { name: 'Cupones', route: '/coupons' }, ], }, - { - name: 'Sucursales', - icon: , - route: '/branches', - }, - { - name: 'Usuarios', - icon: , - route: '/users', - }, ]; + if (userRole === UserRole.ADMIN) { + generalMenuItems.push( + { + name: 'Sucursales', + icon: , + route: '/branches', + }, + { + name: 'Usuarios', + icon: , + route: '/users', + }, + ); + } + const reportMenuItems: MenuItem[] = [ { name: 'Reportes', @@ -123,12 +120,6 @@ const Sidebar = () => { ]; const otherMenuItems: MenuItem[] = [ - { - name: 'Configuración', - icon: , - route: '/settings', - color: 'text-gray-400 hover:bg-[#5E6780] hover:text-white', - }, { name: 'Cerrar sesión', icon: , @@ -139,7 +130,6 @@ const Sidebar = () => { const handleNavigation = (route: string) => { router.push(route); - setOpenSubmenu(null); }; return ( @@ -236,7 +226,7 @@ const Sidebar = () => { )} {isOpen && item.subItems && openSubmenu === item.name && ( -
+
{item.subItems.map((sub, index) => { const isSubActive = pathname === sub.route; return ( @@ -323,25 +313,6 @@ const Sidebar = () => { })}
- - {/* Footer Avatar */} -
- {isOpen && user && ( -
- -
-

{user.name}

-

{user.email}

-
-
- )} -
); }; diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 8544560..b10af13 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -1,10 +1,11 @@ 'use client'; -import React, { useState } from 'react'; +import React from 'react'; import { PencilSquareIcon, EyeIcon } from '@heroicons/react/24/solid'; import { Colors } from '@/styles/styles'; import CheckButton from './CheckButton'; import Pagination from './Pagination'; +import Loader from './Loader'; export interface Column { key: string; @@ -33,8 +34,11 @@ interface TableProps { }; onEdit?: (item: T) => void; onView?: (item: T) => void; - onSelect?: (selected: T[]) => void; pagination?: PaginationProps; + selectedRows: T[]; + setSelectedRows: (rows: T[]) => void; + isLoading?: boolean; + showSelector?: boolean; } function getValueSafely(item: T, key: string): unknown { @@ -52,32 +56,27 @@ const Table = ({ customColors, onEdit, onView, - onSelect, pagination, + selectedRows, + setSelectedRows, + isLoading = false, + showSelector = true, }: TableProps) => { - const [selectedRows, setSelectedRows] = useState([]); - const isAllSelected = data.length > 0 && selectedRows.length === data.length; const toggleSelectAll = () => { - const newSelected = isAllSelected ? [] : data.map((_, i) => i); + const newSelected = isAllSelected ? [] : data; setSelectedRows(newSelected); - if (onSelect) { - onSelect(newSelected.map((i) => data[i])); - } }; - const toggleSelectRow = (index: number) => { - let newSelected: number[]; - if (selectedRows.includes(index)) { - newSelected = selectedRows.filter((i) => i !== index); + const toggleSelectRow = (item: T) => { + let newSelected: T[]; + if (selectedRows.includes(item)) { + newSelected = selectedRows.filter((i) => i !== item); } else { - newSelected = [...selectedRows, index]; + newSelected = [...selectedRows, item]; } setSelectedRows(newSelected); - if (onSelect) { - onSelect(newSelected.map((i) => data[i])); - } }; return ( @@ -92,14 +91,16 @@ const Table = ({ }`} > - - - + {showSelector && ( + + + + )} {columns.map((column) => ( {column.label} @@ -112,66 +113,79 @@ const Table = ({ - {data.map((item, index) => { - const isSelected = selectedRows.includes(index); - return ( - - - toggleSelectRow(index)} - strokeColor={Colors.stroke} - /> - - {columns.map((column) => ( - - {column.render - ? column.render(item) - : String(getValueSafely(item, column.key) ?? '')} - - ))} - {(onEdit || onView) && ( - -
- {onEdit && ( - - )} - {onView && ( - - )} -
- - )} - - ); - })} + {isLoading ? ( + + +
+ +
+ + + ) : ( + data.map((item, index) => { + const isSelected = selectedRows.includes(item); + return ( + + {' '} + {showSelector && ( + + toggleSelectRow(item)} + strokeColor={Colors.stroke} + /> + + )} + {columns.map((column) => ( + + {column.render + ? column.render(item) + : String(getValueSafely(item, column.key) ?? '')} + + ))} + {(onEdit || onView) && ( + +
+ {onEdit && ( + + )} + {onView && ( + + )} +
+ + )} + + ); + }) + )} diff --git a/src/components/TableContainer.tsx b/src/components/TableContainer.tsx index d35aee3..ab89de2 100644 --- a/src/components/TableContainer.tsx +++ b/src/components/TableContainer.tsx @@ -9,13 +9,12 @@ import { Colors } from '@/styles/styles'; interface TableContainerProps { title: string; dropdownComponent?: React.ReactNode; - onAddClick: () => void; - onSearch: (query: string) => void; + onAddClick?: () => void; + onSearch?: (query: string) => void; tableData: T[]; tableColumns: Column[]; onEdit?: (item: T) => void; onView?: (item: T) => void; - onSelect?: (selected: T[]) => void; pagination: { currentPage: number; totalPages: number; @@ -31,6 +30,11 @@ interface TableContainerProps { rowBorder?: string; }; addButtonText?: string; + actions?: { + label: string; + onClick: (values: T[]) => void; + }[]; + isLoading?: boolean; } export default function TableContainer({ @@ -42,11 +46,13 @@ export default function TableContainer({ tableColumns, onEdit, onView, - onSelect, pagination, customColors, addButtonText = 'Agregar Producto', + actions, + isLoading = false, }: TableContainerProps) { + const [selectedRows, setSelectedRows] = useState([]); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const dropdownItemHeight = 35; // px const dropdownOptionsCount = pagination.itemsPerPageOptions.length; @@ -70,9 +76,12 @@ export default function TableContainer({ {/* Contenedor para ActionsTable */}
@@ -81,6 +90,8 @@ export default function TableContainer({ ({ }} onEdit={onEdit} onView={onView} - onSelect={onSelect} pagination={{ currentPage: pagination.currentPage, totalPages: pagination.totalPages, @@ -97,6 +107,8 @@ export default function TableContainer({ onPageChange: pagination.onPageChange, onItemsPerPageChange: pagination.onItemsPerPageChange, }} + isLoading={isLoading} + showSelector={actions ? true : false} /> diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index a2fe96c..542a62a 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -26,6 +26,10 @@ interface User { role: string; isValidated: boolean; profilePicture?: string; + branch?: { + id: string; + name: string; + }; } interface AuthContextType { @@ -79,7 +83,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { try { const profile = await api.user.getProfile(decoded.sub, token); - if (!['admin', 'branch_admin'].includes(profile.role.toLowerCase())) { toast.error('Acceso denegado: no tienes permisos de administrador'); return null; @@ -92,6 +95,11 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { email: profile.email, role: profile.role, isValidated: profile.isValidated ?? false, + profilePicture: profile.profile.profilePicture, + branch: { + id: profile.branch?.id, + name: profile.branch?.name, + }, }; } catch (error) { console.error('Error al obtener perfil del usuario:', error); diff --git a/src/lib/helpers/googleMapsConfig.ts b/src/lib/helpers/googleMapsConfig.ts new file mode 100644 index 0000000..1907442 --- /dev/null +++ b/src/lib/helpers/googleMapsConfig.ts @@ -0,0 +1,8 @@ +import type { Libraries } from '@react-google-maps/api'; + +export const GOOGLE_MAPS_LIBRARIES: Libraries = ['places', 'maps']; + +export const GOOGLE_MAPS_OPTIONS = { + googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? '', + libraries: GOOGLE_MAPS_LIBRARIES, +}; diff --git a/src/lib/helpers/parsePlaceFromSear.ts b/src/lib/helpers/parsePlaceFromSear.ts new file mode 100644 index 0000000..dea9aab --- /dev/null +++ b/src/lib/helpers/parsePlaceFromSear.ts @@ -0,0 +1,42 @@ +// utils/parsePlaceFromSearchBox.ts + +import { Dispatch, SetStateAction } from 'react'; + +interface ParsePlaceParams { + ref: google.maps.places.SearchBox | null; + setLatitude: Dispatch>; + setLongitude: Dispatch>; + setAddress: Dispatch>; +} + +export const parsePlaceFromSearchBox = ({ + ref, + setLatitude, + setLongitude, + setAddress, +}: ParsePlaceParams) => { + if (!ref) { + console.warn('Referencia de SearchBox no disponible'); + return; + } + + const places = ref.getPlaces(); + + if (!places || places.length === 0) { + console.warn('No se encontraron lugares'); + return; + } + + const place = places[0]; + + if (place.geometry?.location) { + const lat = place.geometry.location.lat(); + const lng = place.geometry.location.lng(); + + setLatitude(lat); + setLongitude(lng); + setAddress(place.formatted_address || ''); + } else { + console.warn('Lugar sin coordenadas válidas'); + } +}; diff --git a/src/lib/utils/priceFormatter.ts b/src/lib/utils/priceFormatter.ts new file mode 100644 index 0000000..0040a86 --- /dev/null +++ b/src/lib/utils/priceFormatter.ts @@ -0,0 +1,5 @@ +export function formatPrice(price: number): string { + if (isNaN(price) || price < 0) return '0.00'; + + return (price / 100).toFixed(2); +} diff --git a/src/lib/utils/useFormatDate.ts b/src/lib/utils/useFormatDate.ts new file mode 100644 index 0000000..ccf37ab --- /dev/null +++ b/src/lib/utils/useFormatDate.ts @@ -0,0 +1,57 @@ +import { format } from 'date-fns'; + +/** + * Sets the time of a Date object to 12:00 PM to prevent timezone offset issues when sending to backend. + */ +export function fixToNoon(date: Date): Date { + const d = new Date(date); + d.setHours(12, 0, 0, 0); + return d; +} + +/** + * Parses a date string or Date object from the backend (ISO format) and prevents -1 day shift due to timezone. + */ +export function parseApiDate(date: string | Date): Date { + if (typeof date === 'string') { + const [year, month, day] = date.split('T')[0].split('-').map(Number); + return new Date(year, month - 1, day, 12, 0, 0); + } + return new Date(date); +} +/** + * Converts a date from "dd/MM/yyyy" format to "yyyy-MM-dd". Used for sending birth dates to the backend. + */ +export function convertSlashDateToIso(dateStr: string): string { + const parts = dateStr.split('/'); + if (parts.length !== 3) return dateStr; + const [day, month, year] = parts; + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; +} + +/** + * Formats a date (string or Date object) into "dd/MM/yyyy" format for display (e.g. in read-only inputs). + */ +export function formatDateSafe(date: string | Date): string { + try { + const d = parseApiDate(date); + return format(d, 'dd/MM/yyyy'); + } catch { + return typeof date === 'string' ? date : date.toString(); + } +} + +/** + * Formats a date (string or Date object) into "yyyy-MM-dd" format for use in fields. + */ +export function formatDateForInput(date: string | Date): string { + try { + const d = parseApiDate(date); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } catch { + return ''; + } +} diff --git a/src/lib/validations/categorySchema.ts b/src/lib/validations/categorySchema.ts index a83b8ae..f183c99 100644 --- a/src/lib/validations/categorySchema.ts +++ b/src/lib/validations/categorySchema.ts @@ -8,7 +8,7 @@ export const categorySchema = z.object({ description: z .string() .min(1, 'La descripción es requerida') - .max(255, 'La descripción no puede exceder 500 caracteres'), + .max(255, 'La descripción no puede exceder 255 caracteres'), }); export type CategoryFormValues = z.infer; diff --git a/src/lib/validations/couponsSchema.tsx b/src/lib/validations/couponsSchema.ts similarity index 100% rename from src/lib/validations/couponsSchema.tsx rename to src/lib/validations/couponsSchema.ts diff --git a/src/lib/validations/editProfileSchema.ts b/src/lib/validations/editProfileSchema.ts index befc761..fdd50af 100644 --- a/src/lib/validations/editProfileSchema.ts +++ b/src/lib/validations/editProfileSchema.ts @@ -24,8 +24,8 @@ export const editProfileSchema = z .string() .optional() .refine( - (value) => value === undefined || /^\+\d{8,15}$/.test(value), - 'El teléfono debe iniciar con + y tener entre 8 y 15 dígitos', + (value) => value === undefined || /^\d{8,15}$/.test(value), + 'El teléfono debe tener entre 8 y 15 dígitos numéricos', ), birthDate: z .string() diff --git a/src/lib/validations/loginSchema.ts b/src/lib/validations/loginSchema.ts index de6ea89..442cd4a 100644 --- a/src/lib/validations/loginSchema.ts +++ b/src/lib/validations/loginSchema.ts @@ -8,5 +8,6 @@ export const loginSchema = z.object({ password: z .string() .nonempty('La contraseña es obligatoria') - .min(6, 'La contraseña debe tener al menos 6 caracteres'), + .min(8, 'La contraseña debe tener al menos 8 caracteres') + .max(255, 'La contraseña no puede exceder los 255 caracteres'), }); diff --git a/src/lib/validations/newPresentationSchema.ts b/src/lib/validations/newPresentationSchema.ts index 7601b71..c2ef24f 100644 --- a/src/lib/validations/newPresentationSchema.ts +++ b/src/lib/validations/newPresentationSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const newPresentationSchema = z.object({ name: z.string().min(1, 'El nombre de la presentación es requerido'), - description: z.string().optional(), // o .min(1, 'Descripción requerida') si es obligatoria + description: z.string().min(1, 'Descripción requerida'), quantity: z .string() .min(1, 'La cantidad es requerida') diff --git a/src/lib/validations/registerSchema.ts b/src/lib/validations/registerSchema.ts index 88fbdf4..ced98b0 100644 --- a/src/lib/validations/registerSchema.ts +++ b/src/lib/validations/registerSchema.ts @@ -2,35 +2,35 @@ import { z } from 'zod'; export const registerSchema = z .object({ - nombre: z + firstName: z .string() + .nonempty('El nombre es obligatorio') .min(2, 'El nombre debe tener al menos 2 caracteres') .max(50, 'El nombre no puede exceder los 50 caracteres') - .regex(/^[a-zA-Z\s]+$/, 'El nombre solo puede contener letras') - .nonempty('El nombre es obligatorio'), - apellido: z + .regex(/^[a-zA-Z\s]+$/, 'El nombre solo puede contener letras'), + lastName: z .string() + .nonempty('El apellido es obligatorio') .min(2, 'El apellido debe tener al menos 2 caracteres') .max(50, 'El apellido no puede exceder los 50 caracteres') - .regex(/^[a-zA-Z\s]+$/, 'El apellido solo puede contener letras') - .nonempty('El apellido es obligatorio'), + .regex(/^[a-zA-Z\s]+$/, 'El apellido solo puede contener letras'), email: z .string() .nonempty('El email es obligatorio') .email('Formato de email inválido'), - cedula: z + documentId: z .string() .nonempty('La cédula es obligatoria') .regex(/^\d+$/, 'La cédula debe contener solo números'), - telefono: z + phoneNumber: z .string() .transform((value) => (value?.trim() === '' ? null : value)) .nullable() .refine( - (value) => value === null || /^\+\d{8,15}$/.test(value), - 'El teléfono debe iniciar con + y tener entre 8 y 15 dígitos', + (value) => value === null || /^\d{8,15}$/.test(value), + 'El teléfono debe tener entre 8 y 15 dígitos numéricos', ), - fechaNacimiento: z + birthDate: z .string() .nonempty('La fecha de nacimiento es obligatoria') .regex( @@ -54,7 +54,7 @@ export const registerSchema = z message: 'Debes tener al menos 14 años', }, ), - genero: z + gender: z .string() .transform((value) => (value?.trim() === '' ? null : value)) .nullable() @@ -67,12 +67,13 @@ export const registerSchema = z password: z .string() .min(8, 'La contraseña debe tener al menos 8 caracteres') - .regex(/[A-Z]/, 'Debe tener al menos una letra mayúscula') - .regex(/[a-z]/, 'Debe tener al menos una letra minúscula') - .regex(/\d/, 'Debe tener al menos un número') - .regex(/[!@#$%^&*]/, 'Debe tener al menos un símbolo especial (!@#$%^&*)') + .max(255, 'La contraseña no puede exceder los 255 caracteres') + .optional(), + confirmPassword: z + .string() + .min(8, 'La contraseña debe tener al menos 8 caracteres') + .max(255, 'La contraseña no puede exceder los 255 caracteres') .optional(), - confirmPassword: z.string().optional(), }) .refine( (data) => { diff --git a/src/lib/validations/updatePasswordSchema.ts b/src/lib/validations/updatePasswordSchema.ts index a9e64c8..ecbff31 100644 --- a/src/lib/validations/updatePasswordSchema.ts +++ b/src/lib/validations/updatePasswordSchema.ts @@ -2,23 +2,23 @@ import { z } from 'zod'; export const updatePasswordSchema = z .object({ - password: z.string().nonempty('La contraseña es obligatoria'), + password: z + .string() + .nonempty('La contraseña actual es obligatoria') + .min(8, 'La contraseña debe tener al menos 8 caracteres') + .max(255, 'La contraseña no puede exceder 255 caracteres'), + newPassword: z .string() - .min(8, 'La confirmación debe tener al menos 8 caracteres') - .regex(/[A-Z]/, 'Debe tener al menos una letra mayúscula') - .regex(/[a-z]/, 'Debe tener al menos una letra minúscula') - .regex(/\d/, 'Debe tener al menos un número') - .regex(/[!@#$%^&*]/, 'Debe tener al menos un símbolo especial (!@#$%^&*)') - .nonempty('La confirmación de contraseña es obligatoria'), + .nonempty('La contraseña es obligatoria') + .min(8, 'La contraseña debe tener al menos 8 caracteres') + .max(255, 'La contraseña no puede exceder 255 caracteres'), + confirmPassword: z .string() - .min(8, 'La confirmación debe tener al menos 8 caracteres') - .regex(/[A-Z]/, 'Debe tener al menos una letra mayúscula') - .regex(/[a-z]/, 'Debe tener al menos una letra minúscula') - .regex(/\d/, 'Debe tener al menos un número') - .regex(/[!@#$%^&*]/, 'Debe tener al menos un símbolo especial (!@#$%^&*)') - .nonempty('La confirmación de contraseña es obligatoria'), + .nonempty('La confirmación de contraseña es obligatoria') + .min(8, 'La contraseña debe tener al menos 8 caracteres') + .max(255, 'La contraseña no puede exceder 255 caracteres'), }) .refine((data) => data.newPassword === data.confirmPassword, { message: 'Las contraseñas no coinciden',