pull/32/head
suvodip ghosh 2025-04-25 11:38:47 +00:00
parent 5a32c1a7d2
commit 9091219729
96 changed files with 2579 additions and 444 deletions

106
package-lock.json generated
View File

@ -39,7 +39,8 @@
"simplemde": "^1.11.2", "simplemde": "^1.11.2",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
"xlsx": "^0.18.5"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@ -3977,6 +3978,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ansi-align": { "node_modules/ansi-align": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
@ -4685,6 +4695,19 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz",
@ -4862,6 +4885,15 @@
"typo-js": "*" "typo-js": "*"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -4950,6 +4982,18 @@
"integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -5673,6 +5717,15 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -14730,6 +14783,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stdin-discarder": { "node_modules/stdin-discarder": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz",
@ -16017,6 +16082,24 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz",
@ -16108,6 +16191,27 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml2js": { "node_modules/xml2js": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",

View File

@ -41,7 +41,8 @@
"simplemde": "^1.11.2", "simplemde": "^1.11.2",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
"xlsx": "^0.18.5"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View File

@ -0,0 +1 @@
<svg width="64px" height="64px" viewBox="0 -140 780 780" enable-background="new 0 0 780 500" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="m168.38 169.35c-8.399-5.774-19.359-8.668-32.88-8.668h-52.346c-4.145 0-6.435 2.073-6.87 6.215l-21.264 133.48c-0.221 1.311 0.107 2.51 0.981 3.6 0.869 1.092 1.962 1.635 3.271 1.635h24.864c4.361 0 6.758-2.068 7.198-6.215l5.888-35.986c0.215-1.744 0.982-3.162 2.291-4.254 1.308-1.09 2.944-1.803 4.907-2.129 1.963-0.324 3.814-0.488 5.562-0.488 1.743 0 3.814 0.111 6.217 0.328 2.397 0.217 3.925 0.324 4.58 0.324 18.756 0 33.478-5.285 44.167-15.867 10.684-10.576 16.032-25.242 16.032-44.004 0-12.868-4.203-22.191-12.598-27.974zm-26.989 40.08c-1.094 7.635-3.926 12.649-8.506 15.049-4.581 2.403-11.124 3.599-19.629 3.599l-10.797 0.326 5.563-35.007c0.434-2.397 1.851-3.597 4.252-3.597h6.218c8.72 0 15.049 1.257 18.975 3.761 3.924 2.51 5.233 7.801 3.924 15.869z" fill="#003087"></path><path d="m720.79 160.68h-24.207c-2.406 0-3.822 1.2-4.254 3.601l-21.266 136.1-0.328 0.654c0 1.096 0.436 2.127 1.311 3.109 0.867 0.98 1.963 1.471 3.27 1.471h21.596c4.137 0 6.428-2.068 6.871-6.215l21.264-133.81v-0.325c-1e-3 -3.055-1.423-4.581-4.257-4.581z" fill="#009CDE"></path><path d="m428.31 213.36c0-1.088-0.438-2.126-1.305-3.105-0.875-0.981-1.857-1.475-2.945-1.475h-25.191c-2.404 0-4.367 1.096-5.891 3.271l-34.678 51.039-14.395-49.074c-1.096-3.487-3.492-5.236-7.197-5.236h-24.541c-1.093 0-2.074 0.492-2.941 1.475-0.875 0.979-1.309 2.019-1.309 3.105 0 0.439 2.127 6.871 6.379 19.303 4.252 12.436 8.832 25.85 13.74 40.246 4.908 14.393 7.469 22.031 7.688 22.896-17.886 24.432-26.825 37.518-26.825 39.26 0 2.838 1.415 4.254 4.253 4.254h25.191c2.398 0 4.36-1.088 5.89-3.27l83.427-120.4c0.433-0.432 0.65-1.192 0.65-2.29z" fill="#003087"></path><path d="m662.89 208.78h-24.865c-3.057 0-4.904 3.6-5.559 10.799-5.678-8.722-16.031-13.089-31.084-13.089-15.703 0-29.064 5.89-40.076 17.668-11.016 11.778-16.521 25.632-16.521 41.552 0 12.871 3.762 23.121 11.285 30.752 7.525 7.639 17.611 11.451 30.266 11.451 6.324 0 12.758-1.311 19.301-3.926 6.543-2.617 11.664-6.105 15.379-10.469 0 0.219-0.223 1.197-0.654 2.941-0.441 1.748-0.656 3.061-0.656 3.926 0 3.494 1.414 5.234 4.254 5.234h22.576c4.139 0 6.541-2.068 7.193-6.215l13.416-85.39c0.215-1.31-0.111-2.507-0.982-3.599-0.877-1.088-1.965-1.635-3.273-1.635zm-42.694 64.454c-5.562 5.453-12.27 8.178-20.121 8.178-6.328 0-11.449-1.742-15.377-5.234-3.928-3.482-5.891-8.281-5.891-14.395 0-8.064 2.727-14.886 8.182-20.447 5.445-5.562 12.213-8.342 20.283-8.342 6.102 0 11.174 1.799 15.213 5.396 4.031 3.6 6.055 8.562 6.055 14.889-2e-3 7.851-2.783 14.505-8.344 19.955z" fill="#009CDE"></path><path d="m291.23 208.78h-24.865c-3.058 0-4.908 3.6-5.563 10.799-5.889-8.722-16.25-13.089-31.081-13.089-15.704 0-29.065 5.89-40.078 17.668-11.016 11.778-16.521 25.632-16.521 41.552 0 12.871 3.763 23.121 11.288 30.752 7.525 7.639 17.61 11.451 30.262 11.451 6.104 0 12.433-1.311 18.975-3.926 6.543-2.617 11.778-6.105 15.704-10.469-0.875 2.615-1.309 4.906-1.309 6.867 0 3.494 1.417 5.234 4.253 5.234h22.574c4.141 0 6.543-2.068 7.198-6.215l13.413-85.39c0.215-1.31-0.111-2.507-0.981-3.599-0.873-1.088-1.962-1.635-3.269-1.635zm-42.695 64.616c-5.563 5.35-12.382 8.016-20.447 8.016-6.329 0-11.4-1.742-15.214-5.234-3.819-3.482-5.726-8.281-5.726-14.395 0-8.064 2.725-14.886 8.18-20.447 5.449-5.562 12.211-8.343 20.284-8.343 6.104 0 11.175 1.8 15.214 5.397 4.032 3.6 6.052 8.562 6.052 14.889-1e-3 8.07-2.781 14.779-8.343 20.117z" fill="#003087"></path><path d="m540.04 169.35c-8.398-5.774-19.355-8.668-32.879-8.668h-52.02c-4.363 0-6.764 2.073-7.197 6.215l-21.266 133.48c-0.221 1.311 0.107 2.51 0.982 3.6 0.865 1.092 1.961 1.635 3.27 1.635h26.826c2.617 0 4.361-1.416 5.236-4.252l5.889-37.949c0.217-1.744 0.98-3.162 2.291-4.254 1.309-1.09 2.943-1.803 4.908-2.129 1.961-0.324 3.812-0.488 5.561-0.488 1.744 0 3.814 0.111 6.215 0.328 2.398 0.217 3.93 0.324 4.58 0.324 18.76 0 33.479-5.285 44.168-15.867 10.688-10.576 16.031-25.242 16.031-44.004 1e-3 -12.868-4.2-22.192-12.595-27.974zm-33.533 53.819c-4.799 3.271-11.998 4.906-21.592 4.906l-10.471 0.328 5.562-35.008c0.432-2.396 1.85-3.598 4.252-3.598h5.887c4.799 0 8.615 0.219 11.455 0.654 2.83 0.438 5.561 1.799 8.178 4.088 2.619 2.291 3.926 5.619 3.926 9.979 0 9.164-2.402 15.377-7.197 18.651z" fill="#009CDE"></path></g></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,22 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 514.56 280.44">
<defs>
<style>
.cls-1 {
fill: #414042;
}
.cls-1, .cls-2 {
stroke-width: 0px;
}
.cls-2 {
fill: #00ad7d;
}
.cls-2:hover {
fill: #FFFFFF;
}
</style>
</defs>
<path class="cls-1" d="M0,97.77h25.72v12.86c2.93-4.46,7.37-8.1,13.3-10.92,5.93-2.82,12.3-4.23,19.11-4.23,8.1,0,15.56,2.06,22.37,6.17,6.81,4.11,12.27,9.98,16.38,17.62,4.11,7.64,6.16,16.56,6.16,26.78s-2.06,19.29-6.16,26.86c-4.11,7.58-9.6,13.36-16.47,17.35-6.87,3.99-14.3,5.99-22.28,5.99-6.93,0-13.36-1.38-19.29-4.14-5.93-2.76-10.31-6.37-13.12-10.83v99.18H0V97.77ZM69.67,165.94c4.76-5.17,7.13-11.8,7.13-19.9s-2.38-14.94-7.13-20.17c-4.76-5.22-10.89-7.84-18.41-7.84s-13.68,2.61-18.5,7.84c-4.82,5.23-7.22,11.95-7.22,20.17s2.41,14.74,7.22,19.9c4.81,5.17,10.98,7.75,18.5,7.75s13.65-2.58,18.41-7.75ZM134.67,190.25c-6.87-3.99-12.36-9.78-16.47-17.35-4.11-7.58-6.17-16.53-6.17-26.86s2.05-19.14,6.17-26.78c4.11-7.63,9.57-13.5,16.38-17.62,6.81-4.11,14.27-6.17,22.37-6.17,6.81,0,13.18,1.41,19.11,4.23,5.93,2.82,10.36,6.46,13.3,10.92v-12.33h25.72v95.13h-25.72v-12.15c-2.82,4.46-7.2,8.07-13.12,10.83-5.93,2.76-12.36,4.14-19.29,4.14-7.99,0-15.41-2-22.28-5.99ZM182.41,165.94c4.76-5.17,7.14-11.8,7.14-19.9s-2.38-14.94-7.14-20.17c-4.76-5.22-10.89-7.84-18.41-7.84s-13.68,2.61-18.5,7.84c-4.82,5.23-7.22,11.95-7.22,20.17s2.41,14.74,7.22,19.9c4.81,5.17,10.98,7.75,18.5,7.75s13.65-2.58,18.41-7.75ZM272.69,196.24c-14.56,0-25.31-3.7-32.24-11.1-6.93-7.4-10.39-17.73-10.39-31v-56.37h26.07v53.73c0,7.4,1.94,12.98,5.81,16.73,3.88,3.76,9.22,5.64,16.03,5.64,6.22,0,11.6-2.05,16.12-6.17,4.52-4.11,6.78-9.69,6.78-16.73v-53.2h25.72v182.68h-25.72v-96.71c-6.11,8.34-15.5,12.51-28.19,12.51ZM348.44,252.44l142.86-43.16V75.57l-164.71-41.93v40.87h-25.72V0l213.68,56.19v171.75l-166.12,51.62v-27.13Z"/>
<path class="cls-2" d="M362.18,97.77h26.07v53.73c0,15.03,7.1,22.55,21.32,22.55s21.32-7.51,21.32-22.55v-53.73h26.07v56.37c0,13.15-4.05,23.46-12.15,30.91-8.1,7.46-19.85,11.19-35.23,11.19s-27.13-3.76-35.23-11.27c-8.1-7.52-12.16-17.79-12.16-30.83v-56.37Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,127 @@
import { useState, useRef } from 'react';
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { X } from "lucide-react";
import PocketBase from 'pocketbase';
const pb = new PocketBase('https://tst-pb.s38.siliconpin.com');
const INVOICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
export function AvatarUpload({ userId }: { userId: string }) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setSelectedFile(e.target.files[0]);
}
};
const handleRemoveFile = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleUpload = async () => {
if (!selectedFile || !userId) return;
setIsUploading(true);
try {
// 1. Upload to PocketBase
const formData = new FormData();
formData.append('avatar', selectedFile);
// Update PocketBase user record
const pbRecord = await pb.collection('users').update(userId, formData);
// Get the avatar URL from PocketBase
const avatarUrl = pb.getFileUrl(pbRecord, pbRecord.avatar, {
thumb: '100x100'
});
// 2. Update PHP backend session
const response = await fetch(`${INVOICE_API_URL}?query=login`, {
method: 'POST',
credentials: 'include', // Important for sessions to work
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
avatar: avatarUrl,
query: 'avatar_update'
})
});
if (!response.ok) {
throw new Error('Failed to update session');
}
// Success - you might want to refresh the user data or show a success message
window.location.reload();
} catch (error) {
console.error('Error uploading avatar:', error);
alert('Failed to update avatar');
} finally {
setIsUploading(false);
}
};
return (
<div className="space-y-4">
{!selectedFile ? (
<div className="space-y-2">
<div className="flex items-center gap-4">
<Label
htmlFor="avatar"
className="bg-primary hover:bg-primary/90 text-primary-foreground py-2 px-4 text-sm rounded-md cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
Change Avatar
</Label>
<Input
type="file"
id="avatar"
ref={fileInputRef}
accept="image/jpeg,image/png,image/gif"
className="hidden"
onChange={handleFileChange}
/>
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
Browse
</Button>
</div>
<p className="text-xs text-muted-foreground">
JPG, GIF or PNG. 1MB max.
</p>
</div>
) : (
<div className="flex items-center justify-between p-3 space-x-2">
<div className="truncate max-w-[200px]">
<p className="text-sm font-medium truncate">{selectedFile.name}</p>
<p className="text-xs text-muted-foreground">{(selectedFile.size / 1024).toFixed(2)} KB</p>
</div>
<Button
size="sm"
className="text-xs p-1 h-fit"
onClick={handleUpload}
disabled={isUploading}
>
{isUploading ? 'Uploading...' : 'Update'}
</Button>
<Button
size="sm"
onClick={handleRemoveFile}
className="bg-red-500 hover:bg-red-600 text-xs p-1 h-fit"
disabled={isUploading}
>
Remove
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,591 @@
import React, { useEffect, useState, useMemo } from "react";
import { useIsLoggedIn } from '../lib/isLoggedIn';
import Loader from "./ui/loader";
import { PDFDownloadLink } from '@react-pdf/renderer';
import InvoicePDF from "../lib/InvoicePDF";
import { Eye, Download, ChevronUp, ChevronDown, Search } from "lucide-react";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
export default function UserBillingList() {
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
const [billingData, setBillingData] = useState([]);
const [dataLoading, setDataLoading] = useState(true);
const [apiError, setApiError] = useState(null);
const [selectedItem, setSelectedItem] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false);
// Sorting state
const [sortConfig, setSortConfig] = useState({ key: 'created_at', direction: 'desc' });
// Filtering state
const [filters, setFilters] = useState({
status: '',
cycle: '',
service: ''
});
// Search state
const [searchTerm, setSearchTerm] = useState('');
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage] = useState(10);
const INVOICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
useEffect(() => {
if (isLoggedIn) {
fetchBillingData();
}
}, [isLoggedIn]);
const fetchBillingData = async () => {
try {
const res = await fetch(`${INVOICE_API_URL}?query=invoice-info`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
});
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const data = await res.json();
// For regular users, filter to only show their own billing data
const filteredData = sessionData?.user_type === 'admin'
? data.data || []
: (data.data || []).filter(item => item.user === sessionData?.email);
setBillingData(filteredData);
} catch (err) {
setApiError(err.message);
} finally {
setDataLoading(false);
}
};
const handleViewItem = (item) => {
setSelectedItem(item);
setDialogOpen(true);
};
const closeModal = () => {
setDialogOpen(false);
setSelectedItem(null);
};
// Sorting functionality
const requestSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
setCurrentPage(1);
};
// Filter and sort data
const filteredAndSortedData = useMemo(() => {
let filteredData = [...billingData];
// Apply search
if (searchTerm) {
filteredData = filteredData.filter(item =>
Object.values(item).some(
val => val && val.toString().toLowerCase().includes(searchTerm.toLowerCase())
)
);
}
// Apply filters
if (filters.status) {
filteredData = filteredData.filter(item => item.status === filters.status);
}
if (filters.cycle) {
filteredData = filteredData.filter(item => item.cycle === filters.cycle);
}
if (filters.service) {
filteredData = filteredData.filter(item => item.service === filters.service);
}
// Apply sorting
if (sortConfig.key) {
filteredData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
}
return filteredData;
}, [billingData, searchTerm, filters, sortConfig]);
const currentItems = useMemo(() => {
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
return filteredAndSortedData.slice(indexOfFirstItem, indexOfLastItem);
}, [currentPage, itemsPerPage, filteredAndSortedData]);
const totalPages = Math.ceil(filteredAndSortedData.length / itemsPerPage);
const paginate = (pageNumber) => {
if (pageNumber > 0 && pageNumber <= totalPages) {
setCurrentPage(pageNumber);
}
};
const getPaginationRange = () => {
const range = [];
const maxVisiblePages = 5;
range.push(1);
if (currentPage > 3) {
range.push('...');
}
let start = Math.max(2, currentPage - 1);
let end = Math.min(totalPages - 1, currentPage + 1);
if (currentPage <= 3) {
end = Math.min(4, totalPages - 1);
} else if (currentPage >= totalPages - 2) {
start = Math.max(totalPages - 3, 2);
}
for (let i = start; i <= end; i++) {
if (i > 1 && i < totalPages) {
range.push(i);
}
}
if (currentPage < totalPages - 2) {
range.push('...');
}
if (totalPages > 1) {
range.push(totalPages);
}
return range;
};
// Get unique values for filter dropdowns
const uniqueServices = [...new Set(billingData.map(item => item.service))];
const uniqueStatuses = ['completed', 'pending', 'failed'];
const uniquecycles = ['monthly', 'yearly', 'one-time'];
// Reset filters
const resetFilters = () => {
setFilters({
status: '',
cycle: '',
service: ''
});
setSearchTerm('');
setCurrentPage(1);
};
// Handle filter change
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters(prev => ({
...prev,
[name]: value
}));
setCurrentPage(1);
};
// Handle search
const handleSearch = (e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
};
const getStatusColor = (status) => {
switch (status) {
case 'completed': return 'bg-green-100 text-green-800';
case 'pending': return 'bg-yellow-100 text-yellow-800';
case 'failed': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
return new Date(dateString).toLocaleDateString(undefined, options);
};
const formatCurrency = (amount) => {
if (!amount) return '$0.00';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(parseFloat(amount));
};
if (loading || dataLoading) return <Loader />;
if (error || apiError) return <p>Error: {error?.message || apiError}</p>;
if (!isLoggedIn) {
return <p className="text-center mt-8">You need to be logged in to view this page. <a href="/" className="text-[#6d9e37]">Click Here</a> to go to the homepage.</p>;
}
return (
<section className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">My Billing History</h1>
</div>
{/* Results Count */}
<div className="mb-2 text-sm text-gray-600">
Showing {currentItems.length} of {filteredAndSortedData.length} results
</div>
<div className="bg-white p-4 rounded-t-lg shadow">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Search */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={handleSearch}
className="bg-[#262626] pl-10 w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
/>
</div>
{/* Status Filter */}
<select
name="status"
value={filters.status}
onChange={handleFilterChange}
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
>
<option value="">All Statuses</option>
{uniqueStatuses.map(status => (
<option key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</option>
))}
</select>
{/* cycle Filter */}
<select
name="cycle"
value={filters.cycle}
onChange={handleFilterChange}
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
>
<option value="">All cycles</option>
{uniquecycles.map(cycle => (
<option key={cycle} value={cycle}>
{cycle.charAt(0).toUpperCase() + cycle.slice(1)}
</option>
))}
</select>
{/* Service Filter */}
<select
name="service"
value={filters.service}
onChange={handleFilterChange}
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
>
<option value="">All Services</option>
{uniqueServices.map(service => (
<option key={service} value={service}>
{service}
</option>
))}
</select>
</div>
{/* Reset Filters Button */}
{(filters.status || filters.cycle || filters.service || searchTerm) && (
<div className="mt-3">
<Button
onClick={resetFilters}
size="sm"
>
Reset Filters
</Button>
</div>
)}
</div>
<div className="bg-white rounded-b-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('billing_id')}
>
<div className="flex items-center">
Billing ID
{sortConfig.key === 'billing_id' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('service')}
>
<div className="flex items-center">
Service
{sortConfig.key === 'service' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('name')}
>
<div className="flex items-center">
Name
{sortConfig.key === 'name' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('amount')}
>
<div className="flex items-center">
Amount
{sortConfig.key === 'amount' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('status')}
>
<div className="flex items-center">
Status
{sortConfig.key === 'status' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('created_at')}
>
<div className="flex items-center">
Created At
{sortConfig.key === 'created_at' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentItems.length > 0 ? (
currentItems.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.billing_id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.service}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.name??item.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">
{formatCurrency(item.amount)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(item.status)}`}>
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(item.created_at)}
</td>
<td className="inline-flex px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
title="View Details"
onClick={() => handleViewItem(item)}
className="text-indigo-600 hover:text-indigo-900 mr-2"
>
<Eye className="w-5 h-5" />
</button>
<PDFDownloadLink
title="Download PDF"
document={<InvoicePDF data={item} />}
fileName={`invoice_${item.billing_id}.pdf`}
className="text-[#6d9e37] hover:text-green-600"
>
{({ loading }) => (loading ? '...' : <Download className="w-5 h-5" />)}
</PDFDownloadLink>
</td>
</tr>
))
) : (
<tr>
<td colSpan="7" className="px-6 py-4 text-center text-sm text-gray-500">
No records found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{filteredAndSortedData.length > itemsPerPage && (
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => paginate(1)}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
First
</Button>
<Button
onClick={() => paginate(currentPage - 1)}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
Previous
</Button>
{getPaginationRange().map((item, index) => {
if (item === '...') {
return (
<span key={`ellipsis-${index}`} className="px-2 py-1">
...
</span>
);
}
return (
<Button
key={`page-${item}`}
onClick={() => paginate(item)}
variant={currentPage === item ? "default" : "outline"}
size="sm"
>
{item}
</Button>
);
})}
<Button
onClick={() => paginate(currentPage + 1)}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Next
</Button>
<Button
onClick={() => paginate(totalPages)}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Last
</Button>
</div>
</div>
)}
{/* Dialog for View */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="">
Billing Details
</DialogTitle>
<DialogDescription>
Detailed information about your billing record
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<h3 className="font-semibold text-neutral-950">Billing ID</h3>
<p className="text-neutral-400 font-medium">{selectedItem?.billing_id}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">Service</h3>
<p className="text-neutral-400 font-medium">{selectedItem?.service}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">User</h3>
<p className="text-neutral-400 font-medium">{selectedItem?.user}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">cycle</h3>
<p className="text-neutral-400 font-medium">{selectedItem?.cycle}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">Amount</h3>
<p className="text-neutral-400 font-medium">{formatCurrency(selectedItem?.amount)}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">Status</h3>
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(selectedItem?.status)}`}>
{selectedItem?.status?.charAt(0).toUpperCase() + selectedItem?.status?.slice(1)}
</span>
</div>
<div>
<h3 className="font-semibold text-neutral-950">Created At</h3>
<p className="text-neutral-400 font-medium">{formatDate(selectedItem?.created_at)}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">Updated At</h3>
<p className="text-neutral-400 font-medium">{formatDate(selectedItem?.updated_at)}</p>
</div>
</div>
<DialogFooter>
<PDFDownloadLink
document={<InvoicePDF data={selectedItem} />}
fileName={`invoice_${selectedItem?.billing_id}.pdf`}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
{({ loading }) => (loading ? 'Preparing PDF...' : 'Download PDF')}
</PDFDownloadLink>
<Button
onClick={closeModal}
variant="outline"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}

View File

@ -34,22 +34,23 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
const [userEmail, setUserEmail] = useState(''); const [userEmail, setUserEmail] = useState('');
const [panelType, setPanelType] = useState(''); const [panelType, setPanelType] = useState('');
const [panelServicesId, setPanelServicesId] = useState('');
const API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/'; const API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
const SERVICES_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/services/'; const SERVICES_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/services/';
// const BILLING_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/index.php'; // const BILLING_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/index.php';
const [selectedTenure, setSelectedTenure] = useState(''); const [selectedcycle, setSelectedcycle] = useState('');
const [selectedPrice, setSelectedPrice] = useState(0); const [selectedPrice, setSelectedPrice] = useState(0);
const handleCheckboxChange = (tenure, price) => { const handleCheckboxChange = (cycle, price) => {
if (selectedTenure === tenure) { if (selectedcycle === cycle) {
setSelectedTenure(''); setSelectedcycle('');
setSelectedPrice(0); setSelectedPrice(0);
} else { } else {
setSelectedTenure(tenure); setSelectedcycle(cycle);
setSelectedPrice(price); setSelectedPrice(price);
} }
// console.log(selectedTenure, ' ', selectedPrice); // console.log(selectedcycle, ' ', selectedPrice);
}; };
const handlePanelBuyNow = () => { const handlePanelBuyNow = () => {
// Disable button during processing // Disable button during processing
@ -59,9 +60,9 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
showToast('Loading...'); showToast('Loading...');
const formData = new FormData(); const formData = new FormData();
formData.append('service', panelType); formData.append('service', panelType);
formData.append('tenure', selectedTenure); formData.append('serviceId', panelServicesId);
formData.append('cycle', selectedcycle);
formData.append('amount', 1); //selectedPrice formData.append('amount', 1); //selectedPrice
fetch(`${API_URL}?query=initiate_payment`, { fetch(`${API_URL}?query=initiate_payment`, {
method: 'POST', method: 'POST',
body: formData, body: formData,
@ -551,13 +552,16 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
<select <select
id="app-type" id="app-type"
value={panelType} value={panelType}
onChange={(e) => setPanelType(e.target.value)} onChange={(e) => {
setPanelType(e.target.value);
setPanelServicesId(e.target.options[e.target.selectedIndex].dataset.id);
}}
className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]" className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
> >
<option value="">-Select-</option> <option value="">-Select-</option>
<option value="Hestia-Panel">🧰 Hestia Panel</option> <option value="Hestia-Panel" data-id="4ksYhjFy">🧰 Hestia Panel</option>
<option value="Webmin">🖥 Webmin</option> <option value="Webmin" data-id="5ksYAhFz">🖥 Webmin</option>
<option value="cPanel">📊 cPanel</option> <option value="cPanel" data-id="6jgThjZx">📊 cPanel</option>
</select> </select>
</div> </div>
) )
@ -575,21 +579,21 @@ export const DomainSetupForm = ({ defaultSubdomain }) => {
))} ))}
</ul> </ul>
<div className='flex flex-row justify-between items-center gap-x-6'> <div className='flex flex-row justify-between items-center gap-x-6'>
<label className={`border ${selectedTenure === 'monthly' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer`}> <label className={`border ${selectedcycle === 'monthly' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer`}>
<input type="checkbox" checked={selectedTenure === 'monthly'} onChange={() => handleCheckboxChange('monthly', 200)} className="hidden" /> <input type="checkbox" checked={selectedcycle === 'monthly'} onChange={() => handleCheckboxChange('monthly', 200)} className="hidden" />
<p className='text-3xl font-bold text-center'>200</p> <p className='text-3xl font-bold text-center'>200</p>
<span>Monthly</span> <span>Monthly</span>
</label> </label>
<label className={`border ${selectedTenure === 'yearly' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer`}> <label className={`border ${selectedcycle === 'yearly' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer`}>
<input type="checkbox" checked={selectedTenure === 'yearly'} onChange={() => handleCheckboxChange('yearly', 2000)} className="hidden" /> <input type="checkbox" checked={selectedcycle === 'yearly'} onChange={() => handleCheckboxChange('yearly', 2000)} className="hidden" />
<p className='text-3xl font-bold text-center'>2000</p> <p className='text-3xl font-bold text-center'>2000</p>
<span>Yearly</span> <span>Yearly</span>
</label> </label>
</div> </div>
<div className='text-white'> <div className='text-white'>
{selectedTenure && ( {selectedcycle && (
<p className={`${selectedTenure === 'monthly' ? 'text-left' : 'text-end'}`}>You selected <strong>{selectedTenure}</strong> plan at {selectedPrice}</p> <p className={`${selectedcycle === 'monthly' ? 'text-left' : 'text-end'}`}>You selected <strong>{selectedcycle}</strong> plan at {selectedPrice}</p>
)} )}
</div> </div>
</> </>

View File

@ -186,12 +186,7 @@ export default function HireHumanDeveloper() {
<h4 className="text-sm font-semibold mb-1 text-neutral-950">Specialized Skills:</h4> <h4 className="text-sm font-semibold mb-1 text-neutral-950">Specialized Skills:</h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedType.skills.map((skill, index) => ( {selectedType.skills.map((skill, index) => (
<span <span key={index} className="inline-block bg-zinc-100 text-zinc-800 text-xs px-2 py-1 rounded">{skill}</span>
key={index}
className="inline-block bg-zinc-100 text-zinc-800 text-xs px-2 py-1 rounded"
>
{skill}
</span>
))} ))}
</div> </div>
</div> </div>

View File

@ -1,17 +1,25 @@
import React, {useState, useEffect} from "react"; import React, {useState, useEffect} from "react";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Input } from "./ui/input";
import QRCode from "react-qr-code"; import QRCode from "react-qr-code";
import { Dialog, DialogContent, DialogHeader, DialogTitle} from "./ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle} from "./ui/dialog";
import { Toast } from './Toast'; import { Toast } from './Toast';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "./ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "./ui/card";
import { FileX, Copy, CheckCircle2, AlertCircle, X } from "lucide-react";
const API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/index.php'; import Loader from "./ui/loader";
const API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
export default function MakePayment(){ export default function MakePayment(){
const [initialOrderData, setInitialOrderData] = useState(null); const [initialOrderData, setInitialOrderData] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [showQRModal, setShowQRModal] = useState(false); const [showQRModal, setShowQRModal] = useState(false);
const [showHelpMessage, setShowHelpMessage] = useState(false);
const [transactionId, setTransactionId] = useState('');
const [orderIdInput, setOrderIdInput] = useState('');
const [upiPaymentLink, setUpiPaymentLink] = useState(""); const [upiPaymentLink, setUpiPaymentLink] = useState("");
const [copied, setCopied] = useState(false);
const [getOrderId, setGetOrderId] = useState();
const [saveUpiResponse, setSaveUpiResponse] = useState({ message: '', visible: false, isSuccess: false });
useEffect(() => { useEffect(() => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@ -22,7 +30,7 @@ export default function MakePayment(){
setIsLoading(false); setIsLoading(false);
return; return;
} }
setGetOrderId(orderId);
const getInitialOrderData = () => { const getInitialOrderData = () => {
setIsLoading(true); setIsLoading(true);
const formData = new FormData(); const formData = new FormData();
@ -67,7 +75,7 @@ export default function MakePayment(){
function generateUPILink(amount, transactionId) { function generateUPILink(amount, transactionId) {
// Replace these with your actual merchant details // Replace these with your actual merchant details
const merchantUPI = "siliconpin@ybl"; const merchantUPI = "7001601485@okbizaxis";
const merchantName = "SiliconPin"; const merchantName = "SiliconPin";
const currency = "INR"; const currency = "INR";
@ -78,8 +86,8 @@ export default function MakePayment(){
// Construct UPI payment link // Construct UPI payment link
return `upi://pay?pa=${merchantUPI}&pn=${encodedMerchantName}&am=${amount}&cu=${currency}&tn=${transactionNote}`; return `upi://pay?pa=${merchantUPI}&pn=${encodedMerchantName}&am=${amount}&cu=${currency}&tn=${transactionNote}`;
} }
// waiting payment update // waiting payment update
// Payment update not recieved if // Payment update not recieved if
function redirectToPayU() { function redirectToPayU() {
if (!initialOrderData?.payment_data || !initialOrderData.payment_url) { if (!initialOrderData?.payment_data || !initialOrderData.payment_url) {
console.error('Payment data not loaded yet'); console.error('Payment data not loaded yet');
@ -106,6 +114,17 @@ export default function MakePayment(){
form.submit(); form.submit();
} }
useEffect(() => {
let timer;
if (showQRModal) {
setShowHelpMessage(false);
timer = setTimeout(() => {
setShowHelpMessage(true);
}, 2000);
}
return () => clearTimeout(timer); // Cleanup on unmount or when modal closes
}, [showQRModal]);
function handleQRPaymentClick() { function handleQRPaymentClick() {
if (!upiPaymentLink) { if (!upiPaymentLink) {
alert('Payment information is not ready. Please wait.'); alert('Payment information is not ready. Please wait.');
@ -114,10 +133,71 @@ export default function MakePayment(){
setShowQRModal(true); setShowQRModal(true);
} }
const handleCopy = () => {
navigator.clipboard.writeText(getOrderId)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1000); // Reset after 1 second
})
.catch(err => {
console.error('Failed to copy text: ', err);
});
};
const handlePayPalPayment = () => {
const rawInrAmount = initialOrderData.payment_data?.amount || 0;
const exchangeRate = 80;
const convertedUSD = rawInrAmount / exchangeRate;
const usdAmount = Math.max(convertedUSD, 1).toFixed(2);
const paypalUrl = `https://www.paypal.com/paypalme/dwdconsultancy/${usdAmount}`;
window.open(paypalUrl, '_blank');
};
const handleSaveUPIPayment = async () => {
try {
const response = await fetch(`${API_URL}?query=save-upi-payment`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
orderId: orderIdInput,
transactionId: transactionId
})
});
const data = await response.json();
if (data.success === true) {
setSaveUpiResponse({
message: `Transaction ID ${transactionId} saved. We'll contact you shortly.`,
visible: true,
isSuccess: true
});
setShowHelpMessage(false);
}
} catch (error) {
console.error('Error:', error);
setSaveUpiResponse({
message: 'Failed to save payment details. Please try again.',
visible: true,
isSuccess: false
});
}
};
if (isLoading) { if (isLoading) {
return <div className="flex items-center justify-center min-h-screen"> return <Loader />;
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div> }
</div>;
if(!initialOrderData){
return (
<div className="flex flex-col items-center justify-center flex-grow py-10 text-center px-4 text-gray-600 mt-20">
<FileX className="mb-4 text-gray-500" size={100} />
<p className="text-lg"> No bill was found. <a className="text-[#6d9e37] font-medium hover:underline" href="/profile"> Click here </a> to go to your profile.</p>
</div>
);
} }
if (error) { if (error) {
@ -148,15 +228,45 @@ export default function MakePayment(){
<div className="flex flex-col"> <div className="flex flex-col">
<span className=""><strong>Pay Using:</strong></span> <span className=""><strong>Pay Using:</strong></span>
<Button variant="outline" className="" onClick={handleQRPaymentClick} >UPI QR</Button> <hr className="border-b border-b-[#6d9e37] mb-4" />
<span className="text-gray-500 text-center font-semibold my-2">OR</span>
<Button className="" onClick={redirectToPayU} >Payment Gateway</Button> <Button className="" onClick={handleQRPaymentClick}>UPI QR Code</Button>
<span className="text-center mt-2 text-gray-400 text-xs">Applicable 2% Transaction Charge if using Payment Gateway</span> <span className="text-center mt-2 text-gray-400 text-xs">Banking Service by DWD Consultancy Services (0% Processing Fee)</span>
<div className="flex items-center my-2">
<div className="flex-grow border-t border-gray-500"></div>
<span className="mx-2 text-gray-500 font-semibold">OR</span>
<div className="flex-grow border-t border-gray-500"></div>
</div>
<Button className="text-[#414042] font-bold border-[2px] border-[#00ad7d] hover:bg-[#00ad7d]/80" onClick={redirectToPayU} variant="outline">
Pay with&nbsp;
<img className="w-[50px]" src="/assets/payu-logo.svg" alt="" />
</Button>
<Button
variant="outline"
className="mt-4 text-[#414042] font-bold border-[2px] border-b-[#003087] border-r-[#003087] border-t-[#009CDE] border-l-[#009CDE] hover:bg-gradient-to-r hover:from-[#009CDE] hover:to-[#003087]"
onClick={handlePayPalPayment}
>
Pay with&nbsp;
<img className="w-[50px]" src="/assets/paypal-logo-white.svg" alt="PayPal" />
</Button>
{/* <span className="text-center mt-2 text-gray-400 text-xs">2% Payment Gateway Charge</span> */}
{/* Add this divider and PayPal button */}
{/* <div className="flex items-center my-2">
<div className="flex-grow border-t border-gray-500"></div>
<span className="mx-2 text-gray-500 font-semibold">OR</span>
<div className="flex-grow border-t border-gray-500"></div>
</div> */}
{/* <span className="text-center mt-2 text-gray-400 text-xs">3.5% Payment Processing Fee</span> */}
</div> </div>
</Card> </Card>
{/* QR Code Modal */} {/* QR Code Modal */}
<Dialog open={showQRModal} onOpenChange={setShowQRModal}> <Dialog open={showQRModal} onOpenChange={(open) => {setShowQRModal(open); if (!open) {setShowHelpMessage(false);}}}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Scan QR Code to Pay</DialogTitle> <DialogTitle>Scan QR Code to Pay</DialogTitle>
@ -168,13 +278,45 @@ export default function MakePayment(){
<p className="text-sm text-gray-500 text-center"> <p className="text-sm text-gray-500 text-center">
Scan this QR code with any UPI app to complete your payment Scan this QR code with any UPI app to complete your payment
</p> </p>
<div className="w-full p-3 bg-gray-100 text-gray-500 rounded-md break-all text-xs"> <Button variant="outline" onClick={handleCopy} className="text-sm" >{copied ? 'Copied!' : getOrderId} &nbsp; {!copied && <Copy size={15} />}</Button>
{upiPaymentLink}
{showHelpMessage && (
<div className="w-full mt-4 p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<p className="text-sm text-yellow-800 mb-3">
If you haven't received payment confirmation, please share your Order Id & transaction ID with us.
</p>
<Input type="text" value={orderIdInput} onChange={(e) => setOrderIdInput(e.target.value)} placeholder="Enter your Order ID" className="w-full p-2 border border-gray-300 rounded-md text-sm mb-2" />
<Input type="text" value={transactionId} onChange={(e) => setTransactionId(e.target.value)} placeholder="Enter your transaction ID" className="w-full p-2 border border-gray-300 rounded-md text-sm mb-2" />
<Button onClick={handleSaveUPIPayment} className="w-full text-sm">Save Transaction ID</Button>
</div> </div>
<Button variant="outline" onClick={() => navigator.clipboard.writeText(upiPaymentLink)} className="text-sm" >Copy UPI Link</Button> )}
{saveUpiResponse.visible && (
<div className={`fixed bottom-0 z-50 flex items-center justify-center`}>
<div className={`relative p-6 rounded-lg shadow-lg max-w-xs md:max-w-md w-full ${saveUpiResponse.isSuccess ? 'bg-green-50 border border-green-200 text-green-800' : 'bg-red-50 border border-red-200 text-red-800'} animate-fade-in-up`}>
<button
onClick={() => setSaveUpiResponse(prev => ({...prev, visible: false}))}
className={`absolute top-3 right-3 p-1 rounded-full ${saveUpiResponse.isSuccess ? 'hover:bg-green-100 text-green-500' : 'hover:bg-red-100 text-red-500'} transition-colors`}>
<X className="w-4 h-4" />
</button>
<div className="flex flex-col items-center text-center gap-4">
<div className="flex items-center gap-3">
{saveUpiResponse.isSuccess ? (
<CheckCircle2 className="w-6 h-6 text-green-500" />
) : (
<AlertCircle className="w-6 h-6 text-red-500" />
)}
<p className="text-base font-medium">{saveUpiResponse.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => window.location.href = '/'} className={saveUpiResponse.isSuccess ? 'border-green-300' : 'border-red-300'}>Back to Home</Button>
</div>
</div>
</div>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
); );
} }
// Transaction ID ${transactionId} saved. We'll contact you shortly.

View File

@ -1,34 +1,63 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState, useMemo } from "react";
import { useIsLoggedIn } from '../../lib/isLoggedIn'; import { useIsLoggedIn } from '../../lib/isLoggedIn';
import Loader from "../../components/ui/loader"; import Loader from "../../components/ui/loader";
import { PDFDownloadLink, PDFViewer } from '@react-pdf/renderer'; import { PDFDownloadLink } from '@react-pdf/renderer';
import InvoicePDF from "../../lib/InvoicePDF"; // We'll create this component next import InvoicePDF from "../../lib/InvoicePDF";
import { Eye, Pencil, Trash2, Download, ChevronUp, ChevronDown, Search, ArrowLeft, FileText } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import * as XLSX from 'xlsx';
export default function AllSellingList() { export default function AllSellingList() {
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn(); const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
const [billingData, setBillingData] = useState([]); const [billingData, setBillingData] = useState([]);
const [usersData, setUsersData] = useState([]);
const [dataLoading, setDataLoading] = useState(true); const [dataLoading, setDataLoading] = useState(true);
const [apiError, setApiError] = useState(null); const [apiError, setApiError] = useState(null);
const [selectedItem, setSelectedItem] = useState(null); const [selectedItem, setSelectedItem] = useState(null);
const [showModal, setShowModal] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
service: '', service: '',
tenure: 'monthly', serviceId: 'service-1',
cycle: 'monthly',
amount: '', amount: '',
user: '', user: '',
status: 'pending' siliconId: '',
name: '',
status: 'pending',
remarks: ''
}); });
const [currentStep, setCurrentStep] = useState(1);
const [selectedCustomer, setSelectedCustomer] = useState(null);
const [customerSearchTerm, setCustomerSearchTerm] = useState('');
// Sorting state
const [sortConfig, setSortConfig] = useState({ key: 'created_at', direction: 'desc' });
// Filtering state
const [filters, setFilters] = useState({ status: '', cycle: '', service: '' });
// Search state
const [searchTerm, setSearchTerm] = useState('');
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage] = useState(10);
const INVOICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/'; const INVOICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
useEffect(() => { useEffect(() => {
if (isLoggedIn && sessionData?.user_type === 'admin') { if (isLoggedIn && sessionData?.user_type === 'admin') {
fetchBillingData(); fetchBillingData();
fetchUsersData();
} }
}, [isLoggedIn, sessionData]); }, [isLoggedIn, sessionData]);
const fetchBillingData = async () => { const fetchBillingData = async () => {
try { try {
setDataLoading(true);
const res = await fetch(`${INVOICE_API_URL}?query=all-selling-list`, { const res = await fetch(`${INVOICE_API_URL}?query=all-selling-list`, {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
@ -47,23 +76,55 @@ export default function AllSellingList() {
} }
}; };
const fetchUsersData = async () => {
try {
const res = await fetch(`${INVOICE_API_URL}?query=get-all-users`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
});
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const data = await res.json();
setUsersData(data.data || []);
} catch (err) {
setApiError(err.message);
}
};
// Filter customers based on search term
const filteredCustomers = useMemo(() => {
return usersData.filter(user =>
user.name.toLowerCase().includes(customerSearchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(customerSearchTerm.toLowerCase()) ||
user.siliconId.toLowerCase().includes(customerSearchTerm.toLowerCase())
);
}, [usersData, customerSearchTerm]);
const handleViewItem = (item) => { const handleViewItem = (item) => {
setSelectedItem(item); setSelectedItem(item);
setShowModal(true);
setEditMode(false); setEditMode(false);
setDialogOpen(true);
}; };
const handleEditItem = (item) => { const handleEditItem = (item) => {
setSelectedItem(item); setSelectedItem(item);
setFormData({ setFormData({
service: item.service, service: item.service,
tenure: item.tenure, serviceId: item.serviceId,
cycle: item.cycle,
amount: item.amount, amount: item.amount,
user: item.user, user: item.user,
status: item.status siliconId: item.siliconId,
name: item.name,
status: item.status,
remarks: item.remarks,
billing_date: item.billing_date
}); });
setEditMode(true); setEditMode(true);
setShowModal(true); setDialogOpen(true);
}; };
const handleDeleteItem = async (id) => { const handleDeleteItem = async (id) => {
@ -87,15 +148,12 @@ export default function AllSellingList() {
const handleCreateNew = () => { const handleCreateNew = () => {
setSelectedItem(null); setSelectedItem(null);
setFormData({ setFormData({ service: '', serviceId: 'service-2', cycle: 'monthly', amount: '', user: '', siliconId: '', name: '', status: 'pending', remarks: '', billing_date: new Date().toISOString().split('T')[0] });
service: '',
tenure: 'monthly',
amount: '',
user: '',
status: 'pending'
});
setEditMode(true); setEditMode(true);
setShowModal(true); setCurrentStep(1);
setSelectedCustomer(null);
setCustomerSearchTerm('');
setDialogOpen(true);
}; };
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
@ -118,7 +176,8 @@ export default function AllSellingList() {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
fetchBillingData(); fetchBillingData();
setShowModal(false); setDialogOpen(false);
setCurrentStep(1);
} catch (err) { } catch (err) {
setApiError(err.message); setApiError(err.message);
} }
@ -132,10 +191,157 @@ export default function AllSellingList() {
})); }));
}; };
const closeModal = () => { const handleCustomerSelect = (customer) => {
setShowModal(false); setSelectedCustomer(customer);
setSelectedItem(null); setFormData(prev => ({
setEditMode(false); ...prev,
user: customer.email,
siliconId: customer.siliconId,
name: customer.name
}));
setCurrentStep(2);
};
const handleBackToCustomerSelect = () => {
setCurrentStep(1);
setSelectedCustomer(null);
};
// Export to Excel function
const exportToExcel = () => {
const worksheet = XLSX.utils.json_to_sheet(filteredAndSortedData.map(item => ({
'Billing ID': item.billing_id,
'Service': item.service,
'Customer Name': item.name,
'Customer Email': item.user,
'Silicon ID': item.siliconId,
'Amount': item.amount,
'cycle': item.cycle,
'Status': item.status,
'Created At': formatDate(item.created_at),
'Remarks': item.remarks
})));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Billing Data");
XLSX.writeFile(workbook, `billing_data_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
// Sorting functionality
const requestSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
setCurrentPage(1);
};
// Filter and sort data
const filteredAndSortedData = useMemo(() => {
let filteredData = [...billingData];
if (searchTerm) {
filteredData = filteredData.filter(item =>
Object.values(item).some(
val => val && val.toString().toLowerCase().includes(searchTerm.toLowerCase())
)
);
}
if (filters.status) {
filteredData = filteredData.filter(item => item.status === filters.status);
}
if (filters.cycle) {
filteredData = filteredData.filter(item => item.cycle === filters.cycle);
}
if (filters.service) {
filteredData = filteredData.filter(item => item.service === filters.service);
}
if (sortConfig.key) {
filteredData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
}
return filteredData;
}, [billingData, searchTerm, filters, sortConfig]);
const currentItems = useMemo(() => {
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
return filteredAndSortedData.slice(indexOfFirstItem, indexOfLastItem);
}, [currentPage, itemsPerPage, filteredAndSortedData]);
const totalPages = Math.ceil(filteredAndSortedData.length / itemsPerPage);
const paginate = (pageNumber) => {
if (pageNumber > 0 && pageNumber <= totalPages) {
setCurrentPage(pageNumber);
}
};
const getPaginationRange = () => {
const range = [];
const maxVisiblePages = 5;
range.push(1);
if (currentPage > 3) {
range.push('...');
}
let start = Math.max(2, currentPage - 1);
let end = Math.min(totalPages - 1, currentPage + 1);
if (currentPage <= 3) {
end = Math.min(4, totalPages - 1);
} else if (currentPage >= totalPages - 2) {
start = Math.max(totalPages - 3, 2);
}
for (let i = start; i <= end; i++) {
if (i > 1 && i < totalPages) {
range.push(i);
}
}
if (currentPage < totalPages - 2) {
range.push('...');
}
if (totalPages > 1) {
range.push(totalPages);
}
return range;
};
const uniqueServices = [...new Set(billingData.map(item => item.service))];
const uniqueStatuses = ['completed', 'pending', 'failed'];
const uniquecycles = ['monthly', 'yearly', 'one-time'];
const resetFilters = () => {
setFilters({
status: '',
cycle: '',
service: ''
});
setSearchTerm('');
setCurrentPage(1);
};
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters(prev => ({
...prev,
[name]: value
}));
setCurrentPage(1);
};
const handleSearch = (e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}; };
const getStatusColor = (status) => { const getStatusColor = (status) => {
@ -171,30 +377,193 @@ export default function AllSellingList() {
<section className="container mx-auto px-4 py-8"> <section className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Billing Management</h1> <h1 className="text-2xl font-bold">Billing Management</h1>
<button <div className="flex gap-2">
onClick={handleCreateNew} <Button onClick={handleCreateNew} variant="outline">
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Create New Create New
</button> </Button>
<Button onClick={exportToExcel} variant="outline" className="flex items-center gap-2">
<FileText className="w-4 h-4" /> Export to Excel
</Button>
</div>
</div> </div>
<div className="bg-white rounded-lg shadow overflow-hidden"> {/* Search and Filter Section */}
<div className="mb-2 text-sm text-gray-600">
Showing {currentItems.length} of {filteredAndSortedData.length} results
</div>
<div className="bg-white p-4 rounded-t-lg shadow">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={handleSearch}
className="bg-[#262626] pl-10 w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
/>
</div>
<select
name="status"
value={filters.status}
onChange={handleFilterChange}
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
>
<option value="">All Statuses</option>
{uniqueStatuses.map(status => (
<option key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</option>
))}
</select>
<select
name="cycle"
value={filters.cycle}
onChange={handleFilterChange}
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
>
<option value="">All cycles</option>
{uniquecycles.map(cycle => (
<option key={cycle} value={cycle}>
{cycle.charAt(0).toUpperCase() + cycle.slice(1)}
</option>
))}
</select>
<select
name="service"
value={filters.service}
onChange={handleFilterChange}
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
>
<option value="">All Services</option>
{uniqueServices.map(service => (
<option key={service} value={service}>
{service}
</option>
))}
</select>
</div>
{(filters.status || filters.cycle || filters.service || searchTerm) && (
<div className="mt-3">
<Button
onClick={resetFilters}
size="sm"
>
Reset Filters
</Button>
</div>
)}
</div>
<div className="bg-white rounded-b-lg shadow overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Billing ID</th> <th
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Service</th> className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th> onClick={() => requestSort('billing_id')}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th> >
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <div className="flex items-center">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created At</th> Billing ID
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> {sortConfig.key === 'billing_id' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('service')}
>
<div className="flex items-center">
Service
{sortConfig.key === 'service' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('name')}
>
<div className="flex items-center">
Name
{sortConfig.key === 'name' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('user')}
>
<div className="flex items-center">
User
{sortConfig.key === 'user' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('amount')}
>
<div className="flex items-center">
Amount
{sortConfig.key === 'amount' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('status')}
>
<div className="flex items-center">
Status
{sortConfig.key === 'status' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('created_at')}
>
<div className="flex items-center">
Created At
{sortConfig.key === 'created_at' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{billingData.map((item) => ( {currentItems.length > 0 ? (
currentItems.map((item) => (
<tr key={item.id} className="hover:bg-gray-50"> <tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.billing_id} {item.billing_id}
@ -202,6 +571,9 @@ export default function AllSellingList() {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.service} {item.service}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.name ?? item.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.user} {item.user}
</td> </td>
@ -216,61 +588,196 @@ export default function AllSellingList() {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(item.created_at)} {formatDate(item.created_at)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="inline-flex px-6 py-4 whitespace-nowrap text-sm font-medium">
<button <button
title="View Items"
onClick={() => handleViewItem(item)} onClick={() => handleViewItem(item)}
className="text-indigo-600 hover:text-indigo-900 mr-2" className="text-indigo-600 hover:text-indigo-900 mr-2"
> >
View <Eye className="w-5 h-5" />
</button> </button>
<button <button
title="Edit Items"
onClick={() => handleEditItem(item)} onClick={() => handleEditItem(item)}
className="text-yellow-600 hover:text-yellow-900 mr-2" className="text-yellow-600 hover:text-yellow-900 mr-2"
> >
Edit <Pencil className="w-5 h-5" />
</button> </button>
<button <button
title="Delete Items"
onClick={() => handleDeleteItem(item.id)} onClick={() => handleDeleteItem(item.id)}
className="text-red-600 hover:text-red-900 mr-2" className="text-red-600 hover:text-red-900 mr-2"
> >
Delete <Trash2 className="w-5 h-5" />
</button> </button>
<PDFDownloadLink <PDFDownloadLink
title="Download PDF"
document={<InvoicePDF data={item} />} document={<InvoicePDF data={item} />}
fileName={`invoice_${item.billing_id}.pdf`} fileName={`invoice_${item.billing_id}.pdf`}
className="text-green-600 hover:text-green-900" className="text-[#6d9e37] hover:text-green-600"
> >
{({ loading }) => (loading ? 'Preparing...' : 'PDF')} {({ loading }) => (loading ? '...' : <Download className="w-5 h-5" />)}
</PDFDownloadLink> </PDFDownloadLink>
</td> </td>
</tr> </tr>
))} ))
) : (
<tr>
<td colSpan="7" className="px-6 py-4 text-center text-sm text-gray-500">
No records found
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{/* Modal for View/Edit */} {/* Pagination */}
{showModal && ( {filteredAndSortedData.length > itemsPerPage && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"> <div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <div className="text-sm text-gray-600">
<div className="p-6"> Page {currentPage} of {totalPages}
<div className="flex justify-between items-start"> </div>
<h2 className="text-xl font-bold mb-4"> <div className="flex flex-wrap gap-2">
{editMode ? (selectedItem ? 'Edit Billing' : 'Create New Billing') : 'Billing Details'} <Button
</h2> onClick={() => paginate(1)}
<button disabled={currentPage === 1}
onClick={closeModal} variant="outline"
className="text-gray-500 hover:text-gray-700" size="sm"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> First
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /> </Button>
</svg> <Button
</button> onClick={() => paginate(currentPage - 1)}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
Previous
</Button>
{getPaginationRange().map((item, index) => {
if (item === '...') {
return (
<span key={`ellipsis-${index}`} className="px-2 py-1">
...
</span>
);
}
return (
<Button
key={`page-${item}`}
onClick={() => paginate(item)}
variant={currentPage === item ? "default" : "outline"}
size="sm"
>
{item}
</Button>
);
})}
<Button
onClick={() => paginate(currentPage + 1)}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Next
</Button>
<Button
onClick={() => paginate(totalPages)}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Last
</Button>
</div>
</div>
)}
{/* Dialog for View/Edit/Create */}
<Dialog open={dialogOpen} onOpenChange={(open) => {
if (!open) {
setCurrentStep(1);
setSelectedCustomer(null);
}
setDialogOpen(open);
}}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editMode ? (selectedItem ? 'Edit Billing' : 'Create New Billing') : 'Billing Details'}
</DialogTitle>
</DialogHeader>
{editMode && !selectedItem && currentStep === 1 ? (
<div>
<div className="relative mb-4">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Search customers..."
value={customerSearchTerm}
onChange={(e) => setCustomerSearchTerm(e.target.value)}
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md outline-none"
/>
</div> </div>
{editMode ? ( <h3 className="font-semibold mb-4">Select Customer</h3>
{filteredCustomers.length > 0 ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{filteredCustomers.map(user => (
<div
key={user.id}
onClick={() => handleCustomerSelect(user)}
className="p-3 border rounded-md cursor-pointer hover:bg-gray-50"
>
<div className="flex justify-between items-center">
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-gray-600">{user.email}</p>
</div>
<div className="text-sm">
<span className="bg-gray-100 px-2 py-1 rounded">ID: {user.siliconId}</span>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-center text-gray-500 py-4">No customers found</p>
)}
</div>
) : editMode ? (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{!selectedItem && selectedCustomer && (
<div className="mb-6 p-4 bg-gray-50 rounded-md">
<div className="flex justify-between items-start">
<div>
<h3 className="font-semibold">Customer Information</h3>
<p className="text-sm"><span className="font-medium">Name:</span> {selectedCustomer.name}</p>
<p className="text-sm"><span className="font-medium">Email:</span> {selectedCustomer.email}</p>
<p className="text-sm"><span className="font-medium">Silicon ID:</span> {selectedCustomer.siliconId}</p>
</div>
{!selectedItem && (
<Button
type="button"
onClick={handleBackToCustomerSelect}
variant="ghost"
size="sm"
className="text-gray-500"
>
<ArrowLeft className="w-4 h-4 mr-1" /> Change Customer
</Button>
)}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Service</label> <label className="block text-sm font-medium text-gray-700 mb-1">Service</label>
@ -279,17 +786,17 @@ export default function AllSellingList() {
name="service" name="service"
value={formData.service} value={formData.service}
onChange={handleInputChange} onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md" className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tenure</label> <label className="block text-sm font-medium text-gray-700 mb-1">cycle</label>
<select <select
name="tenure" name="cycle"
value={formData.tenure} value={formData.cycle}
onChange={handleInputChange} onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md" className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
> >
<option value="monthly">Monthly</option> <option value="monthly">Monthly</option>
<option value="yearly">Yearly</option> <option value="yearly">Yearly</option>
@ -303,120 +810,140 @@ export default function AllSellingList() {
name="amount" name="amount"
value={formData.amount} value={formData.amount}
onChange={handleInputChange} onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md" className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
step="0.01" step="0.01"
min="0" min="0"
required required
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">User Email</label>
<input
type="email"
name="user"
value={formData.user}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label> <label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select <select
name="status" name="status"
value={formData.status} value={formData.status}
onChange={handleInputChange} onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md" className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
> >
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="failed">Failed</option> <option value="failed">Failed</option>
</select> </select>
</div> </div>
<div className="">
<label htmlFor="billing_date" className="block text-sm font-medium text-gray-700 mb-1">Billing Date</label>
<input
type="date"
name="billing_date"
value={formData.billing_date}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
required
/>
</div>
<div className="">
<label htmlFor="remarks" className="block text-sm font-medium text-gray-700 mb-1">Remarks</label>
<input
type="text"
name="remarks"
value={formData.remarks}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
required
/>
</div>
</div> </div>
<div className="mt-6 flex justify-end space-x-4"> <DialogFooter>
<button <Button
type="submit" type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
> >
{selectedItem ? 'Update' : 'Create'} {selectedItem ? 'Update' : 'Create'}
</button> </Button>
<button <Button
type="button" type="button"
onClick={closeModal} onClick={() => {
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300" setDialogOpen(false);
setCurrentStep(1);
}}
variant="outline"
> >
Cancel Cancel
</button> </Button>
</div> </DialogFooter>
</form> </form>
) : ( ) : (
<> <>
<div className="grid grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div> <div>
<h3 className="font-semibold text-gray-700">Billing ID</h3> <h3 className="font-semibold text-gray-700">Billing ID</h3>
<p>{selectedItem.billing_id}</p> <p>{selectedItem?.billing_id}</p>
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-700">Service</h3> <h3 className="font-semibold text-gray-700">Service</h3>
<p>{selectedItem.service}</p> <p>{selectedItem?.service}</p>
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-700">User</h3> <h3 className="font-semibold text-gray-700">User</h3>
<p>{selectedItem.user}</p> <p>{selectedItem?.user}</p>
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-700">Tenure</h3> <h3 className="font-semibold text-gray-700">Silicon ID</h3>
<p>{selectedItem.tenure}</p> <p>{selectedItem?.siliconId}</p>
</div>
<div>
<h3 className="font-semibold text-gray-700">cycle</h3>
<p>{selectedItem?.cycle}</p>
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-700">Amount</h3> <h3 className="font-semibold text-gray-700">Amount</h3>
<p>{formatCurrency(selectedItem.amount)}</p> <p>{formatCurrency(selectedItem?.amount)}</p>
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-700">Status</h3> <h3 className="font-semibold text-gray-700">Status</h3>
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(selectedItem.status)}`}> <span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(selectedItem?.status)}`}>
{selectedItem.status.charAt(0).toUpperCase() + selectedItem.status.slice(1)} {selectedItem?.status.charAt(0).toUpperCase() + selectedItem?.status.slice(1)}
</span> </span>
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-700">Created At</h3> <h3 className="font-semibold text-gray-700">Created At</h3>
<p>{formatDate(selectedItem.created_at)}</p> <p>{formatDate(selectedItem?.created_at)}</p>
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-700">Updated At</h3> <h3 className="font-semibold text-gray-700">Updated At</h3>
<p>{formatDate(selectedItem.updated_at)}</p> <p>{formatDate(selectedItem?.updated_at)}</p>
</div>
<div className="md:col-span-2">
<h3 className="font-semibold text-gray-700">Remarks</h3>
<p>{selectedItem?.remarks}</p>
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end space-x-4"> <DialogFooter>
<PDFDownloadLink <PDFDownloadLink
document={<InvoicePDF data={selectedItem} />} document={<InvoicePDF data={selectedItem} />}
fileName={`invoice_${selectedItem.billing_id}.pdf`} fileName={`invoice_${selectedItem?.billing_id}.pdf`}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700" className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
> >
{({ loading }) => (loading ? 'Preparing PDF...' : 'Download PDF')} {({ loading }) => (loading ? 'Preparing PDF...' : 'Download PDF')}
</PDFDownloadLink> </PDFDownloadLink>
<button <Button
onClick={() => handleEditItem(selectedItem)} onClick={() => handleEditItem(selectedItem)}
className="px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700" className="px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700"
> >
Edit Edit
</button> </Button>
<button <Button
onClick={closeModal} onClick={() => setDialogOpen(false)}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300" variant="outline"
> >
Close Close
</button> </Button>
</div> </DialogFooter>
</> </>
)} )}
</div> </DialogContent>
</div> </Dialog>
</div>
)}
</section> </section>
); );
} }

View File

@ -1,52 +0,0 @@
import { useState, useRef } from 'react';
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { X } from "lucide-react"; // Import an icon for the remove button
export function AvatarUpload() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setSelectedFile(e.target.files[0]);
}
};
const handleRemoveFile = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = ''; // Reset file input
}
};
return (
<div className="space-y-4">
{!selectedFile ? (
<div className="space-y-2">
<div className="flex items-center gap-4">
<Label
htmlFor="avatar"
className="bg-primary hover:bg-primary/90 text-primary-foreground py-2 px-4 text-sm rounded-md cursor-pointer transition-colorsfocus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
Change Avatar
</Label>
<Input type="file" id="avatar" ref={fileInputRef} accept="image/jpeg,image/png,image/gif" className="hidden" onChange={handleFileChange}/>
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>Browse</Button>
</div>
<p className="text-xs text-muted-foreground">
JPG, GIF or PNG. 1MB max.
</p>
</div>
) : (
<div className="flex items-center justify-between p-3 space-x-2">
<div className="truncate max-w-[200px]">
<p className="text-sm font-medium truncate">{selectedFile.name}</p>
<p className="text-xs text-muted-foreground">{(selectedFile.size / 1024).toFixed(2)} KB</p>
</div>
<Button size="sm" className="text-xs p-1 h-fit">Update</Button>
<Button size="sm" onClick={handleRemoveFile} className="bg-red-500 hover:bg-red-600 text-xs p-1 h-fit">Remove</Button>
</div>
)}
</div>
);
}
export default AvatarUpload;

View File

@ -1,5 +1,6 @@
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "./ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "./ui/card";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Label } from "./ui/label"; import { Label } from "./ui/label";
@ -7,9 +8,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from ".
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
import { Textarea } from "./ui/textarea"; import { Textarea } from "./ui/textarea";
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import UpdateAvatar from './UpdateAvatar'; import {AvatarUpload} from './AvatarUpload';
import {localizeTime} from "../lib/localizeTime"; import {localizeTime} from "../lib/localizeTime";
import Loader from "./ui/loader"; import Loader from "./ui/loader";
import { Eye, Pencil, Trash2, Download, ChevronUp, ChevronDown, Search } from "lucide-react";
import { PDFDownloadLink } from '@react-pdf/renderer';
import InvoicePDF from "../lib/InvoicePDF";
import { useIsLoggedIn } from '../lib/isLoggedIn';
interface SessionData { interface SessionData {
[key: string]: any; [key: string]: any;
} }
@ -19,12 +25,35 @@ interface UserData {
session_data: SessionData; session_data: SessionData;
user_avatar: string; user_avatar: string;
} }
interface BillingItems {
[key: string]: any;
}
type ToastState = {
visible: boolean;
message: string;
};
type Invoice = {
billing_id: string;
};
type PaymentData = {
txn_id: string;
user_email: string;
};
export default function ProfilePage() { export default function ProfilePage() {
const { isLoggedIn, loading, sessionData } = useIsLoggedIn();
const typedSessionData = sessionData as SessionData | null;
const [userData, setUserData] = useState<UserData | null>(null); const [userData, setUserData] = useState<UserData | null>(null);
const [invoiceList, setInvoiceList] = useState<any[]>([]); const [invoiceList, setInvoiceList] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedData, setSelectedData] = useState<BillingItems | null>(null);
const [toast, setToast] = useState<ToastState>({ visible: false, message: '' });
const [txnId, setTxnId] = useState<string>('');
const [userEmail, setUserEmail] = useState<string>('');
// console.log('isLoggedIn', sessionData.id)
const USER_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/'; const USER_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/users/';
const INVOICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/invoice/'; const INVOICE_API_URL = 'https://host-api.cs1.hz.siliconpin.com/v1/invoice/';
useEffect(() => { useEffect(() => {
@ -77,6 +106,27 @@ export default function ProfilePage() {
getInvoiceListData(); getInvoiceListData();
}, []); }, []);
const showToast = (message: string) => {
setToast({ visible: true, message });
setTimeout(() => setToast({ visible: false, message: '' }), 3000);
};
const handlePanelBuyNow = (invoice: Invoice) => {
// setTxnId(data.txn_id);
// setUserEmail(data.user_email);
window.location.href = `/make-payment?query=get-initiated_payment&orderId=${invoice.billing_id}`;
showToast('Redirecting to payment page...');
};
const handleViewItems = (items: BillingItems) => {
setDialogOpen(true);
setSelectedData(items);
};
const sessionLogOut = () => { const sessionLogOut = () => {
fetch(`${USER_API_URL}?query=logout`, { fetch(`${USER_API_URL}?query=logout`, {
method: 'GET', method: 'GET',
@ -97,6 +147,9 @@ export default function ProfilePage() {
if (!userData) { if (!userData) {
return (<Loader />); return (<Loader />);
} }
{
// console.log('selectedData', selectedData)
}
return ( return (
<div className="space-y-6 container mx-auto"> <div className="space-y-6 container mx-auto">
<Separator /> <Separator />
@ -115,7 +168,7 @@ export default function ProfilePage() {
<AvatarImage src={userData.session_data?.user_avatar} /> <AvatarImage src={userData.session_data?.user_avatar} />
<AvatarFallback>JP</AvatarFallback> <AvatarFallback>JP</AvatarFallback>
</Avatar> </Avatar>
<UpdateAvatar /> {typedSessionData?.id && <AvatarUpload userId={typedSessionData.id} />}
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@ -140,7 +193,7 @@ export default function ProfilePage() {
<CardHeader> <CardHeader>
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<CardTitle>Billing Information</CardTitle> <CardTitle>Billing Information</CardTitle>
<a href="" className="hover:bg-[#6d9e37] hover:text-white transtion duration-500 py-1 px-2 rounded">View All</a> <a href="/profile/billing-info" className="hover:bg-[#6d9e37] hover:text-white transtion duration-500 py-1 px-2 rounded">View All</a>
</div> </div>
<CardDescription> <CardDescription>
@ -149,25 +202,45 @@ export default function ProfilePage() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr> <tr>
<th className="text-left">Invoice</th> <th className="text-left">Order ID</th>
<th className="text-left">Invoice Date</th> <th className="text-center">Invoice Date</th>
<th className="text-left">Description</th> <th className="text-left">Description</th>
<th className="text-right">Amount</th> <th className="text-center">Amount</th>
<th className="text-right">Status</th> <th className="text-center">Status</th>
<th className="text-center">Action</th> <th className="text-center">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ {
invoiceList.map((invoice, index) => ( invoiceList.slice(-5).map((invoice, index) => (
<tr key={index}> <tr key={index} className="">
<td>{invoice.invoice_number}</td> <td>{invoice.billing_id}</td>
<td>{invoice.invoice_date}</td> <td className="text-center">{invoice?.created_at.split(' ')[0]}</td>
<td>{invoice.notes ? invoice.notes : ''}</td> <td className="line-clamp-1">{invoice.service}</td>
<td className="text-right">{invoice.total_amount}</td> <td className="text-center">{invoice.amount}</td>
<td className="text-right">{invoice.status}</td> <td className={`text-center text-sm rounded-full h-fit ${invoice.status === 'pending' ? 'text-yellow-500' : invoice.status === 'completed' ? 'text-green-500' : 'text-red-500'}`}>{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}</td>
<td className="text-center"> <td className="text-center flex justify-center items-center gap-2 p-2">
<a href="">View</a> <button onClick={() => handleViewItems(invoice)}>
<Eye />
</button>
<PDFDownloadLink
title="Download PDF"
document={<InvoicePDF data={invoice} />}
fileName={`invoice_${invoice.billing_id}.pdf`}
className="text-[#6d9e37] hover:text-green-600"
>
{({ loading }) => (loading ? '...' : <Download className="w-5 h-5" />)}
</PDFDownloadLink>
{
invoice.status !== 'completed' ? (
<Button onClick={() => {handlePanelBuyNow(invoice)}} variant="outline" size="sm">
{
invoice.status === 'pending' ? 'Pay' : invoice.status === 'failed' ? 'Retry' : ''
}
</Button>
) : ''
}
</td> </td>
</tr> </tr>
)) ))
@ -205,9 +278,7 @@ export default function ProfilePage() {
<Card className="mt-6"> <Card className="mt-6">
<CardHeader> <CardHeader>
<CardTitle>Danger Zone</CardTitle> <CardTitle>Danger Zone</CardTitle>
<CardDescription> <CardDescription>These actions are irreversible. Proceed with caution.</CardDescription>
These actions are irreversible. Proceed with caution.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
@ -219,6 +290,56 @@ export default function ProfilePage() {
</Card> </Card>
</div> </div>
</div> </div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="">Billing Details</DialogTitle>
<DialogDescription className="">Review the details for Billing ID: <span className="font-bold">{selectedData?.billing_id}</span></DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4 text-sm text-gray-700">
<div className="flex justify-between">
<span className="font-bold">Service:</span>
<span>{selectedData?.service}</span>
</div>
<div className="flex justify-between">
<span className="font-bold">cycle:</span>
<span className="capitalize">{selectedData?.cycle}</span>
</div>
<div className="flex justify-between">
<span className="font-bold">Amount:</span>
<span>${selectedData?.amount}</span>
</div>
<div className="flex justify-between">
<span className="font-bold">User:</span>
<span>{selectedData?.user}</span>
</div>
<div className="flex justify-between">
<span className="font-bold">Status:</span>
<span className={`px-2 py-0.5 rounded text-white text-xs ${selectedData?.status === 'pending' ? 'bg-yellow-500' : selectedData?.status === 'completed' ? 'bg-green-500' : 'bg-red-500'}`}>
{selectedData?.status}
</span>
</div>
<hr className="my-2 border-gray-200" />
<div className="flex justify-between">
<span className="font-bold">Created At:</span>
<span>{localizeTime(selectedData?.created_at)}</span>
</div>
<div className="flex justify-between">
<span className="font-bold">Updated At:</span>
<span>{selectedData?.updated_at ? localizeTime(selectedData.updated_at) : '—'}</span>
</div>
<div className="flex justify-between">
<span className="font-bold">Silicon ID:</span>
<span>{selectedData?.siliconId}</span>
</div>
</div>
<DialogFooter className="mt-6">
{/* Optional: Add action buttons here */}
{/* <Button onClick={() => setDialogOpen(false)}>Close</Button> */}
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -294,4 +294,17 @@ document.addEventListener('DOMContentLoaded', function() {
font-size: 15px; font-size: 15px;
} }
} }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.3s ease-out forwards;
}
</style> </style>

View File

@ -1,12 +1,24 @@
import React from 'react'; import React from 'react';
import { Page, Text, View, Document, StyleSheet } from '@react-pdf/renderer'; import { Page, Text, View, Document, StyleSheet, Image, Font } from '@react-pdf/renderer';
import {localizeTime} from "../lib/localizeTime";
import { IndianRupee } from "lucide-react";
import NotoSans from "../lib/fonts/static/NotoSans_Condensed-Bold.ttf";
Font.register({
family: "NotoSans",
src: NotoSans,
});
// console.log('Fonts', Font)
// Create styles // Create styles
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
display: "flex",
flexDirection: 'column', flexDirection: 'column',
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
padding: 40 padding: 40,
},
currencyValue: {
fontSize: 12,
fontWeight: 'bold'
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@ -14,7 +26,18 @@ const styles = StyleSheet.create({
marginBottom: 20, marginBottom: 20,
borderBottomWidth: 2, borderBottomWidth: 2,
borderBottomColor: '#6d9e37', borderBottomColor: '#6d9e37',
paddingBottom: 10 paddingBottom: 10,
alignItems: 'center'
},
logoContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginBottom: 5
},
logoImage: {
width: 30,
height: 30
}, },
title: { title: {
fontSize: 24, fontSize: 24,
@ -54,15 +77,18 @@ const styles = StyleSheet.create({
borderBottomColor: '#EEEEEE' borderBottomColor: '#EEEEEE'
}, },
col1: { col1: {
width: '40%' width: '40%',
fontSize: '12px'
}, },
col2: { col2: {
width: '30%', width: '30%',
textAlign: 'right' textAlign: 'right',
fontSize: '12px'
}, },
col3: { col3: {
width: '30%', width: '30%',
textAlign: 'right' textAlign: 'right',
fontSize: '12px'
}, },
total: { total: {
flexDirection: 'row', flexDirection: 'row',
@ -90,6 +116,10 @@ const styles = StyleSheet.create({
} }
}); });
// Base64 encoded SVG logo (replace with your actual logo)
const SILICONPIN_LOGO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAACXBIWXMAAA7EAAAOxAGVKw4bAAABgElEQVR4nO3bsW0UURRA0TfYLThEDreatUSGC7BdBi4D0QAiWiQ3g0OTQgvsONiICv4J7pF+PE//jmaiN/vMDpzHWWyfedhnzqvv4sPqi8j/CoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmE1Ylvn0eT7+PMzNyhmOv+bv6cf8vlo5xMxsi59/8WW+zjZPi6f4Ns/LZ+iTpSkIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYbWZ/WD3E3N/dzuFl6cLOvB7/zPfT28zqlZ3Zz8BZvsV1eTH3f6vv4nqULSrDNovvo38IpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmDeAd5vHuV+dwDGAAAAAElFTkSuQmCC';
const InvoicePDF = ({ data }) => { const InvoicePDF = ({ data }) => {
const statusColor = data.status === 'completed' ? '#10B981' : '#EF4444'; const statusColor = data.status === 'completed' ? '#10B981' : '#EF4444';
@ -98,27 +128,29 @@ const InvoicePDF = ({ data }) => {
<Page size="A4" style={styles.page}> <Page size="A4" style={styles.page}>
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<View>
<Text style={styles.title}>INVOICE</Text> <Text style={styles.title}>INVOICE</Text>
<Text style={styles.label}>Invoice #: {data.billing_id}</Text> <Text style={styles.label}>Invoice #: {data.billing_id}</Text>
<Text style={styles.label}>Date: {new Date(data.created_at).toLocaleDateString()}</Text> <Text style={styles.label}>Date: {localizeTime(data.created_at)}</Text>
</View>
<View>
<Text style={styles.label}>Your Company</Text>
<Text style={styles.value}>123 Business Street</Text>
<Text style={styles.value}>City, Country</Text>
<Text style={styles.value}>contact@yourcompany.com</Text>
</View>
</View> </View>
{/* Bill To */} {/* Bill To */}
<View style={styles.section}> <View style={styles.section}>
<View style={styles.row}> <View style={styles.row}>
<View> <View style={{display: 'flex', flexDirection: 'row', gap: '10px'}}>
<Text style={styles.label}>BILL TO:</Text> <Text style={styles.label}>BILL TO:</Text>
<Text style={styles.value}>{data.user}</Text> <Text style={styles.value}>{data.name}</Text>
</View> </View>
<View> {
data.status === 'completed' && (
<View style={{display: 'flex', flexDirection: 'row', gap: '10px'}}>
<Text style={styles.label}>PAID ON:</Text>
<Text style={styles.value}>
{data.payment_date}
</Text>
</View>
)
}
<View style={{display: 'flex', flexDirection: 'row', gap: '10px'}}>
<Text style={styles.label}>STATUS:</Text> <Text style={styles.label}>STATUS:</Text>
<Text style={[styles.value, { color: statusColor }]}> <Text style={[styles.value, { color: statusColor }]}>
{data.status.toUpperCase()} {data.status.toUpperCase()}
@ -133,16 +165,16 @@ const InvoicePDF = ({ data }) => {
<View style={styles.section}> <View style={styles.section}>
<View style={styles.tableHeader}> <View style={styles.tableHeader}>
<Text style={styles.col1}>DESCRIPTION</Text> <Text style={styles.col1}>DESCRIPTION</Text>
<Text style={styles.col2}>TENURE</Text> <Text style={styles.col2}>CYCLE</Text>
<Text style={styles.col3}>AMOUNT</Text> <Text style={styles.col3}>AMOUNT</Text>
</View> </View>
<View style={styles.tableRow}> <View style={styles.tableRow}>
<Text style={styles.col1}>{data.service}</Text> <Text style={styles.col1}>{data.service}</Text>
<Text style={styles.col2}>{data.tenure}</Text> <Text style={styles.col2}>{data.cycle}</Text>
<Text style={styles.col3}> <Text style={{fontFamily: 'NotoSans', ...styles.col3}}>
{new Intl.NumberFormat('en-US', { {new Intl.NumberFormat('en-IN', {
style: 'currency', style: 'currency',
currency: 'USD' currency: 'INR',
}).format(parseFloat(data.amount))} }).format(parseFloat(data.amount))}
</Text> </Text>
</View> </View>
@ -155,30 +187,43 @@ const InvoicePDF = ({ data }) => {
<View style={{width: '30%'}}> <View style={{width: '30%'}}>
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.label}>SUBTOTAL:</Text> <Text style={styles.label}>SUBTOTAL:</Text>
<Text style={styles.value}> <Text style={{fontFamily: 'NotoSans', ...styles.value}}>
{new Intl.NumberFormat('en-US', { {new Intl.NumberFormat('en-IN', {
style: 'currency', style: 'currency',
currency: 'USD' currency: 'INR',
}).format(parseFloat(data.amount))} }).format(parseFloat(data.amount))}
</Text> </Text>
</View> </View>
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.label}>TOTAL:</Text> <Text style={styles.label}>TOTAL:</Text>
<Text style={styles.totalText}> <Text style={{fontFamily: 'NotoSans', ...styles.totalText}}>
{new Intl.NumberFormat('en-US', { {new Intl.NumberFormat('en-IN', {
style: 'currency', style: 'currency',
currency: 'USD' currency: 'INR',
}).format(parseFloat(data.amount))} }).format(parseFloat(data.amount))}
</Text> </Text>
</View> </View>
</View> </View>
</View> </View>
<View style={styles.header} />
<View style={{}}>
<View style={styles.logoContainer}>
<Image
style={styles.logoImage}
src={SILICONPIN_LOGO}
/>
<Text style={{fontSize: '12px', fontWeight: 'bold', color: '#6d9e37'}}>SiliconPin, Habra, W.B. 743271, India, contact@siliconpin.com</Text>
</View>
{/* <Text style={styles.value}></Text>
<Text style={styles.value}></Text>
<Text style={styles.value}></Text> */}
</View>
{/* Footer */} {/* Footer */}
<View style={styles.footer}> {/* <View style={styles.footer}>
<Text>Thank you for your business!</Text> <Text>Thank you for your business!</Text>
<Text>Please make payments payable to Your Company</Text> <Text>Please make payments payable to SiliconPin</Text>
</View> </View> */}
</Page> </Page>
</Document> </Document>
); );

219
src/lib/InvoicePDF_V1.jsx Normal file
View File

@ -0,0 +1,219 @@
import React from 'react';
import { Page, Text, View, Document, StyleSheet, Image, Font } from '@react-pdf/renderer';
import {localizeTime} from "../lib/localizeTime";
import { IndianRupee } from "lucide-react";
import NotoSans from "../lib/fonts/static/NotoSans_Condensed-Bold.ttf";
Font.register({
family: "NotoSans",
src: NotoSans,
});
// console.log('Fonts', Font)
// Create styles
const styles = StyleSheet.create({
page: {
display: "flex",
flexDirection: 'column',
backgroundColor: '#FFFFFF',
padding: 40,
},
currencyValue: {
fontSize: 12,
fontWeight: 'bold'
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 20,
borderBottomWidth: 2,
borderBottomColor: '#6d9e37',
paddingBottom: 10
},
logoContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginBottom: 5
},
logoImage: {
width: 30,
height: 30
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#6d9e37'
},
section: {
marginBottom: 20
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 5
},
label: {
fontSize: 12,
fontWeight: 'bold'
},
value: {
fontSize: 12
},
divider: {
borderBottomWidth: 1,
borderBottomColor: '#EEEEEE',
marginVertical: 10
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#F5F5F5',
padding: 5,
marginBottom: 5
},
tableRow: {
flexDirection: 'row',
padding: 5,
borderBottomWidth: 1,
borderBottomColor: '#EEEEEE'
},
col1: {
width: '40%'
},
col2: {
width: '30%',
textAlign: 'right'
},
col3: {
width: '30%',
textAlign: 'right'
},
total: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 10
},
totalText: {
fontSize: 14,
fontWeight: 'bold'
},
footer: {
position: 'absolute',
bottom: 30,
left: 40,
right: 40,
textAlign: 'center',
fontSize: 10,
color: '#999999'
},
status: {
padding: 3,
borderRadius: 3,
fontSize: 10,
fontWeight: 'bold'
}
});
// Base64 encoded SVG logo (replace with your actual logo)
const SILICONPIN_LOGO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAACXBIWXMAAA7EAAAOxAGVKw4bAAABgElEQVR4nO3bsW0UURRA0TfYLThEDreatUSGC7BdBi4D0QAiWiQ3g0OTQgvsONiICv4J7pF+PE//jmaiN/vMDpzHWWyfedhnzqvv4sPqi8j/CoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmE1Ylvn0eT7+PMzNyhmOv+bv6cf8vlo5xMxsi59/8WW+zjZPi6f4Ns/LZ+iTpSkIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYbWZ/WD3E3N/dzuFl6cLOvB7/zPfT28zqlZ3Zz8BZvsV1eTH3f6vv4nqULSrDNovvo38IpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmDeAd5vHuV+dwDGAAAAAElFTkSuQmCC';
const InvoicePDF = ({ data }) => {
const statusColor = data.status === 'completed' ? '#10B981' : '#EF4444';
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.title}>INVOICE</Text>
<Text style={styles.label}>Invoice #: {data.billing_id}</Text>
<Text style={styles.label}>Date: {localizeTime(data.created_at)}</Text>
</View>
<View>
<View style={styles.logoContainer}>
<Image
style={styles.logoImage}
src={SILICONPIN_LOGO}
/>
<Text style={styles.title}>SiliconPin</Text>
</View>
<Text style={styles.value}>121 Lalbari, GourBongo Road</Text>
<Text style={styles.value}>Habra, W.B. 743271, India</Text>
<Text style={styles.value}>contact@siliconpin.com</Text>
</View>
</View>
{/* Bill To */}
<View style={styles.section}>
<View style={styles.row}>
<View>
<Text style={styles.label}>BILL TO:</Text>
<Text style={styles.value}>{data.name}</Text>
</View>
<View>
<Text style={styles.label}>STATUS:</Text>
<Text style={[styles.value, { color: statusColor }]}>
{data.status.toUpperCase()}
</Text>
</View>
</View>
</View>
<View style={styles.divider} />
{/* Items Table */}
<View style={styles.section}>
<View style={styles.tableHeader}>
<Text style={styles.col1}>DESCRIPTION</Text>
<Text style={styles.col2}>cycle</Text>
<Text style={styles.col3}>AMOUNT</Text>
</View>
<View style={styles.tableRow}>
<Text style={styles.col1}>{data.service}</Text>
<Text style={styles.col2}>{data.cycle}</Text>
<Text style={{fontFamily: 'NotoSans', ...styles.col3}}>
{new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
}).format(parseFloat(data.amount))}
</Text>
</View>
</View>
<View style={styles.divider} />
{/* Total */}
<View style={styles.total}>
<View style={{width: '30%'}}>
<View style={styles.row}>
<Text style={styles.label}>SUBTOTAL:</Text>
<Text style={{fontFamily: 'NotoSans', ...styles.value}}>
{new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
}).format(parseFloat(data.amount))}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>TOTAL:</Text>
<Text style={{fontFamily: 'NotoSans', ...styles.totalText}}>
{new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
}).format(parseFloat(data.amount))}
</Text>
</View>
</View>
</View>
{/* Footer */}
<View style={styles.footer}>
<Text>Thank you for your business!</Text>
<Text>Please make payments payable to SiliconPin</Text>
</View>
</Page>
</Document>
);
};
export default InvoicePDF;

Binary file not shown.

93
src/lib/fonts/OFL.txt Normal file
View File

@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

136
src/lib/fonts/README.txt Normal file
View File

@ -0,0 +1,136 @@
Noto Sans Variable Font
=======================
This download contains Noto Sans as both variable fonts and static fonts.
Noto Sans is a variable font with these axes:
wdth
wght
This means all the styles are contained in these files:
NotoSans-VariableFont_wdth,wght.ttf
NotoSans-Italic-VariableFont_wdth,wght.ttf
If your app fully supports variable fonts, you can now pick intermediate styles
that arent available as static fonts. Not all apps support variable fonts, and
in those cases you can use the static font files for Noto Sans:
static/NotoSans_ExtraCondensed-Thin.ttf
static/NotoSans_ExtraCondensed-ExtraLight.ttf
static/NotoSans_ExtraCondensed-Light.ttf
static/NotoSans_ExtraCondensed-Regular.ttf
static/NotoSans_ExtraCondensed-Medium.ttf
static/NotoSans_ExtraCondensed-SemiBold.ttf
static/NotoSans_ExtraCondensed-Bold.ttf
static/NotoSans_ExtraCondensed-ExtraBold.ttf
static/NotoSans_ExtraCondensed-Black.ttf
static/NotoSans_Condensed-Thin.ttf
static/NotoSans_Condensed-ExtraLight.ttf
static/NotoSans_Condensed-Light.ttf
static/NotoSans_Condensed-Regular.ttf
static/NotoSans_Condensed-Medium.ttf
static/NotoSans_Condensed-SemiBold.ttf
static/NotoSans_Condensed-Bold.ttf
static/NotoSans_Condensed-ExtraBold.ttf
static/NotoSans_Condensed-Black.ttf
static/NotoSans_SemiCondensed-Thin.ttf
static/NotoSans_SemiCondensed-ExtraLight.ttf
static/NotoSans_SemiCondensed-Light.ttf
static/NotoSans_SemiCondensed-Regular.ttf
static/NotoSans_SemiCondensed-Medium.ttf
static/NotoSans_SemiCondensed-SemiBold.ttf
static/NotoSans_SemiCondensed-Bold.ttf
static/NotoSans_SemiCondensed-ExtraBold.ttf
static/NotoSans_SemiCondensed-Black.ttf
static/NotoSans-Thin.ttf
static/NotoSans-ExtraLight.ttf
static/NotoSans-Light.ttf
static/NotoSans-Regular.ttf
static/NotoSans-Medium.ttf
static/NotoSans-SemiBold.ttf
static/NotoSans-Bold.ttf
static/NotoSans-ExtraBold.ttf
static/NotoSans-Black.ttf
static/NotoSans_ExtraCondensed-ThinItalic.ttf
static/NotoSans_ExtraCondensed-ExtraLightItalic.ttf
static/NotoSans_ExtraCondensed-LightItalic.ttf
static/NotoSans_ExtraCondensed-Italic.ttf
static/NotoSans_ExtraCondensed-MediumItalic.ttf
static/NotoSans_ExtraCondensed-SemiBoldItalic.ttf
static/NotoSans_ExtraCondensed-BoldItalic.ttf
static/NotoSans_ExtraCondensed-ExtraBoldItalic.ttf
static/NotoSans_ExtraCondensed-BlackItalic.ttf
static/NotoSans_Condensed-ThinItalic.ttf
static/NotoSans_Condensed-ExtraLightItalic.ttf
static/NotoSans_Condensed-LightItalic.ttf
static/NotoSans_Condensed-Italic.ttf
static/NotoSans_Condensed-MediumItalic.ttf
static/NotoSans_Condensed-SemiBoldItalic.ttf
static/NotoSans_Condensed-BoldItalic.ttf
static/NotoSans_Condensed-ExtraBoldItalic.ttf
static/NotoSans_Condensed-BlackItalic.ttf
static/NotoSans_SemiCondensed-ThinItalic.ttf
static/NotoSans_SemiCondensed-ExtraLightItalic.ttf
static/NotoSans_SemiCondensed-LightItalic.ttf
static/NotoSans_SemiCondensed-Italic.ttf
static/NotoSans_SemiCondensed-MediumItalic.ttf
static/NotoSans_SemiCondensed-SemiBoldItalic.ttf
static/NotoSans_SemiCondensed-BoldItalic.ttf
static/NotoSans_SemiCondensed-ExtraBoldItalic.ttf
static/NotoSans_SemiCondensed-BlackItalic.ttf
static/NotoSans-ThinItalic.ttf
static/NotoSans-ExtraLightItalic.ttf
static/NotoSans-LightItalic.ttf
static/NotoSans-Italic.ttf
static/NotoSans-MediumItalic.ttf
static/NotoSans-SemiBoldItalic.ttf
static/NotoSans-BoldItalic.ttf
static/NotoSans-ExtraBoldItalic.ttf
static/NotoSans-BlackItalic.ttf
Get started
-----------
1. Install the font files you want to use
2. Use your app's font picker to view the font family and all the
available styles
Learn more about variable fonts
-------------------------------
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
https://variablefonts.typenetwork.com
https://medium.com/variable-fonts
In desktop apps
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
Online
https://developers.google.com/fonts/docs/getting_started
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
Installing fonts
MacOS: https://support.apple.com/en-us/HT201749
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
Android Apps
https://developers.google.com/fonts/docs/android
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
License
-------
Please read the full license text (OFL.txt) to understand the permissions,
restrictions and requirements for usage, redistribution, and modification.
You can use them in your products & projects print or digital,
commercial or otherwise.
This isn't legal advice, please consider consulting a lawyer and see the full
license for all details.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,11 @@
export function localizeTime(timeValue: string, targetTimeZone?: string): string { export function localizeTime(timeValue?: string, targetTimeZone?: string): string {
// 1. Auto-detect user's timezone if none provided if (!timeValue) return 'Invalid date';
if (!targetTimeZone) { if (!targetTimeZone) {
targetTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; targetTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
} }
// 2. Format the date in the target timezone const date = new Date(timeValue.replace(' ', 'T') + 'Z');
const date = new Date(timeValue.replace(' ', 'T') + 'Z'); // Ensure UTC
const formatter = new Intl.DateTimeFormat('en-US', { const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: targetTimeZone, timeZone: targetTimeZone,
year: 'numeric', year: 'numeric',
@ -28,10 +28,3 @@ export function localizeTime(timeValue: string, targetTimeZone?: string): string
return `${year}-${month}-${day} ${hour}:${minute}:${second}`; return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
} }
// Usage:
// const localTime = localizeTime(ticket.created_at); // Auto-detects timezone
// console.log(localTime); // "2025-04-03 20:14:50" (in user's local time)
// const timeInTokyo = localizeTime(ticket.created_at, "Asia/Tokyo");
// console.log(timeInTokyo); // "2025-04-04 05:14:50" (Tokyo time)

View File

@ -1,21 +0,0 @@
---
import Layout from "../layouts/Layout.astro";
// const phpHello = `$_SESSION['userName']`;
import UserProfile from "../components/UserProfile";
---
<Layout title="Profile Page">
<UserProfile client:load />
<!-- <div class="flex items-center justify-center min-h-screen bg-gray-700">
<div class="w-96 p-6 shadow-lg rounded-lg bg-white text-center">
<img class="w-24 h-24 rounded-full border-4 border-blue-500 mx-auto" src="/profile.jpg" alt="Profile Picture" />
<h2 class="text-2xl font-semibold mt-4" set:html={phpHello ? phpHello : 'User Name'}></h2>
<p class="text-gray-600">Frontend Developer</p>
<p class="text-gray-500 text-sm mt-2">"Building amazing UI experiences one component at a time."</p>
<div class="flex justify-center space-x-4 mt-4">
<button class="bg-blue-500 text-white px-4 py-2 rounded-lg">Follow</button>
<button class="bg-gray-200 text-black px-4 py-2 rounded-lg">Message</button>
</div>
</div>
</div> -->
</Layout>

View File

@ -0,0 +1,7 @@
---
import Layout from "../../layouts/Layout.astro";
import BillingInformation from "../../components/BillingInfo";
---
<Layout title="">
<BillingInformation client:load />
</Layout>

View File

@ -0,0 +1,9 @@
---
import Layout from "../../layouts/Layout.astro";
// const phpHello = `$_SESSION['userName']`;
import UserProfile from "../../components/UserProfile";
---
<Layout title="Profile Page">
<UserProfile client:load />
</Layout>

View File

@ -1106,6 +1106,11 @@ acorn@^8.14.1:
resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz" resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz"
integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==
adler-32@~1.3.0:
version "1.3.1"
resolved "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz"
integrity sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==
ansi-align@^3.0.1: ansi-align@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz" resolved "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz"
@ -1449,6 +1454,14 @@ ccount@^2.0.0:
resolved "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz" resolved "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz"
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
cfb@~1.2.1:
version "1.2.2"
resolved "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz"
integrity sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==
dependencies:
adler-32 "~1.3.0"
crc-32 "~1.2.0"
chalk@^5.0.0, chalk@5.2.0: chalk@^5.0.0, chalk@5.2.0:
version "5.2.0" version "5.2.0"
resolved "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz" resolved "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz"
@ -1557,6 +1570,11 @@ codemirror@*, codemirror@^5.65.15:
resolved "https://registry.npmjs.org/codemirror/-/codemirror-5.65.19.tgz" resolved "https://registry.npmjs.org/codemirror/-/codemirror-5.65.19.tgz"
integrity sha512-+aFkvqhaAVr1gferNMuN8vkTSrWIFvzlMV9I2KBLCWS2WpZ2+UAkZjlMZmEuT+gcXTi6RrGQCkWq1/bDtGqhIA== integrity sha512-+aFkvqhaAVr1gferNMuN8vkTSrWIFvzlMV9I2KBLCWS2WpZ2+UAkZjlMZmEuT+gcXTi6RrGQCkWq1/bDtGqhIA==
codepage@~1.15.0:
version "1.15.0"
resolved "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz"
integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==
color-convert@^2.0.1: color-convert@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
@ -1625,6 +1643,11 @@ cookie@^1.0.1:
resolved "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz" resolved "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz"
integrity sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA== integrity sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==
crc-32@~1.2.0, crc-32@~1.2.1:
version "1.2.2"
resolved "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz"
integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==
cross-spawn@^7.0.3, cross-spawn@^7.0.6: cross-spawn@^7.0.3, cross-spawn@^7.0.6:
version "7.0.6" version "7.0.6"
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz"
@ -2047,6 +2070,11 @@ formdata-polyfill@^4.0.10:
dependencies: dependencies:
fetch-blob "^3.1.2" fetch-blob "^3.1.2"
frac@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz"
integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==
fraction.js@^4.3.7: fraction.js@^4.3.7:
version "4.3.7" version "4.3.7"
resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz" resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz"
@ -5028,6 +5056,13 @@ split-on-first@^1.0.0:
resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz" resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz"
integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==
ssf@~0.11.2:
version "0.11.2"
resolved "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz"
integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==
dependencies:
frac "~1.1.2"
stdin-discarder@^0.1.0: stdin-discarder@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz" resolved "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz"
@ -5707,6 +5742,16 @@ widest-line@^5.0.0:
dependencies: dependencies:
string-width "^7.0.0" string-width "^7.0.0"
wmf@~1.0.1:
version "1.0.2"
resolved "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz"
integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==
word@~0.3.0:
version "0.3.0"
resolved "https://registry.npmjs.org/word/-/word-0.3.0.tgz"
integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0" version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
@ -5734,6 +5779,19 @@ wrap-ansi@^9.0.0:
string-width "^7.0.0" string-width "^7.0.0"
strip-ansi "^7.1.0" strip-ansi "^7.1.0"
xlsx@^0.18.5:
version "0.18.5"
resolved "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz"
integrity sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==
dependencies:
adler-32 "~1.3.0"
cfb "~1.2.1"
codepage "~1.15.0"
crc-32 "~1.2.1"
ssf "~0.11.2"
wmf "~1.0.1"
word "~0.3.0"
"xml2js@^0.5.0 || ^0.6.2": "xml2js@^0.5.0 || ^0.6.2":
version "0.6.2" version "0.6.2"
resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz" resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz"