diff --git a/Captura de tela 2025-10-07 133523.png b/Captura de tela 2025-10-07 133523.png deleted file mode 100644 index 813dbe5..0000000 Binary files a/Captura de tela 2025-10-07 133523.png and /dev/null differ diff --git a/Captura de tela 2025-10-07 150744.png b/Captura de tela 2025-10-07 150744.png new file mode 100644 index 0000000..2828187 Binary files /dev/null and b/Captura de tela 2025-10-07 150744.png differ diff --git a/Inter,Manrope.zip b/Inter,Manrope.zip new file mode 100644 index 0000000..813604f Binary files /dev/null and b/Inter,Manrope.zip differ diff --git a/Inter,Manrope/Inter/Inter-Italic-VariableFont_opsz,wght.ttf b/Inter,Manrope/Inter/Inter-Italic-VariableFont_opsz,wght.ttf new file mode 100644 index 0000000..43ed4f5 Binary files /dev/null and b/Inter,Manrope/Inter/Inter-Italic-VariableFont_opsz,wght.ttf differ diff --git a/Inter,Manrope/Inter/Inter-Italic-VariableFont_opsz,wght.ttf:Zone.Identifier b/Inter,Manrope/Inter/Inter-Italic-VariableFont_opsz,wght.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/Inter-Italic-VariableFont_opsz,wght.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/Inter-VariableFont_opsz,wght.ttf b/Inter,Manrope/Inter/Inter-VariableFont_opsz,wght.ttf new file mode 100644 index 0000000..e31b51e Binary files /dev/null and b/Inter,Manrope/Inter/Inter-VariableFont_opsz,wght.ttf differ diff --git a/Inter,Manrope/Inter/Inter-VariableFont_opsz,wght.ttf:Zone.Identifier b/Inter,Manrope/Inter/Inter-VariableFont_opsz,wght.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/Inter-VariableFont_opsz,wght.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/OFL.txt b/Inter,Manrope/Inter/OFL.txt new file mode 100644 index 0000000..d05ec4b --- /dev/null +++ b/Inter,Manrope/Inter/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +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. diff --git a/Inter,Manrope/Inter/OFL.txt:Zone.Identifier b/Inter,Manrope/Inter/OFL.txt:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/OFL.txt:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/README.txt b/Inter,Manrope/Inter/README.txt new file mode 100644 index 0000000..b92a417 --- /dev/null +++ b/Inter,Manrope/Inter/README.txt @@ -0,0 +1,118 @@ +Inter Variable Font +=================== + +This download contains Inter as both variable fonts and static fonts. + +Inter is a variable font with these axes: + opsz + wght + +This means all the styles are contained in these files: + Inter/Inter-VariableFont_opsz,wght.ttf + Inter/Inter-Italic-VariableFont_opsz,wght.ttf + +If your app fully supports variable fonts, you can now pick intermediate styles +that aren’t available as static fonts. Not all apps support variable fonts, and +in those cases you can use the static font files for Inter: + Inter/static/Inter_18pt-Thin.ttf + Inter/static/Inter_18pt-ExtraLight.ttf + Inter/static/Inter_18pt-Light.ttf + Inter/static/Inter_18pt-Regular.ttf + Inter/static/Inter_18pt-Medium.ttf + Inter/static/Inter_18pt-SemiBold.ttf + Inter/static/Inter_18pt-Bold.ttf + Inter/static/Inter_18pt-ExtraBold.ttf + Inter/static/Inter_18pt-Black.ttf + Inter/static/Inter_24pt-Thin.ttf + Inter/static/Inter_24pt-ExtraLight.ttf + Inter/static/Inter_24pt-Light.ttf + Inter/static/Inter_24pt-Regular.ttf + Inter/static/Inter_24pt-Medium.ttf + Inter/static/Inter_24pt-SemiBold.ttf + Inter/static/Inter_24pt-Bold.ttf + Inter/static/Inter_24pt-ExtraBold.ttf + Inter/static/Inter_24pt-Black.ttf + Inter/static/Inter_28pt-Thin.ttf + Inter/static/Inter_28pt-ExtraLight.ttf + Inter/static/Inter_28pt-Light.ttf + Inter/static/Inter_28pt-Regular.ttf + Inter/static/Inter_28pt-Medium.ttf + Inter/static/Inter_28pt-SemiBold.ttf + Inter/static/Inter_28pt-Bold.ttf + Inter/static/Inter_28pt-ExtraBold.ttf + Inter/static/Inter_28pt-Black.ttf + Inter/static/Inter_18pt-ThinItalic.ttf + Inter/static/Inter_18pt-ExtraLightItalic.ttf + Inter/static/Inter_18pt-LightItalic.ttf + Inter/static/Inter_18pt-Italic.ttf + Inter/static/Inter_18pt-MediumItalic.ttf + Inter/static/Inter_18pt-SemiBoldItalic.ttf + Inter/static/Inter_18pt-BoldItalic.ttf + Inter/static/Inter_18pt-ExtraBoldItalic.ttf + Inter/static/Inter_18pt-BlackItalic.ttf + Inter/static/Inter_24pt-ThinItalic.ttf + Inter/static/Inter_24pt-ExtraLightItalic.ttf + Inter/static/Inter_24pt-LightItalic.ttf + Inter/static/Inter_24pt-Italic.ttf + Inter/static/Inter_24pt-MediumItalic.ttf + Inter/static/Inter_24pt-SemiBoldItalic.ttf + Inter/static/Inter_24pt-BoldItalic.ttf + Inter/static/Inter_24pt-ExtraBoldItalic.ttf + Inter/static/Inter_24pt-BlackItalic.ttf + Inter/static/Inter_28pt-ThinItalic.ttf + Inter/static/Inter_28pt-ExtraLightItalic.ttf + Inter/static/Inter_28pt-LightItalic.ttf + Inter/static/Inter_28pt-Italic.ttf + Inter/static/Inter_28pt-MediumItalic.ttf + Inter/static/Inter_28pt-SemiBoldItalic.ttf + Inter/static/Inter_28pt-BoldItalic.ttf + Inter/static/Inter_28pt-ExtraBoldItalic.ttf + Inter/static/Inter_28pt-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. diff --git a/Inter,Manrope/Inter/README.txt:Zone.Identifier b/Inter,Manrope/Inter/README.txt:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/README.txt:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Black.ttf b/Inter,Manrope/Inter/static/Inter_18pt-Black.ttf new file mode 100644 index 0000000..89673de Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Black.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Black.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-Black.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Black.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-BlackItalic.ttf b/Inter,Manrope/Inter/static/Inter_18pt-BlackItalic.ttf new file mode 100644 index 0000000..b33602f Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-BlackItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-BlackItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-BlackItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-BlackItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Bold.ttf b/Inter,Manrope/Inter/static/Inter_18pt-Bold.ttf new file mode 100644 index 0000000..57704d1 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Bold.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Bold.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-Bold.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Bold.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-BoldItalic.ttf b/Inter,Manrope/Inter/static/Inter_18pt-BoldItalic.ttf new file mode 100644 index 0000000..d53a199 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-BoldItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-BoldItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-BoldItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-BoldItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-ExtraBold.ttf b/Inter,Manrope/Inter/static/Inter_18pt-ExtraBold.ttf new file mode 100644 index 0000000..e71c601 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-ExtraBold.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-ExtraBold.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-ExtraBold.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-ExtraBold.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-ExtraBoldItalic.ttf b/Inter,Manrope/Inter/static/Inter_18pt-ExtraBoldItalic.ttf new file mode 100644 index 0000000..df45062 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-ExtraBoldItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-ExtraBoldItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-ExtraBoldItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-ExtraBoldItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-ExtraLight.ttf b/Inter,Manrope/Inter/static/Inter_18pt-ExtraLight.ttf new file mode 100644 index 0000000..f9c6cfc Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-ExtraLight.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-ExtraLight.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-ExtraLight.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-ExtraLight.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-ExtraLightItalic.ttf b/Inter,Manrope/Inter/static/Inter_18pt-ExtraLightItalic.ttf new file mode 100644 index 0000000..275f305 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-ExtraLightItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-ExtraLightItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-ExtraLightItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-ExtraLightItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Italic.ttf b/Inter,Manrope/Inter/static/Inter_18pt-Italic.ttf new file mode 100644 index 0000000..14d3595 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Italic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Italic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-Italic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Italic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Light.ttf b/Inter,Manrope/Inter/static/Inter_18pt-Light.ttf new file mode 100644 index 0000000..acae361 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Light.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Light.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-Light.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Light.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-LightItalic.ttf b/Inter,Manrope/Inter/static/Inter_18pt-LightItalic.ttf new file mode 100644 index 0000000..f69e18b Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-LightItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-LightItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-LightItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-LightItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Medium.ttf b/Inter,Manrope/Inter/static/Inter_18pt-Medium.ttf new file mode 100644 index 0000000..71d9017 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Medium.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Medium.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-Medium.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Medium.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-MediumItalic.ttf b/Inter,Manrope/Inter/static/Inter_18pt-MediumItalic.ttf new file mode 100644 index 0000000..5c8c8b1 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-MediumItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-MediumItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-MediumItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-MediumItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Regular.ttf b/Inter,Manrope/Inter/static/Inter_18pt-Regular.ttf new file mode 100644 index 0000000..ce097c8 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Regular.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Regular.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-Regular.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Regular.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-SemiBold.ttf b/Inter,Manrope/Inter/static/Inter_18pt-SemiBold.ttf new file mode 100644 index 0000000..053185e Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-SemiBold.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-SemiBold.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-SemiBold.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-SemiBold.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-SemiBoldItalic.ttf b/Inter,Manrope/Inter/static/Inter_18pt-SemiBoldItalic.ttf new file mode 100644 index 0000000..d9c9896 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-SemiBoldItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-SemiBoldItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-SemiBoldItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-SemiBoldItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Thin.ttf b/Inter,Manrope/Inter/static/Inter_18pt-Thin.ttf new file mode 100644 index 0000000..e68ec47 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Thin.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-Thin.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-Thin.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-Thin.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-ThinItalic.ttf b/Inter,Manrope/Inter/static/Inter_18pt-ThinItalic.ttf new file mode 100644 index 0000000..134e837 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-ThinItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_18pt-ThinItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_18pt-ThinItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_18pt-ThinItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Black.ttf b/Inter,Manrope/Inter/static/Inter_24pt-Black.ttf new file mode 100644 index 0000000..dbb1b3b Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Black.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Black.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-Black.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Black.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-BlackItalic.ttf b/Inter,Manrope/Inter/static/Inter_24pt-BlackItalic.ttf new file mode 100644 index 0000000..b89d61c Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-BlackItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-BlackItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-BlackItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-BlackItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Bold.ttf b/Inter,Manrope/Inter/static/Inter_24pt-Bold.ttf new file mode 100644 index 0000000..e974d96 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Bold.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Bold.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-Bold.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Bold.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-BoldItalic.ttf b/Inter,Manrope/Inter/static/Inter_24pt-BoldItalic.ttf new file mode 100644 index 0000000..1c3d251 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-BoldItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-BoldItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-BoldItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-BoldItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-ExtraBold.ttf b/Inter,Manrope/Inter/static/Inter_24pt-ExtraBold.ttf new file mode 100644 index 0000000..b775c08 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-ExtraBold.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-ExtraBold.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-ExtraBold.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-ExtraBold.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-ExtraBoldItalic.ttf b/Inter,Manrope/Inter/static/Inter_24pt-ExtraBoldItalic.ttf new file mode 100644 index 0000000..3461a92 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-ExtraBoldItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-ExtraBoldItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-ExtraBoldItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-ExtraBoldItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-ExtraLight.ttf b/Inter,Manrope/Inter/static/Inter_24pt-ExtraLight.ttf new file mode 100644 index 0000000..2ec6ca3 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-ExtraLight.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-ExtraLight.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-ExtraLight.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-ExtraLight.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-ExtraLightItalic.ttf b/Inter,Manrope/Inter/static/Inter_24pt-ExtraLightItalic.ttf new file mode 100644 index 0000000..c634a5d Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-ExtraLightItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-ExtraLightItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-ExtraLightItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-ExtraLightItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Italic.ttf b/Inter,Manrope/Inter/static/Inter_24pt-Italic.ttf new file mode 100644 index 0000000..1048b07 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Italic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Italic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-Italic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Italic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Light.ttf b/Inter,Manrope/Inter/static/Inter_24pt-Light.ttf new file mode 100644 index 0000000..1a2a6f2 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Light.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Light.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-Light.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Light.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-LightItalic.ttf b/Inter,Manrope/Inter/static/Inter_24pt-LightItalic.ttf new file mode 100644 index 0000000..ded5a75 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-LightItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-LightItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-LightItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-LightItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Medium.ttf b/Inter,Manrope/Inter/static/Inter_24pt-Medium.ttf new file mode 100644 index 0000000..5c88739 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Medium.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Medium.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-Medium.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Medium.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-MediumItalic.ttf b/Inter,Manrope/Inter/static/Inter_24pt-MediumItalic.ttf new file mode 100644 index 0000000..be091b1 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-MediumItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-MediumItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-MediumItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-MediumItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Regular.ttf b/Inter,Manrope/Inter/static/Inter_24pt-Regular.ttf new file mode 100644 index 0000000..6b088a7 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Regular.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Regular.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-Regular.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Regular.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-SemiBold.ttf b/Inter,Manrope/Inter/static/Inter_24pt-SemiBold.ttf new file mode 100644 index 0000000..ceb8576 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-SemiBold.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-SemiBold.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-SemiBold.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-SemiBold.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-SemiBoldItalic.ttf b/Inter,Manrope/Inter/static/Inter_24pt-SemiBoldItalic.ttf new file mode 100644 index 0000000..6921df2 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-SemiBoldItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-SemiBoldItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-SemiBoldItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-SemiBoldItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Thin.ttf b/Inter,Manrope/Inter/static/Inter_24pt-Thin.ttf new file mode 100644 index 0000000..3505b35 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Thin.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-Thin.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-Thin.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-Thin.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-ThinItalic.ttf b/Inter,Manrope/Inter/static/Inter_24pt-ThinItalic.ttf new file mode 100644 index 0000000..a3e6feb Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-ThinItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_24pt-ThinItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_24pt-ThinItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_24pt-ThinItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Black.ttf b/Inter,Manrope/Inter/static/Inter_28pt-Black.ttf new file mode 100644 index 0000000..66a252f Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Black.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Black.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-Black.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Black.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-BlackItalic.ttf b/Inter,Manrope/Inter/static/Inter_28pt-BlackItalic.ttf new file mode 100644 index 0000000..3c8fdf9 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-BlackItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-BlackItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-BlackItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-BlackItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Bold.ttf b/Inter,Manrope/Inter/static/Inter_28pt-Bold.ttf new file mode 100644 index 0000000..14db994 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Bold.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Bold.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-Bold.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Bold.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-BoldItalic.ttf b/Inter,Manrope/Inter/static/Inter_28pt-BoldItalic.ttf new file mode 100644 index 0000000..704b12b Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-BoldItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-BoldItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-BoldItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-BoldItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-ExtraBold.ttf b/Inter,Manrope/Inter/static/Inter_28pt-ExtraBold.ttf new file mode 100644 index 0000000..6d87cae Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-ExtraBold.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-ExtraBold.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-ExtraBold.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-ExtraBold.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-ExtraBoldItalic.ttf b/Inter,Manrope/Inter/static/Inter_28pt-ExtraBoldItalic.ttf new file mode 100644 index 0000000..1a56735 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-ExtraBoldItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-ExtraBoldItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-ExtraBoldItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-ExtraBoldItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-ExtraLight.ttf b/Inter,Manrope/Inter/static/Inter_28pt-ExtraLight.ttf new file mode 100644 index 0000000..d42b3f5 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-ExtraLight.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-ExtraLight.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-ExtraLight.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-ExtraLight.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-ExtraLightItalic.ttf b/Inter,Manrope/Inter/static/Inter_28pt-ExtraLightItalic.ttf new file mode 100644 index 0000000..90e2f20 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-ExtraLightItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-ExtraLightItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-ExtraLightItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-ExtraLightItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Italic.ttf b/Inter,Manrope/Inter/static/Inter_28pt-Italic.ttf new file mode 100644 index 0000000..c2a143a Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Italic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Italic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-Italic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Italic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Light.ttf b/Inter,Manrope/Inter/static/Inter_28pt-Light.ttf new file mode 100644 index 0000000..5eeff3a Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Light.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Light.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-Light.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Light.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-LightItalic.ttf b/Inter,Manrope/Inter/static/Inter_28pt-LightItalic.ttf new file mode 100644 index 0000000..6b90b76 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-LightItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-LightItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-LightItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-LightItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Medium.ttf b/Inter,Manrope/Inter/static/Inter_28pt-Medium.ttf new file mode 100644 index 0000000..00120fe Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Medium.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Medium.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-Medium.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Medium.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-MediumItalic.ttf b/Inter,Manrope/Inter/static/Inter_28pt-MediumItalic.ttf new file mode 100644 index 0000000..7481e7b Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-MediumItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-MediumItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-MediumItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-MediumItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Regular.ttf b/Inter,Manrope/Inter/static/Inter_28pt-Regular.ttf new file mode 100644 index 0000000..855b6f4 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Regular.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Regular.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-Regular.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Regular.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-SemiBold.ttf b/Inter,Manrope/Inter/static/Inter_28pt-SemiBold.ttf new file mode 100644 index 0000000..8b84efc Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-SemiBold.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-SemiBold.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-SemiBold.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-SemiBold.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-SemiBoldItalic.ttf b/Inter,Manrope/Inter/static/Inter_28pt-SemiBoldItalic.ttf new file mode 100644 index 0000000..2e22c5a Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-SemiBoldItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-SemiBoldItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-SemiBoldItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-SemiBoldItalic.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Thin.ttf b/Inter,Manrope/Inter/static/Inter_28pt-Thin.ttf new file mode 100644 index 0000000..94e6108 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Thin.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-Thin.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-Thin.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-Thin.ttf:Zone.Identifier differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-ThinItalic.ttf b/Inter,Manrope/Inter/static/Inter_28pt-ThinItalic.ttf new file mode 100644 index 0000000..d3d44cd Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-ThinItalic.ttf differ diff --git a/Inter,Manrope/Inter/static/Inter_28pt-ThinItalic.ttf:Zone.Identifier b/Inter,Manrope/Inter/static/Inter_28pt-ThinItalic.ttf:Zone.Identifier new file mode 100644 index 0000000..357bb36 Binary files /dev/null and b/Inter,Manrope/Inter/static/Inter_28pt-ThinItalic.ttf:Zone.Identifier differ diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 78a14b4..be5228f 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,9 +8,11 @@ * @module */ +import type * as alerts from "../alerts.js"; import type * as bootstrap from "../bootstrap.js"; import type * as categories from "../categories.js"; import type * as commentTemplates from "../commentTemplates.js"; +import type * as companies from "../companies.js"; import type * as fields from "../fields.js"; import type * as files from "../files.js"; import type * as invites from "../invites.js"; @@ -39,9 +41,11 @@ import type { * ``` */ declare const fullApi: ApiFromModules<{ + alerts: typeof alerts; bootstrap: typeof bootstrap; categories: typeof categories; commentTemplates: typeof commentTemplates; + companies: typeof companies; fields: typeof fields; files: typeof files; invites: typeof invites; diff --git a/convex/alerts.ts b/convex/alerts.ts new file mode 100644 index 0000000..fa4f556 --- /dev/null +++ b/convex/alerts.ts @@ -0,0 +1,263 @@ +import tls from "tls" + +import { action, mutation, query } from "./_generated/server" +import { api } from "./_generated/api" +import { v } from "convex/values" +import type { Id } from "./_generated/dataModel" + +// Minimal SMTP client (AUTH LOGIN over implicit TLS) +function b64(input: string) { + return Buffer.from(input, "utf8").toString("base64") +} + +async function sendSmtpMail(cfg: { host: string; port: number; username: string; password: string; from: string }, to: string, subject: string, html: string) { + return new Promise((resolve, reject) => { + const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => { + let buffer = "" + const send = (line: string) => socket.write(line + "\r\n") + const wait = (expected: string | RegExp) => + new Promise((res) => { + const onData = (data: Buffer) => { + buffer += data.toString() + const lines = buffer.split(/\r?\n/) + const last = lines.filter(Boolean).slice(-1)[0] ?? "" + if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) { + socket.removeListener("data", onData) + res() + } + } + socket.on("data", onData) + socket.on("error", reject) + }) + + ;(async () => { + await wait(/^220 /) + send(`EHLO ${cfg.host}`) + await wait(/^250-/) + await wait(/^250 /) + send("AUTH LOGIN") + await wait(/^334 /) + send(b64(cfg.username)) + await wait(/^334 /) + send(b64(cfg.password)) + await wait(/^235 /) + send(`MAIL FROM:<${cfg.from.match(/<(.+)>/)?.[1] ?? cfg.from}>`) + await wait(/^250 /) + send(`RCPT TO:<${to}>`) + await wait(/^250 /) + send("DATA") + await wait(/^354 /) + const headers = [ + `From: ${cfg.from}`, + `To: ${to}`, + `Subject: ${subject}`, + "MIME-Version: 1.0", + "Content-Type: text/html; charset=UTF-8", + ].join("\r\n") + send(headers + "\r\n\r\n" + html + "\r\n.") + await wait(/^250 /) + send("QUIT") + socket.end() + resolve() + })().catch(reject) + }) + socket.on("error", reject) + }) +} + +export const log = mutation({ + args: { + tenantId: v.string(), + companyId: v.optional(v.id("companies")), + companyName: v.string(), + usagePct: v.number(), + threshold: v.number(), + range: v.string(), + recipients: v.array(v.string()), + deliveredCount: v.number(), + }, + handler: async (ctx, args) => { + const now = Date.now() + await ctx.db.insert("alerts", { + tenantId: args.tenantId, + companyId: args.companyId, + companyName: args.companyName, + usagePct: args.usagePct, + threshold: args.threshold, + range: args.range, + recipients: args.recipients, + deliveredCount: args.deliveredCount, + createdAt: now, + }) + }, +}) + +export const list = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + limit: v.optional(v.number()), + companyId: v.optional(v.id("companies")), + start: v.optional(v.number()), + end: v.optional(v.number()), + }, + handler: async (ctx, { tenantId, viewerId, limit, companyId, start, end }) => { + // Only admins can see the full alerts log + const user = await ctx.db.get(viewerId) + if (!user || user.tenantId !== tenantId || (user.role ?? "").toUpperCase() !== "ADMIN") { + return [] + } + let items = await ctx.db + .query("alerts") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + if (companyId) items = items.filter((a) => a.companyId === companyId) + if (typeof start === "number") items = items.filter((a) => a.createdAt >= start) + if (typeof end === "number") items = items.filter((a) => a.createdAt < end) + return items + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, Math.max(1, Math.min(limit ?? 200, 500))) + }, +}) + +export const managersForCompany = query({ + args: { tenantId: v.string(), companyId: v.id("companies") }, + handler: async (ctx, { tenantId, companyId }) => { + const users = await ctx.db + .query("users") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .collect() + return users.filter((u) => (u.role ?? "").toUpperCase() === "MANAGER") + }, +}) + +export const lastForCompanyBySlug = query({ + args: { tenantId: v.string(), slug: v.string() }, + handler: async (ctx, { tenantId, slug }) => { + const company = await ctx.db + .query("companies") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) + .first() + if (!company) return null + const items = await ctx.db + .query("alerts") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + const matches = items.filter((a) => a.companyId === company._id) + if (matches.length === 0) return null + const last = matches.sort((a, b) => b.createdAt - a.createdAt)[0] + return { createdAt: last.createdAt, usagePct: last.usagePct, threshold: last.threshold } + }, +}) + +export const tenantIds = query({ + args: {}, + handler: async (ctx) => { + const companies = await ctx.db.query("companies").collect() + return Array.from(new Set(companies.map((c) => c.tenantId))) + }, +}) + +export const existsForCompanyRange = query({ + args: { tenantId: v.string(), companyId: v.id("companies"), start: v.number(), end: v.number() }, + handler: async (ctx, { tenantId, companyId, start, end }) => { + const items = await ctx.db + .query("alerts") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + return items.some((a) => a.companyId === companyId && a.createdAt >= start && a.createdAt < end) + }, +}) + +export const sendHoursUsageAlerts = action({ + args: { range: v.optional(v.string()), threshold: v.optional(v.number()) }, + handler: async (ctx, { range, threshold }) => { + const R = (range ?? "30d") as string + const T = typeof threshold === "number" ? threshold : 90 + + const smtp = { + host: process.env.SMTP_ADDRESS!, + port: Number(process.env.SMTP_PORT ?? 465), + username: process.env.SMTP_USERNAME!, + password: process.env.SMTP_PASSWORD!, + from: process.env.MAILER_SENDER_EMAIL || "no-reply@example.com", + } + if (!smtp.host || !smtp.username || !smtp.password) { + console.warn("SMTP not configured; skipping alerts send") + return { sent: 0 } + } + + const targetHour = Number(process.env.ALERTS_LOCAL_HOUR ?? 8) + const now = new Date() + const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "America/Sao_Paulo", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) + const parts = Object.fromEntries(fmt.formatToParts(now).map((p) => [p.type, p.value])) as Record + const hourSP = Number(parts.hour) + if (hourSP !== targetHour) { + return { skipped: true, reason: "hour_guard" } + } + + const dayKey = `${parts.year}-${parts.month}-${parts.day}` + // compute start/end of Sao Paulo day in UTC milliseconds + const startSP = new Date(`${dayKey}T00:00:00-03:00`).getTime() + const endSP = startSP + 24 * 60 * 60 * 1000 + + const tenants = await ctx.runQuery(api.alerts.tenantIds, {}) + let totalSent = 0 + + for (const tenantId of tenants) { + const report = await ctx.runQuery(api.reports.hoursByClientInternal, { tenantId, range: R }) + type Item = { + companyId: Id<"companies"> + name: string + internalMs: number + externalMs: number + totalMs: number + contractedHoursPerMonth: number | null + } + const items = (report.items ?? []) as Item[] + const candidates = items.filter((i) => i.contractedHoursPerMonth != null && (i.totalMs / 3600000) / (i.contractedHoursPerMonth || 1) * 100 >= T) + + for (const item of candidates) { + const already = await ctx.runQuery(api.alerts.existsForCompanyRange, { tenantId, companyId: item.companyId, start: startSP, end: endSP }) + if (already) continue + const managers = await ctx.runQuery(api.alerts.managersForCompany, { tenantId, companyId: item.companyId }) + if (managers.length === 0) continue + const usagePct = (((item.totalMs / 3600000) / (item.contractedHoursPerMonth || 1)) * 100) + const subject = `Alerta: uso de horas em ${item.name} acima de ${T}%` + const body = ` +

Olá,

+

O uso de horas contratadas para ${item.name} atingiu ${usagePct.toFixed(1)}%.

+
    +
  • Horas internas: ${(item.internalMs/3600000).toFixed(2)}
  • +
  • Horas externas: ${(item.externalMs/3600000).toFixed(2)}
  • +
  • Total: ${(item.totalMs/3600000).toFixed(2)}
  • +
  • Contratadas/mês: ${item.contractedHoursPerMonth}
  • +
+

Reveja a alocação da equipe e, se necessário, ajuste o atendimento.

+ ` + let delivered = 0 + for (const m of managers) { + try { + await sendSmtpMail(smtp, m.email, subject, body) + delivered += 1 + } catch (error) { + console.error("Failed to send alert to", m.email, error) + } + } + totalSent += delivered + await ctx.runMutation(api.alerts.log, { + tenantId, + companyId: item.companyId, + companyName: item.name, + usagePct, + threshold: T, + range: R, + recipients: managers.map((m) => m.email), + deliveredCount: delivered, + }) + } + } + + return { sent: totalSent } + }, +}) diff --git a/convex/companies.ts b/convex/companies.ts new file mode 100644 index 0000000..07a149a --- /dev/null +++ b/convex/companies.ts @@ -0,0 +1,16 @@ +import { query } from "./_generated/server"; +import { v } from "convex/values"; +import { requireStaff } from "./rbac"; + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId) + const companies = await ctx.db + .query("companies") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + return companies.map((c) => ({ id: c._id, name: c.name })) + }, +}) + diff --git a/convex/crons.ts b/convex/crons.ts new file mode 100644 index 0000000..8e3b829 --- /dev/null +++ b/convex/crons.ts @@ -0,0 +1,9 @@ +import { cronJobs } from "convex/server" +import { api } from "./_generated/api" + +const crons = cronJobs() + +// Check hourly and the action will gate by America/Sao_Paulo hour +crons.interval("hours-usage-alerts-hourly", { hours: 1 }, api.alerts.sendHoursUsageAlerts) + +export default crons diff --git a/convex/reports.ts b/convex/reports.ts index e111fbe..4a4d6e7 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -128,10 +128,11 @@ function formatDateKey(timestamp: number) { } export const slaOverview = query({ - args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, - handler: async (ctx, { tenantId, viewerId }) => { + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + handler: async (ctx, { tenantId, viewerId, companyId }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); - const tickets = await fetchScopedTickets(ctx, tenantId, viewer); + let tickets = await fetchScopedTickets(ctx, tenantId, viewer); + if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) const queues = await fetchQueues(ctx, tenantId); const now = Date.now(); @@ -179,10 +180,11 @@ export const slaOverview = query({ }); export const csatOverview = query({ - args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, - handler: async (ctx, { tenantId, viewerId, range }) => { + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + handler: async (ctx, { tenantId, viewerId, range, companyId }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); - const tickets = await fetchScopedTickets(ctx, tenantId, viewer); + let tickets = await fetchScopedTickets(ctx, tenantId, viewer); + if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) const surveysAll = await collectCsatSurveys(ctx, tickets); const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); @@ -415,7 +417,7 @@ export const hoursByClient = query({ // Accumulate by company type Acc = { - companyId: string + companyId: Id<"companies"> name: string isAvulso: boolean internalMs: number @@ -428,7 +430,7 @@ export const hoursByClient = query({ for (const t of tickets) { // only consider tickets updated in range as a proxy for recent work if (t.updatedAt < startMs || t.updatedAt >= endMs) continue - const companyId = (t as any).companyId ?? null + const companyId = t.companyId ?? null if (!companyId) continue let acc = map.get(companyId) @@ -436,17 +438,82 @@ export const hoursByClient = query({ const company = await ctx.db.get(companyId) acc = { companyId, - name: (company as any)?.name ?? "Sem empresa", - isAvulso: Boolean((company as any)?.isAvulso ?? false), + name: company?.name ?? "Sem empresa", + isAvulso: Boolean(company?.isAvulso ?? false), internalMs: 0, externalMs: 0, totalMs: 0, - contractedHoursPerMonth: (company as any)?.contractedHoursPerMonth ?? null, + contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null, } map.set(companyId, acc) } - const internal = ((t as any).internalWorkedMs ?? 0) as number - const external = ((t as any).externalWorkedMs ?? 0) as number + const internal = t.internalWorkedMs ?? 0 + const external = t.externalWorkedMs ?? 0 + acc.internalMs += internal + acc.externalMs += external + acc.totalMs += internal + external + } + + const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) + return { + rangeDays: days, + items: items.map((i) => ({ + companyId: i.companyId, + name: i.name, + isAvulso: i.isAvulso, + internalMs: i.internalMs, + externalMs: i.externalMs, + totalMs: i.totalMs, + contractedHoursPerMonth: i.contractedHoursPerMonth ?? null, + })), + } + }, +}) + +// Internal variant used by scheduled jobs: skips viewer scoping and aggregates for the whole tenant +export const hoursByClientInternal = query({ + args: { tenantId: v.string(), range: v.optional(v.string()) }, + handler: async (ctx, { tenantId, range }) => { + const tickets = await fetchTickets(ctx, tenantId) + + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - days * ONE_DAY_MS + + type Acc = { + companyId: Id<"companies"> + name: string + isAvulso: boolean + internalMs: number + externalMs: number + totalMs: number + contractedHoursPerMonth?: number | null + } + const map = new Map() + + for (const t of tickets) { + if (t.updatedAt < startMs || t.updatedAt >= endMs) continue + const companyId = t.companyId ?? null + if (!companyId) continue + + let acc = map.get(companyId) + if (!acc) { + const company = await ctx.db.get(companyId) + acc = { + companyId, + name: company?.name ?? "Sem empresa", + isAvulso: Boolean(company?.isAvulso ?? false), + internalMs: 0, + externalMs: 0, + totalMs: 0, + contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null, + } + map.set(companyId, acc) + } + const internal = t.internalWorkedMs ?? 0 + const external = t.externalWorkedMs ?? 0 acc.internalMs += internal acc.externalMs += external acc.totalMs += internal + external diff --git a/convex/schema.ts b/convex/schema.ts index 520387c..7430928 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -33,6 +33,20 @@ export default defineSchema({ .index("by_tenant_slug", ["tenantId", "slug"]) .index("by_tenant", ["tenantId"]), + alerts: defineTable({ + tenantId: v.string(), + companyId: v.optional(v.id("companies")), + companyName: v.string(), + usagePct: v.number(), + threshold: v.number(), + range: v.string(), + recipients: v.array(v.string()), + createdAt: v.number(), + deliveredCount: v.number(), + }) + .index("by_tenant_created", ["tenantId", "createdAt"]) + .index("by_tenant", ["tenantId"]), + queues: defineTable({ tenantId: v.string(), name: v.string(), diff --git a/src/app/admin/alerts/page.tsx b/src/app/admin/alerts/page.tsx new file mode 100644 index 0000000..eacaf79 --- /dev/null +++ b/src/app/admin/alerts/page.tsx @@ -0,0 +1,123 @@ +"use client" + +import { useQuery } from "convex/react" +import { api } from "@/convex/_generated/api" +import type { Id, Doc } from "@/convex/_generated/dataModel" +import { useAuth } from "@/lib/auth-client" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { useMemo, useState } from "react" +import { Button } from "@/components/ui/button" + +export default function AdminAlertsPage() { + const [companyId, setCompanyId] = useState("all") + const [range, setRange] = useState("30d") + const { session, convexUserId } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + const now = new Date() + const days = range === "7d" ? 7 : range === "30d" ? 30 : range === "90d" ? 90 : null + const end = now.getTime() + const start = days ? end - days * 24 * 60 * 60 * 1000 : undefined + const alertsRaw = useQuery( + api.alerts.list, + convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" + ) as Doc<"alerts">[] | undefined + const alerts = useMemo(() => { + let list = alertsRaw ?? [] + if (companyId !== "all") { + list = list.filter((a) => String(a.companyId) === companyId) + } + if (typeof start === "number") list = list.filter((a) => a.createdAt >= start) + if (typeof end === "number") list = list.filter((a) => a.createdAt < end) + return list.sort((a, b) => b.createdAt - a.createdAt) + }, [alertsRaw, companyId, start, end]) + const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined + + return ( +
+ + + Alertas enviados + + Histórico dos e-mails de alerta de uso de horas disparados automaticamente. + + +
+ + + +
+
+
+ + {!alerts ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : alerts.length === 0 ? ( +

+ Nenhum alerta enviado ainda. +

+ ) : ( +
+ + + + + + + + + + + + + + {alerts.map((a) => ( + + + + + + + + + + ))} + +
QuandoEmpresaUsoLimitePeríodoDestinatáriosEntregues
+ {new Date(a.createdAt).toLocaleString("pt-BR")} + {a.companyName}{a.usagePct.toFixed(1)}%{a.threshold}%{a.range}{a.recipients.join(", ")}{a.deliveredCount}
+
+ )} +
+
+
+ ) +} diff --git a/src/app/api/admin/alerts/hours-usage/route.ts b/src/app/api/admin/alerts/hours-usage/route.ts new file mode 100644 index 0000000..0287f3e --- /dev/null +++ b/src/app/api/admin/alerts/hours-usage/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { assertAdminSession } from "@/lib/auth-server" +import { env } from "@/lib/env" +import { prisma } from "@/lib/prisma" +import { sendSmtpMail } from "@/server/email-smtp" + +export const runtime = "nodejs" + +function fmtHours(ms: number) { + return (ms / 3600000).toFixed(2) +} + +export async function POST(request: Request) { + const session = await assertAdminSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + if (!env.SMTP) return NextResponse.json({ error: "SMTP não configurado" }, { status: 500 }) + + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? "30d" + const threshold = Number(searchParams.get("threshold") ?? 90) + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? "tenant-atlas" + + // Ensure user exists in Convex to obtain a typed viewerId + let viewerId: Id<"users"> | null = null + try { + const ensured = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = (ensured?._id ?? null) as Id<"users"> | null + } catch (error) { + console.error("Failed to synchronize user with Convex for alerts", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + if (!viewerId) return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + + const report = await client.query(api.reports.hoursByClient, { + tenantId, + viewerId, + range, + }) + + type HoursByClientItem = { + companyId: Id<"companies"> + name: string + internalMs: number + externalMs: number + totalMs: number + contractedHoursPerMonth: number | null + } + const items = (report.items ?? []) as HoursByClientItem[] + const alerts = items.filter((i) => i.contractedHoursPerMonth != null && (i.totalMs / 3600000) / (i.contractedHoursPerMonth || 1) * 100 >= threshold) + + for (const item of alerts) { + // Find managers of the company in Prisma + const managers = await prisma.user.findMany({ + where: { + tenantId, + companyId: item.companyId, + role: "MANAGER", + }, + select: { email: true, name: true }, + }) + if (managers.length === 0) continue + + const subject = `Alerta: uso de horas em ${item.name} acima de ${threshold}%` + const body = ` +

Olá,

+

O uso de horas contratadas para ${item.name} atingiu ${(((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100).toFixed(1)}%.

+
    +
  • Horas internas: ${fmtHours(item.internalMs)}
  • +
  • Horas externas: ${fmtHours(item.externalMs)}
  • +
  • Total: ${fmtHours(item.totalMs)}
  • +
  • Contratadas/mês: ${item.contractedHoursPerMonth}
  • +
+

Reveja a alocação da equipe e, se necessário, ajuste o atendimento.

+ ` + let delivered = 0 + for (const m of managers) { + try { + await sendSmtpMail( + { + host: env.SMTP!.host, + port: env.SMTP!.port, + username: env.SMTP!.username, + password: env.SMTP!.password, + from: env.SMTP!.from!, + }, + m.email, + subject, + body + ) + delivered += 1 + } catch (error) { + console.error("Failed to send alert to", m.email, error) + } + } + try { + await client.mutation(api.alerts.log, { + tenantId, + companyId: item.companyId, + companyName: item.name, + usagePct: (((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100), + threshold, + range, + recipients: managers.map((m) => m.email), + deliveredCount: delivered, + }) + } catch (error) { + console.error("Failed to log alert in Convex", error) + } + } + + return NextResponse.json({ sent: alerts.length }) +} diff --git a/src/app/api/admin/companies/last-alerts/route.ts b/src/app/api/admin/companies/last-alerts/route.ts new file mode 100644 index 0000000..1524538 --- /dev/null +++ b/src/app/api/admin/companies/last-alerts/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import { env } from "@/lib/env" +import { assertAdminSession } from "@/lib/auth-server" + +export const runtime = "nodejs" + +export async function GET(request: Request) { + const session = await assertAdminSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + const client = new ConvexHttpClient(convexUrl) + + const { searchParams } = new URL(request.url) + const slugsParam = searchParams.get("slugs") + if (!slugsParam) return NextResponse.json({ items: {} }) + const slugs = slugsParam.split(",").map((s) => s.trim()).filter(Boolean) + + const tenantId = session.user.tenantId ?? "tenant-atlas" + const result: Record = {} + for (const slug of slugs) { + try { + const last = (await client.query(api.alerts.lastForCompanyBySlug, { tenantId, slug })) as + | { createdAt: number; usagePct: number; threshold: number } + | null + result[slug] = last + } catch { + result[slug] = null + } + } + + return NextResponse.json({ items: result }) +} diff --git a/src/app/api/reports/backlog.csv/route.ts b/src/app/api/reports/backlog.csv/route.ts index e8cb99c..2fba522 100644 --- a/src/app/api/reports/backlog.csv/route.ts +++ b/src/app/api/reports/backlog.csv/route.ts @@ -6,21 +6,10 @@ import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { rowsToCsv } from "@/lib/csv" export const runtime = "nodejs" -function csvEscape(value: unknown): string { - const s = value == null ? "" : String(value) - if (/[",\n]/.test(s)) { - return '"' + s.replace(/"/g, '""') + '"' - } - return s -} - -function rowsToCsv(rows: Array>): string { - return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" -} - export async function GET(request: Request) { const session = await assertAuthenticatedSession() if (!session) { @@ -62,7 +51,7 @@ export async function GET(request: Request) { tenantId, viewerId: viewerId as unknown as Id<"users">, range, - companyId: companyId as any, + companyId: companyId as unknown as Id<"companies">, }) const rows: Array> = [] diff --git a/src/app/api/reports/csat.csv/route.ts b/src/app/api/reports/csat.csv/route.ts index a57e350..e65c55e 100644 --- a/src/app/api/reports/csat.csv/route.ts +++ b/src/app/api/reports/csat.csv/route.ts @@ -6,21 +6,10 @@ import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { rowsToCsv } from "@/lib/csv" export const runtime = "nodejs" -function csvEscape(value: unknown): string { - const s = value == null ? "" : String(value) - if (/[",\n]/.test(s)) { - return '"' + s.replace(/"/g, '""') + '"' - } - return s -} - -function rowsToCsv(rows: Array>): string { - return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" -} - export async function GET(request: Request) { const session = await assertAuthenticatedSession() if (!session) { @@ -34,6 +23,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const range = searchParams.get("range") ?? undefined + const companyId = searchParams.get("companyId") ?? undefined const client = new ConvexHttpClient(convexUrl) const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID @@ -62,11 +52,13 @@ export async function GET(request: Request) { tenantId, viewerId: viewerId as unknown as Id<"users">, range, + companyId: companyId as unknown as Id<"companies">, }) const rows: Array> = [] rows.push(["Relatório", "CSAT"]) rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')]) + if (companyId) rows.push(["EmpresaId", companyId]) rows.push([]) rows.push(["Métrica", "Valor"]) // header rows.push(["CSAT médio", report.averageScore ?? "—"]) @@ -96,4 +88,3 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Falha ao gerar CSV de CSAT" }, { status: 500 }) } } - diff --git a/src/app/api/reports/hours-by-client.csv/route.ts b/src/app/api/reports/hours-by-client.csv/route.ts index 0a9db89..4228d72 100644 --- a/src/app/api/reports/hours-by-client.csv/route.ts +++ b/src/app/api/reports/hours-by-client.csv/route.ts @@ -6,17 +6,9 @@ import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { rowsToCsv } from "@/lib/csv" export const runtime = "nodejs" - -function csvEscape(value: unknown): string { - const s = value == null ? "" : String(value) - if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"' - return s -} -function rowsToCsv(rows: Array>): string { - return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" -} function msToHours(ms: number) { return (ms / 3600000).toFixed(2) } @@ -29,6 +21,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const range = searchParams.get("range") ?? undefined + const q = searchParams.get("q")?.toLowerCase().trim() ?? "" const client = new ConvexHttpClient(convexUrl) const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID @@ -58,9 +51,11 @@ export async function GET(request: Request) { const rows: Array> = [] rows.push(["Relatório", "Horas por cliente"]) rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')]) + if (q) rows.push(["Filtro", q]) rows.push([]) rows.push(["Cliente", "Avulso", "Horas internas", "Horas externas", "Horas totais", "Horas contratadas/mês", "% uso"]) - for (const item of report.items) { + const items = q ? report.items.filter((i: any) => String(i.name).toLowerCase().includes(q)) : report.items + for (const item of items) { const internalH = msToHours(item.internalMs) const externalH = msToHours(item.externalMs) const totalH = msToHours(item.totalMs) @@ -72,7 +67,7 @@ export async function GET(request: Request) { return new NextResponse(csv, { headers: { "Content-Type": "text/csv; charset=UTF-8", - "Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d.csv"`, + "Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d${q ? `-${encodeURIComponent(q)}` : ''}.csv"`, "Cache-Control": "no-store", }, }) @@ -80,4 +75,3 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Falha ao gerar CSV de horas por cliente" }, { status: 500 }) } } - diff --git a/src/app/api/reports/sla.csv/route.ts b/src/app/api/reports/sla.csv/route.ts index 94187cb..e0c53d0 100644 --- a/src/app/api/reports/sla.csv/route.ts +++ b/src/app/api/reports/sla.csv/route.ts @@ -34,6 +34,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const range = searchParams.get("range") ?? undefined + const companyId = searchParams.get("companyId") ?? undefined const client = new ConvexHttpClient(convexUrl) const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID @@ -62,11 +63,13 @@ export async function GET(request: Request) { tenantId, viewerId: viewerId as unknown as Id<"users">, range, + companyId: companyId as unknown as Id<"companies">, }) const rows: Array> = [] rows.push(["Relatório", "SLA e produtividade"]) rows.push(["Período", range ?? "—"]) + if (companyId) rows.push(["EmpresaId", companyId]) rows.push([]) rows.push(["Métrica", "Valor"]) // header @@ -98,4 +101,3 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Falha ao gerar CSV de SLA" }, { status: 500 }) } } - diff --git a/src/app/api/reports/tickets-by-channel.csv/route.ts b/src/app/api/reports/tickets-by-channel.csv/route.ts index aef39d3..e2bf2f4 100644 --- a/src/app/api/reports/tickets-by-channel.csv/route.ts +++ b/src/app/api/reports/tickets-by-channel.csv/route.ts @@ -6,21 +6,10 @@ import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { rowsToCsv } from "@/lib/csv" export const runtime = "nodejs" -function csvEscape(value: unknown): string { - const s = value == null ? "" : String(value) - if (/[",\n]/.test(s)) { - return '"' + s.replace(/"/g, '""') + '"' - } - return s -} - -function rowsToCsv(rows: Array>): string { - return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" -} - export async function GET(request: Request) { const session = await assertAuthenticatedSession() if (!session) { @@ -63,7 +52,7 @@ export async function GET(request: Request) { tenantId, viewerId: viewerId as unknown as Id<"users">, range, - companyId: companyId as any, + companyId: companyId as unknown as Id<"companies">, }) const channels = report.channels diff --git a/src/app/api/tickets/[id]/export/pdf/route.ts b/src/app/api/tickets/[id]/export/pdf/route.ts index d48eb9f..0d7f293 100644 --- a/src/app/api/tickets/[id]/export/pdf/route.ts +++ b/src/app/api/tickets/[id]/export/pdf/route.ts @@ -5,6 +5,8 @@ import { NextResponse } from "next/server" // @ts-ignore – no ambient types for this path; declared in types/ import PDFDocument from "pdfkit/js/pdfkit.standalone.js" import { format } from "date-fns" +import fs from "fs" +import path from "path" import { ptBR } from "date-fns/locale" import { ConvexHttpClient } from "convex/browser" @@ -123,21 +125,21 @@ function formatDurationMs(ms: number | null | undefined) { return `${seconds}s` } -function buildTimelineMessage(type: string, payload: any): string | null { - if (!payload || typeof payload !== "object") payload = {} - const to = payload.toLabel ?? payload.to - const assignee = payload.assigneeName ?? payload.assigneeId - const queue = payload.queueName ?? payload.queueId - const requester = payload.requesterName - const author = payload.authorName ?? payload.authorId - const actor = payload.actorName ?? payload.actorId - const attachmentName = payload.attachmentName - const subjectTo = payload.to - const pauseReason = payload.pauseReasonLabel ?? payload.pauseReason - const pauseNote = payload.pauseNote - const sessionDuration = formatDurationMs(payload.sessionDurationMs) - const categoryName = payload.categoryName - const subcategoryName = payload.subcategoryName +function buildTimelineMessage(type: string, payload: Record | null | undefined): string | null { + const p = payload ?? {} + const to = (p.toLabel as string | undefined) ?? (p.to as string | undefined) + const assignee = (p.assigneeName as string | undefined) ?? (p.assigneeId as string | undefined) + const queue = (p.queueName as string | undefined) ?? (p.queueId as string | undefined) + const requester = p.requesterName as string | undefined + const author = (p.authorName as string | undefined) ?? (p.authorId as string | undefined) + const actor = (p.actorName as string | undefined) ?? (p.actorId as string | undefined) + const attachmentName = p.attachmentName as string | undefined + const subjectTo = p.to as string | undefined + const pauseReason = (p.pauseReasonLabel as string | undefined) ?? (p.pauseReason as string | undefined) + const pauseNote = p.pauseNote as string | undefined + const sessionDuration = formatDurationMs((p.sessionDurationMs as number | undefined) ?? null) + const categoryName = p.categoryName as string | undefined + const subcategoryName = p.subcategoryName as string | undefined switch (type) { case "STATUS_CHANGED": @@ -247,8 +249,9 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st const doc = new PDFDocument({ size: "A4", margin: 56 }) const chunks: Buffer[] = [] - doc.on("data", (chunk: any) => { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk) + doc.on("data", (chunk: unknown) => { + const buf = typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer) + chunks.push(buf) }) const pdfBufferPromise = new Promise((resolve, reject) => { @@ -256,8 +259,48 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st doc.on("error", reject) }) + // Register custom fonts (Inter) if available + try { + const pubRegular = path.join(process.cwd(), "public", "fonts", "Inter-Regular.ttf") + const pubBold = path.join(process.cwd(), "public", "fonts", "Inter-Bold.ttf") + const fontRegular = fs.existsSync(pubRegular) + ? pubRegular + : path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Regular.ttf") + const fontBold = fs.existsSync(pubBold) + ? pubBold + : path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Bold.ttf") + const D = doc as unknown as { + registerFont?: (name: string, src: string) => void + _fontFamilies?: Record + roundedRect?: (x: number, y: number, w: number, h: number, r: number) => void + } + if (fs.existsSync(fontRegular)) { + D.registerFont?.("Inter", fontRegular) + } + if (fs.existsSync(fontBold)) { + D.registerFont?.("Inter-Bold", fontBold) + } + } catch {} + + const D = doc as unknown as { _fontFamilies?: Record; roundedRect?: (x:number,y:number,w:number,h:number,r:number)=>void } + const hasInter = Boolean(D._fontFamilies && (D._fontFamilies as Record)["Inter-Bold"]) + + // Header with logo and brand bar + try { + const logoPath = path.join(process.cwd(), "public", "rever-8.png") + if (fs.existsSync(logoPath)) { + doc.image(logoPath, doc.page.margins.left, doc.y, { width: 120 }) + } + } catch {} + doc.moveDown(0.5) + doc + .fillColor("#00e8ff") + .rect(doc.page.margins.left, doc.y, doc.page.width - doc.page.margins.left - doc.page.margins.right, 3) + .fill() + doc.moveDown(0.5) + // Título - doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference} — ${ticket.subject}`) + doc.fillColor("#0F172A").font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference} — ${ticket.subject}`) doc.moveDown(0.25) // Linha abaixo do título doc @@ -276,30 +319,53 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st const badgeX = doc.page.margins.left const badgeY = doc.y doc.save() - doc.font("Helvetica-Bold").fontSize(badgeFontSize) + doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(badgeFontSize) const badgeTextWidth = doc.widthOfString(statusText) const badgeHeight = badgeFontSize + badgePaddingY * 2 const badgeWidth = badgeTextWidth + badgePaddingX * 2 - ;(doc as any).roundedRect?.(badgeX, badgeY, badgeWidth, badgeHeight, 4) ?? doc.rect(badgeX, badgeY, badgeWidth, badgeHeight) + D.roundedRect?.(badgeX, badgeY, badgeWidth, badgeHeight, 4) ?? doc.rect(badgeX, badgeY, badgeWidth, badgeHeight) doc.fill(badgeColor) doc.fillColor("#FFFFFF").text(statusText, badgeX + badgePaddingX, badgeY + badgePaddingY) doc.restore() doc.y = badgeY + badgeHeight + 8 - // Metadados básicos - doc - .fillColor("#0F172A") - .font("Helvetica") - .fontSize(11) - .text(`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`, { lineGap: 2 }) - .moveDown(0.15) - .text(`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`, { lineGap: 2 }) - .moveDown(0.15) - .text(`Fila: ${ticket.queue ?? "—"}`, { lineGap: 2 }) + // Metadados em duas colunas + const leftX = doc.page.margins.left + const colGap = 24 + const colWidth = (doc.page.width - doc.page.margins.left - doc.page.margins.right - colGap) / 2 + const rightX = leftX + colWidth + colGap + const startY = doc.y + const drawMeta = (x: number, lines: string[]) => { + doc.save() + doc.x = x + doc.fillColor("#0F172A").font(hasInter ? "Inter" : "Helvetica").fontSize(11) + for (const line of lines) { + doc.text(line, { width: colWidth, lineGap: 2 }) + } + const currY = doc.y + doc.restore() + return currY + } + const leftLines = [ + `Status: ${statusText}`, + `Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`, + `Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`, + `Fila: ${ticket.queue ?? "—"}`, + ] + const rightLines = [ + `Solicitante: ${ticket.requester.name} (${ticket.requester.email})`, + `Responsável: ${ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído"}`, + `Criado em: ${formatDateTime(ticket.createdAt)}`, + `Atualizado em: ${formatDateTime(ticket.updatedAt)}`, + ] + const leftY = drawMeta(leftX, leftLines) + const rightY = drawMeta(rightX, rightLines) + doc.y = Math.max(leftY, rightY) + doc.moveDown(0.5) doc.moveDown(0.75) doc - .font("Helvetica-Bold") + .font(hasInter ? "Inter-Bold" : "Helvetica-Bold") .fontSize(12) .text("Solicitante") doc @@ -309,12 +375,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st .stroke() doc.moveDown(0.3) doc - .font("Helvetica") + .font(hasInter ? "Inter" : "Helvetica") .fontSize(11) .text(`${ticket.requester.name} (${ticket.requester.email})`) doc.moveDown(0.5) - doc.font("Helvetica-Bold").fontSize(12).text("Responsável") + doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Responsável") doc .strokeColor("#E2E8F0") .moveTo(doc.page.margins.left, doc.y) @@ -322,7 +388,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st .stroke() doc.moveDown(0.3) doc - .font("Helvetica") + .font(hasInter ? "Inter" : "Helvetica") .fontSize(11) .text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído") @@ -345,58 +411,58 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st if (ticket.summary) { doc.moveDown(0.75) - doc.font("Helvetica-Bold").fontSize(12).text("Resumo") + doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Resumo") doc .strokeColor("#E2E8F0") .moveTo(doc.page.margins.left, doc.y) .lineTo(doc.page.width - doc.page.margins.right, doc.y) .stroke() doc - .font("Helvetica") + .font(hasInter ? "Inter" : "Helvetica") .fontSize(11) .text(ticket.summary, { align: "justify", lineGap: 2 }) } if (ticket.description) { doc.moveDown(0.75) - doc.font("Helvetica-Bold").fontSize(12).text("Descrição") + doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Descrição") doc .strokeColor("#E2E8F0") .moveTo(doc.page.margins.left, doc.y) .lineTo(doc.page.width - doc.page.margins.right, doc.y) .stroke() doc - .font("Helvetica") + .font(hasInter ? "Inter" : "Helvetica") .fontSize(11) .text(htmlToPlainText(ticket.description), { align: "justify", lineGap: 2 }) } if (ticket.comments.length > 0) { doc.addPage() - doc.font("Helvetica-Bold").fontSize(14).text("Comentários") + doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Comentários") doc.moveDown(0.6) const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) commentsSorted.forEach((comment, index) => { const visibility = comment.visibility === "PUBLIC" ? "Público" : "Interno" doc - .font("Helvetica-Bold") + .font(hasInter ? "Inter-Bold" : "Helvetica-Bold") .fontSize(11) .text(`${comment.author.name} • ${visibility} • ${formatDateTime(comment.createdAt)}`) doc.moveDown(0.15) const body = htmlToPlainText(comment.body) if (body) { doc - .font("Helvetica") + .font(hasInter ? "Inter" : "Helvetica") .fontSize(11) .text(body, { align: "justify", lineGap: 2, indent: 6 }) } if (comment.attachments.length > 0) { doc.moveDown(0.25) - doc.font("Helvetica").fontSize(10).text("Anexos:") + doc.font(hasInter ? "Inter" : "Helvetica").fontSize(10).text("Anexos:") comment.attachments.forEach((attachment) => { doc - .font("Helvetica") + .font(hasInter ? "Inter" : "Helvetica") .fontSize(10) .text(`• ${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 }) }) @@ -415,13 +481,13 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st if (ticket.timeline.length > 0) { doc.addPage() - doc.font("Helvetica-Bold").fontSize(14).text("Linha do tempo") + doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Linha do tempo") doc.moveDown(0.6) const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) timelineSorted.forEach((event) => { const label = timelineLabel[event.type] ?? event.type doc - .font("Helvetica-Bold") + .font(hasInter ? "Inter-Bold" : "Helvetica-Bold") .fontSize(11) .text(`${label} • ${formatDateTime(event.createdAt)}`) doc.moveDown(0.15) @@ -429,14 +495,14 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st const friendly = buildTimelineMessage(event.type, event.payload) if (friendly) { doc - .font("Helvetica") + .font(hasInter ? "Inter" : "Helvetica") .fontSize(10) .text(friendly, { indent: 16, lineGap: 1 }) } else { const payloadText = stringifyPayload(event.payload) if (payloadText) { doc - .font("Helvetica") + .font(hasInter ? "Inter" : "Helvetica") .fontSize(10) .text(payloadText, { indent: 16, lineGap: 1 }) } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 49084cf..9f35c60 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -2,12 +2,7 @@ import { AppShell } from "@/components/app-shell" import { SectionCards } from "@/components/section-cards" import { SiteHeader } from "@/components/site-header" import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel" -import dynamic from "next/dynamic" - -const TicketQueueSummaryCards = dynamic( - () => import("@/components/tickets/ticket-queue-summary").then((m) => ({ default: m.TicketQueueSummaryCards })), - { ssr: false } -) +import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" import { ChartAreaInteractive } from "@/components/chart-area-interactive" export default function Dashboard() { diff --git a/src/app/reports/csat/page.tsx b/src/app/reports/csat/page.tsx index c24934b..83d0715 100644 --- a/src/app/reports/csat/page.tsx +++ b/src/app/reports/csat/page.tsx @@ -11,13 +11,6 @@ export default function ReportsCsatPage() { - - Exportar CSV - - - } /> } > diff --git a/src/app/reports/sla/page.tsx b/src/app/reports/sla/page.tsx index 91cec01..824f935 100644 --- a/src/app/reports/sla/page.tsx +++ b/src/app/reports/sla/page.tsx @@ -11,13 +11,6 @@ export default function ReportsSlaPage() { - - Exportar CSV - - - } /> } > diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index 78ef647..e779e7f 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -36,6 +36,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: const [isPending, startTransition] = useTransition() const [form, setForm] = useState>({}) const [editingId, setEditingId] = useState(null) + const [lastAlerts, setLastAlerts] = useState>({}) const resetForm = () => setForm({}) @@ -43,6 +44,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: const r = await fetch("/api/admin/companies", { credentials: "include" }) const json = (await r.json()) as { companies: Company[] } setCompanies(json.companies) + void loadLastAlerts(json.companies) } function handleEdit(c: Company) { @@ -50,6 +52,20 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: setForm({ ...c }) } + async function loadLastAlerts(list: Company[] = companies) { + if (!list || list.length === 0) return + const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") }) + try { + const r = await fetch(`/api/admin/companies/last-alerts?${params.toString()}`, { credentials: "include" }) + const json = (await r.json()) as { items: Record } + setLastAlerts(json.items ?? {}) + } catch { + // ignore + } + } + + useMemo(() => { void loadLastAlerts(companies) }, []) + async function handleSubmit(e: React.FormEvent) { e.preventDefault() const payload = { @@ -191,6 +207,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Domínio Telefone CNPJ + Último alerta Ações @@ -207,6 +224,11 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: {c.domain ?? "—"} {c.phone ?? "—"} {c.cnpj ?? "—"} + + {lastAlerts[c.slug] + ? `${new Date(lastAlerts[c.slug]!.createdAt).toLocaleString("pt-BR")}` + : "—"} + - - 90 dias - 30 dias - 7 dias - - - + 90 dias + 30 dias + 7 dias + + + {/* Mobile time range select */} + + + {/* Export button aligned at the end */} + + + {report === undefined ? ( diff --git a/src/components/reports/backlog-report.tsx b/src/components/reports/backlog-report.tsx index 95e5290..7e137c0 100644 --- a/src/components/reports/backlog-report.tsx +++ b/src/components/reports/backlog-report.tsx @@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" const PRIORITY_LABELS: Record = { LOW: "Baixa", @@ -29,12 +30,14 @@ const STATUS_LABELS: Record = { export function BacklogReport() { const [timeRange, setTimeRange] = useState("90d") + const [companyId, setCompanyId] = useState("all") const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const data = useQuery( api.reports.backlogOverview, - convexUserId ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip" + convexUserId ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) } : "skip" ) + const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined const mostCriticalPriority = useMemo(() => { if (!data) return null @@ -102,8 +105,19 @@ export function BacklogReport() { Acompanhe a evolução dos tickets pelas fases do fluxo de atendimento. + diff --git a/src/components/reports/csat-report.tsx b/src/components/reports/csat-report.tsx index 85f7dfc..39f19b1 100644 --- a/src/components/reports/csat-report.tsx +++ b/src/components/reports/csat-report.tsx @@ -6,9 +6,12 @@ import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { useAuth } from "@/lib/auth-client" import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" function formatScore(value: number | null) { if (value === null) return "—" @@ -16,12 +19,16 @@ function formatScore(value: number | null) { } export function CsatReport() { + const [companyId, setCompanyId] = useState("all") const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const data = useQuery( api.reports.csatOverview, - convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" + convexUserId + ? ({ tenantId, viewerId: convexUserId as Id<"users">, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) }) + : "skip" ) + const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined if (!data) { return ( @@ -42,6 +49,24 @@ export function CsatReport() { CSAT médio Média das respostas recebidas. + + + + {formatScore(data.averageScore)} diff --git a/src/components/reports/hours-report.tsx b/src/components/reports/hours-report.tsx index 7ff4560..691850a 100644 --- a/src/components/reports/hours-report.tsx +++ b/src/components/reports/hours-report.tsx @@ -11,23 +11,40 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { Input } from "@/components/ui/input" function formatHours(ms: number) { const hours = ms / 3600000 return hours.toFixed(2) } +type HoursItem = { + companyId: string + name: string + isAvulso: boolean + internalMs: number + externalMs: number + totalMs: number + contractedHoursPerMonth?: number | null +} + export function HoursReport() { const [timeRange, setTimeRange] = useState("90d") + const [query, setQuery] = useState("") const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const data = useQuery( api.reports.hoursByClient, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip" - ) as { rangeDays: number; items: Array<{ companyId: string; name: string; isAvulso: boolean; internalMs: number; externalMs: number; totalMs: number; contractedHoursPerMonth?: number | null }> } | undefined + ) as { rangeDays: number; items: HoursItem[] } | undefined const items = data?.items ?? [] + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return items + return items.filter((it) => it.name.toLowerCase().includes(q)) + }, [items, query]) return (
@@ -35,17 +52,25 @@ export function HoursReport() { Horas por cliente Horas internas e externas registradas por empresa. - - - - 90 dias - 30 dias - 7 dias - + +
@@ -63,11 +88,11 @@ export function HoursReport() { - {items.map((row) => { + {filtered.map((row) => { const totalH = Number(formatHours(row.totalMs)) const contracted = row.contractedHoursPerMonth ?? null const pct = contracted ? Math.round((totalH / contracted) * 100) : null - const pctBadgeVariant = pct !== null && pct >= 90 ? "destructive" : "secondary" + const pctBadgeVariant: "secondary" | "destructive" = pct !== null && pct >= 90 ? "destructive" : "secondary" return ( {row.name} @@ -78,7 +103,7 @@ export function HoursReport() { {contracted ?? "—"} {pct !== null ? ( - + {pct}% ) : ( @@ -96,4 +121,3 @@ export function HoursReport() { ) } - diff --git a/src/components/reports/sla-report.tsx b/src/components/reports/sla-report.tsx index ffbf405..bb0bf8e 100644 --- a/src/components/reports/sla-report.tsx +++ b/src/components/reports/sla-report.tsx @@ -7,9 +7,12 @@ import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { useAuth } from "@/lib/auth-client" import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { useState } from "react" function formatMinutes(value: number | null) { if (value === null) return "—" @@ -21,12 +24,16 @@ function formatMinutes(value: number | null) { } export function SlaReport() { + const [companyId, setCompanyId] = useState("all") const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const data = useQuery( api.reports.slaOverview, - convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" + convexUserId + ? ({ tenantId, viewerId: convexUserId as Id<"users">, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) }) + : "skip" ) + const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined const queueTotal = useMemo( () => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0, @@ -97,6 +104,24 @@ export function SlaReport() { Distribuição dos {queueTotal} tickets abertos por fila de atendimento. + + + + diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..e292ffe --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,12 @@ +export function csvEscape(value: unknown): string { + const s = value == null ? "" : String(value) + if (/[",\n]/.test(s)) { + return '"' + s.replace(/"/g, '""') + '"' + } + return s +} + +export function rowsToCsv(rows: Array>): string { + return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" +} + diff --git a/src/lib/env.ts b/src/lib/env.ts index 7bb7e66..fd6f2b9 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -6,6 +6,12 @@ const envSchema = z.object({ NEXT_PUBLIC_CONVEX_URL: z.string().url().optional(), DATABASE_URL: z.string().min(1).optional(), NEXT_PUBLIC_APP_URL: z.string().url().optional(), + SMTP_ADDRESS: z.string().optional(), + SMTP_PORT: z.coerce.number().optional(), + SMTP_USERNAME: z.string().optional(), + SMTP_PASSWORD: z.string().optional(), + SMTP_TLS: z.string().optional(), + MAILER_SENDER_EMAIL: z.string().optional(), }) const parsed = envSchema.safeParse(process.env) @@ -21,4 +27,14 @@ export const env = { NEXT_PUBLIC_CONVEX_URL: parsed.data.NEXT_PUBLIC_CONVEX_URL, DATABASE_URL: parsed.data.DATABASE_URL, NEXT_PUBLIC_APP_URL: parsed.data.NEXT_PUBLIC_APP_URL, + SMTP: parsed.data.SMTP_ADDRESS && parsed.data.SMTP_USERNAME && parsed.data.SMTP_PASSWORD + ? { + host: parsed.data.SMTP_ADDRESS, + port: parsed.data.SMTP_PORT ?? 465, + username: parsed.data.SMTP_USERNAME, + password: parsed.data.SMTP_PASSWORD, + tls: (parsed.data.SMTP_TLS ?? "true").toLowerCase() === "true", + from: parsed.data.MAILER_SENDER_EMAIL ?? "no-reply@example.com", + } + : null, } diff --git a/src/lib/time.ts b/src/lib/time.ts new file mode 100644 index 0000000..fbf8f26 --- /dev/null +++ b/src/lib/time.ts @@ -0,0 +1,35 @@ +export function getTZParts(date: Date, timeZone: string) { + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + const parts = Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value])) as Record + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour: Number(parts.hour), + minute: Number(parts.minute), + second: Number(parts.second), + } +} + +export function dateKeyTZ(date: Date, timeZone: string) { + const p = getTZParts(date, timeZone) + const y = String(p.year).padStart(4, "0") + const m = String(p.month).padStart(2, "0") + const d = String(p.day).padStart(2, "0") + return `${y}-${m}-${d}` +} + +export function isAtHourTZ(date: Date, timeZone: string, hour: number) { + const p = getTZParts(date, timeZone) + return p.hour === hour +} + diff --git a/src/server/email-smtp.ts b/src/server/email-smtp.ts new file mode 100644 index 0000000..b4ec093 --- /dev/null +++ b/src/server/email-smtp.ts @@ -0,0 +1,69 @@ +import tls from "tls" + +type SmtpConfig = { + host: string + port: number + username: string + password: string + from: string +} + +function b64(input: string) { + return Buffer.from(input, "utf8").toString("base64") +} + +export async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: string) { + return new Promise((resolve, reject) => { + const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => { + let buffer = "" + const send = (line: string) => socket.write(line + "\r\n") + const wait = (expected: string | RegExp) => + new Promise((res, rej) => { + const onData = (data: Buffer) => { + buffer += data.toString() + const lines = buffer.split(/\r?\n/) + const last = lines.filter(Boolean).slice(-1)[0] ?? "" + if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) { + socket.removeListener("data", onData) + res() + } + } + socket.on("data", onData) + socket.on("error", rej) + }) + + ;(async () => { + await wait(/^220 /) + send(`EHLO ${cfg.host}`) + await wait(/^250-/) + await wait(/^250 /) + send("AUTH LOGIN") + await wait(/^334 /) + send(b64(cfg.username)) + await wait(/^334 /) + send(b64(cfg.password)) + await wait(/^235 /) + send(`MAIL FROM:<${cfg.from.match(/<(.+)>/)?.[1] ?? cfg.from}>`) + await wait(/^250 /) + send(`RCPT TO:<${to}>`) + await wait(/^250 /) + send("DATA") + await wait(/^354 /) + const headers = [ + `From: ${cfg.from}`, + `To: ${to}`, + `Subject: ${subject}`, + "MIME-Version: 1.0", + "Content-Type: text/html; charset=UTF-8", + ].join("\r\n") + send(headers + "\r\n\r\n" + html + "\r\n.") + await wait(/^250 /) + send("QUIT") + socket.end() + resolve() + })().catch(reject) + }) + socket.on("error", reject) + }) +} + diff --git a/tests/csv.test.ts b/tests/csv.test.ts new file mode 100644 index 0000000..3e78ed9 --- /dev/null +++ b/tests/csv.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest" +import { csvEscape, rowsToCsv } from "@/lib/csv" + +describe("csvEscape", () => { + it("escapes quotes and commas", () => { + expect(csvEscape('a,b')).toBe('"a,b"') + expect(csvEscape('a"b')).toBe('"a""b"') + expect(csvEscape('simple')).toBe('simple') + }) +}) + +describe("rowsToCsv", () => { + it("joins rows and columns with newline and commas", () => { + const csv = rowsToCsv([["A", "B"], [1, 2]]) + expect(csv).toBe("A,B\n1,2\n") + }) +}) + diff --git a/tests/time.test.ts b/tests/time.test.ts new file mode 100644 index 0000000..4652ec9 --- /dev/null +++ b/tests/time.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest" +import { dateKeyTZ, getTZParts, isAtHourTZ } from "@/lib/time" + +describe("time tz helpers", () => { + it("computes date key in timezone", () => { + const d = new Date("2025-10-07T10:30:00.000Z") + const key = dateKeyTZ(d, "America/Sao_Paulo") + // 2025-10-07 07:30 in Sao Paulo (UTC-3) + expect(key).toBe("2025-10-07") + }) + + it("matches hour in timezone", () => { + const d = new Date("2025-10-07T11:00:00.000Z") // 08:00 BRT (no DST) + expect(isAtHourTZ(d, "America/Sao_Paulo", 8)).toBe(true) + }) +}) + diff --git a/vitest.config.ts b/vitest.config.ts index 8fdccb2..58baeed 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,8 +11,7 @@ export default defineConfig({ test: { environment: "node", globals: true, - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "tests/**/*.test.ts"], setupFiles: ["./vitest.setup.ts"], }, }) -