Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3e6bb71bf
|
||
|
|
a819151772
|
||
|
|
a71c6fdb70
|
||
|
|
87d9cb31d1 | ||
|
|
35816f0a8a
|
||
|
|
3673f20079
|
||
|
|
0dfaf65b1e
|
||
|
|
2f4ea534e4 | ||
|
|
1a5f00fd47
|
||
|
|
951518b30f
|
||
|
|
c04a3c6252
|
||
|
|
d07780ec7c | ||
|
|
9a8bde285d
|
||
|
|
30d9962e4b | ||
|
|
8fa22fca01
|
||
|
|
f44b476c2e | ||
|
|
0046dbf1a3 | ||
|
|
7d23e5f3c4 | ||
|
|
cef58d9974
|
||
|
|
da0099b697
|
||
|
|
9258576635 | ||
|
|
173efc2b73
|
||
|
|
22b2cfbe8e
|
||
|
|
be20a84e49
|
||
|
|
0271f2e156
|
||
|
|
228650e936
|
||
|
|
96408487bc | ||
|
|
aae2848012 |
@@ -85,10 +85,43 @@ jobs:
|
||||
docker.io/rmcampos/polpa-gestao-api:${{ steps.version.outputs.tag }}-prisma
|
||||
docker.io/rmcampos/polpa-gestao-api:latest-prisma
|
||||
|
||||
- name: Output Docker image URLs
|
||||
run: |
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "🚀 Docker Images Published Successfully!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "API Image:"
|
||||
echo " rmcampos/polpa-gestao-api:${{ steps.version.outputs.tag }}"
|
||||
echo " rmcampos/polpa-gestao-api:latest"
|
||||
echo ""
|
||||
echo "Prisma Image:"
|
||||
echo " rmcampos/polpa-gestao-api:${{ steps.version.outputs.tag }}-prisma"
|
||||
echo " rmcampos/polpa-gestao-api:latest-prisma"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
|
||||
{
|
||||
echo "## 🚀 Docker Images Published Successfully!"
|
||||
echo ""
|
||||
echo "### API Image"
|
||||
echo '```'
|
||||
echo "rmcampos/polpa-gestao-api:${{ steps.version.outputs.tag }}"
|
||||
echo "rmcampos/polpa-gestao-api:latest"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### Prisma Image"
|
||||
echo '```'
|
||||
echo "rmcampos/polpa-gestao-api:${{ steps.version.outputs.tag }}-prisma"
|
||||
echo "rmcampos/polpa-gestao-api:latest-prisma"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
|
||||
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-backend:
|
||||
name: Build
|
||||
runs-on: easynode-debian
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/backend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./backend
|
||||
run: npm i
|
||||
|
||||
- name: Build backend
|
||||
working-directory: ./backend
|
||||
run: npx tsc -p tsconfig.prod.json
|
||||
|
||||
build-frontend:
|
||||
name: Build
|
||||
runs-on: easynode-debian
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm i
|
||||
|
||||
- name: Run lint
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ./frontend
|
||||
run: npm run build
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: easynode-debian
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
has_changes: ${{ steps.check-changes.outputs.has_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -119,63 +119,17 @@ jobs:
|
||||
'
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: easynode-debian
|
||||
needs: terraform-plan
|
||||
if: >
|
||||
(github.event_name == 'push' || github.event_name == 'workflow_run' || inputs.apply == 'true')
|
||||
&& needs.terraform-plan.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: production
|
||||
url: https://polpa-gestao.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform
|
||||
|
||||
- name: Setup Doppler CLI
|
||||
uses: dopplerhq/cli-action@v4
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
env:
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
doppler run --config prd_secrets -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: doppler run --config prd_secrets -- terraform init -input=false
|
||||
|
||||
- name: Terraform Apply
|
||||
working-directory: terraform
|
||||
if: steps.check-changes.outputs.has_changes == 'true'
|
||||
env:
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: doppler run --config prd_secrets -- timeout 2m terraform apply tfplan
|
||||
|
||||
@@ -35,12 +35,6 @@ jobs:
|
||||
run: npm run lint
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
VITE_BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -50,8 +44,13 @@ jobs:
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
echo "Generated build number: ${BUILD_NUMBER}"
|
||||
echo "Generated build number: ${BUILD_NUMBER}"
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
VITE_BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
run: docker buildx inspect --bootstrap
|
||||
@@ -82,11 +81,34 @@ jobs:
|
||||
cache-to: type=registry,ref=rmcampos/polpa-gestao-app:buildcache,mode=max
|
||||
build-args: |
|
||||
VITE_BACKEND_SERVER=${{ vars.BACKEND_SERVER_URL }}
|
||||
VITE_BUILD=${{ steps.version.outputs.tag }}
|
||||
|
||||
VITE_BUILD_NUMBER=${{ steps.version.outputs.build_number }}
|
||||
|
||||
- name: Output Docker image URLs
|
||||
run: |
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "🚀 Docker Images Published Successfully!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "App Image:"
|
||||
echo " rmcampos/polpa-gestao-app:${{ steps.version.outputs.tag }}"
|
||||
echo " rmcampos/polpa-gestao-app:latest"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
|
||||
{
|
||||
echo "## 🚀 Docker Images Published Successfully!"
|
||||
echo ""
|
||||
echo "### App Image"
|
||||
echo '```'
|
||||
echo "rmcampos/polpa-gestao-app:${{ steps.version.outputs.tag }}"
|
||||
echo "rmcampos/polpa-gestao-app:latest"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
|
||||
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# AGENTS.md
|
||||
|
||||
This document provides context and guidelines for AI agents interacting with the Polpa Gestão project.
|
||||
This document provides context and guidelines for AI agents interacting with the Polpa Go project.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Polpa Gestão is a full-stack business management web application designed for managing customers, products, sales, and delivery routes. It serves as a comprehensive platform for administrative tasks within a business.
|
||||
Polpa Go is a full-stack business management web application designed for managing customers, products, sales, and delivery routes. It serves as a comprehensive platform for administrative tasks within a business.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
+75
-5
@@ -7,16 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## api-v2026.06.16.7 && app-v2026.06.16.8 - 2026-06-16
|
||||
## 27/07/2026
|
||||
|
||||
### Adicionado
|
||||
- Nova categoria para POS by Industry: Casa de carnes
|
||||
|
||||
### Corrigido
|
||||
- Bug de filtro na tela de Customers agora filtra corretamente.
|
||||
- Na tela de dashboard na consulta POS By Industry agora considera apenas customers ativos.
|
||||
- Notificaçao Toast agora pode ser clicada para fechar corretamente.
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/polpa-gestao-app:app-v2026.07.27.65
|
||||
docker pull rmcampos/polpa-gestao-api:api-v2026.07.27.61
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 24/07/2026
|
||||
|
||||
### Adicionado
|
||||
- Opção de clicar para ver ou expandir os clientes por indústria na tela de dashboard.
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/polpa-gestao-app:app-v2026.07.24.44
|
||||
docker pull rmcampos/polpa-gestao-api:api-v2026.07.24.43
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 03/07/2026
|
||||
|
||||
### Adicionado
|
||||
- Botão para iniciar uma venda a partir de um PDV na lista de rotas.
|
||||
|
||||
### Alterado
|
||||
- Tela de gerenciar Paradas na página de Rotas, removido o dropdown e adicionado um campo de texto para pesquisa manual.
|
||||
- Nome do app alterado de Polpa Gestão para Polpa Go.
|
||||
- Versão das dependências no frontend e backend, atualizado para as últimas versões disponíveis.
|
||||
|
||||
### Corrigido
|
||||
- Tamanho e viewport em dispositivos móveis empurrando components para fora da tela visíveil.
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/polpa-gestao-app:app-v2026.07.03.32
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 24/06/2026
|
||||
|
||||
### Alterado
|
||||
- Dependências do projeto atualizadas para a última versão, incluindo React.
|
||||
|
||||
### Corrigido
|
||||
- Número do build da versão não aparecendo corretamente.
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/polpa-gestao-api:api-v2026.06.24.22
|
||||
docker pull rmcampos/polpa-gestao-app:app-v2026.06.24.23
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-16
|
||||
|
||||
### Changed
|
||||
- Container Registry to Docker Hub.
|
||||
- Migrated from GitHub to Gitea.
|
||||
|
||||
### Docker images
|
||||
- [rmcampos/polpa-gestao-app:app-v2026.06.16.8](https://hub.docker.com/layers/rmcampos/polpa-gestao-app/app-v2026.06.16.8/images/sha256-030671a44d04701da05abdf15dec4f1c834745a1c13bf404eca19050b92bcf3a)
|
||||
- [rmcampos/polpa-gestao-api:api-v2026.06.16.7](https://hub.docker.com/layers/rmcampos/polpa-gestao-api/api-v2026.06.16.7/images/sha256-57e283fffcaac2a48d95f0ca11f73d8b1937ffc2346a9f4d93dc7b4cd7a3eaa6)
|
||||
- [rmcampos/polpa-gestao-api:api-v2026.06.16.7-prisma](https://hub.docker.com/layers/rmcampos/polpa-gestao-api/api-v2026.06.16.7-prisma/images/sha256-74225e87715fea0803e78ffd1d7f1d9f2c14d058650caa5db2dd504ae7454e46)
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/polpa-gestao-app:app-v2026.06.16.8
|
||||
docker pull rmcampos/polpa-gestao-api:api-v2026.06.16.7
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-13 #1
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Gemini Context: Polpa Gestão
|
||||
# Gemini Context: Polpa Go
|
||||
|
||||
This project is a full-stack business management web application designed for managing customers, products, sales, and delivery routes.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Polpa Gestão
|
||||
# Polpa Go
|
||||
|
||||
A full-stack business management web application for managing customers, points-of-sale (POS), products, sales, and delivery routes.
|
||||
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
FROM node:22-bookworm-slim AS deps
|
||||
FROM node:24-bookworm-slim AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -10,7 +10,7 @@ RUN npm ci && DATABASE_URL=postgres://dummy npx prisma generate
|
||||
|
||||
FROM deps AS prisma
|
||||
|
||||
FROM node:22-bookworm-slim AS builder
|
||||
FROM node:24-bookworm-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -24,7 +24,7 @@ COPY src ./src
|
||||
|
||||
RUN npx tsc -p tsconfig.prod.json
|
||||
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
FROM node:24-bookworm-slim AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
Generated
+118
-82
@@ -11,23 +11,23 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/rate-limit": "^11.0.0",
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@fastify/jwt": "^10.1.0",
|
||||
"@fastify/rate-limit": "^11.1.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"pg": "^8.13.3"
|
||||
"dotenv": "^17.4.2",
|
||||
"fastify": "^5.9.0",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/pg": "^8.11.11",
|
||||
"prisma": "^7.7.0",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prisma": "^7.8.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
@@ -114,6 +114,22 @@
|
||||
"fastify-plugin": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie/node_modules/fastify-plugin": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||
@@ -134,6 +150,22 @@
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors/node_modules/fastify-plugin": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
@@ -186,9 +218,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/jwt": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz",
|
||||
"integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.1.0.tgz",
|
||||
"integrity": "sha512-U1y8ZbxoH1Pjon3euzPJmbCkuYBM+hrQlFWLQWvKmJGCNT6mVsAolnVJdEWfXeQOKpgmuRVCIsPll5RLZxj10A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -203,11 +235,27 @@
|
||||
"dependencies": {
|
||||
"@fastify/error": "^4.2.0",
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"fast-jwt": "^6.0.2",
|
||||
"fast-jwt": "^6.2.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"steed": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/jwt/node_modules/fastify-plugin": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/merge-json-schemas": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
|
||||
@@ -248,9 +296,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/rate-limit": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.0.0.tgz",
|
||||
"integrity": "sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==",
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.1.0.tgz",
|
||||
"integrity": "sha512-BeJ9tizLvmTXGD7deYU5G04OtHhwk5uHxbpEPVp09gKvUBIXmau/4Bshxhu9ci54MvVWfGjCEx4RzvsTntojwA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -264,7 +312,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
@@ -734,12 +782,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
|
||||
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
@@ -943,19 +991,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/c12/node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
@@ -1089,9 +1124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -1210,15 +1245,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-jwt": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz",
|
||||
"integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==",
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.2.4.tgz",
|
||||
"integrity": "sha512-IoQa53wI6TbARU2yelb0L44ggFQnP2qVcwswCSYHbCAWuwpr70icDb3QjG0v01I8Tt01rVGDkN/rRvpk0lKFTA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"asn1.js": "^5.4.1",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"mnemonist": "^0.40.0"
|
||||
"mnemonist": "^0.40.0",
|
||||
"safe-regex2": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -1262,9 +1298,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "5.8.5",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
|
||||
"integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==",
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.9.0.tgz",
|
||||
"integrity": "sha512-VMS5lE0zj+MZlJpQa3Qv5iGjfun0H2N7VRgoBwpcTNQ2bdIQpv7fDpb+HGteGbicBsGkzGS+X+hdx9mmrfWuHQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1284,7 +1320,7 @@
|
||||
"abstract-logging": "^2.0.1",
|
||||
"avvio": "^9.0.0",
|
||||
"fast-json-stringify": "^6.0.0",
|
||||
"find-my-way": "^9.0.0",
|
||||
"find-my-way": "^9.6.0",
|
||||
"light-my-request": "^6.0.0",
|
||||
"pino": "^9.14.0 || ^10.1.0",
|
||||
"process-warning": "^5.0.0",
|
||||
@@ -1295,9 +1331,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fastify-plugin": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
|
||||
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1340,9 +1376,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
|
||||
"integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==",
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
|
||||
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -1590,9 +1626,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.40.3",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
|
||||
"version": "0.40.4",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
|
||||
"integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.4"
|
||||
@@ -1699,14 +1735,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
"pg-protocol": "^1.13.0",
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
@@ -1714,7 +1750,7 @@
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
@@ -1726,16 +1762,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
@@ -1748,18 +1784,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
@@ -2358,9 +2394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2372,9 +2408,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
|
||||
+12
-12
@@ -13,22 +13,22 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/rate-limit": "^11.0.0",
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@fastify/jwt": "^10.1.0",
|
||||
"@fastify/rate-limit": "^11.1.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"pg": "^8.13.3"
|
||||
"dotenv": "^17.4.2",
|
||||
"fastify": "^5.9.0",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/pg": "^8.11.11",
|
||||
"prisma": "^7.7.0",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prisma": "^7.8.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const POS_INDUSTRIES = new Set([
|
||||
'Academia',
|
||||
'Arena de esporte',
|
||||
'Assadão',
|
||||
'Casa de carnes',
|
||||
'Conveniência',
|
||||
'Hamburgueria',
|
||||
'Lanchonete',
|
||||
|
||||
@@ -254,6 +254,46 @@ export default async function dashboardRoutes(app: FastifyInstance) {
|
||||
.sort((a, b) => b.count - a.count);
|
||||
});
|
||||
|
||||
app.get('/pos-by-industry', async (request, reply) => {
|
||||
const { industry } = request.query as { industry?: string };
|
||||
|
||||
if (!industry || industry.trim() === '') {
|
||||
return reply.status(400).send({ error: 'Industry query parameter is required' });
|
||||
}
|
||||
|
||||
const fallbackIndustry = 'Não Informado';
|
||||
const normalizedIndustry = industry.trim();
|
||||
const isFallback = normalizedIndustry === fallbackIndustry;
|
||||
|
||||
const poses = await prisma.customerPos.findMany({
|
||||
where: {
|
||||
disabledAt: null,
|
||||
customer: { disabledAt: null },
|
||||
...(isFallback
|
||||
? { OR: [{ industry: { equals: null } }, { industry: { equals: '' } }] }
|
||||
: { industry: normalizedIndustry })
|
||||
},
|
||||
include: {
|
||||
customer: {
|
||||
select: { name: true }
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ customer: { name: 'asc' } },
|
||||
{ address: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
return poses.map((pos) => ({
|
||||
id: pos.id,
|
||||
customerName: pos.customer.name,
|
||||
address: pos.address,
|
||||
personName: pos.personName,
|
||||
phone: pos.phone,
|
||||
industry: pos.industry?.trim() || fallbackIndustry
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/inactive-pos', async () => {
|
||||
const now = new Date();
|
||||
|
||||
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# dockerhub-cleanup.sh
|
||||
#
|
||||
# Keeps only the N most recently updated tags in a Docker Hub repository
|
||||
# and deletes the rest, using the Docker Hub v2 API.
|
||||
#
|
||||
# Usage:
|
||||
# ./dockerhub-cleanup.sh -u <dockerhub_username> -r <namespace/repo> [-k 5] [-e "latest,stable"] [--yes]
|
||||
#
|
||||
# Examples:
|
||||
# Dry run (default, nothing is deleted):
|
||||
# ./dockerhub-cleanup.sh -u johndoe -r johndoe/myapp -k 5
|
||||
#
|
||||
# Actually delete:
|
||||
# ./dockerhub-cleanup.sh -u johndoe -r johndoe/myapp -k 5 --yes
|
||||
#
|
||||
# Keep 5 newest, never delete "latest" or "stable" even if old:
|
||||
# ./dockerhub-cleanup.sh -u johndoe -r johndoe/myapp -k 5 -e "latest,stable" --yes
|
||||
#
|
||||
# You will be prompted for your Docker Hub password (or a Personal Access
|
||||
# Token, recommended) — it is never passed as a CLI argument or logged.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
KEEP=5
|
||||
EXCLUDE=""
|
||||
EXECUTE=false
|
||||
USERNAME=""
|
||||
REPO=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 -u <username> -r <namespace/repo> [-k keep_count] [-e tag1,tag2] [--yes]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-u) USERNAME="$2"; shift 2 ;;
|
||||
-r) REPO="$2"; shift 2 ;;
|
||||
-k) KEEP="$2"; shift 2 ;;
|
||||
-e) EXCLUDE="$2"; shift 2 ;;
|
||||
--yes) EXECUTE=true; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown argument: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$USERNAME" || -z "$REPO" ]] && usage
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "This script requires 'jq'. Install it first (e.g. apt install jq / brew install jq)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -rsp "Docker Hub password or access token for $USERNAME: " PASSWORD
|
||||
echo
|
||||
|
||||
echo "Authenticating..."
|
||||
TOKEN=$(curl -s -H "Content-Type: application/json" \
|
||||
-X POST -d "{\"username\": \"$USERNAME\", \"password\": \"$PASSWORD\"}" \
|
||||
https://hub.docker.com/v2/users/login/ | jq -r .token)
|
||||
|
||||
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
|
||||
echo "Authentication failed. Check your username/password (or use a Personal Access Token)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Fetching tags for $REPO..."
|
||||
ALL_TAGS_JSON="[]"
|
||||
URL="https://hub.docker.com/v2/repositories/${REPO}/tags/?page_size=100&ordering=last_updated"
|
||||
|
||||
while [[ -n "$URL" && "$URL" != "null" ]]; do
|
||||
PAGE=$(curl -s -H "Authorization: JWT ${TOKEN}" "$URL")
|
||||
ALL_TAGS_JSON=$(jq -s '.[0] + .[1].results' <(echo "$ALL_TAGS_JSON") <(echo "$PAGE"))
|
||||
URL=$(echo "$PAGE" | jq -r '.next')
|
||||
done
|
||||
|
||||
# ordering=last_updated from the API is ascending oldest->newest in some
|
||||
# accounts, so re-sort locally to be safe: newest first.
|
||||
SORTED=$(echo "$ALL_TAGS_JSON" | jq 'sort_by(.last_updated) | reverse')
|
||||
TOTAL=$(echo "$SORTED" | jq 'length')
|
||||
|
||||
echo "Found $TOTAL tags. Keeping the $KEEP most recent."
|
||||
[[ -n "$EXCLUDE" ]] && echo "Always keeping (excluded from deletion): $EXCLUDE"
|
||||
|
||||
IFS=',' read -ra EXCLUDE_ARR <<< "$EXCLUDE"
|
||||
|
||||
KEPT=0
|
||||
DELETED=0
|
||||
|
||||
echo "$SORTED" | jq -c '.[]' | while read -r ENTRY; do
|
||||
TAG=$(echo "$ENTRY" | jq -r '.name')
|
||||
UPDATED=$(echo "$ENTRY" | jq -r '.last_updated')
|
||||
|
||||
IS_EXCLUDED=false
|
||||
for EX in "${EXCLUDE_ARR[@]:-}"; do
|
||||
[[ -n "$EX" && "$TAG" == "$EX" ]] && IS_EXCLUDED=true
|
||||
done
|
||||
|
||||
if $IS_EXCLUDED; then
|
||||
echo "KEEP (excluded) $TAG (last updated: $UPDATED)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# index within the (non-excluded) loop isn't tracked across subshell
|
||||
# iterations reliably in a pipe, so we recompute rank using a counter file.
|
||||
COUNTER_FILE="/tmp/.dockerhub_cleanup_counter"
|
||||
if [[ ! -f "$COUNTER_FILE" ]]; then echo 0 > "$COUNTER_FILE"; fi
|
||||
RANK=$(cat "$COUNTER_FILE")
|
||||
|
||||
if [[ "$RANK" -lt "$KEEP" ]]; then
|
||||
echo "KEEP $TAG (last updated: $UPDATED)"
|
||||
echo $((RANK + 1)) > "$COUNTER_FILE"
|
||||
else
|
||||
if $EXECUTE; then
|
||||
echo "DELETE $TAG (last updated: $UPDATED)"
|
||||
curl -s -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X DELETE -H "Authorization: JWT ${TOKEN}" \
|
||||
"https://hub.docker.com/v2/repositories/${REPO}/tags/${TAG}/"
|
||||
else
|
||||
echo "WOULD DELETE (dry run) $TAG (last updated: $UPDATED)"
|
||||
fi
|
||||
echo $((RANK + 1)) > "$COUNTER_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f /tmp/.dockerhub_cleanup_counter
|
||||
|
||||
if ! $EXECUTE; then
|
||||
echo
|
||||
echo "Dry run complete. No tags were deleted. Re-run with --yes to actually delete."
|
||||
fi
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:24-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Polpa Gestão - Gestão de Polpas</title>
|
||||
<title>Polpa Go - Gestão de Polpas</title>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
|
||||
@@ -21,11 +21,11 @@
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="description" content="Polpa Gestão - Gestão de Polpas" />
|
||||
<meta name="description" content="Polpa Go - Gestão de Polpas" />
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Polpa Gestão">
|
||||
<meta name="apple-mobile-web-app-title" content="Polpa Go">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+522
-229
File diff suppressed because it is too large
Load Diff
@@ -15,23 +15,24 @@
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.0"
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/bootstrap": "^5.2.11",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.1.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vite": "^8.1.3",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"vite-plugin-pwa": {
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function App() {
|
||||
<div className="spinner-border text-primary mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="m-0 text-white-50">Loading Polpa Gestão...</p>
|
||||
<p className="m-0 text-white-50">Loading Polpa Go...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Sidebar({ onLogout }: SidebarProps) {
|
||||
<>
|
||||
{/* Mobile top bar */}
|
||||
<div className="mobile-topbar d-flex d-md-none">
|
||||
<h3 className="fw-bold m-0" style={{ color: 'var(--primary-color)' }}>Polpa Gestão</h3>
|
||||
<h3 className="fw-bold m-0" style={{ color: 'var(--primary-color)' }}>Polpa Go</h3>
|
||||
<button
|
||||
className="btn btn-link p-0"
|
||||
style={{ color: 'var(--text-primary)', fontSize: '1.5rem', lineHeight: 1 }}
|
||||
@@ -36,7 +36,7 @@ export default function Sidebar({ onLogout }: SidebarProps) {
|
||||
<div className={`sidebar d-flex flex-column${isOpen ? ' open' : ''}`}>
|
||||
<div className="mb-3 px-2 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 className="fw-bold m-0" style={{ color: 'var(--primary-color)' }}>Polpa Gestão</h3>
|
||||
<h3 className="fw-bold m-0" style={{ color: 'var(--primary-color)' }}>Polpa Go</h3>
|
||||
<small className="text-secondary">Welcome, {userName}</small>
|
||||
</div>
|
||||
<button className="btn btn-link p-0 d-md-none" style={{ color: 'var(--text-secondary)', fontSize: '1.25rem' }} onClick={closeMenu} aria-label="Close menu">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
@@ -20,6 +21,7 @@
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
border: 1px solid;
|
||||
min-width: 320px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
--warning-color: #f59e0b;
|
||||
}
|
||||
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-dark);
|
||||
@@ -76,6 +80,7 @@ body {
|
||||
|
||||
.main-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
padding: 2rem;
|
||||
margin-left: 250px; /* Sidebar width */
|
||||
}
|
||||
@@ -158,7 +163,7 @@ body {
|
||||
top: 0;
|
||||
border-right: 1px solid var(--glass-border);
|
||||
border-bottom: none;
|
||||
padding: 1.5rem 1rem;
|
||||
padding: calc(60px + 1.5rem) 1rem 1.5rem;
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function ClosestPos() {
|
||||
<div className="text-secondary mt-4">No points of sale with coordinates were found.</div>
|
||||
)}
|
||||
|
||||
<footer className="text-center text-secondary small mt-auto pt-4">© 2026 Polpa Gestão</footer>
|
||||
<footer className="text-center text-secondary small mt-auto pt-4">© 2026 Polpa Go</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Customer, CustomerPOS } from '../types';
|
||||
|
||||
function filterCustomers(customers: Customer[], filterText: string): Customer[] {
|
||||
const normalizedFilter = filterText.trim().toLowerCase();
|
||||
if (!normalizedFilter) return customers;
|
||||
const digits = normalizedFilter.replace(/\D/g, '');
|
||||
const isPhoneSearch = /^\d+$/.test(normalizedFilter) && digits.length >= 3;
|
||||
|
||||
const addressFilterMatches = (pos: CustomerPOS[] | undefined, text: string): boolean => {
|
||||
if (pos && Array.isArray(pos)) {
|
||||
const normalizedText = text.toLowerCase();
|
||||
for (let i = 0; i < pos.length; i++) {
|
||||
const normalizedAddress = pos[i].address
|
||||
.replace(/á/g, 'a')
|
||||
.replace(/ã/g, 'a')
|
||||
.replace(/â/g, 'a')
|
||||
.replace(/ê/g, 'e')
|
||||
.replace(/é/g, 'e')
|
||||
.replace(/ó/g, 'o')
|
||||
.replace(/ú/g, 'u')
|
||||
.replace(/ç/g, 'c')
|
||||
.toLowerCase();
|
||||
if (normalizedAddress.includes(normalizedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return customers.filter((c: Customer) =>
|
||||
c.name.toLowerCase().includes(normalizedFilter) ||
|
||||
(c.personName && c.personName.toLowerCase().includes(normalizedFilter)) ||
|
||||
(isPhoneSearch && c.phone && c.phone.replace(/\D/g, '').includes(digits)) ||
|
||||
addressFilterMatches(c.pos, normalizedFilter)
|
||||
);
|
||||
}
|
||||
|
||||
describe('Customer name filter', () => {
|
||||
const customers: Customer[] = [
|
||||
{
|
||||
name: 'Bakery 3 st',
|
||||
phone: '1234567890',
|
||||
pos: [],
|
||||
},
|
||||
{
|
||||
name: 'Bakery 4 st',
|
||||
phone: '9876543210',
|
||||
pos: [],
|
||||
},
|
||||
{
|
||||
name: 'Another Customer',
|
||||
phone: '1112223333',
|
||||
pos: [],
|
||||
},
|
||||
];
|
||||
|
||||
it('finds "Bakery 3 st" when typing a single word', () => {
|
||||
expect(filterCustomers(customers, 'Bakery')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('finds "Bakery 3 st" when typing a space followed by a number', () => {
|
||||
expect(filterCustomers(customers, ' 3')).toHaveLength(1);
|
||||
expect(filterCustomers(customers, ' 3')[0].name).toBe('Bakery 3 st');
|
||||
});
|
||||
|
||||
it('finds "Bakery 3 st" when typing the full name with spaces', () => {
|
||||
expect(filterCustomers(customers, 'Bakery 3 st')).toHaveLength(1);
|
||||
expect(filterCustomers(customers, 'Bakery 3 st')[0].name).toBe('Bakery 3 st');
|
||||
});
|
||||
|
||||
it('finds "Bakery 3 st" when typing trailing spaces', () => {
|
||||
expect(filterCustomers(customers, ' Bakery 3 st ')).toHaveLength(1);
|
||||
expect(filterCustomers(customers, ' Bakery 3 st ')[0].name).toBe('Bakery 3 st');
|
||||
});
|
||||
|
||||
it('returns all customers when filter is only spaces', () => {
|
||||
expect(filterCustomers(customers, ' ')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const POS_INDUSTRY_OPTIONS = [
|
||||
'Academia',
|
||||
'Arena de esporte',
|
||||
'Assadão',
|
||||
'Casa de carnes',
|
||||
'Conveniência',
|
||||
'Hamburgueria',
|
||||
'Lanchonete',
|
||||
@@ -68,6 +69,7 @@ export default function Customers() {
|
||||
|
||||
const addressFilterMatches = (pos: CustomerPOS[] | undefined, text: string): boolean => {
|
||||
if (pos && Array.isArray(pos)) {
|
||||
const normalizedText = text.toLowerCase();
|
||||
for (let i = 0; i < pos.length; i++) {
|
||||
const normalizedAddress = pos[i].address
|
||||
.replace(/á/g, 'a')
|
||||
@@ -79,21 +81,24 @@ export default function Customers() {
|
||||
.replace(/ú/g, 'u')
|
||||
.replace(/ç/g, 'c')
|
||||
.toLowerCase();
|
||||
return normalizedAddress.includes(text.toLowerCase());
|
||||
if (normalizedAddress.includes(normalizedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
if (!filterText.trim()) return customers;
|
||||
const lower = filterText.toLowerCase();
|
||||
const digits = filterText.replace(/\D/g, '');
|
||||
const normalizedFilter = filterText.trim().toLowerCase();
|
||||
if (!normalizedFilter) return customers;
|
||||
const digits = normalizedFilter.replace(/\D/g, '');
|
||||
const isPhoneSearch = /^\d+$/.test(normalizedFilter) && digits.length >= 3;
|
||||
return customers.filter((c: Customer) =>
|
||||
c.name.toLowerCase().includes(lower) ||
|
||||
(c.personName && c.personName.toLowerCase().includes(lower)) ||
|
||||
(digits && c.phone && c.phone.replace(/\D/g, '').includes(digits)) ||
|
||||
addressFilterMatches(c.pos, filterText)
|
||||
c.name.toLowerCase().includes(normalizedFilter) ||
|
||||
(c.personName && c.personName.toLowerCase().includes(normalizedFilter)) ||
|
||||
(isPhoneSearch && c.phone && c.phone.replace(/\D/g, '').includes(digits)) ||
|
||||
addressFilterMatches(c.pos, normalizedFilter)
|
||||
);
|
||||
}, [customers, filterText]);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import axios from 'axios';
|
||||
import type { FridgePosSummary, InactivePosSummary, IndustriesSummary, RegionsSummary, SalesByCustomer, SalesByProduct, SalesSummary } from '../types';
|
||||
import type { FridgePosSummary, IndustryPosDetail, InactivePosSummary, IndustriesSummary, RegionsSummary, SalesByCustomer, SalesByProduct, SalesSummary } from '../types';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [range, setRange] = useState('last-30-days');
|
||||
@@ -13,6 +13,10 @@ export default function Dashboard() {
|
||||
const [fridgePoses, setFridgePoses] = useState<FridgePosSummary[]>([]);
|
||||
const [showFridgesModal, setShowFridgesModal] = useState(false);
|
||||
const [loadingFridgePoses, setLoadingFridgePoses] = useState(false);
|
||||
const [showIndustryModal, setShowIndustryModal] = useState(false);
|
||||
const [selectedIndustry, setSelectedIndustry] = useState<string | null>(null);
|
||||
const [industryPoses, setIndustryPoses] = useState<IndustryPosDetail[]>([]);
|
||||
const [loadingIndustryPoses, setLoadingIndustryPoses] = useState(false);
|
||||
const [inactivePoses, setInactivePoses] = useState<InactivePosSummary[]>([]);
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
@@ -77,12 +81,35 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const openIndustryModal = async (industry: string) => {
|
||||
setSelectedIndustry(industry);
|
||||
setShowIndustryModal(true);
|
||||
setLoadingIndustryPoses(true);
|
||||
try {
|
||||
const response = await axios.get(`${apiBase}/api/dashboard/pos-by-industry`, {
|
||||
params: { industry }
|
||||
});
|
||||
setIndustryPoses(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load industry POS details', err);
|
||||
setIndustryPoses([]);
|
||||
} finally {
|
||||
setLoadingIndustryPoses(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeIndustryModal = () => {
|
||||
setShowIndustryModal(false);
|
||||
setSelectedIndustry(null);
|
||||
setIndustryPoses([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4">
|
||||
<h2 className="m-0 fw-bold">Dashboard</h2>
|
||||
<div style={{ width: '200px' }}>
|
||||
<select
|
||||
<div style={{ width: '100%', maxWidth: '200px' }}>
|
||||
<select
|
||||
className="form-select bg-dark text-white border-secondary"
|
||||
aria-label="Date range"
|
||||
value={range}
|
||||
@@ -200,7 +227,20 @@ export default function Dashboard() {
|
||||
) : (
|
||||
<ul className="list-group list-group-flush" style={{ background: 'transparent' }}>
|
||||
{industriesSummary.map((item) => (
|
||||
<li key={item.industry} className="list-group-item d-flex justify-content-between align-items-center text-white px-0" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
|
||||
<li
|
||||
key={item.industry}
|
||||
className="list-group-item d-flex justify-content-between align-items-center text-white px-0"
|
||||
style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)', cursor: 'pointer' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => openIndustryModal(item.industry)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openIndustryModal(item.industry);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{item.industry}</span>
|
||||
<span className="badge bg-secondary rounded-pill">{item.count}</span>
|
||||
</li>
|
||||
@@ -363,6 +403,63 @@ export default function Dashboard() {
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{showIndustryModal && selectedIndustry && createPortal(
|
||||
<>
|
||||
<div className="modal-backdrop fade show" style={{ zIndex: 1040 }}></div>
|
||||
<div
|
||||
className="modal fade show d-block"
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="industry-modal-title"
|
||||
style={{ zIndex: 1050 }}
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div className="modal-content glass-card">
|
||||
<div className="modal-header border-bottom-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<h5 id="industry-modal-title" className="modal-title text-white">{selectedIndustry}</h5>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={closeIndustryModal}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{loadingIndustryPoses ? (
|
||||
<p className="text-secondary mb-0">Loading...</p>
|
||||
) : industryPoses.length === 0 ? (
|
||||
<p className="text-secondary mb-0">No POS found for this industry.</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-dark table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Address</th>
|
||||
<th>Contact Person</th>
|
||||
<th>Phone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{industryPoses.map((pos) => (
|
||||
<tr key={pos.id}>
|
||||
<td>{pos.customerName}</td>
|
||||
<td>{pos.address}</td>
|
||||
<td>{pos.personName || '—'}</td>
|
||||
<td>{pos.phone || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer border-top-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<button type="button" className="btn btn-outline-light" onClick={closeIndustryModal}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function Login({ setToken }: LoginProps) {
|
||||
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: '100vh', width: '100vw', padding: '1rem' }}>
|
||||
<div className="glass-card p-5 animate-fade-in" style={{ width: '100%', maxWidth: '450px' }}>
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="fw-bold" style={{ color: 'var(--primary-color)' }}>Polpa Gestão</h2>
|
||||
<h2 className="fw-bold" style={{ color: 'var(--primary-color)' }}>Polpa Go</h2>
|
||||
<p className="text-secondary">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '../context/toast';
|
||||
import type { Customer, CustomerPOS, Route } from '../types';
|
||||
import { CustomerPosCombobox } from '../components/CustomerPosCombobox';
|
||||
|
||||
type RouteStopApiItem = {
|
||||
customerPosId?: string;
|
||||
@@ -49,12 +51,14 @@ const normalizeRoute = (route: RouteApiResponse | Route): Route => {
|
||||
|
||||
export default function RoutesPage() {
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [routesData, setRoutesData] = useState<Route[]>([]);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showStopsModal, setShowStopsModal] = useState(false);
|
||||
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null);
|
||||
const [selectedPosIdToAdd, setSelectedPosIdToAdd] = useState('');
|
||||
const [stopFilterText, setStopFilterText] = useState('');
|
||||
const [newRoute, setNewRoute] = useState<Route>({ name: '', completed: false, dayOfWeek: 0, customerPos: [] });
|
||||
const [editingRoute, setEditingRoute] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -153,6 +157,7 @@ export default function RoutesPage() {
|
||||
const openStopsModal = (route: Route) => {
|
||||
setSelectedRoute(normalizeRoute(route));
|
||||
setSelectedPosIdToAdd('');
|
||||
setStopFilterText('');
|
||||
fetchCustomers();
|
||||
setShowStopsModal(true);
|
||||
};
|
||||
@@ -175,6 +180,7 @@ export default function RoutesPage() {
|
||||
await axios.put(`${apiBase}/api/routes/${selectedRoute.id}`, { customerPosIds: newPosIds });
|
||||
|
||||
setSelectedPosIdToAdd('');
|
||||
setStopFilterText('');
|
||||
fetchRoutes();
|
||||
const res = await axios.get<RouteApiResponse>(`${apiBase}/api/routes/${selectedRoute.id}`);
|
||||
setSelectedRoute(normalizeRoute(res.data));
|
||||
@@ -290,6 +296,9 @@ export default function RoutesPage() {
|
||||
<a href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(cp.address)}`} target="_blank" rel="noopener noreferrer" className="text-secondary small">View on Google Maps</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button className="btn btn-sm btn-outline-success" onClick={() => navigate(`/sales?posId=${cp.id}`)}>New Sale</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{(!route.customerPos || route.customerPos.length === 0) && (
|
||||
@@ -396,20 +405,21 @@ export default function RoutesPage() {
|
||||
<form onSubmit={handleAddStop}>
|
||||
<div className="row g-3">
|
||||
<div className="col-md-9">
|
||||
<select className="form-select form-select-sm" value={selectedPosIdToAdd} onChange={e => setSelectedPosIdToAdd(e.target.value)} required>
|
||||
<option value="" disabled>Select a Customer POS...</option>
|
||||
{customers.map((c: Customer) => (
|
||||
c.pos && c.pos.length > 0 && (
|
||||
<optgroup key={c.id} label={c.name}>
|
||||
{c.pos
|
||||
.filter((p: CustomerPOS) => Boolean(p.id))
|
||||
.map((p: CustomerPOS) => (
|
||||
<option key={p.id} value={p.id}>{p.address} {p.phone ? `(${p.phone})` : ''}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)
|
||||
))}
|
||||
</select>
|
||||
<CustomerPosCombobox
|
||||
customers={customers}
|
||||
selectedPosId={selectedPosIdToAdd}
|
||||
filterText={stopFilterText}
|
||||
onFilterTextChange={(value) => {
|
||||
setStopFilterText(value);
|
||||
if (selectedPosIdToAdd) {
|
||||
setSelectedPosIdToAdd('');
|
||||
}
|
||||
}}
|
||||
onSelectPos={(posId, displayText) => {
|
||||
setSelectedPosIdToAdd(posId);
|
||||
setStopFilterText(displayText);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<button type="submit" className="btn btn-sm btn-success w-100" disabled={loading || !selectedPosIdToAdd}>{loading ? '...' : 'Add Stop'}</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { createPortal } from 'react-dom';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '../context/toast';
|
||||
@@ -73,6 +74,7 @@ const buildSaleCopyText = (sale: Sale) => {
|
||||
|
||||
export default function Sales() {
|
||||
const toast = useToast();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [sales, setSales] = useState<Sale[]>([]);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
@@ -128,6 +130,35 @@ export default function Sales() {
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(function posIdFromUrlEffect() {
|
||||
const posId = searchParams.get('posId');
|
||||
if (!posId || customers.length === 0) return;
|
||||
|
||||
const customerWithPos = customers.find((c) =>
|
||||
c.pos?.some((p) => p.id === posId)
|
||||
);
|
||||
if (!customerWithPos) return;
|
||||
|
||||
const pos = customerWithPos.pos?.find((p) => p.id === posId);
|
||||
if (!pos) return;
|
||||
|
||||
// Syncing local modal state from a URL param (posId) is a legitimate use of an
|
||||
// effect here; there's no way to compute this during render since it depends on
|
||||
// async-loaded `customers`. See tech-debt issue for a proper render-time rewrite.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setEditingMode(false);
|
||||
setEditingSaleId(null);
|
||||
setCustomerFilter(formatCustomerPosDisplay(customerWithPos.name, pos.address));
|
||||
setCustomerPosId(posId);
|
||||
setPaymentMethod('Cash');
|
||||
setPaymentDueDate('');
|
||||
setPaymentDate('');
|
||||
setComments('');
|
||||
setNextVisitDate('');
|
||||
setCart([]);
|
||||
setShowModal(true);
|
||||
}, [customers, searchParams]);
|
||||
|
||||
const filteredSales = useMemo(() => {
|
||||
if (!filterText.trim()) return sales;
|
||||
const lower = filterText.toLowerCase();
|
||||
|
||||
@@ -26,6 +26,15 @@ export type IndustriesSummary = {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type IndustryPosDetail = {
|
||||
id: string;
|
||||
customerName: string;
|
||||
address: string;
|
||||
personName: string | null;
|
||||
phone: string;
|
||||
industry: string;
|
||||
}
|
||||
|
||||
export type RegionsSummary = {
|
||||
region: string;
|
||||
count: number;
|
||||
|
||||
@@ -10,8 +10,8 @@ export default defineConfig({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['assets/*.png', 'assets/*.ico', 'assets/*.xml'],
|
||||
manifest: {
|
||||
name: 'Polpa Gestão',
|
||||
short_name: 'Polpa Gestão',
|
||||
name: 'Polpa Go',
|
||||
short_name: 'Polpa Go',
|
||||
description: 'Manage point of sales, products, routes and sales.',
|
||||
theme_color: '#0f172a',
|
||||
background_color: '#0f172a',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user