feat: added tab products to manager the client price

master
adriano 2025-06-12 17:58:22 -03:00
parent d747e063d7
commit f9786de1b7
26 changed files with 2213 additions and 445 deletions

View File

@ -19,6 +19,7 @@ flask-jwt-extended = "*"
flask-bcrypt = "*"
flask-cors = "*"
mypy = "*"
requests = "*"
[dev-packages]

255
backend/Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "41c45852f7940e8cbf541673b9e42d7a8b139527a30b88edad5b6620405e117a"
"sha256": "3798a8dec1f82bd3b8d8e6089e8110ae5e152e1e76d9b78810ba5b4cfab33c2f"
},
"pipfile-spec": 6,
"requires": {
@ -104,6 +104,112 @@
"markers": "python_version >= '3.9'",
"version": "==1.9.0"
},
"certifi": {
"hashes": [
"sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6",
"sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"
],
"markers": "python_version >= '3.6'",
"version": "==2025.4.26"
},
"charset-normalizer": {
"hashes": [
"sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4",
"sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45",
"sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7",
"sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0",
"sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7",
"sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d",
"sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d",
"sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0",
"sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184",
"sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db",
"sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b",
"sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64",
"sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b",
"sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8",
"sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff",
"sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344",
"sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58",
"sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e",
"sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471",
"sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148",
"sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a",
"sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836",
"sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e",
"sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63",
"sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c",
"sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1",
"sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01",
"sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366",
"sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58",
"sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5",
"sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c",
"sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2",
"sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a",
"sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597",
"sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b",
"sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5",
"sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb",
"sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f",
"sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0",
"sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941",
"sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0",
"sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86",
"sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7",
"sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7",
"sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455",
"sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6",
"sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4",
"sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0",
"sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3",
"sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1",
"sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6",
"sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981",
"sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c",
"sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980",
"sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645",
"sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7",
"sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12",
"sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa",
"sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd",
"sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef",
"sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f",
"sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2",
"sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d",
"sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5",
"sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02",
"sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3",
"sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd",
"sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e",
"sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214",
"sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd",
"sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a",
"sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c",
"sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681",
"sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba",
"sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f",
"sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a",
"sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28",
"sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691",
"sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82",
"sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a",
"sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027",
"sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7",
"sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518",
"sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf",
"sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b",
"sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9",
"sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544",
"sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da",
"sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509",
"sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f",
"sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a",
"sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.2"
},
"click": {
"hashes": [
"sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202",
@ -154,12 +260,12 @@
},
"flask-cors": {
"hashes": [
"sha256:4592c1570246bf7beee96b74bc0adbbfcb1b0318f6ba05c412e8909eceec3393",
"sha256:6332073356452343a8ccddbfec7befdc3fdd040141fe776ec9b94c262f058657"
"sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c",
"sha256:d81bcb31f07b0985be7f48406247e9243aced229b7747219160a0559edd678db"
],
"index": "pypi",
"markers": "python_version >= '3.9' and python_version < '4.0'",
"version": "==6.0.0"
"version": "==6.0.1"
},
"flask-jwt-extended": {
"hashes": [
@ -247,6 +353,14 @@
"markers": "python_version < '3.14' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))",
"version": "==3.2.3"
},
"idna": {
"hashes": [
"sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9",
"sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"
],
"markers": "python_version >= '3.6'",
"version": "==3.10"
},
"importlib-resources": {
"hashes": [
"sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c",
@ -680,67 +794,67 @@
},
"pymongo": {
"hashes": [
"sha256:007450b8c8d17b4e5b779ab6e1938983309eac26b5b8f0863c48effa4b151b07",
"sha256:02f0e1af87280697a1a8304238b863d4eee98c8b97f554ee456c3041c0f3a021",
"sha256:1397eac713b84946210ab556666cfdd787eee824e910fbbe661d147e110ec516",
"sha256:1bac84ee40032bec4c089e92970893157fcd0ef40b81157404ceb4c1dac8ba72",
"sha256:209efd3b62cdbebd3cc7a76d5e37414ad08c9bfe8b28ae73695ade065d5b1277",
"sha256:267eff6a66da5cf5255b3bcd257984619e9c4d41a53578d4e1d827553a51cf40",
"sha256:2d377bb0811e0a9676bacb21a4f87ef307f2e9a40a625660c113a9c0ae897e8c",
"sha256:3d631d879e934b46222f5092d8951cbb9fe83542649697c8d342ea7b5479f118",
"sha256:3f33b8c1405d05517dce06756f2800b37dd098216cae5903cd80ad4f0a9dad08",
"sha256:46c8bce9af98556110a950939f3eaa3f7648308d60df65feb783c780f8b9bfa9",
"sha256:50c503b7e809e54740704ec4c87a0f2ccdb910c3b1d36c07dbd2029b6eaa6a50",
"sha256:51081910a91e3451db74b7265ee290c72220412aa8897d6dfe28f6e5d80b685b",
"sha256:5303e2074b85234e337ebe622d353ce38a35696cd47a7d970f84b545288aee01",
"sha256:5adc1349fd5c94d5dfbcbd1ad9858d1df61945a07f5905dcf17bb62eb4c81f93",
"sha256:5dea2f6b44697eda38a11ef754d2adfff5373c51b1ffda00b9fedc5facbd605f",
"sha256:61733c8f1ded90ab671a08033ee99b837073c73e505b3b3b633a55a0326e77f4",
"sha256:6208b83e7d566935218c0837f3b74c7d2dda83804d5d843ce21a55f22255ab74",
"sha256:66800de4f4487e7c437991b44bc1e717aadaf06e67451a760efe5cd81ce86575",
"sha256:6a8f060f8ad139d1d45f75ef7aa0084bd7f714fc666f98ef00009efc7db34acd",
"sha256:6b91f59137e46cd3ff17d5684a18e8006d65d0ee62eb1068b512262d1c2c5ae8",
"sha256:81b46d9bc62128c3d968336f8635bcfce33d8e9e1fc6be6ebdfb98effaccb9c7",
"sha256:82c36928c1c26580ce4f2497a6875968636e87c77108ff253d76b1355181a405",
"sha256:899a5ea9cd32b1b0880015fdceaa36a41140a8c2ce8621626c52f7023724aed6",
"sha256:8af08ba2886f08d334bc7e5d5c662c60ea2f16e813a2c35106f399463fa11087",
"sha256:8e11ea726ff8ddc8c8393895cd7e93a57e2558c27273d3712797895c53d25692",
"sha256:8e90195cb5aee24a67a29adde54c1dae4d9744e17e4585bea3a83bfff96db46c",
"sha256:92a06e3709e3c7e50820d352d3d4e60015406bcba69808937dac2a6d22226fde",
"sha256:92f5e75ae265e798be1a8a40a29e2ab934e156f3827ca0e1c47e69d43f4dcb31",
"sha256:936f7be9ed6919e3be7369b858d1c58ebaa4f3ef231cf4860779b8ba3b4fcd11",
"sha256:99a52cfbf31579cc63c926048cd0ada6f96c98c1c4c211356193e07418e6207c",
"sha256:a9fe172e93551ddfdb94b9ad34dccebc4b7b680dc1d131bc6bd661c4a5b2945c",
"sha256:b2afe49109b4d498d8e55ac9692915f2a3fce0bd31646bb7ed41f9ab3546ca19",
"sha256:b54e19e0f6c8a7ad0c5074a8cbefb29c12267c784ceb9a1577a62bbc43150161",
"sha256:b63d9d8be87f4be11972c5a63d815974c298ada59a2e1d56ef5b6984d81c544a",
"sha256:b9288188101506a9d1aa3f70f65b7f5f499f8f7d5c23ec86a47551d756e32059",
"sha256:bd0c9322fdf1b9e8a5c99ca337bd9a99d972ba57c976e77b5017366ba26725e1",
"sha256:bd23119f9d0358aa1f78174d2eda88ca5c882a722e25ca31197402278acddc6e",
"sha256:be048fb78e165243272a8cdbeb40d53eace82424b95417ab3ab6ec8e9b00c59b",
"sha256:c02160ab3a67eca393a2a2bb83dccddf4db2196d0d7c6a980a55157e4bdadc06",
"sha256:c03e02129ad202d8e146480b398c4a3ea18266ee0754b6a4805de6baf4a6a8c7",
"sha256:c7d740560710be0c514bc9d26f5dcbb3c85dbb6b450c4c3246d8136ca84055bd",
"sha256:cef461fae88ac51cd6b3f81adf58171113c58c0e77c82c751b3bdcef516cfeb1",
"sha256:d10d3967e87c21869f084af5716d02626a17f6f9ccc9379fcbece5821c2a9fb4",
"sha256:d4b4942e5566a134fe34c03d7182a0b346e4a478defe625dc430dd5a178ad96e",
"sha256:d684d9b385d97ab821d2ae74628c81a8bd12a4e5004a3ded0ec8c20381d62d0e",
"sha256:d81d159bd23d8ac53a6e819cccee991cb9350ab2541dfaa25aeb2f712d23b0a5",
"sha256:d842e11eb94f7074314ff1d97a05790539a1d74c3048ce50ea9f0da1f4f96b0a",
"sha256:d9a1d7d49d0d364520894116133d017b6e0e2d5131eb31c8553552fa77a65085",
"sha256:dc9e412911f210d9b0eca42d25c22d3725809dda03dedbaf6f9ffa192d461905",
"sha256:e4a7855933011026898ea0d4532fbd83cef63a76205c823a4ef5557d970df1f1",
"sha256:e7d349066f4c229d638a30f1f53ec3a4aaf4a4fc568491bdf77e7415a96003fb",
"sha256:ea47a64ed9918be0fa8a4a11146a80f546c09e0d65fd08e90a5c00366a59bdb0",
"sha256:f0b26cd4e090161927b7a81741a3627a41b74265dfb41c6957bfb474504b4b42",
"sha256:f39791a88cd5ec1760f65e878af419747c6f94ce74f9293735cbba6025ff4d0d",
"sha256:fb780d9d284ffdf7922edd4a6d7ba08e54a6680f85f64f91fa9cc2617dd488b7",
"sha256:fca24e4df05501420b2ce2207c03f21fcbdfac1e3f41e312e61b8f416c5b4963",
"sha256:fe497c885b08600a022646f00f4d3303697c5289990acec250e2be2e1699ca23"
"sha256:01d4b136e2e71c1ecf20cd38222eea154b347b4230eae1db2fa0a4a81ae824cd",
"sha256:047fde40672831d546fde0f57b494556db1467283864e0faf10a109a87b5153d",
"sha256:08dd70224978831ce7dc76016205d9b56c86aa337077f65a54b672240e7e9d6d",
"sha256:0a9db585de9622ea0834833c4d48b873f83d10ec31c7370fb92c87d5f4e1b805",
"sha256:0d46d18854fed2196ea85fff3ee985c89658dd8c090292e6b174aa1387fe5dc4",
"sha256:113fc90ea0c7fd6dc288844c325cab76fce06dfccecf45eac55c384a167abad4",
"sha256:1ab8f8272f882e24e45772f20134bd0e2ad31b08153a068cf812c377088bb3b1",
"sha256:2c49a8274e79093697f274946e9e78079edf504158790401b538ba417c1cae5d",
"sha256:3078a59ce15625a91963afc5086c69995877df10ea261bb82d099a1bbbe80efe",
"sha256:43f618d956b8f97e2483452d0a49d652bb07088c34cbf00917de6d61d31df4f4",
"sha256:443de2fd340f0b899a59d5b4fd4c773f016e8cbed8075975783bced3583fa0a8",
"sha256:47cc337065de00c087260aa8e8b91e1a3688b69cd1b181a478c1e84cf248d41d",
"sha256:493c162b783614c69efdfc41ebe9521df6cc5da6be3d25c92535db111e804952",
"sha256:494ce1f93d507e8e170886dbefbef32662ac0cfe5b1734a65b6e523103b7ef3c",
"sha256:4e314f863a338c1c716788d3b3a7be566cca8b4351aa637aefa9c2609d1c643e",
"sha256:4fad51a0d951523e5f43d8271d074b700a64f46b347da176646816b9e1540a44",
"sha256:59ad9f47a95bd330e423fa506714494d84f1af6b4095f3a30f7d69f0ed741d93",
"sha256:59f5b845773a1d20c6808656a4ca63975f790e3fd5343a4a72ca1506ab852258",
"sha256:5a8ca3e3383b2f33fe765e114af37211c3ba2a4654745756a94aa322b1c52523",
"sha256:5f69576ccb93c1859075493a7890f9a3b3a89fd46427745ce08bc82100d3f60d",
"sha256:6109e0dd1c814534a0c583a59d7a2e2210b6a0ee2dba98dc6589ad2b53e20f84",
"sha256:6249560e51a6e881cca6258d2de1241d30f50d45d8c7fc45205e1776306f44d0",
"sha256:629026d35e41f069a8d49347ff76c7c205cd97b62537da7498b008ceba542c85",
"sha256:643b79645944899b2addb4ce03d9a6718dd1f4a0e1fa71fecdc49e6dea072b62",
"sha256:6492565cd7bb10cb6104401af446926141249095953b57c108c4bdcf3452fa3d",
"sha256:65702467612d338028551fa0f6b3107ef48280d9a21f4cadba0128ee6eea82da",
"sha256:6823c2783c5da1bb0dc9759387db4f8ac266563f42fdb6709d6b15ca5a7e06b7",
"sha256:6cec4206aac402f5097ee5d3f620c0e596f541f4872e91b3c936174d5dcc2d01",
"sha256:6fb3d549a735c77470187d84d2087495be96cf86ce1dfc91ba3f69bf2abeded2",
"sha256:716584c6af2e849f219da87decda6ac52f786a388f704af9cadc94133dc054e2",
"sha256:71a6640278af8bc13c9272fdd2df90e62e2cdad8e864ffb04d1a8c7dda0131c1",
"sha256:759c879af24af5a4256e3675180b2d392bb7f009ccf1693fc12af66d38aadea7",
"sha256:77f5c0d57f381c76f143b951ac8e879d5ea57f7e51a3ecdd906a0d2cd3eb25e1",
"sha256:784077af7e4c35987132dfe8ee559041bdb07bde17c95cd507275b26cb5c2742",
"sha256:78f86db7b94891ba61adbe00d4cf6eeead51bac2fb0cf6b8ec7c954236fe1ca3",
"sha256:7b0d614275cef7457fb0c9ae5831b35361bbb826f09ceb7d289f78c69f8bad45",
"sha256:84f595bc1c1e8c7c784f52e6803540b8963df5ed561bfc2caa8b79111d59e987",
"sha256:86138e17f67fc959b609cf449ddcd3089acdb75d4b1c42a0d2b258128418f5a0",
"sha256:865f76463744169f44f49747ec3b01120a8c98c6d635fb12a5f5a86fcb0a462d",
"sha256:8e994e4e99980a87b73be43b27820d20ff823eb966abd01f2242b71c0723d685",
"sha256:90189695c019bf9ea1c701dea20f922bb6c11d0379ebfef4894e227bbac1f67c",
"sha256:906db7bbe127bc295a1482226b4ebf55d3f6442424ad5e64f2db164d03e794dd",
"sha256:9318befdd8b78c2c4743ab42a86c6b6783209716aa1c5260b3517c6eea8b934c",
"sha256:99eeb43a318558fe074f88ffaad4ac027ef2a02cdce4f81602a5fbb1332908d0",
"sha256:a01b02180c6cfb6103cb8a27f2bbd7e017c7e2b3c5e34894c614aa8e63ee17df",
"sha256:a2e557dedf2ca26a6ad6a6d41003177de113606256754f70f75d3e6902ae26e9",
"sha256:a56960e5e7c2972332879ca5b6bd6bb39df0e344cba3d3cf4513fa2dfce54a1f",
"sha256:aad7474fc4b35265bab66b54319d31a7aae948095f8b03ea883ea4997fac98e8",
"sha256:ad6256e8157505052e3b7733e4b0c6038244ff082715f3a2ad8289ccf7ba16ac",
"sha256:bdbfb7828f06f3f1a928abd3e4c67487167e2c85a5598194157edfc732f3a571",
"sha256:c899acff9404b004ca7ba06ff7e30fb38ddf527431d75a5af6cb1133365c9a40",
"sha256:df0233ebfab9fe6f9cd41f5931261f53deee0e0b2e0b0fc5a515dc54cf39d789",
"sha256:eb6c206ad5d3e9e33ed84495e9b1b87af37590256d3274ee62749ac43a5fe2b1",
"sha256:ebf010ad764c81f0f7f85777c9e6684f2bdac65ff113b88a98946826e7318df0",
"sha256:f2d58b020d8c71eb9456cb435f355ff0b578259c0cf5a79ca3efab386ebb71de",
"sha256:fab03d9a4107d46864a9797dfedb0645b2dd97dfb2b1ba582e471c0c4e692ebe",
"sha256:fc7ff2857fb3e9d865230f4226e57045daa9b09bdb21e4c6809f438e85f86fff"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==4.13.0"
"version": "==4.13.1"
},
"pymysql": {
"hashes": [
@ -800,6 +914,15 @@
"markers": "python_version >= '3.9'",
"version": "==0.36.2"
},
"requests": {
"hashes": [
"sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c",
"sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==2.32.4"
},
"rpds-py": {
"hashes": [
"sha256:0317177b1e8691ab5879f4f33f4b6dc55ad3b344399e23df2e499de7b10a548d",
@ -1018,6 +1141,14 @@
"markers": "python_version >= '2'",
"version": "==2025.2"
},
"urllib3": {
"hashes": [
"sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466",
"sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"
],
"markers": "python_version >= '3.9'",
"version": "==2.4.0"
},
"werkzeug": {
"hashes": [
"sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e",

View File

@ -5,6 +5,7 @@ from .extensions import init_mongo, init_jwt
from .errors.handlers import register_error_handlers
from .routes.usage_routes import usage_ns
from .routes.auth_routes import auth_ns
from .routes.billing_routes import billing_ns
from flask_cors import CORS
def create_app():
@ -35,6 +36,7 @@ def create_app():
api.add_namespace(usage_ns, path='/usage')
api.add_namespace(auth_ns, path='/auth')
api.add_namespace(billing_ns, path='/billing')
register_error_handlers(app)

View File

@ -23,6 +23,9 @@ class Config:
FRONTEND_URL = os.getenv("FRONTEND_URL", 3000)
BILLING_API_URL = os.getenv("BILLING_API_URL")
BILLING_API_TOKEN = os.getenv("BILLING_API_TOKEN")

View File

@ -0,0 +1,15 @@
from flask_restx import fields, Namespace
billing_ns = Namespace('billing', description='Authentication')
product_model = billing_ns.model('Product', {
'name': fields.String(required=True, description='Name of the product'),
'description': fields.String(required=True, description='Description of the product'),
'price': fields.Float(required=True, description='Price of the product'),
})
update_price_model = billing_ns.model('UpdatePrice', {
'price': fields.Float(required=False, description='New product price'),
'description': fields.String(required=False, description='New description of the product'),
'price': fields.Float(required=False, description='New price of the product'),
})

View File

@ -0,0 +1,41 @@
from flask_restx import Namespace, Resource, fields
from flask import request, current_app
import requests
from bson import json_util
from app.db.models import UserModel
from app.docs.biling_models import billing_ns, product_model, update_price_model
from app.config import Config
BILLING_API_URL = Config.BILLING_API_URL
HEADERS = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {Config.BILLING_API_TOKEN}'
}
@billing_ns.route('/product')
class CreateProduct(Resource):
@billing_ns.expect(product_model)
def post(self):
data = request.get_json()
response = requests.post(url=f'{BILLING_API_URL}/billing/product', json=data, headers=HEADERS)
return response.json(), response.status_code
@billing_ns.route('/product/<string:product_id>')
class UpdateProduct(Resource):
@billing_ns.expect(update_price_model)
def patch(self, product_id):
data = request.get_json()
response = requests.patch(url=f'{BILLING_API_URL}/billing/product/{product_id}', json=data, headers=HEADERS)
return response.json(), response.status_code
@billing_ns.route('/products')
class ListProducts(Resource):
def get(self):
response = requests.get(url=f'{BILLING_API_URL}/billing/products', headers=HEADERS)
return response.json(), response.status_code

View File

@ -10,6 +10,7 @@ import os
from typing import List, Dict, Any, Optional
from app.utils.mysql_query import execute_query
from app.utils.calc_api_usage import calculate_api_usage
import math
class TranscriptionReportService:
@ -24,8 +25,11 @@ class TranscriptionReportService:
def _fetch_mongo_data(self, page: int = 1, page_size: int = 20, all_data: Optional[bool] = False) -> Dict[str, int]:
collection = self.mongo_client["billing-api"]["api_pricings"]
result_stt = collection.find({"type": "stt"})
collection = self.mongo_client["billing-api"]["api_usages"]
# Define os produtos válidos a partir da tabela de preços
pricing_collection = self.mongo_client["billing-api"]["api_pricings"]
result_stt = pricing_collection.find({"product": {"$nin": ["whatsapp"]}})
products = [t["product"] for t in result_stt]
match_stage = {
@ -39,55 +43,129 @@ class TranscriptionReportService:
}
}
group_stage = {
lookup_stage = {
"$lookup": {
"from": "api_pricings",
"localField": "product",
"foreignField": "product",
"as": "pricing"
}
}
unwind_stage = {
"$unwind": "$pricing"
}
# Agrupa por sessionId + type + product
group_stage_1 = {
"$group": {
"_id": "$sessionId",
"count": {"$sum": 1},
"firstCreatedAt": {"$first": "$createdAt"},
"callerIds": {"$addToSet": "$callerId"},
"_id": {
"sessionId": "$sessionId",
"type": "$pricing.type",
"product": "$product"
},
"usage": {"$sum": {"$toDouble": "$usage"}},
"totalCost": {"$sum": {"$toDouble": "$total_cost"}},
"totalUsage": {"$sum": {"$toDouble": "$usage"}},
"price": {"$first": {"$toDouble": "$price"}},
"product": {"$first": "$product"}
"callerIds": {"$addToSet": "$callerId"},
"firstCreatedAt": {"$min": "$createdAt"}
}
}
# Agrupa por sessionId final, montando maps de uso e custo
group_stage_2 = {
"$group": {
"_id": "$_id.sessionId",
"count": {"$sum": 1},
"firstCreatedAt": {"$first": "$firstCreatedAt"},
"callerIds": {"$first": "$callerIds"},
"totalCost": {"$sum": "$totalCost"},
"usageByType": {
"$push": {
"k": "$_id.type",
"v": "$usage"
}
},
"costByType": {
"$push": {
"k": "$_id.type",
"v": "$totalCost"
}
},
"usageByProduct": {
"$push": {
"k": "$_id.product",
"v": "$usage"
}
}
}
}
project_stage = {
"$project": {
"count": 1,
"firstCreatedAt": 1,
"callerIds": 1,
"totalCost": 1,
"usageByType": {"$arrayToObject": "$usageByType"},
"costByType": {"$arrayToObject": "$costByType"},
"usageByProduct": {"$arrayToObject": "$usageByProduct"}
}
}
sort_stage = {"$sort": {"firstCreatedAt": 1}}
pipeline = [match_stage, group_stage, sort_stage]
# Monta o pipeline
pipeline = [
match_stage,
lookup_stage,
unwind_stage,
group_stage_1,
group_stage_2,
project_stage,
sort_stage
]
if not all_data:
# Aplica paginação se all_data for False
pipeline.extend([
{"$skip": (page - 1) * page_size},
{"$limit": page_size}
])
# Executa pipeline com ou sem paginação
collection = self.mongo_client["billing-api"]["api_usages"]
# Executa agregação principal
self.mongo_results = list(collection.aggregate(pipeline))
self.unique_ids = [doc["_id"] for doc in self.mongo_results]
# print("=====> mongoResults: ", self.mongo_results)
# print("=====> self.mongo_results: ", self.mongo_results)
# Sempre calcula o total (para controle)
# Pipeline para contagem total
count_pipeline = [
match_stage,
group_stage,
lookup_stage,
unwind_stage,
group_stage_1,
group_stage_2,
{"$count": "total"}
]
count_result = list(collection.aggregate(count_pipeline))
total = count_result[0]["total"] if count_result else 0
return {
return {
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size
}
}
def _fetch_mysql_data(self, hit_report: Optional[bool] = False)-> List[Dict[str, Any]]:
def _fetch_mysql_data(self, hit_report: Optional[bool] = False)-> List[Dict[str, Any]]:
collection = self.mongo_client["billing-api"]["api_products"]
products = list(collection.find({}))
sql = f"""SELECT
uniqueid,
src,
@ -108,57 +186,85 @@ class TranscriptionReportService:
rows = execute_query(self.company_id, sql)
if hit_report:
collection = self.mongo_client["billing-api"]["api_pricings"]
result_stt = collection.find({"type": "stt"})
# collection = self.mongo_client["billing-api"]["api_pricings"]
# result_stt = collection.find({"type": "stt"})
result_stt = [{"product": t["product"], "clientPrice": t["clientPrice"]} for t in result_stt if "clientPrice" in t]
# result_stt = [{"product": t["product"], "clientPrice": t["clientPrice"]} for t in result_stt if "clientPrice" in t]
for row in rows:
for row in rows:
row["companyId"] = self.company_id
if rowMongo := next((m for m in self.mongo_results if m["_id"] == row["uniqueid"] ), None):
row["custo_HIT"] = rowMongo["totalCost"]
row["price"] = rowMongo["price"]
p = [p for p in result_stt if p['product'] == rowMongo["product"]]
if len(p) > 0:
row["client_price"] = p[0]["clientPrice"]
# row["client_total_cost"] = calculate_api_usage(float(p[0]["clientPrice"]), 60, float(rowMongo["totalUsage"]))
row["custo_hit"] = f"{float(rowMongo["totalCost"])}"
row["qtd_token_input"] = rowMongo.get('usageByType', {}).get('input', 0)
row["qtd_token_output"] = rowMongo.get('usageByType', {}).get('output', 0)
for key in row:
if isinstance(row[key], datetime):
row[key] = row[key].isoformat(sep=' ')
elif isinstance(row[key], Decimal):
row[key] = float(row[key])
elif key == "uniqueid":
row[key] = str(row[key])
self.client_price_row(products, row)
self.formate_properties(row)
else:
for row in rows:
row["total_min"] = f"{(int(row['total_billsec']) / 60):.2f}"
del row["end_call"]
for key in row:
if isinstance(row[key], datetime):
row[key] = row[key].isoformat(sep=' ')
elif isinstance(row[key], Decimal):
row[key] = float(row[key])
elif key == "uniqueid":
row[key] = str(row[key])
self.client_price_row(products, row)
self.formate_properties(row)
return rows
def formate_properties(self, row):
for key in row:
if isinstance(row[key], datetime):
row[key] = row[key].isoformat(sep=' ')
elif isinstance(row[key], Decimal):
row[key] = float(row[key])
elif key == "uniqueid":
row[key] = str(row[key])
def client_price_row(self, products, row):
if products and len(products) > 0 and products[0]["priceHistory"]:
matched_period = None
last_period = None
for period in products[0]["priceHistory"]:
last_period = period
start = period['startDate'].date()
end = period['endDate'].date() if period['endDate'] else None
start_call = row['start_call'].date()
if end:
if start <= start_call <= end:
matched_period = period
break
else:
if start_call >= start:
matched_period = period
break
f"{(int(row['total_billsec']) / 60):.2f}"
if matched_period:
row['client_total_cost'] = f"{((int(row['total_billsec']) / 60) * matched_period['price'])}"
row["client_price"] = matched_period['price']
else:
row['client_total_cost'] = f"{((int(row['total_billsec']) / 60) * last_period['price'])}"
row["client_price"] = last_period['price']
def _create_excel(self, data: list, hit_report: Optional[bool] = False) -> str:
if hit_report:
header_mapping = {
"companyId": "companyId",
"uniqueid": "sessionId",
"total_billsec": "tempo (billsec)",
"custo_HIT": "custo_HIT",
"price": "tarifa ($/min)",
"client_price": "valor_cobrado"
"companyId": "Empresa",
"uniqueid": "Identificador da chamada",
"src": "Origem",
"dst": "Destino",
"total_billsec": "Quantidade de segundos",
"custo_hit": "Custo HIT",
"qtd_token_input": "Quantidade de tokens(input)",
"qtd_token_output": "Quantidade de tokens(output)",
"client_total_cost": "Custo Cliente",
"client_price": "Preço Cliente por Minuto",
"start_call": "Inicio",
"end_call": "Fim"
}
else:
header_mapping = {
@ -167,7 +273,8 @@ class TranscriptionReportService:
"dst": "Destino",
"start_call": "Inicio da Chamada",
"total_billsec": "Duração (Em segundos)",
"total_min": "Duração (Em minutos)"
"total_min": "Duração (Em minutos)",
"client_total_cost": "Custo Cliente",
}
# Filtrar e ordenar os dados conforme header_mapping

41
backend/test.py 100644
View File

@ -0,0 +1,41 @@
from datetime import datetime
costs = [
{"name": 'test', "date": datetime(2025, 6, 22, 17), "value": 10},
{"name": 'test2', "date": datetime(2025, 7, 10, 17), "value": 11},
{"name": 'test3', "date": datetime(2025, 8, 10, 17), "value": 12}
]
periods = [
{
'startDate': datetime(2025, 6, 9, 17, 18, 25, 356000),
'endDate': datetime(2025, 7, 10, 17, 18, 25, 356000),
'price': 0.05
},
{
'startDate': datetime(2025, 8, 11, 17, 18, 25, 356000),
'endDate': None,
'price': 0.06
}
]
# Verificar se cada cost está dentro de algum período
for cost in costs:
cost_date = cost['date']
cost_value = cost["value"]
matched_period = None
for period in periods:
start = period['startDate']
end = period['endDate']
if end:
if start <= cost_date <= end:
matched_period = period
break
else:
if cost_date >= start:
matched_period = period
break
if matched_period:
print(f"{cost['name']} está dentro da vigência: preço = {matched_period['price']} | value * cost_value: {cost_value * matched_period['price']}")

View File

@ -9,6 +9,7 @@ import { LogOut } from "lucide-react"
import TranscriptionTable from "@/components/transcription-table"
import ModelPricesTable from "@/components/model-prices-table"
import CostUpdateForm from "@/components/cost-update-form"
import ProductManagement from "@/components/product-management"
export default function Dashboard() {
const [isAuthenticated, setIsAuthenticated] = useState(false)
@ -61,7 +62,8 @@ export default function Dashboard() {
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="transcription">Dados de Transcrição</TabsTrigger>
<TabsTrigger value="models">Preços dos Modelos</TabsTrigger>
<TabsTrigger value="costs">Atualizar Custos</TabsTrigger>
{/* <TabsTrigger value="costs">Atualizar Custos</TabsTrigger> */}
<TabsTrigger value="products">Produtos</TabsTrigger>
</TabsList>
<TabsContent value="transcription">
@ -91,14 +93,26 @@ export default function Dashboard() {
<TabsContent value="costs">
<Card>
<CardHeader>
<CardTitle>Atualizar Custos Consumidos</CardTitle>
<CardDescription>Atualize os custos de uso do produto consumido por período</CardDescription>
<CardTitle>Atualizar Custos</CardTitle>
<CardDescription>Atualize os custos de uso dos produtos</CardDescription>
</CardHeader>
<CardContent>
<CostUpdateForm />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="products">
<Card>
<CardHeader>
<CardTitle>Gerenciar Produtos</CardTitle>
<CardDescription>Crie, edite e gerencie produtos e seus preços</CardDescription>
</CardHeader>
<CardContent>
<ProductManagement />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</main>
</div>

View File

@ -24,8 +24,7 @@ interface ModelPrice {
billingBy: string
billingUnit: number
currency: string
price: string
clientPrice?: string // Campo opcional que pode não vir da API
price: string
createdAt: {
$date: string
}
@ -53,8 +52,7 @@ export default function ModelPricesTable() {
type: string
billingBy: string
billingUnit: string
currency: string
clientPrice: string
currency: string
price: string
}>({
product: "",
@ -62,14 +60,13 @@ export default function ModelPricesTable() {
type: "",
billingBy: "",
billingUnit: "",
currency: "",
clientPrice: "",
currency: "",
price: "",
})
const [isSaving, setIsSaving] = useState(false)
// Filtros
const [typeFilter, setTypeFilter] = useState("stt")
const [typeFilter, setTypeFilter] = useState("")
const [providerFilter, setProviderFilter] = useState("")
const getAuthHeaders = () => {
@ -123,8 +120,7 @@ export default function ModelPricesTable() {
type: item.type,
billingBy: item.billingBy,
billingUnit: item.billingUnit.toString(),
currency: item.currency,
clientPrice: item.clientPrice || "",
currency: item.currency,
price: item.price,
})
}
@ -137,8 +133,7 @@ export default function ModelPricesTable() {
type: "",
billingBy: "",
billingUnit: "",
currency: "",
clientPrice: "",
currency: "",
price: "",
})
}
@ -158,8 +153,7 @@ export default function ModelPricesTable() {
type: editValues.type,
billingBy: editValues.billingBy,
billingUnit: Number.parseInt(editValues.billingUnit),
currency: editValues.currency,
clientPrice: editValues.clientPrice,
currency: editValues.currency,
price: editValues.price,
}),
})
@ -173,8 +167,7 @@ export default function ModelPricesTable() {
type: "",
billingBy: "",
billingUnit: "",
currency: "",
clientPrice: "",
currency: "",
price: "",
})
} else {
@ -194,7 +187,7 @@ export default function ModelPricesTable() {
}, [])
// Opções para os selects
const typeOptions = ["stt", "tts", "llm", "embedding", "vision"]
const typeOptions = ["stt", "tts", "llm", "embedding", "input", "output", "vision"]
const providerOptions = ["openai", "aws", "google", "anthropic", "mistral", "azure"]
const currencyOptions = ["dollar", "real", "euro"]
const billingByOptions = ["second", "minute", "character", "token", "image", "request"]
@ -224,7 +217,7 @@ export default function ModelPricesTable() {
id="providerFilter"
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
placeholder="openai,aws"
placeholder="openai,anthropic"
/>
</div>
@ -263,8 +256,7 @@ export default function ModelPricesTable() {
<TableHead>Cobrança Por</TableHead>
<TableHead>Unidade</TableHead>
<TableHead>Moeda</TableHead>
<TableHead>Preço</TableHead>
<TableHead>Preço Cliente</TableHead>
<TableHead>Preço</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
@ -395,22 +387,7 @@ export default function ModelPricesTable() {
`$${item.price}`
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
type="number"
step="0.0001"
value={editValues.clientPrice}
onChange={(e) => setEditValues((prev) => ({ ...prev, clientPrice: e.target.value }))}
className="w-24"
placeholder="0.000"
/>
) : item.clientPrice ? (
`$${item.clientPrice}`
) : (
"-"
)}
</TableCell>
<TableCell>
{isEditing ? (
<div className="flex gap-2">

View File

@ -0,0 +1,543 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Textarea } from "@/components/ui/textarea"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Edit2, Plus, Save, X, Loader2, RefreshCw, History } from "lucide-react"
// Usar as variáveis de ambiente para as URLs
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api/v1"
interface PriceHistory {
startDate: string
endDate: string | null
price: number
}
interface Product {
_id: string
name: string
description: string
priceHistory: PriceHistory[]
createdAt: string
updatedAt: string
__v: number
}
interface ProductsResponse {
msg: string
products: Product[]
}
interface CreateProductRequest {
name: string
description: string
price: number
}
interface UpdateProductRequest {
name?: string
description?: string
price?: number
}
export default function ProductManagement() {
const [products, setProducts] = useState<Product[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
// Estados para criação de produto
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [createForm, setCreateForm] = useState<CreateProductRequest>({
name: "",
description: "",
price: 0,
})
// Estados para edição de produto
const [editingId, setEditingId] = useState<string | null>(null)
const [editForm, setEditForm] = useState<UpdateProductRequest>({
name: "",
description: "",
price: 0,
})
const [isUpdating, setIsUpdating] = useState(false)
// Estados para histórico de preços
const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
const getAuthHeaders = () => {
const token = localStorage.getItem("access_token")
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}
}
const fetchProducts = async () => {
setIsLoading(true)
setError("")
try {
const response = await fetch(`${API_BASE_URL}/billing/products`, {
method: "GET",
headers: getAuthHeaders(),
})
if (response.ok) {
const result: ProductsResponse = await response.json()
setProducts(result.products || [])
} else {
const errorData = await response.json()
// Para erro 400, exibir a mensagem específica da API
if (response.status === 400 && errorData.msg) {
setError(errorData.msg)
} else {
setError(errorData.message || errorData.msg || "Erro ao buscar produtos")
}
}
} catch (err) {
setError("Erro de conexão com o servidor")
} finally {
setIsLoading(false)
}
}
const createProduct = async () => {
if (!createForm.name || !createForm.description || createForm.price <= 0) {
setError("Preencha todos os campos corretamente")
return
}
setIsCreating(true)
setError("")
setSuccess("")
try {
const response = await fetch(`${API_BASE_URL}/billing/product`, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify(createForm),
})
if (response.ok) {
const result = await response.json()
setSuccess("Produto criado com sucesso!")
setCreateForm({ name: "", description: "", price: 0 })
setIsCreateDialogOpen(false)
await fetchProducts()
} else {
const errorData = await response.json()
// Para erro 400, exibir a mensagem específica da API
if (response.status === 400 && errorData.msg) {
setError(errorData.msg)
} else {
setError(errorData.message || errorData.msg || "Erro ao criar produto")
}
}
} catch (err) {
setError("Erro de conexão com o servidor")
} finally {
setIsCreating(false)
}
}
const updateProduct = async (productId: string) => {
// Verificar se há pelo menos um campo para atualizar
if (Object.keys(editForm).length === 0) {
setError("Nenhuma alteração para salvar")
return
}
// Se o preço estiver definido, verificar se é válido
if (editForm.price !== undefined && (isNaN(editForm.price) || editForm.price <= 0)) {
setError("Digite um preço válido")
return
}
setIsUpdating(true)
setError("")
setSuccess("")
try {
const response = await fetch(`${API_BASE_URL}/billing/product/${productId}`, {
method: "PATCH",
headers: getAuthHeaders(),
body: JSON.stringify(editForm),
})
if (response.ok) {
const result = await response.json()
setSuccess("Produto atualizado com sucesso!")
setEditingId(null)
setEditForm({})
await fetchProducts()
} else {
const errorData = await response.json()
// Para erro 400, exibir a mensagem específica da API
if (response.status === 400 && errorData.msg) {
setError(errorData.msg)
} else {
setError(errorData.message || errorData.msg || "Erro ao atualizar produto")
}
}
} catch (err) {
setError("Erro de conexão com o servidor")
} finally {
setIsUpdating(false)
}
}
const startEdit = (product: Product) => {
setEditingId(product._id)
const currentPrice = getCurrentPrice(product)
setEditForm({
name: product.name,
description: product.description,
price: currentPrice,
})
}
const cancelEdit = () => {
setEditingId(null)
setEditForm({})
}
const getCurrentPrice = (product: Product): number => {
return product.priceHistory.find((p) => p.endDate === null)?.price || 0
}
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleString("pt-BR")
}
const formatDateCompact = (dateString: string): string => {
const date = new Date(dateString)
return `${date.getDate().toString().padStart(2, "0")}/${(date.getMonth() + 1).toString().padStart(2, "0")}/${date.getFullYear()}, ${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`
}
const showHistory = (product: Product) => {
setSelectedProduct(product)
setHistoryDialogOpen(true)
}
useEffect(() => {
fetchProducts()
}, [])
return (
<div className="space-y-6">
{/* Cabeçalho com botão de criar */}
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Produtos Cadastrados</h3>
<p className="text-sm text-gray-500">Gerencie produtos e seus preços</p>
</div>
<div className="flex gap-2">
<Button onClick={fetchProducts} disabled={isLoading} variant="outline">
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
Atualizar
</Button>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Novo Produto
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Criar Novo Produto</DialogTitle>
<DialogDescription>Preencha as informações do novo produto</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nome do Produto *</Label>
<Input
id="name"
value={createForm.name}
onChange={(e) => setCreateForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Ex: Produto HIT"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Descrição *</Label>
<Textarea
id="description"
value={createForm.description}
onChange={(e) => setCreateForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Descreva o produto..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="price">Preço Inicial *</Label>
<Input
id="price"
type="number"
step="0.001"
value={createForm.price}
onChange={(e) =>
setCreateForm((prev) => ({ ...prev, price: Number.parseFloat(e.target.value) || 0 }))
}
placeholder="0.060"
/>
</div>
<div className="flex gap-2 pt-4">
<Button onClick={createProduct} disabled={isCreating} className="flex-1">
{isCreating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
Criar Produto
</Button>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} disabled={isCreating}>
Cancelar
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>{success}</AlertDescription>
</Alert>
)}
{/* Tabela de Produtos */}
<Card>
<CardHeader>
<CardTitle>Lista de Produtos ({products.length} produtos)</CardTitle>
</CardHeader>
<CardContent>
{products.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Descrição</TableHead>
<TableHead>Preço Atual</TableHead>
<TableHead>Criado em</TableHead>
<TableHead>Atualizado em</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product._id}>
<TableCell>
{editingId === product._id ? (
<Input
value={editForm.name}
onChange={(e) => setEditForm((prev) => ({ ...prev, name: e.target.value }))}
/>
) : (
<div className="font-medium">{product.name}</div>
)}
</TableCell>
<TableCell>
{editingId === product._id ? (
<Textarea
value={editForm.description}
onChange={(e) => setEditForm((prev) => ({ ...prev, description: e.target.value }))}
rows={2}
/>
) : (
<div className="max-w-xs truncate" title={product.description}>
{product.description}
</div>
)}
</TableCell>
<TableCell>
{editingId === product._id ? (
<Input
type="number"
step="0.001"
value={editForm.price}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, price: Number.parseFloat(e.target.value) || 0 }))
}
className="w-24"
/>
) : (
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className="cursor-pointer hover:bg-gray-200 transition-colors"
onClick={() => showHistory(product)}
title="Clique para ver o histórico de preços"
>
${getCurrentPrice(product)}
</Badge>
{product.priceHistory && product.priceHistory.length > 1 && (
<Badge variant="outline" className="text-xs">
{product.priceHistory.length} alterações
</Badge>
)}
</div>
)}
</TableCell>
<TableCell>{formatDate(product.createdAt)}</TableCell>
<TableCell>{formatDate(product.updatedAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
{editingId === product._id ? (
<>
<Button size="sm" onClick={() => updateProduct(product._id)} disabled={isUpdating}>
{isUpdating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</Button>
<Button size="sm" variant="outline" onClick={cancelEdit} disabled={isUpdating}>
<X className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button size="sm" variant="outline" onClick={() => startEdit(product)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={() => showHistory(product)}>
<History className="h-4 w-4" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
{isLoading ? "Carregando..." : "Nenhum produto encontrado. Crie seu primeiro produto!"}
</div>
)}
</CardContent>
</Card>
{/* Dialog de Histórico de Preços - Ajustado para caber na tela */}
<Dialog open={historyDialogOpen} onOpenChange={setHistoryDialogOpen}>
<DialogContent className="w-[95%] max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Histórico de Preços - {selectedProduct?.name}</DialogTitle>
<DialogDescription>
Visualize todas as alterações de preço deste produto ({selectedProduct?.priceHistory?.length || 0}{" "}
registros)
</DialogDescription>
</DialogHeader>
{selectedProduct && selectedProduct.priceHistory && selectedProduct.priceHistory.length > 0 ? (
<div className="space-y-4">
<div className="overflow-x-auto">
<Table className="w-full">
<TableHeader>
<TableRow>
<TableHead>Data Início</TableHead>
<TableHead>Data Fim</TableHead>
<TableHead>Preço</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duração</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedProduct.priceHistory
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())
.map((history, index) => {
const startDate = new Date(history.startDate)
const endDate = history.endDate ? new Date(history.endDate) : null
const duration = endDate
? Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24))
: Math.ceil((new Date().getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24))
return (
<TableRow key={index} className={history.endDate === null ? "bg-blue-50" : ""}>
<TableCell className="text-sm">{formatDateCompact(history.startDate)}</TableCell>
<TableCell className="text-sm">
{history.endDate ? formatDateCompact(history.endDate) : "Em vigor"}
</TableCell>
<TableCell>
<Badge variant={history.endDate === null ? "default" : "secondary"}>
${history.price}
</Badge>
</TableCell>
<TableCell>
<Badge variant={history.endDate === null ? "default" : "outline"}>
{history.endDate === null ? "Atual" : "Histórico"}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600">
{duration} {duration === 1 ? "dia" : "dias"}
{history.endDate === null && " (em vigor)"}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* Resumo do histórico - Simplificado para economizar espaço */}
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<h4 className="font-medium text-sm text-gray-700 mb-2">Resumo do Histórico</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-gray-500">Total de alterações:</span>
<div className="font-medium">{selectedProduct.priceHistory.length}</div>
</div>
<div>
<span className="text-gray-500">Preço inicial:</span>
<div className="font-medium">
$
{selectedProduct.priceHistory
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())[0]
?.price.toFixed(3)}
</div>
</div>
<div>
<span className="text-gray-500">Preço atual:</span>
<div className="font-medium">${getCurrentPrice(selectedProduct)}</div>
</div>
<div>
<span className="text-gray-500">Produto criado em:</span>
<div className="font-medium">{formatDateCompact(selectedProduct.createdAt)}</div>
</div>
</div>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
Nenhum histórico de preços encontrado para este produto.
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -22,19 +22,22 @@ interface ClientTranscriptionData {
start_call: string
total_billsec: number
total_min: string
client_total_cost: string
}
interface HitTranscriptionData {
uniqueid: string
src: string
dst: string
start_call: string
end_call: string
total_billsec: number
companyId: string
custo_HIT: number
price: number
client_price: string
companyId: string // Empresa
uniqueid: string // Identificador da chamada
src: string // Origem
dst: string // Destino
total_billsec: number // Quantidade de segundos
qtd_token_input: number // Quantidade de tokens(input)
qtd_token_output: number // Quantidade de tokens(output)
custo_hit: string // Custo HIT
client_total_cost: string // Custo Cliente
client_price: string // Preço Cliente por Minuto
start_call: string // Inicio
end_call: string // Fim
}
type TranscriptionData = ClientTranscriptionData | HitTranscriptionData
@ -239,7 +242,7 @@ export default function TranscriptionTable() {
<Label htmlFor="pageSize">Registros por página</Label>
<Select
value={pageSize.toString()}
onValueChange={(value) => {
onValueChange={(value:any) => {
setPageSize(Number(value))
setCurrentPage(1)
}}
@ -299,53 +302,65 @@ export default function TranscriptionTable() {
<Table>
<TableHeader>
<TableRow>
<TableHead>ID Único</TableHead>
<TableHead>Origem</TableHead>
<TableHead>Destino</TableHead>
<TableHead>Início</TableHead>
{who === "hit" && <TableHead>Fim</TableHead>}
<TableHead>Duração (s)</TableHead>
{who === "client" ? (
<TableHead>Total (min)</TableHead>
) : (
{who === "hit" ? (
<>
<TableHead>Empresa</TableHead>
<TableHead>Identificador da chamada</TableHead>
<TableHead>Origem</TableHead>
<TableHead>Destino</TableHead>
<TableHead>Quantidade de segundos</TableHead>
<TableHead>Quantidade de tokens (input)</TableHead>
<TableHead>Quantidade de tokens (output)</TableHead>
<TableHead>Custo HIT</TableHead>
<TableHead>Preço</TableHead>
<TableHead>Preço Cliente</TableHead>
<TableHead>Custo Cliente</TableHead>
<TableHead>Preço Cliente por Minuto</TableHead>
<TableHead>Início</TableHead>
<TableHead>Fim</TableHead>
</>
) : (
<>
<TableHead>ID Único</TableHead>
<TableHead>Origem</TableHead>
<TableHead>Destino</TableHead>
<TableHead>Início</TableHead>
<TableHead>Duração (s)</TableHead>
<TableHead>Total (min)</TableHead>
<TableHead>Custo Cliente</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
{data.map((item, index) => (
<TableRow key={item.uniqueid || index}>
<TableCell>{item.uniqueid || "-"}</TableCell>
<TableCell>{item.src || "-"}</TableCell>
<TableCell>{item.dst || "-"}</TableCell>
<TableCell>{item.start_call || "-"}</TableCell>
{who === "hit" && <TableCell>{(item as HitTranscriptionData).end_call || "-"}</TableCell>}
<TableCell>{item.total_billsec || "-"}</TableCell>
{who === "client" ? (
<TableCell>{(item as ClientTranscriptionData).total_min || "-"}</TableCell>
) : (
<TableRow key={(item as any).uniqueid || index}>
{who === "hit" ? (
<>
<TableCell>{(item as HitTranscriptionData).companyId || "-"}</TableCell>
<TableCell>{(item as HitTranscriptionData).uniqueid || "-"}</TableCell>
<TableCell>{(item as HitTranscriptionData).src || "-"}</TableCell>
<TableCell>{(item as HitTranscriptionData).dst || "-"}</TableCell>
<TableCell>{(item as HitTranscriptionData).total_billsec || "-"}</TableCell>
<TableCell>{(item as HitTranscriptionData).qtd_token_input || "-"}</TableCell>
<TableCell>{(item as HitTranscriptionData).qtd_token_output || "-"}</TableCell>
<TableCell>
{(item as HitTranscriptionData).custo_HIT
? `$${(item as HitTranscriptionData).custo_HIT.toFixed(6)}`
: "-"}
{(item as HitTranscriptionData).custo_hit ? `$${(item as HitTranscriptionData).custo_hit}` : "-"}
</TableCell>
<TableCell>{(item as HitTranscriptionData).client_total_cost || "-"}</TableCell>
<TableCell>
{(item as HitTranscriptionData).price
? `$${(item as HitTranscriptionData).price.toFixed(4)}`
: "-"}
</TableCell>
<TableCell>
{(item as HitTranscriptionData).client_price
? `$${(item as HitTranscriptionData).client_price}`
: "-"}
{(item as HitTranscriptionData).client_price ? `$${(item as HitTranscriptionData).client_price}` : "-"}
</TableCell>
<TableCell>{(item as HitTranscriptionData).start_call || "-"}</TableCell>
<TableCell>{(item as HitTranscriptionData).end_call || "-"}</TableCell>
</>
) : (
<>
<TableCell>{(item as ClientTranscriptionData).uniqueid || "-"}</TableCell>
<TableCell>{(item as ClientTranscriptionData).src || "-"}</TableCell>
<TableCell>{(item as ClientTranscriptionData).dst || "-"}</TableCell>
<TableCell>{(item as ClientTranscriptionData).start_call || "-"}</TableCell>
<TableCell>{(item as ClientTranscriptionData).total_billsec || "-"}</TableCell>
<TableCell>{(item as ClientTranscriptionData).total_min || "-"}</TableCell>
<TableCell>{(item as ClientTranscriptionData).client_total_cost || "-"}</TableCell>
</>
)}
</TableRow>

View File

@ -0,0 +1,97 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "transcriptio-usage-frontend",
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
@ -9,17 +9,18 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.513.0",
"lucide-react": "^0.514.0",
"next": "15.2.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.0"
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",