Compare commits

...
Author SHA1 Message Date
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
75c367f700 Add active customer and fridge totals to dashboard sales summary (#41)
* Initial plan

* feat: add total customer and fridge metrics to dashboard summary

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/7948292d-7cd7-4360-a09a-5434db711f03

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-05-11 16:00:04 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcampos
35cc0e5d01 feat: Next Visit Tracking and Visits Management View (#39)
* Initial plan

* feat: implement Next Visit Tracking and Visits Management View

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/8d2a7533-e172-4839-9acf-48daa6dc3ba7

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: address code review feedback - improve type safety and fix wording

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/8d2a7533-e172-4839-9acf-48daa6dc3ba7

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: regenerate Prisma client to include nextVisitDate and visitedAt fields

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/e7506c35-a040-449e-b066-102921983690

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* feat: display day name in the next visit card

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
2026-05-11 15:40:00 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
ab464cee56 Add fridge count to customer POS records (#37)
* Initial plan

* Add fridge count to customer POS

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/eeb51ab2-fe81-4dfc-8dfd-7ecf96a5e3bd

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-05-11 14:41:17 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
39f2fffcc0 Implement single searchable customer/POS combobox in Sale modal (#32)
* Initial plan

* feat: add customer filter in sales modal customer select

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/3bfe5336-fda4-4c8f-b8ba-04bdf8219b30

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* feat: replace sales customer select with searchable combobox

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/0f0c88bc-019b-4edc-9e0b-4c7c4675d2ed

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: improve sales customer combobox accessibility and shared formatting

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/0f0c88bc-019b-4edc-9e0b-4c7c4675d2ed

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: add keyboard navigation and aria support to customer combobox

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/0f0c88bc-019b-4edc-9e0b-4c7c4675d2ed

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: refine combobox option focus styling and key handling

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/0f0c88bc-019b-4edc-9e0b-4c7c4675d2ed

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* chore: polish combobox filtering and active descendant handling

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/0f0c88bc-019b-4edc-9e0b-4c7c4675d2ed

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: make customer combobox dropdown fully opaque

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/323c9f36-4c6e-47d2-ba7f-d148e21a1c45

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* chore: extract combobox dropdown color constants

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/323c9f36-4c6e-47d2-ba7f-d148e21a1c45

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-05-08 16:33:39 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
9e8aff62c3 feat: Add customer filter to Sales page (#30)
* Initial plan

* feat: add customer filter to Sales page

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/96da234b-0507-4920-8a3f-7ee592ce28b3

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-05-08 15:38:57 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
65fb7c0b3a Add deployment build number to frontend sidebar and wire it through CD/local builds (#28)
* Initial plan

* feat: add sidebar build number from VITE_BUILD_NUMBER

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/a3fa6082-565d-42d7-9a7f-2d0b77b21c23

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-05-07 17:00:54 -03:00
rmcamposandGitHub eedfaf6aa7 chore: upgrade prisma (#26)
* chore: add issue command to gemini

* feat: upgrade prisma to v7. Working locally
2026-05-07 16:17:20 -03:00
rmcampos cb2d4534fc fix: terraform file for newly backup sections 2026-05-05 23:24:45 +02:00
rmcamposandGitHub 111a8f6333 feat: add automate backups (#23)
* feat: add automated backup routine

* feat: update deploy file to include r2 secrets
2026-05-04 18:46:03 -03:00
rmcampos cfd139666d chore: add support to ngrok 2026-04-30 00:06:23 +02:00
rmcamposGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposCopilot
429a4bacf6 feat: improve exported text and sales page (#24)
* Initial plan

* Add delete, clone, and edit functionality for sales

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/f8c673c1-7796-4d84-8952-09f525e5703d

* fix: issues raised by copilot

* Update frontend/src/pages/Sales.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update frontend/src/pages/Sales.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update backend/src/routes/sales.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat: update exported text and sales page

* chore: add dockerignore

* fix: remove modal glass and improve copy to whats

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-29 18:22:35 -03:00
CopilotandGitHub 5d550e92a2 Add Past Week / Past Month / Past Year smart filters to dashboard (#22) 2026-04-21 00:03:27 -03:00
rmcamposandGitHub 1cd71ccd7a Update main.tf 2026-04-20 22:51:04 -03:00
rmcamposandGitHub 5ad13a0783 feat: automate deployment (#20) 2026-04-20 22:44:33 -03:00
CopilotandGitHub e5129a18ca feat: Add stats summary cards to Dashboard (#19) 2026-04-20 22:44:07 -03:00
rmcamposGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposCopilot
9ac0f58f0a feat: improve dashboard. Issue #15 (#17)
* feat: improve dashboard. Issue #15

* fix: add aria-label to date range select for accessibility

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/dab0d39e-cb38-4df9-b1dd-8e824ee48fce

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* perf: eliminate N+1 queries in /sales-by-customer endpoint

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/8a3243df-a2dc-45af-a2cb-983f267ac900

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* Update backend/src/routes/dashboard.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* perf: eliminate N+1 queries in /sales-by-product using groupBy

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/72b19782-ccc7-4653-9ddb-ab39bde7114b

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* feat: enable to run and test on VPS

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 19:03:03 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
d2109218a5 Add live search filter input to Customers page (#16)
* Initial plan

* Add live search filter input to Customers page

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/826c4090-008f-463e-bf2d-581e214ab023

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-04-15 16:57:59 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcampos
2ab79f3886 Show optional person name on customer cards (#13)
* Initial plan

* feat: show customer person name in customer cards when available

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/96d3237d-ad6d-4ed1-b5b5-11e5bd011891

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* refactor: avoid duplicate trim when rendering customer person name

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/96d3237d-ad6d-4ed1-b5b5-11e5bd011891

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
2026-04-15 16:48:29 -03:00
rmcampos 0e4b58513f chore(backend): bump dependencies 2026-04-15 20:42:05 +02:00
rmcampos d8ac1ed064 chore(frontend): bump vite to v8 2026-04-15 20:16:16 +02:00
rmcampos 9c24ae1d85 chore(frontend): bump typescript to v6 2026-04-15 20:08:21 +02:00
rmcampos e006cbccba chore(frontend): bump dependencies 2026-04-15 20:06:53 +02:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcamposCopilot
8ec79378a8 Add edit, clone, and delete actions for sales (#10)
* Initial plan

* Add delete, clone, and edit functionality for sales

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/f8c673c1-7796-4d84-8952-09f525e5703d

* fix: issues raised by copilot

* Update frontend/src/pages/Sales.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update frontend/src/pages/Sales.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update backend/src/routes/sales.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-25 16:29:49 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
c12aeaabf3 Make customer document (CPF/CNPJ) optional (#8)
* Initial plan

* Make customer document (CPF/CNPJ) optional

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/b6b18dae-73ef-47f9-b426-b4da695e2913

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-03-25 14:22:58 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
5538221546 Allow Customer POS Edit (#6)
* Initial plan

* Allow Customer POS Edit - add edit mode to Manage POS modal

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/714e24ad-4dd1-428c-b994-27ac3c2f241b

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-03-25 10:37:35 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcamposCopilot
2a4c964c42 Add optional personName field to Customer and POS (#4)
* Initial plan

* Add person name to customers and POS

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/a929d818-ae1f-49ae-82ff-35037f9c1c97

* Change personName column type to VARCHAR(30)

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/68104e85-af93-4653-a7f8-e3d37c1b0c10

* Restore dropped pos relation in Customer model

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/5892e5e3-06d1-4c7c-a378-1469a1a3868c

* Update Customers.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Customers.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update customers.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-25 10:20:27 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
aad97f075c Add root-level README.md (#2)
* Initial plan

* Create README.md with project overview, features, Docker setup, and Terraform deployment info

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/3f7f7e0f-4c2d-49fa-90fe-d7c2e65a33eb

* Replace docker-compose with docker compose throughout README.md

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/3e8d8540-b495-4412-8f39-3b9e4b2cacf8

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-03-23 13:58:20 -03:00
rmcampos 20b4feef2b feat: improve app name and icon 2026-03-20 17:38:57 -03:00
91 changed files with 18674 additions and 1466 deletions
+11
View File
@@ -0,0 +1,11 @@
description = "Create a formatted GitHub issue"
prompt = """
Act as a project manager. Based on the following input: {{args}}
Create a GitHub issue using the `gh` CLI tool with this exact structure:
- Title: [Feat/Bug] <short summary>
- Body: A detailed description, technical requirements, and acceptance criteria.
- Label: Automatically determine if it's 'bug', 'enhencement', or 'task'.
Once the user confirms the plan, execute the command:
`gh issue create --title "[Type] Title" --body "..." --label "..."
"""
+166
View File
@@ -0,0 +1,166 @@
name: Deploy to prod
on:
workflow_dispatch:
inputs:
backend_image:
description: "Backend image tag (full image reference)"
required: false
frontend_image:
description: "Frontend image tag (full image reference)"
required: false
apply:
description: "Apply changes after plan"
required: false
default: "true"
workflow_run:
workflows: [ "Backend CD", "Frontend CD" ]
types: [ completed ]
jobs:
terraform-plan:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
outputs:
no_changes: ${{ steps.check-changes.outputs.no_changes }}
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Setup Kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Validate cluster access
run: |
kubectl cluster-info
kubectl get namespace polpa-gestao
- name: Determine deployment values
id: deploy-vars
run: |
backend_image="${{ github.event.inputs.backend_image }}"
frontend_image="${{ github.event.inputs.frontend_image }}"
latest_backend_tag="$(git tag --list 'api-v*' | sort -V | tail -n1)"
echo "latest backend tag=$latest_backend_tag"
latest_frontend_tag="$(git tag --list 'app-v*' | sort -V | tail -n1)"
echo "latest frontend tag=$latest_frontend_tag"
if [ -z "$backend_image" ]; then
backend_image="ghcr.io/rmcampos/polpa-gestao/backend:$latest_backend_tag"
fi
if [ -z "$frontend_image" ]; then
frontend_image="ghcr.io/rmcampos/polpa-gestao/frontend:$latest_frontend_tag"
fi
echo "Resolved backend_image=$backend_image"
echo "Resolved frontend_image=$frontend_image"
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
- name: Terraform Fmt -check -diff
working-directory: terraform
run: terraform fmt -check -diff
- name: Terraform Init
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
- name: Terraform Validate
working-directory: terraform
run: terraform validate
- name: Terraform Plan
id: check-changes
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: |
timeout 1m terraform plan -input=false -out=tfplan \
-var="db_user=${{ secrets.DB_USER }}" \
-var="db_password=${{ secrets.DB_PASSWORD }}" \
-var="db_name=${{ secrets.DB_NAME }}" \
-var="cpf_cnpj_api_token=${{ secrets.CPF_CNPJ_API_TOKEN }}" \
-var="r2_access_key=${{ secrets.R2_ACCESS_KEY_ID }}" \
-var="r2_secret_key=${{ secrets.R2_SECRET_ACCESS_KEY }}" \
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
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 "No changes to apply."
exit 0
else
echo "Changes detected. Proceeding with apply"
echo "no_changes=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload plan artifact
uses: actions/upload-artifact@v4
with:
name: tfplan
path: terraform/tfplan
terraform-apply:
runs-on: ubuntu-latest
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@v6
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Download plan artifact
uses: actions/download-artifact@v4
with:
name: tfplan
path: terraform
- name: Setup Kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Terraform Init
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
- name: Terraform Apply
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: timeout 1m terraform apply tfplan
+6 -2
View File
@@ -20,8 +20,11 @@ jobs:
run: |
DATE=$(date +'%Y.%m.%d')
TAG="app-v${DATE}.${{ github.run_number }}"
BUILD_NUMBER="${DATE}.${{ github.run_id }}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT
echo "Generated tag: ${TAG}"
echo "Generated build number: ${BUILD_NUMBER}"
- uses: actions/checkout@v6
with:
@@ -38,7 +41,7 @@ jobs:
- run: cd frontend && npm run lint
- run: cd frontend && npm run build
env:
VITE_BUILD_VERSION: ${{ steps.version.outputs.tag }}
VITE_BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
VITE_CPF_CNPJ_API_TOKEN: ${{ secrets.CPF_CNPJ_API_TOKEN }}
- name: Set up Docker Buildx
@@ -72,6 +75,7 @@ jobs:
build-args: |
VITE_BACKEND_SERVER=${{ vars.BACKEND_SERVER_URL }}
VITE_CPF_CNPJ_API_TOKEN=${{ secrets.CPF_CNPJ_API_TOKEN }}
VITE_BUILD_NUMBER=${{ steps.version.outputs.build_number }}
- name: Create and push Git tag
run: |
@@ -79,4 +83,4 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
+4
View File
@@ -1,3 +1,7 @@
node_modules
dist
.env
# IDE
.idea
.vscode
+83
View File
@@ -0,0 +1,83 @@
# Gemini Context: Polpa Gestão
This project is a full-stack business management web application designed for managing customers, products, sales, and delivery routes.
## Project Overview
- **Architecture:** Monorepo-like structure with separate `backend` and `frontend` directories.
- **Backend:** Node.js 22, Fastify 5, TypeScript, Prisma ORM.
- **Frontend:** React 19, TypeScript, Vite, Bootstrap 5.
- **Database:** PostgreSQL 15.
- **Infrastructure:** Docker, Docker Compose, GitHub Actions, Terraform.
## Directory Structure
- `backend/`: Fastify API server and Prisma schema.
- `src/routes/`: API endpoint definitions.
- `prisma/`: Database schema and migrations.
- `frontend/`: React application.
- `src/pages/`: Main view components.
- `src/components/`: Reusable UI components.
- `src/context/`: React context providers (e.g., Toast).
- `docker-compose.yml`: Orchestrates the database, backend, and frontend for local development.
## Building and Running
### Local Development (Recommended)
The easiest way to run the full stack is using Docker Compose:
```bash
docker compose up
```
- **Frontend:** http://localhost:5173
- **Backend API:** http://localhost:3000
- **Database:** localhost:5432 (User: `admin`, Password: `adminpassword`, DB: `polpa_gestao`)
### Manual Setup
#### Backend
```bash
cd backend
npm install
# Ensure DATABASE_URL is set in .env
npx prisma migrate dev
npm run server # or ./run-server.sh which uses ts-node
```
#### Frontend
```bash
cd frontend
npm install
npm run dev
```
## Development Conventions
### Backend
- **Routing:** Register new routes in `backend/src/index.ts` from the `backend/src/routes/` directory.
- **Authentication:** Use the `authenticate` decorator on routes requiring protection. Authentication is JWT-based.
- **Database:** Always update `prisma/schema.prisma` and run `npx prisma migrate dev` for schema changes.
- **Validation:** Use Fastify's built-in schema validation where possible.
### Frontend
- **Styling:** Primarily Bootstrap 5 with custom CSS in `.css` files.
- **State Management:** React Hooks (useState, useEffect) and Context API for global UI state (like notifications).
- **API Calls:** Use `axios`. Environment variables (like `VITE_BACKEND_SERVER`) are used for configuration.
### General
- **Naming:** Use `camelCase` for TypeScript variables, functions, and database fields (as per Prisma schema).
- **Types:** Strictly adhere to TypeScript. Define shared types in `frontend/src/types.ts` or relevant backend models.
- **Testing:** No formal test suite is currently implemented (placeholder in `package.json`). Add tests to `backend/tests` or `frontend/src/__tests__` as needed.
## Common Tasks
- **Seeding the Database:**
Run `backend/run-seed.sh` or `node dist/seed.js` inside the backend container.
- **Adding a new Data Model:**
1. Edit `backend/prisma/schema.prisma`.
2. Run `npx prisma migrate dev --name <migration_name>`.
3. Regenerate Prisma client (done automatically by migrate).
- **Updating Frontend PWA:**
Vite PWA plugin is configured in `frontend/vite.config.ts`.
+84
View File
@@ -0,0 +1,84 @@
# Polpa Gestão
A full-stack business management web application for managing customers, points-of-sale (POS), products, sales, and delivery routes.
## Features
### Customer & POS Management
Manage your customer database including personal and business details (CPF/CNPJ), phone numbers, and multiple points-of-sale per customer. Each customer can have one or more POS locations, making it easy to track deliveries to different addresses.
### Products
Maintain a product catalog with pricing, cost, and stock information. Products are linked to sales so inventory levels are always up to date.
### Sales
Record and track sales transactions with support for delivery status, payment methods, due dates, and additional comments. Each sale can include multiple products, and the status can be updated as orders are processed and delivered.
### Routes
Define delivery routes organized by day of the week and assign customer POS locations to them. This makes it easy to plan and track distribution for each day.
### Dashboard & Authentication
A dashboard provides analytics and reporting. The application includes user management with role-based access control secured by JWT authentication.
## Tech Stack
| Layer | Technology |
|---|---|
| Frontend | React 19, TypeScript, Vite, Bootstrap 5 |
| Backend | Node.js 22, Fastify 5, TypeScript |
| Database | PostgreSQL 15 with Prisma ORM |
| Containerization | Docker, Docker Compose |
| CI/CD | GitHub Actions → GitHub Container Registry |
| Deployment | Terraform |
## Running Locally with Docker
The entire application stack (database, backend API, and frontend) can be started with a single command using Docker Compose.
**Prerequisites:** [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed.
**Start all services:**
```bash
docker compose up
```
This command will:
1. Start a **PostgreSQL 15** database on port `5432`
2. Run **Prisma migrations** automatically to set up the database schema
3. Start the **Fastify backend API** on port `3000`
4. Start the **React frontend** (via Nginx) on port `5173`
**Access the application:**
| Service | URL |
|---|---|
| Frontend | http://localhost:5173 |
| Backend API | http://localhost:3000 |
| Database | `localhost:5432` (user: `admin`, password: `adminpassword`, db: `polpa_gestao`) |
**Optional environment variable:**
To enable CPF/CNPJ document validation, set the `VITE_CPF_CNPJ_API_TOKEN` variable before starting:
```bash
VITE_CPF_CNPJ_API_TOKEN=your_token_here docker compose up
```
**Stop all services:**
```bash
docker compose down
```
Database data is persisted in a Docker volume (`pgdata`) and will survive container restarts. To also remove the volume when stopping, run `docker compose down -v`.
## Deployment
The application is deployed using **Terraform**. The infrastructure-as-code configuration can be found in the following public repository:
[https://github.com/RMCampos/personal-projects-iaac/blob/main/polpa-gestao/main.tf](https://github.com/RMCampos/personal-projects-iaac/blob/main/polpa-gestao/main.tf)
Docker images are built and published to the GitHub Container Registry automatically via GitHub Actions on every push:
- `ghcr.io/rmcampos/polpa-gestao/backend:latest`
- `ghcr.io/rmcampos/polpa-gestao/frontend:latest`
+6 -2
View File
@@ -4,8 +4,9 @@ WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma
COPY prisma.config.ts ./
RUN npm ci && npx prisma generate
RUN npm ci && DATABASE_URL=postgres://dummy npx prisma generate
FROM deps AS prisma
@@ -14,9 +15,11 @@ FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/src/generated/prisma ./src/generated/prisma
COPY package*.json ./
COPY tsconfig.json ./
COPY prisma ./prisma
COPY prisma.config.ts ./
COPY src ./src
RUN npx tsc
@@ -31,8 +34,9 @@ ENV NODE_ENV=production
COPY package*.json ./
COPY prisma ./prisma
COPY prisma.config.ts ./
RUN npm ci --omit=dev && npx prisma generate && npm cache clean --force
RUN npm ci --omit=dev && DATABASE_URL=postgres://dummy npx prisma generate && npm cache clean --force
COPY healthcheck.js ./
COPY --from=builder /app/dist ./dist
+1051 -185
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -13,16 +13,20 @@
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@prisma/client": "^6.19.2",
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"bcrypt": "^6.0.0",
"fastify": "^5.8.2",
"fastify-plugin": "^5.1.0"
"dotenv": "^16.4.7",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"pg": "^8.13.3"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/node": "^25.5.0",
"prisma": "^6.19.2",
"@types/node": "^25.6.0",
"@types/pg": "^8.11.11",
"prisma": "^7.7.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
"typescript": "^6.0.2"
}
}
+12
View File
@@ -0,0 +1,12 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Customer" ADD COLUMN "personName" VARCHAR(30);
-- AlterTable
ALTER TABLE "CustomerPos" ADD COLUMN "personName" VARCHAR(30);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Customer" ALTER COLUMN "document" DROP NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CustomerPos" ADD COLUMN "fridgeCount" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Sale" ADD COLUMN "nextVisitDate" TIMESTAMP(3),
ADD COLUMN "visitedAt" TIMESTAMP(3);
+9 -3
View File
@@ -1,10 +1,11 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
@@ -21,8 +22,9 @@ model User {
model Customer {
id String @id @default(uuid()) @db.Uuid
name String
document String @unique
document String? @unique
phone String?
personName String? @db.VarChar(30)
pos CustomerPos[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -35,6 +37,8 @@ model CustomerPos {
customer Customer @relation(fields: [customerId], references: [id])
address String
phone String
personName String? @db.VarChar(30)
fridgeCount Int @default(0)
sales Sale[]
routes RouteCustomerPos[]
createdAt DateTime @default(now())
@@ -63,6 +67,8 @@ model Sale {
paymentDueDate DateTime?
paymentDate DateTime?
comments String?
nextVisitDate DateTime?
visitedAt DateTime?
products SaleProduct[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+59
View File
@@ -0,0 +1,59 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser.js'
export { Prisma }
export * as $Enums from './enums.js'
export * from './enums.js';
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Customer
*
*/
export type Customer = Prisma.CustomerModel
/**
* Model CustomerPos
*
*/
export type CustomerPos = Prisma.CustomerPosModel
/**
* Model Product
*
*/
export type Product = Prisma.ProductModel
/**
* Model Sale
*
*/
export type Sale = Prisma.SaleModel
/**
* Model SaleProduct
*
*/
export type SaleProduct = Prisma.SaleProductModel
/**
* Model Route
*
*/
export type Route = Prisma.RouteModel
/**
* Model RouteCustomerPos
*
*/
export type RouteCustomerPos = Prisma.RouteCustomerPosModel
+81
View File
@@ -0,0 +1,81 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.js"
import * as $Class from "./internal/class.js"
import * as Prisma from "./internal/prismaNamespace.js"
export * as $Enums from './enums.js'
export * from "./enums.js"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient({
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
* })
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*
* Read more in our [docs](https://pris.ly/d/client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Customer
*
*/
export type Customer = Prisma.CustomerModel
/**
* Model CustomerPos
*
*/
export type CustomerPos = Prisma.CustomerPosModel
/**
* Model Product
*
*/
export type Product = Prisma.ProductModel
/**
* Model Sale
*
*/
export type Sale = Prisma.SaleModel
/**
* Model SaleProduct
*
*/
export type SaleProduct = Prisma.SaleProductModel
/**
* Model Route
*
*/
export type Route = Prisma.RouteModel
/**
* Model RouteCustomerPos
*
*/
export type RouteCustomerPos = Prisma.RouteCustomerPosModel
@@ -0,0 +1,447 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.js"
import type * as Prisma from "./internal/prismaNamespace.js"
export type UuidFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedUuidFilter<$PrismaModel> | string
}
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type UuidWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedUuidWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type FloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedUuidFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedUuidFilter<$PrismaModel> | string
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedUuidWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedUuidWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
+15
View File
@@ -0,0 +1,15 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
// This file is empty because there are no enums in the schema.
export {}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,207 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models.js'
export type * from './prismaNamespace.js'
export const Decimal = runtime.Decimal
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const ModelName = {
User: 'User',
Customer: 'Customer',
CustomerPos: 'CustomerPos',
Product: 'Product',
Sale: 'Sale',
SaleProduct: 'SaleProduct',
Route: 'Route',
RouteCustomerPos: 'RouteCustomerPos'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = runtime.makeStrictEnum({
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
} as const)
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const UserScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
password: 'password',
role: 'role',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
disabledAt: 'disabledAt'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export const CustomerScalarFieldEnum = {
id: 'id',
name: 'name',
document: 'document',
phone: 'phone',
personName: 'personName',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
disabledAt: 'disabledAt'
} as const
export type CustomerScalarFieldEnum = (typeof CustomerScalarFieldEnum)[keyof typeof CustomerScalarFieldEnum]
export const CustomerPosScalarFieldEnum = {
id: 'id',
customerId: 'customerId',
address: 'address',
phone: 'phone',
personName: 'personName',
fridgeCount: 'fridgeCount',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
disabledAt: 'disabledAt'
} as const
export type CustomerPosScalarFieldEnum = (typeof CustomerPosScalarFieldEnum)[keyof typeof CustomerPosScalarFieldEnum]
export const ProductScalarFieldEnum = {
id: 'id',
name: 'name',
price: 'price',
stock: 'stock',
cost: 'cost',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
disabledAt: 'disabledAt'
} as const
export type ProductScalarFieldEnum = (typeof ProductScalarFieldEnum)[keyof typeof ProductScalarFieldEnum]
export const SaleScalarFieldEnum = {
id: 'id',
customerPosId: 'customerPosId',
delivered: 'delivered',
paymentMethod: 'paymentMethod',
paymentDueDate: 'paymentDueDate',
paymentDate: 'paymentDate',
comments: 'comments',
nextVisitDate: 'nextVisitDate',
visitedAt: 'visitedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SaleScalarFieldEnum = (typeof SaleScalarFieldEnum)[keyof typeof SaleScalarFieldEnum]
export const SaleProductScalarFieldEnum = {
saleId: 'saleId',
productId: 'productId',
quantity: 'quantity',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SaleProductScalarFieldEnum = (typeof SaleProductScalarFieldEnum)[keyof typeof SaleProductScalarFieldEnum]
export const RouteScalarFieldEnum = {
id: 'id',
name: 'name',
completed: 'completed',
dayOfWeek: 'dayOfWeek',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type RouteScalarFieldEnum = (typeof RouteScalarFieldEnum)[keyof typeof RouteScalarFieldEnum]
export const RouteCustomerPosScalarFieldEnum = {
routeId: 'routeId',
customerPosId: 'customerPosId'
} as const
export type RouteCustomerPosScalarFieldEnum = (typeof RouteCustomerPosScalarFieldEnum)[keyof typeof RouteCustomerPosScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const QueryMode = {
default: 'default',
insensitive: 'insensitive'
} as const
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
+19
View File
@@ -0,0 +1,19 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This is a barrel export file for all models and their related types.
*
* 🟢 You can import this file directly.
*/
export type * from './models/User.js'
export type * from './models/Customer.js'
export type * from './models/CustomerPos.js'
export type * from './models/Product.js'
export type * from './models/Sale.js'
export type * from './models/SaleProduct.js'
export type * from './models/Route.js'
export type * from './models/RouteCustomerPos.js'
export type * from './commonInputTypes.js'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -28,6 +28,7 @@ import customersRoutes from './routes/customers';
import productsRoutes from './routes/products';
import routesApi from './routes/routes';
import salesRoutes from './routes/sales';
import visitsRoutes from './routes/visits';
import dashboardRoutes from './routes/dashboard';
import healthRoutes from './routes/health';
@@ -36,6 +37,7 @@ app.register(customersRoutes, { prefix: '/api/customers' });
app.register(productsRoutes, { prefix: '/api/products' });
app.register(routesApi, { prefix: '/api/routes' });
app.register(salesRoutes, { prefix: '/api/sales' });
app.register(visitsRoutes, { prefix: '/api/visits' });
app.register(dashboardRoutes, { prefix: '/api/dashboard' });
app.register(healthRoutes, { prefix: '/api/health' });
+9 -2
View File
@@ -1,3 +1,10 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from './generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
export const prisma = new PrismaClient();
const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
export const prisma = new PrismaClient({ adapter });
+32 -12
View File
@@ -1,6 +1,15 @@
import { FastifyInstance } from 'fastify';
import { prisma } from '../prisma';
const parseFridgeCount = (value: unknown): number | undefined => {
if (value === undefined || value === null || value === '') return undefined;
const parsedValue = Number(value);
if (!Number.isInteger(parsedValue) || parsedValue < 0) return 0;
return parsedValue;
};
export default async function customersRoutes(app: FastifyInstance) {
// Get all customers (with pos optionally)
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
@@ -24,9 +33,10 @@ export default async function customersRoutes(app: FastifyInstance) {
// Create customer
app.post('/', { preValidation: [app.authenticate] }, async (request, reply) => {
const { name, document, phone } = request.body as any;
const { name, document, phone, personName } = request.body as any;
const docValue = document || null;
try {
return await prisma.customer.create({ data: { name, document, phone } });
return await prisma.customer.create({ data: { name, document: docValue, phone, personName } });
} catch (e: any) {
if (e.code === 'P2002') return reply.code(400).send({ error: 'Document already exists' });
throw e;
@@ -36,19 +46,23 @@ export default async function customersRoutes(app: FastifyInstance) {
// Update customer
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as any;
const { name, document, phone } = request.body as any;
const { name, document, phone, personName } = request.body as any;
const docValue = document || null;
try {
if (document) {
if (docValue) {
const existing = await prisma.customer.findFirst({
where: { document, id: { not: id } }
where: { document: docValue, id: { not: id } }
});
if (existing) {
return reply.code(400).send({ error: 'Document already exists for another customer' });
}
}
return await prisma.customer.update({ where: { id }, data: { name, document, phone } });
} catch (e) {
return reply.code(404).send({ error: 'Customer not found' });
return await prisma.customer.update({ where: { id }, data: { name, document: docValue, phone, personName } });
} catch (e: any) {
if (e && e.code === 'P2025') {
return reply.code(404).send({ error: 'Customer not found' });
}
return reply.code(400).send({ error: 'Invalid customer data' });
}
});
@@ -63,18 +77,24 @@ export default async function customersRoutes(app: FastifyInstance) {
app.post('/:customerId/pos', { preValidation: [app.authenticate] }, async (request, reply) => {
const { customerId } = request.params as any;
const { address, phone } = request.body as any;
const { address, phone, personName, fridgeCount } = request.body as any;
return prisma.customerPos.create({
data: { customerId, address, phone }
data: { customerId, address, phone, personName, fridgeCount: parseFridgeCount(fridgeCount) ?? 0 }
});
});
app.put('/pos/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as any;
const { address, phone } = request.body as any;
const { address, phone, personName, fridgeCount } = request.body as any;
const normalizedFridgeCount = parseFridgeCount(fridgeCount);
return prisma.customerPos.update({
where: { id },
data: { address, phone }
data: {
address,
phone,
personName,
...(normalizedFridgeCount !== undefined ? { fridgeCount: normalizedFridgeCount } : {})
}
});
});
+173 -53
View File
@@ -1,71 +1,191 @@
import { FastifyInstance } from 'fastify';
import { prisma } from '../prisma';
function getDateRange(range: string): { startDate: Date; endDate: Date | null } {
const now = new Date();
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
let endDate: Date | null = null;
switch (range) {
case 'this-week': {
const day = now.getDay();
const diff = now.getDate() - day + (day === 0 ? -6 : 1);
startDate.setDate(diff);
break;
}
case 'this-month':
startDate.setDate(1);
break;
case 'this-year':
startDate.setMonth(0, 1);
break;
case 'last-7-days':
startDate.setDate(now.getDate() - 7);
break;
case 'last-14-days':
startDate.setDate(now.getDate() - 14);
break;
case 'last-30-days':
startDate.setDate(now.getDate() - 30);
break;
case 'last-90-days':
startDate.setDate(now.getDate() - 90);
break;
case 'past-week': {
const day = now.getDay();
const daysSinceMonday = day === 0 ? 6 : day - 1;
const lastMonday = new Date(now);
lastMonday.setDate(now.getDate() - daysSinceMonday - 7);
lastMonday.setHours(0, 0, 0, 0);
startDate.setTime(lastMonday.getTime());
endDate = new Date(lastMonday);
endDate.setDate(lastMonday.getDate() + 6);
endDate.setHours(23, 59, 59, 999);
break;
}
case 'past-month': {
const firstDay = new Date(now.getFullYear(), now.getMonth() - 1, 1);
firstDay.setHours(0, 0, 0, 0);
startDate.setTime(firstDay.getTime());
endDate = new Date(now.getFullYear(), now.getMonth(), 0);
endDate.setHours(23, 59, 59, 999);
break;
}
case 'past-year': {
const firstDay = new Date(now.getFullYear() - 1, 0, 1);
firstDay.setHours(0, 0, 0, 0);
startDate.setTime(firstDay.getTime());
endDate = new Date(now.getFullYear() - 1, 11, 31);
endDate.setHours(23, 59, 59, 999);
break;
}
default:
startDate.setDate(now.getDate() - 30);
}
return { startDate, endDate };
}
export default async function dashboardRoutes(app: FastifyInstance) {
app.get('/sales-by-route', { preValidation: [app.authenticate] }, async (request, reply) => {
// Basic aggregation: total sales per route
const routes = await prisma.route.findMany({
include: {
app.get('/sales-by-customer', { preValidation: [app.authenticate] }, async (request, reply) => {
const { range } = request.query as { range: string };
const { startDate, endDate } = getDateRange(range);
const sales = await prisma.sale.findMany({
where: { createdAt: { gte: startDate, ...(endDate ? { lte: endDate } : {}) } },
select: {
id: true,
customerPos: {
include: { customerPos: true }
select: {
customerId: true,
customer: { select: { id: true, name: true } }
}
},
products: {
select: {
quantity: true,
product: { select: { price: true } }
}
}
}
});
// To get sales by route, we need to find sales matching the POS included in each route
// Note: a sale is tied to a POS, not directly a Route.
const result = await Promise.all(routes.map(async (r) => {
const posIds = r.customerPos.map(cp => cp.customerPosId);
const sales = await prisma.sale.findMany({
where: { customerPosId: { in: posIds } },
include: { products: { include: { product: true } } }
});
let totalAmount = 0;
sales.forEach(sale => {
sale.products.forEach(sp => {
totalAmount += sp.quantity * sp.product.price;
});
});
const customerMap = new Map<string, { customerId: string; customerName: string; totalSales: number; totalAmount: number }>();
return {
routeId: r.id,
routeName: r.name,
totalSales: sales.length,
totalAmount
};
}));
for (const sale of sales) {
const customerId = sale.customerPos.customerId;
const customerName = sale.customerPos.customer.name;
return result.sort((a, b) => b.totalAmount - a.totalAmount);
let saleAmount = 0;
for (const sp of sale.products) {
saleAmount += sp.quantity * sp.product.price;
}
if (!customerMap.has(customerId)) {
customerMap.set(customerId, { customerId, customerName, totalSales: 0, totalAmount: 0 });
}
const entry = customerMap.get(customerId)!;
entry.totalSales += 1;
entry.totalAmount += saleAmount;
}
return Array.from(customerMap.values())
.sort((a, b) => b.totalAmount - a.totalAmount);
});
app.get('/sales-by-customer', { preValidation: [app.authenticate] }, async (request, reply) => {
const customers = await prisma.customer.findMany({
include: { pos: true }
});
app.get('/sales-by-product', { preValidation: [app.authenticate] }, async (request, reply) => {
const { range } = request.query as { range: string };
const { startDate, endDate } = getDateRange(range);
const result = await Promise.all(customers.map(async (c) => {
const posIds = c.pos.map(p => p.id);
const sales = await prisma.sale.findMany({
where: { customerPosId: { in: posIds } },
include: { products: { include: { product: true } } }
});
let totalAmount = 0;
sales.forEach(sale => {
sale.products.forEach(sp => {
totalAmount += sp.quantity * sp.product.price;
});
});
const [grouped, products] = await Promise.all([
prisma.saleProduct.groupBy({
by: ['productId'],
_sum: { quantity: true },
where: { sale: { createdAt: { gte: startDate, ...(endDate ? { lte: endDate } : {}) } } }
}),
prisma.product.findMany({ select: { id: true, name: true, price: true } })
]);
return {
customerId: c.id,
customerName: c.name,
totalSales: sales.length,
totalAmount
};
}));
const productMap = new Map(products.map(p => [p.id, p]));
return result.sort((a, b) => b.totalAmount - a.totalAmount);
return grouped
.map(g => {
const p = productMap.get(g.productId);
const totalQuantity = g._sum.quantity ?? 0;
return {
productId: g.productId,
productName: p?.name ?? '',
totalQuantity,
totalAmount: totalQuantity * (p?.price ?? 0)
};
})
.filter(item => item.totalQuantity > 0)
.sort((a, b) => b.totalAmount - a.totalAmount);
});
app.get('/sales-summary', { preValidation: [app.authenticate] }, async (request, reply) => {
const { range } = request.query as { range: string };
const { startDate, endDate } = getDateRange(range);
const [sales, totalCustomers, totalFridges] = await Promise.all([
prisma.sale.findMany({
where: { createdAt: { gte: startDate, ...(endDate ? { lte: endDate } : {}) } },
select: {
products: {
select: {
quantity: true,
product: { select: { price: true } }
}
}
}
}),
prisma.customer.count({ where: { disabledAt: null } }),
prisma.customerPos.aggregate({
_sum: { fridgeCount: true },
where: { disabledAt: null }
})
]);
const totalSales = sales.length;
let totalAmount = 0;
for (const sale of sales) {
let saleAmount = 0;
for (const sp of sale.products) {
saleAmount += sp.quantity * sp.product.price;
}
totalAmount += saleAmount;
}
const averageAmount = totalSales > 0 ? totalAmount / totalSales : 0;
return {
totalSales,
totalAmount,
averageAmount,
totalCustomers,
totalFridges: totalFridges._sum.fridgeCount ?? 0
};
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { FastifyInstance } from 'fastify';
import { prisma } from '../prisma';
import { Prisma } from '@prisma/client';
import { Prisma } from '../generated/prisma/client';
export default async function productsRoutes(app: FastifyInstance) {
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
+145 -4
View File
@@ -49,6 +49,7 @@ export default async function salesRoutes(app: FastifyInstance) {
paymentDueDate,
paymentDate,
comments,
nextVisitDate,
products // Array of { productId, quantity }
} = request.body as any;
@@ -72,6 +73,7 @@ export default async function salesRoutes(app: FastifyInstance) {
paymentDueDate: paymentDueDate ? new Date(paymentDueDate) : null,
paymentDate: paymentDate ? new Date(paymentDate) : null,
comments,
nextVisitDate: nextVisitDate ? new Date(nextVisitDate) : null,
products: {
create: products.map((p: any) => ({
productId: p.productId,
@@ -102,28 +104,134 @@ export default async function salesRoutes(app: FastifyInstance) {
}
});
// Update sale (e.g. mark as paid)
// Update sale (e.g. mark as paid, edit fields, replace products)
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as any;
const {
customerPosId,
delivered,
paymentMethod,
paymentDueDate,
paymentDate,
comments
comments,
nextVisitDate,
visitedAt,
products
} = request.body as any;
try {
const existing = await prisma.sale.findUnique({ where: { id } });
const existing = await prisma.sale.findUnique({
where: { id },
include: { products: true }
});
if (!existing) return reply.code(404).send({ error: 'Sale not found' });
if (customerPosId !== undefined) {
const pos = await prisma.customerPos.findUnique({ where: { id: customerPosId } });
if (!pos || pos.disabledAt) return reply.code(400).send({ error: 'Point of sale not found or disabled' });
}
let data: any = {};
const parsedDelivered = parseBoolean(delivered);
if (parsedDelivered !== undefined) data.delivered = parsedDelivered;
if (customerPosId !== undefined) data.customerPosId = customerPosId;
if (paymentMethod !== undefined) data.paymentMethod = paymentMethod;
if (paymentDueDate !== undefined) data.paymentDueDate = paymentDueDate ? new Date(paymentDueDate) : null;
if (paymentDate !== undefined) data.paymentDate = paymentDate ? new Date(paymentDate) : null;
if (comments !== undefined) data.comments = comments;
if (nextVisitDate !== undefined) data.nextVisitDate = nextVisitDate ? new Date(nextVisitDate) : null;
if (visitedAt !== undefined) data.visitedAt = visitedAt ? new Date(visitedAt) : null;
// If products are provided, replace the entire product list with stock management
if (products !== undefined && !Array.isArray(products)) {
return reply.code(400).send({ error: 'products must be an array' });
}
if (Array.isArray(products)) {
// Aggregate quantities by productId to handle duplicates correctly
const aggregatedMap = new Map<string, number>();
for (const p of products) {
if (!p.productId || typeof p.quantity !== 'number' || p.quantity <= 0) {
return reply.code(400).send({ error: 'Invalid product entry' });
}
aggregatedMap.set(p.productId, (aggregatedMap.get(p.productId) || 0) + p.quantity);
}
const aggregatedProducts = Array.from(aggregatedMap.entries()).map(([productId, quantity]) => ({ productId, quantity }));
// Validate new products exist and are enabled in a single query
const productIds = aggregatedProducts.map(p => p.productId);
const foundProducts = await prisma.product.findMany({ where: { id: { in: productIds } } });
for (const p of aggregatedProducts) {
const prod = foundProducts.find(fp => fp.id === p.productId);
if (!prod || prod.disabledAt) return reply.code(400).send({ error: `Product ${p.productId} not found or disabled` });
}
const sale = await prisma.$transaction(async (tx) => {
const lockProductIds = Array.from(
new Set([
...existing.products.map((sp) => sp.productId),
...productIds
])
).sort();
if (lockProductIds.length > 0) {
// Lock all affected product rows in a deterministic order.
await tx.$queryRaw`
SELECT id
FROM "Product"
WHERE id = ANY(${lockProductIds}::uuid[])
FOR UPDATE
`;
}
// Restore stock for all existing products
for (const sp of existing.products) {
await tx.product.update({
where: { id: sp.productId },
data: { stock: { increment: sp.quantity } }
});
}
// Validate stock after restoring by fetching all updated products in one query
const updatedProds = await tx.product.findMany({ where: { id: { in: productIds } } });
for (const p of aggregatedProducts) {
const prod = updatedProds.find(up => up.id === p.productId);
if (!prod || prod.stock < p.quantity) {
throw new Error(`Not enough stock for product ${p.productId}`);
}
}
// Delete old sale products and create new ones
await tx.saleProduct.deleteMany({ where: { saleId: id } });
if (aggregatedProducts.length > 0) {
await tx.saleProduct.createMany({
data: aggregatedProducts.map((p) => ({
saleId: id,
productId: p.productId,
quantity: p.quantity
}))
});
// Deduct stock for new products
for (const p of aggregatedProducts) {
await tx.product.update({
where: { id: p.productId },
data: { stock: { decrement: p.quantity } }
});
}
}
return tx.sale.update({
where: { id },
data,
include: {
customerPos: { include: { customer: true } },
products: { include: { product: true } }
}
});
});
return sale;
}
const sale = await prisma.sale.update({
where: { id },
@@ -134,8 +242,41 @@ export default async function salesRoutes(app: FastifyInstance) {
}
});
return sale;
} catch (e) {
} catch (e: any) {
if (e?.message?.startsWith('Not enough stock')) {
return reply.code(400).send({ error: e.message });
}
return reply.code(500).send({ error: 'Failed to update sale' });
}
});
// Delete sale (hard delete)
app.delete('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as any;
try {
const existing = await prisma.sale.findUnique({
where: { id },
include: { products: true }
});
if (!existing) return reply.code(404).send({ error: 'Sale not found' });
await prisma.$transaction(async (tx) => {
// Restore stock for all products in the sale
for (const sp of existing.products) {
await tx.product.update({
where: { id: sp.productId },
data: { stock: { increment: sp.quantity } }
});
}
// Delete sale products then the sale itself
await tx.saleProduct.deleteMany({ where: { saleId: id } });
await tx.sale.delete({ where: { id } });
});
return { success: true };
} catch (e) {
return reply.code(500).send({ error: 'Failed to delete sale' });
}
});
}
+39
View File
@@ -0,0 +1,39 @@
import { FastifyInstance } from 'fastify';
import { Prisma } from '../generated/prisma/client';
import { prisma } from '../prisma';
function parseBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
}
return undefined;
}
interface VisitsQuery {
showVisited?: string;
}
export default async function visitsRoutes(app: FastifyInstance) {
// Get all visits (sales with nextVisitDate set)
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
const { showVisited } = request.query as VisitsQuery;
const parsedShowVisited = parseBoolean(showVisited);
const where: Prisma.SaleWhereInput = {
nextVisitDate: { not: null },
visitedAt: parsedShowVisited === true ? { not: null } : null
};
return prisma.sale.findMany({
where,
include: {
customerPos: { include: { customer: true } },
products: { include: { product: true } }
},
orderBy: { nextVisitDate: 'asc' }
});
});
}
+1 -3
View File
@@ -1,8 +1,6 @@
import { PrismaClient } from '@prisma/client';
import { prisma } from './prisma';
import bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const existing = await prisma.user.findFirst();
if (!existing) {
+2 -2
View File
@@ -7,8 +7,8 @@
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "CommonJS",
"moduleResolution": "Node",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "es2022",
"outDir": "./dist",
"rootDir": "./src",
+80
View File
@@ -0,0 +1,80 @@
---
services:
postgres:
image: postgres:15
container_name: polpa_gestao_db
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: adminpassword
POSTGRES_DB: polpa_gestao
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d polpa_gestao"]
interval: 5s
timeout: 5s
retries: 5
networks:
- polpa-network
prisma-push:
build:
context: ./backend
target: prisma
image: ghcr.io/rmcampos/polpa-gestao/backend-prisma:latest
container_name: polpa_gestao_prisma_push
environment:
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
command: ["npx", "prisma", "migrate", "deploy"]
depends_on:
postgres:
condition: service_healthy
restart: "no"
networks:
- polpa-network
backend:
build: ./backend
image: ghcr.io/rmcampos/polpa-gestao/backend:latest
container_name: polpa_gestao_backend
environment:
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
prisma-push:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "node", "healthcheck.js"]
interval: 5s
timeout: 5s
retries: 5
networks:
- polpa-network
frontend:
build:
context: ./frontend
args:
VITE_BACKEND_SERVER: "/api/.."
VITE_CPF_CNPJ_API_TOKEN: ${VITE_CPF_CNPJ_API_TOKEN}
VITE_BUILD_NUMBER: snapshot
image: ghcr.io/rmcampos/polpa-gestao/frontend:latest
container_name: polpa_gestao_frontend
ports:
- "5173:80"
depends_on:
- backend
networks:
- polpa-network
volumes:
pgdata:
networks:
polpa-network:
external: true
+2 -1
View File
@@ -63,6 +63,7 @@ services:
args:
VITE_BACKEND_SERVER: http://localhost:3000
VITE_CPF_CNPJ_API_TOKEN: ${VITE_CPF_CNPJ_API_TOKEN}
VITE_BUILD_NUMBER: snapshot
image: ghcr.io/rmcampos/polpa-gestao/frontend:latest
container_name: polpa_gestao_frontend
ports:
@@ -76,4 +77,4 @@ volumes:
pgdata:
networks:
polpa-network:
driver: bridge
external: true
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
npm-debug.log*
.DS_Store
+2
View File
@@ -6,10 +6,12 @@ WORKDIR /app
# Accept build arguments
ARG VITE_BACKEND_SERVER
ARG VITE_CPF_CNPJ_API_TOKEN
ARG VITE_BUILD_NUMBER
# Set as environment variables for the build
ENV VITE_BACKEND_SERVER=$VITE_BACKEND_SERVER
ENV VITE_CPF_CNPJ_API_TOKEN=$VITE_CPF_CNPJ_API_TOKEN
ENV VITE_BUILD_NUMBER=$VITE_BUILD_NUMBER
# Copy package files
COPY package.json package-lock.json ./
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>Polpa Gestão - 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">
+772 -1033
View File
File diff suppressed because it is too large Load Diff
+14 -9
View File
@@ -10,27 +10,32 @@
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.6",
"axios": "^1.15.0",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/bootstrap": "^5.2.10",
"@types/node": "^24.12.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1",
"globals": "^17.5.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.8",
"vite-plugin-pwa": "^1.2.0"
},
"overrides": {
"vite-plugin-pwa": {
"vite": "$vite"
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 263 KiB

+2
View File
@@ -7,6 +7,7 @@ import Customers from './pages/Customers';
import Products from './pages/Products';
import RoutesPage from './pages/Routes';
import Sales from './pages/Sales';
import Visits from './pages/Visits';
import Sidebar from './components/Sidebar';
import { Toast } from './components/Toast';
import { ConfirmDialog } from './components/ConfirmDialog';
@@ -55,6 +56,7 @@ export default function App() {
<Route path="/products" element={<Products />} />
<Route path="/routes" element={<RoutesPage />} />
<Route path="/sales" element={<Sales />} />
<Route path="/visits" element={<Visits />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
+2 -4
View File
@@ -23,12 +23,10 @@
}
.confirm-dialog {
background: var(--card-bg);
background: #1e293b;
border: 1px solid var(--glass-border);
border-radius: 12px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
max-width: 500px;
width: 90%;
animation: slideUp 0.3s ease-out;
@@ -97,7 +95,7 @@
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid var(--glass-border);
background-color: rgba(15, 23, 42, 0.45);
background-color: #0f172a;
border-bottom-left-radius: 11px;
border-bottom-right-radius: 11px;
}
@@ -0,0 +1,176 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Customer } from '../types';
import { formatCustomerPosDisplay } from '../utils/customerPos';
type CustomerPosOption = {
id: string;
customerName: string;
customerPersonName: string;
address: string;
};
type CustomerPosComboboxProps = {
customers: Customer[];
selectedPosId: string;
filterText: string;
onFilterTextChange: (value: string) => void;
onSelectPos: (posId: string, displayText: string) => void;
};
const dropdownBaseColor = '#1e293b';
const dropdownFocusColor = '#334155';
const dropdownTextColor = '#f8fafc';
export function CustomerPosCombobox({
customers,
selectedPosId,
filterText,
onFilterTextChange,
onSelectPos,
}: CustomerPosComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement | null>(null);
const options = useMemo(() => {
return customers.flatMap((customer) => (customer.pos || [])
.filter((pos) => Boolean(pos.id))
.map((pos) => ({
id: pos.id as string,
customerName: customer.name,
customerPersonName: customer.personName || '',
address: pos.address,
})));
}, [customers]);
const filteredOptions = useMemo(() => {
const normalized = filterText.trim().toLowerCase();
if (!normalized) return options;
return options.filter((option) =>
option.customerName.toLowerCase().includes(normalized)
|| option.customerPersonName.toLowerCase().includes(normalized)
|| option.address.toLowerCase().includes(normalized)
);
}, [options, filterText]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const getOptionDisplay = (option: CustomerPosOption) =>
formatCustomerPosDisplay(option.customerName, option.address);
const handleSelectOption = (option: CustomerPosOption) => {
onSelectPos(option.id, getOptionDisplay(option));
setIsOpen(false);
};
const normalizedActiveIndex = isOpen && filteredOptions.length > 0
? (activeIndex >= 0 && activeIndex < filteredOptions.length ? activeIndex : 0)
: -1;
const activeDescendantId = isOpen && normalizedActiveIndex >= 0 && filteredOptions[normalizedActiveIndex]
? `customer-pos-option-${filteredOptions[normalizedActiveIndex].id}`
: undefined;
return (
<div className="position-relative" ref={containerRef}>
<input
type="text"
className="form-control"
placeholder="Search and select customer/POS..."
value={filterText}
onFocus={() => setIsOpen(true)}
onChange={(e) => {
onFilterTextChange(e.target.value);
setIsOpen(true);
setActiveIndex(0);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (filteredOptions.length === 0) return;
setIsOpen(true);
setActiveIndex((current) => (current + 1) % filteredOptions.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (filteredOptions.length === 0) return;
setIsOpen(true);
setActiveIndex((current) => (current <= 0 ? filteredOptions.length - 1 : current - 1));
return;
}
if (e.key === 'Enter' && isOpen && normalizedActiveIndex >= 0 && filteredOptions[normalizedActiveIndex]) {
e.preventDefault();
handleSelectOption(filteredOptions[normalizedActiveIndex]);
return;
}
if (e.key === 'Escape') {
setIsOpen(false);
}
}}
autoComplete="off"
role="combobox"
aria-autocomplete="list"
aria-expanded={isOpen}
aria-controls="customer-pos-combobox-options"
aria-activedescendant={activeDescendantId}
/>
{isOpen && (
<div
id="customer-pos-combobox-options"
role="listbox"
className="list-group position-absolute w-100 mt-1"
style={{
zIndex: 1060,
maxHeight: '260px',
overflowY: 'auto',
backgroundColor: dropdownBaseColor,
border: '1px solid var(--glass-border)',
borderRadius: '4px',
}}
>
{filteredOptions.length > 0 ? (
filteredOptions.map((option, index) => {
const isSelected = selectedPosId === option.id;
const isFocused = normalizedActiveIndex === index;
return (
<button
key={option.id}
id={`customer-pos-option-${option.id}`}
type="button"
className={`list-group-item list-group-item-action text-start ${isSelected ? 'active' : ''}`}
role="option"
aria-selected={isSelected}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
handleSelectOption(option);
}}
style={!isSelected ? { backgroundColor: isFocused ? dropdownFocusColor : dropdownBaseColor, color: dropdownTextColor } : undefined}
>
<div className="fw-semibold">{option.customerName}</div>
<div className="small text-secondary">{option.address}</div>
</button>
);
})
) : (
<div className="list-group-item text-secondary" style={{ backgroundColor: dropdownBaseColor }}>No matches found.</div>
)}
</div>
)}
</div>
);
}
+9
View File
@@ -8,6 +8,7 @@ interface SidebarProps {
export default function Sidebar({ onLogout }: SidebarProps) {
const location = useLocation();
const userName = JSON.parse(localStorage.getItem('user') || '{}').name;
const buildNumber = import.meta.env.VITE_BUILD_NUMBER || 'dev';
const [isOpen, setIsOpen] = useState(false);
const closeMenu = () => setIsOpen(false);
@@ -84,9 +85,17 @@ export default function Sidebar({ onLogout }: SidebarProps) {
>
<i className="bi bi-cart-check me-2"></i> Sales
</Link>
<Link
to="/visits"
className={`nav-link ${location.pathname === '/visits' ? 'active' : ''}`}
onClick={closeMenu}
>
<i className="bi bi-calendar-event me-2"></i> Visits
</Link>
</nav>
<div className="mt-auto">
<div className="text-muted small text-center mb-2">Build {buildNumber}</div>
<button className="btn btn-outline-danger w-100" onClick={onLogout}>
<i className="bi bi-box-arrow-right me-2"></i> Logout
</button>
+6
View File
@@ -115,6 +115,12 @@ body {
z-index: 1050;
}
.modal-content.glass-card {
background-color: #1e293b; /* Solid Slate 800 */
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Mobile top bar */
.mobile-topbar {
display: none;
+167 -51
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import axios from 'axios';
import { useToast } from '../context/toast';
@@ -16,18 +16,21 @@ export default function Customers() {
const toast = useToast();
const [customers, setCustomers] = useState<Customer[]>([]);
const [showModal, setShowModal] = useState(false);
const [newCustomer, setNewCustomer] = useState<Customer>({ name: '', document: '', phone: '' });
const [newCustomer, setNewCustomer] = useState<Customer>({ name: '', document: '', phone: '', personName: '' });
const emptyPos: CustomerPOS = { address: '', phone: '', personName: '', fridgeCount: 0 };
const [editingCustomer, setEditingCustomer] = useState<string | null>(null);
const [showPosModal, setShowPosModal] = useState(false);
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
const [newPos, setNewPos] = useState<CustomerPOS>({ address: '', phone: '' });
const [newPos, setNewPos] = useState<CustomerPOS>(emptyPos);
const [editingPos, setEditingPos] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [docValidation, setDocValidation] = useState<{ valid: boolean | null, loading: boolean }>({ valid: null, loading: false });
const [selectedPhone, setSelectedPhone] = useState<{ number: string, name: string } | null>(null);
const [showPhoneModal, setShowPhoneModal] = useState(false);
const [showDisabled, setShowDisabled] = useState<boolean>(false);
const [filterText, setFilterText] = useState<string>('');
const token = localStorage.getItem('token');
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const cpfCnpjApiToken = import.meta.env.VITE_CPF_CNPJ_API_TOKEN || '';
const fetchCustomers = useCallback(async () => {
@@ -44,6 +47,17 @@ export default function Customers() {
fetchCustomers();
}, [fetchCustomers]);
const filteredCustomers = useMemo(() => {
if (!filterText.trim()) return customers;
const lower = filterText.toLowerCase();
const digits = filterText.replace(/\D/g, '');
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))
);
}, [customers, filterText]);
const handleSaveCustomer = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -55,7 +69,7 @@ export default function Customers() {
await axios.post(`${apiBase}/api/customers`, newCustomer, config);
}
setShowModal(false);
setNewCustomer({ name: '', document: '', phone: '' });
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
setEditingCustomer(null);
fetchCustomers();
toast.showToast(editingCustomer ? 'Customer updated successfully.' : 'Customer created successfully.', 'success');
@@ -109,7 +123,7 @@ export default function Customers() {
const openNewModal = () => {
setEditingCustomer(null);
setNewCustomer({ name: '', document: '', phone: '' });
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
setDocValidation({ valid: null, loading: false });
setShowModal(true);
};
@@ -125,32 +139,59 @@ export default function Customers() {
return;
}
setEditingCustomer(c.id);
setNewCustomer({ name: c.name, document: c.document, phone: c.phone || '' });
setNewCustomer({ name: c.name, document: c.document || '', phone: c.phone || '', personName: c.personName || '' });
setDocValidation({ valid: null, loading: false });
setShowModal(true);
};
const openPosModal = (c: Customer) => {
setSelectedCustomer(c);
setNewPos({ address: '', phone: '' });
setNewPos(emptyPos);
setEditingPos(null);
setShowPosModal(true);
};
const handleAddPos = async (e: React.FormEvent) => {
const closePosModal = () => {
setShowPosModal(false);
setEditingPos(null);
setNewPos(emptyPos);
};
const openEditPos = (p: CustomerPOS) => {
if (!p.id) {
toast.showToast('POS ID is missing. Cannot edit this point of sale.', 'error');
return;
}
setEditingPos(p.id);
setNewPos({ address: p.address, phone: p.phone, personName: p.personName || '', fridgeCount: p.fridgeCount ?? 0 });
};
const cancelEditPos = () => {
setEditingPos(null);
setNewPos(emptyPos);
};
const handleSavePos = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedCustomer) return;
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
await axios.post(`${apiBase}/api/customers/${selectedCustomer.id}/pos`, newPos, config);
setNewPos({ address: '', phone: '' });
if (editingPos) {
await axios.put(`${apiBase}/api/customers/pos/${editingPos}`, newPos, config);
toast.showToast('Point of sale updated successfully.', 'success');
} else {
await axios.post(`${apiBase}/api/customers/${selectedCustomer.id}/pos`, newPos, config);
toast.showToast('Point of sale added successfully.', 'success');
}
setNewPos(emptyPos);
setEditingPos(null);
fetchCustomers();
const res = await axios.get(`${apiBase}/api/customers/${selectedCustomer.id}`, config);
setSelectedCustomer(res.data);
toast.showToast('Point of sale added successfully.', 'success');
} catch (err) {
toast.showToast(`Failed to add POS: ${toErrorMessage(err, 'Unknown error')}`, 'error');
toast.showToast(`Failed to save POS: ${toErrorMessage(err, 'Unknown error')}`, 'error');
} finally {
setLoading(false);
}
@@ -198,7 +239,7 @@ export default function Customers() {
const config = { headers: { Authorization: `Bearer ${token}` } };
await axios.delete(`${apiBase}/api/customers/${editingCustomer}`, config);
setShowModal(false);
setNewCustomer({ name: '', document: '', phone: '' });
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
setEditingCustomer(null);
fetchCustomers();
toast.showToast('Customer disabled successfully.', 'success');
@@ -240,38 +281,57 @@ export default function Customers() {
</div>
</div>
<div className="mb-3">
<input
type="text"
className="form-control"
placeholder="Filter by name, person, or phone..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
</div>
<div className="row g-3">
{customers.map((c: Customer) => (
<div key={c.id} className="col-12 col-md-6 col-lg-4">
<div className="glass-card p-3 h-100 d-flex flex-column">
<div className="mb-2">
<h5 className={`fw-bold m-0 ${c.disabledAt ? 'text-secondary' : 'text-white'}`}>{c.name}</h5>
<div className="text-secondary small mt-1">
<i className="bi bi-file-earmark-text me-1"></i>{formatDocument(c.document)}
{filteredCustomers.map((c: Customer) => {
const customerPersonName = c.personName?.trim();
return (
<div key={c.id} className="col-12 col-md-6 col-lg-4">
<div className="glass-card p-3 h-100 d-flex flex-column">
<div className="mb-2">
<h5 className={`fw-bold m-0 ${c.disabledAt ? 'text-secondary' : 'text-white'}`}>{c.name}</h5>
{customerPersonName && (
<div className="text-secondary small mt-1">
<i className="bi bi-person me-1"></i>{customerPersonName}
</div>
)}
<div className="text-secondary small mt-1">
<i className="bi bi-file-earmark-text me-1"></i>{c.document ? formatDocument(c.document) : 'N/A'}
</div>
</div>
</div>
<div className="d-flex flex-column gap-1 text-secondary small mb-3">
<div>
<i className="bi bi-telephone me-1"></i>
{c.phone ? (
<button className="btn btn-link p-0 text-info fw-bold text-decoration-none align-baseline small" onClick={() => handlePhoneClick(c.phone, c.name)}>
{formatPhone(c.phone)}
</button>
) : 'N/A'}
<div className="d-flex flex-column gap-1 text-secondary small mb-3">
<div>
<i className="bi bi-telephone me-1"></i>
{c.phone ? (
<button className="btn btn-link p-0 text-info fw-bold text-decoration-none align-baseline small" onClick={() => handlePhoneClick(c.phone, c.name)}>
{formatPhone(c.phone)}
</button>
) : 'N/A'}
</div>
<div>
<i className="bi bi-geo-alt me-1"></i>
{c.pos?.length || 0} point{c.pos?.length !== 1 ? 's' : ''} of sale
</div>
</div>
<div>
<i className="bi bi-geo-alt me-1"></i>
{c.pos?.length || 0} point{c.pos?.length !== 1 ? 's' : ''} of sale
<div className="mt-auto d-flex gap-2">
<button className="btn btn-sm btn-outline-light flex-grow-1" onClick={() => openEditModal(c)}>Edit</button>
<button className="btn btn-sm btn-outline-primary flex-grow-1" onClick={() => openPosModal(c)}>Manage POS</button>
</div>
</div>
<div className="mt-auto d-flex gap-2">
<button className="btn btn-sm btn-outline-light flex-grow-1" onClick={() => openEditModal(c)}>Edit</button>
<button className="btn btn-sm btn-outline-primary flex-grow-1" onClick={() => openPosModal(c)}>Manage POS</button>
</div>
</div>
</div>
))}
{customers.length === 0 && (
);
})}
{filteredCustomers.length === 0 && (
<div className="col-12 text-center text-secondary mt-4">
<p>No customers found.</p>
</div>
@@ -295,9 +355,20 @@ export default function Customers() {
<input type="text" className="form-control" value={newCustomer.name} onChange={e => setNewCustomer({ ...newCustomer, name: e.target.value })} required />
</div>
<div className="mb-3">
<label className="form-label text-secondary">Document (CNPJ/CPF)</label>
<label className="form-label text-secondary">Person Name (Optional)</label>
<input
type="text"
className="form-control"
value={newCustomer.personName || ''}
onChange={e => setNewCustomer({ ...newCustomer, personName: e.target.value })}
placeholder="Responsible person name..."
maxLength={30}
/>
</div>
<div className="mb-3">
<label className="form-label text-secondary">Document (CNPJ/CPF) (Optional)</label>
<div className="input-group">
<input type="text" className="form-control" value={newCustomer.document} onChange={handleDocumentChange} placeholder="Type numbers only..." required />
<input type="text" className="form-control" value={newCustomer.document || ''} onChange={handleDocumentChange} placeholder="Type numbers only..." />
{docValidation.loading && <span className="input-group-text bg-secondary text-white border-secondary">...</span>}
{!docValidation.loading && docValidation.valid === true && <span className="input-group-text bg-success text-white border-success">Valid</span>}
{!docValidation.loading && docValidation.valid === false && <span className="input-group-text bg-danger text-white border-danger">Invalid</span>}
@@ -331,7 +402,7 @@ export default function Customers() {
<div className="modal-content glass-card">
<div className="modal-header border-bottom-0" style={{ borderColor: 'var(--glass-border)' }}>
<h5 className="modal-title text-white">Manage POS - {selectedCustomer.name}</h5>
<button type="button" className="btn-close btn-close-white" onClick={() => setShowPosModal(false)}></button>
<button type="button" className="btn-close btn-close-white" onClick={closePosModal}></button>
</div>
<div className="modal-body">
@@ -341,6 +412,11 @@ export default function Customers() {
<li key={p.id} className="list-group-item d-flex justify-content-between align-items-center bg-dark text-white border-secondary">
<div>
<strong>{p.address}</strong>
{p.personName && (
<div className="text-secondary small">
Contact: {p.personName}
</div>
)}
<div className="text-secondary small">
Phone: {p.phone ? (
<button
@@ -351,8 +427,14 @@ export default function Customers() {
</button>
) : 'N/A'}
</div>
<div className="text-secondary small">
Fridges: {p.fridgeCount ?? 0}
</div>
</div>
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-primary" onClick={() => openEditPos(p)}>Edit</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => handleDeletePos(p.id)}>Remove</button>
</div>
<button className="btn btn-sm btn-outline-danger" onClick={() => handleDeletePos(p.id)}>Remove</button>
</li>
))}
{(!selectedCustomer.pos || selectedCustomer.pos.length === 0) && (
@@ -362,10 +444,10 @@ export default function Customers() {
<div className="card bg-transparent border-secondary">
<div className="card-header border-secondary text-white fw-bold bg-dark bg-opacity-50">
Add New POS
{editingPos ? 'Edit POS' : 'Add New POS'}
</div>
<div className="card-body">
<form onSubmit={handleAddPos}>
<form onSubmit={handleSavePos}>
<div className="row g-3">
<div className="col-md-7">
<label className="form-label text-secondary small">Address</label>
@@ -373,10 +455,44 @@ export default function Customers() {
</div>
<div className="col-md-5">
<label className="form-label text-secondary small">Phone (Optional)</label>
<div className="d-flex gap-2">
<input type="text" className="form-control form-control-sm" value={newPos.phone} onChange={e => setNewPos({ ...newPos, phone: formatPhone(e.target.value) })} placeholder="(11) 99999-9999" maxLength={15} />
<button type="submit" className="btn btn-sm btn-success" disabled={loading}>{loading ? '...' : 'Add'}</button>
</div>
<input type="text" className="form-control form-control-sm" value={newPos.phone} onChange={e => setNewPos({ ...newPos, phone: formatPhone(e.target.value) })} placeholder="(11) 99999-9999" maxLength={15} />
</div>
<div className="col-md-7">
<label className="form-label text-secondary small">Person Name (Optional)</label>
<input
type="text"
className="form-control form-control-sm"
value={newPos.personName || ''}
onChange={e =>
setNewPos({
...newPos,
personName: e.target.value.slice(0, 30),
})
}
placeholder="Contact person name..."
maxLength={30}
/>
</div>
<div className="col-md-2">
<label className="form-label text-secondary small">Fridges</label>
<input
type="number"
className="form-control form-control-sm"
value={newPos.fridgeCount ?? 0}
onChange={e =>
setNewPos({
...newPos,
fridgeCount: Math.max(0, Number.parseInt(e.target.value || '0', 10) || 0),
})
}
min={0}
/>
</div>
<div className="col-md-3 d-flex align-items-end gap-2">
{editingPos && (
<button type="button" className="btn btn-sm btn-outline-secondary w-100" onClick={cancelEditPos}>Cancel</button>
)}
<button type="submit" className="btn btn-sm btn-success w-100" disabled={loading}>{loading ? '...' : editingPos ? 'Save' : 'Add'}</button>
</div>
</div>
</form>
@@ -385,7 +501,7 @@ export default function Customers() {
</div>
<div className="modal-footer border-top-0" style={{ borderColor: 'var(--glass-border)' }}>
<button type="button" className="btn btn-outline-light" onClick={() => setShowPosModal(false)}>Close</button>
<button type="button" className="btn btn-outline-light" onClick={closePosModal}>Close</button>
</div>
</div>
</div>
+150 -15
View File
@@ -1,43 +1,175 @@
import { useEffect, useState } from 'react';
import axios from 'axios';
import type { SalesByCustomer, SalesByRoute } from '../types';
import type { SalesByCustomer, SalesByProduct, SalesSummary } from '../types';
export default function Dashboard() {
const [salesByRoute, setSalesByRoute] = useState<SalesByRoute[]>([]);
const [range, setRange] = useState('last-30-days');
const [salesByProduct, setSalesByProduct] = useState<SalesByProduct[]>([]);
const [salesByCustomer, setSalesByCustomer] = useState<SalesByCustomer[]>([]);
const [salesSummary, setSalesSummary] = useState<SalesSummary | null>(null);
const token = localStorage.getItem('token');
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const ranges = [
{ label: 'This Week', value: 'this-week' },
{ label: 'This Month', value: 'this-month' },
{ label: 'This Year', value: 'this-year' },
{ label: 'Past Week', value: 'past-week' },
{ label: 'Past Month', value: 'past-month' },
{ label: 'Past Year', value: 'past-year' },
{ label: 'Last 7 Days', value: 'last-7-days' },
{ label: 'Last 14 Days', value: 'last-14-days' },
{ label: 'Last 30 Days', value: 'last-30-days' },
{ label: 'Last 90 Days', value: 'last-90-days' },
];
useEffect(() => {
const fetchDashboard = async () => {
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const resRoutes = await axios.get(`${apiBase}/api/dashboard/sales-by-route`, config);
const config = {
headers: { Authorization: `Bearer ${token}` },
params: { range }
};
const resProducts = await axios.get(`${apiBase}/api/dashboard/sales-by-product`, config);
const resCustomers = await axios.get(`${apiBase}/api/dashboard/sales-by-customer`, config);
const resSummary = await axios.get(`${apiBase}/api/dashboard/sales-summary`, config);
setSalesByRoute(resRoutes.data);
setSalesByProduct(resProducts.data);
setSalesByCustomer(resCustomers.data);
setSalesSummary(resSummary.data);
} catch (err) {
console.error('Failed to load dashboard', err);
}
};
fetchDashboard();
}, [token, apiBase]);
}, [token, apiBase, range]);
const top3Products = [...salesByProduct]
.sort((a, b) => b.totalQuantity - a.totalQuantity)
.slice(0, 3);
const top3Customers = [...salesByCustomer]
.sort((a, b) => b.totalAmount - a.totalAmount)
.slice(0, 3);
const { totalCustomers, totalFridges } = salesSummary ?? { totalCustomers: 0, totalFridges: 0 };
return (
<div>
<h2 className="mb-4 fw-bold">Dashboard</h2>
<div className="d-flex justify-content-between align-items-center mb-4">
<h2 className="m-0 fw-bold">Dashboard</h2>
<div style={{ width: '200px' }}>
<select
className="form-select bg-dark text-white border-secondary"
aria-label="Date range"
value={range}
onChange={(e) => setRange(e.target.value)}
>
{ranges.map((r) => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
</div>
</div>
{/* Stats Cards */}
<div className="row g-4 mb-4">
{/* Total Sales Value */}
<div className="col-12 col-sm-6 col-lg-3">
<div className="glass-card p-4 h-100">
<h6 className="text-secondary mb-2">Total Sales Value</h6>
<h3 className="fw-bold text-success mb-0">
R$ {salesSummary ? salesSummary.totalAmount.toFixed(2) : '—'}
</h3>
<small className="text-secondary">
{salesSummary ? `${salesSummary.totalSales} sale(s)` : ''}
</small>
</div>
</div>
{/* Average Sale Value */}
<div className="col-12 col-sm-6 col-lg-3">
<div className="glass-card p-4 h-100">
<h6 className="text-secondary mb-2">Average Sale Value</h6>
<h3 className="fw-bold text-primary mb-0">
R$ {salesSummary ? salesSummary.averageAmount.toFixed(2) : '—'}
</h3>
<small className="text-secondary">per sale</small>
</div>
</div>
{/* Total Customers */}
<div className="col-12 col-sm-6 col-lg-3">
<div className="glass-card p-4 h-100">
<h6 className="text-secondary mb-2">Total Customers</h6>
<h3 className="fw-bold text-info mb-0">{salesSummary ? totalCustomers : '—'}</h3>
<small className="text-secondary">active customers</small>
</div>
</div>
{/* Total Fridges */}
<div className="col-12 col-sm-6 col-lg-3">
<div className="glass-card p-4 h-100">
<h6 className="text-secondary mb-2">Total Fridges</h6>
<h3 className="fw-bold text-warning mb-0">{salesSummary ? totalFridges : '—'}</h3>
<small className="text-secondary">active POS total</small>
</div>
</div>
{/* Top 3 Products */}
<div className="col-12 col-sm-6 col-lg-3">
<div className="glass-card p-4 h-100">
<h6 className="text-secondary mb-2">Top 3 Products</h6>
{top3Products.length === 0 ? (
<p className="text-secondary mb-0 small">No data available.</p>
) : (
<ol className="mb-0 ps-3">
{top3Products.map((item) => (
<li key={item.productId} className="text-white mb-1">
<span>{item.productName}</span>
<br />
<small className="text-secondary">{item.totalQuantity} items sold</small>
</li>
))}
</ol>
)}
</div>
</div>
{/* Top 3 Customers */}
<div className="col-12 col-sm-6 col-lg-3">
<div className="glass-card p-4 h-100">
<h6 className="text-secondary mb-2">Top 3 Customers</h6>
{top3Customers.length === 0 ? (
<p className="text-secondary mb-0 small">No data available.</p>
) : (
<ol className="mb-0 ps-3">
{top3Customers.map((item) => (
<li key={item.customerId} className="text-white mb-1">
<span>{item.customerName}</span>
<br />
<small className="text-secondary">R$ {item.totalAmount.toFixed(2)}</small>
</li>
))}
</ol>
)}
</div>
</div>
</div>
<div className="row g-4">
{/* Sales by Route */}
{/* Sales by Product */}
<div className="col-12 col-md-6">
<div className="glass-card p-4 h-100">
<h4 className="mb-3">Sales by Route</h4>
{salesByRoute.length === 0 ? <p className="text-secondary">No data available.</p> : (
<h4 className="mb-3">Sales by Product</h4>
{salesByProduct.length === 0 ? <p className="text-secondary">No data available.</p> : (
<ul className="list-group list-group-flush" style={{ background: 'transparent' }}>
{salesByRoute.map((item: SalesByRoute) => (
<li key={item.routeId} className="list-group-item d-flex justify-content-between align-items-center text-white" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
{item.routeName}
{salesByProduct.map((item: SalesByProduct) => (
<li key={item.productId} className="list-group-item d-flex justify-content-between align-items-center text-white" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
<div>
<div>{item.productName}</div>
<small className="text-secondary">{item.totalQuantity} items sold</small>
</div>
<span className="badge bg-primary rounded-pill">R$ {item.totalAmount.toFixed(2)}</span>
</li>
))}
@@ -54,7 +186,10 @@ export default function Dashboard() {
<ul className="list-group list-group-flush" style={{ background: 'transparent' }}>
{salesByCustomer.map((item: SalesByCustomer) => (
<li key={item.customerId} className="list-group-item d-flex justify-content-between align-items-center text-white" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
{item.customerName}
<div>
<div>{item.customerName}</div>
<small className="text-secondary">{item.totalSales} purchases</small>
</div>
<span className="badge bg-success rounded-pill">R$ {item.totalAmount.toFixed(2)}</span>
</li>
))}
+1 -1
View File
@@ -12,7 +12,7 @@ export default function Login({ setToken }: LoginProps) {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
+1 -1
View File
@@ -13,7 +13,7 @@ export default function Products() {
const [loading, setLoading] = useState(false);
const [showDisabled, setShowDisabled] = useState<boolean>(false);
const token = localStorage.getItem('token');
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const fetchProducts = useCallback(async () => {
try {
+1 -1
View File
@@ -59,7 +59,7 @@ export default function RoutesPage() {
const [editingRoute, setEditingRoute] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const token = localStorage.getItem('token');
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const fetchRoutes = useCallback(async () => {
try {
+249 -69
View File
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import axios from 'axios';
import { useToast } from '../context/toast';
import type { Customer, CustomerPOS, Product, Sale, SaleProduct } from '../types';
import { CustomerPosCombobox } from '../components/CustomerPosCombobox';
import type { Customer, Product, Sale, SaleProduct } from '../types';
import { formatCustomerPosDisplay } from '../utils/customerPos';
type ProductCart = {
productId: string;
@@ -12,19 +14,14 @@ type ProductCart = {
const formatCurrency = (value: number) => `R$ ${value.toFixed(2)}`;
const formatDate = (value: string) => new Date(value).toLocaleDateString('pt-BR');
const formatDateTime = (value: string) => new Date(value).toLocaleString('pt-BR');
const buildSaleCopyText = (sale: Sale) => {
const customerName = sale.customerPos?.customer?.name || 'Unknown';
const customerDocument = sale.customerPos?.customer?.document || 'N/A';
const customerPerson = sale.customerPos?.personName || sale.customerPos?.customer?.personName || 'N/A';
const customerPhone = sale.customerPos?.customer?.phone || 'N/A';
const posAddress = sale.customerPos?.address || 'Unknown';
const posPhone = sale.customerPos?.phone || 'N/A';
const paymentStatus = sale.paymentDate ? 'Pago' : 'Pendente';
const saleDate = formatDateTime(sale.createdAt);
const dueDate = !sale.paymentDate && sale.paymentDueDate ? formatDate(sale.paymentDueDate) : null;
const products = (sale.products || []).map((sp: SaleProduct) => {
const productName = sp.product?.name || 'Unknown Product';
@@ -54,32 +51,21 @@ const buildSaleCopyText = (sale: Sale) => {
const lines: string[] = [
'Novo pedido gerado com sucesso! 🚀',
`Data: ${saleDate}`,
'',
'*CLIENTE*',
`- Nome: ${customerName}`,
`- CPF/CNPJ: ${customerDocument}`,
`- Responsável: ${customerPerson}`,
`- Telefone: ${customerPhone}`,
'',
'*ENTREGA* 📦',
`- Endereço: ${posAddress}`,
`- Telefone do Ponto de Venda: ${posPhone}`,
'',
'*ITENS* 🛒',
...(products.length > 0 ? products : ['- Nenhum item']),
'',
'---',
'',
`Total 💰: ${formatCurrency(total)}`,
`Pagamento: ${translatePaymentMethod(sale.paymentMethod)}`,
`Status: ${paymentStatus}`,
];
if (dueDate) {
lines.push(`Data de Vencimento: ${dueDate}`);
}
if (sale.comments) {
lines.push('', `Observações: ${sale.comments}`);
lines.push(`Observações: ${sale.comments}`);
}
return lines.join('\n');
@@ -96,15 +82,20 @@ export default function Sales() {
const [loading, setLoading] = useState(false);
const [togglingSaleId, setTogglingSaleId] = useState<string | null>(null);
const [togglingDeliverySaleId, setTogglingDeliverySaleId] = useState<string | null>(null);
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const [customerPosId, setCustomerPosId] = useState('');
const [paymentMethod, setPaymentMethod] = useState('Cash');
const [paymentDueDate, setPaymentDueDate] = useState('');
const [paymentDate, setPaymentDate] = useState('');
const [comments, setComments] = useState('');
const [nextVisitDate, setNextVisitDate] = useState('');
const [cart, setCart] = useState<ProductCart[]>([]);
const [showDelivered, setShowDelivered] = useState(false);
const [filterText, setFilterText] = useState<string>('');
const [customerFilter, setCustomerFilter] = useState('');
const [editingMode, setEditingMode] = useState(false);
const [editingSaleId, setEditingSaleId] = useState<string | null>(null);
const token = localStorage.getItem('token');
@@ -128,16 +119,76 @@ export default function Sales() {
fetchData();
}, [fetchData]);
const filteredSales = useMemo(() => {
if (!filterText.trim()) return sales;
const lower = filterText.toLowerCase();
return sales.filter((sale: Sale) =>
(sale.customerPos?.customer?.name || '').toLowerCase().includes(lower) ||
(sale.customerPos?.personName || '').toLowerCase().includes(lower) ||
(sale.customerPos?.customer?.personName || '').toLowerCase().includes(lower) ||
(sale.customerPos?.address || '').toLowerCase().includes(lower)
);
}, [sales, filterText]);
const handleOpenModal = () => {
setEditingMode(false);
setEditingSaleId(null);
setCustomerFilter('');
setCustomerPosId('');
setPaymentMethod('Cash');
setPaymentDueDate('');
setPaymentDate('');
setComments('');
setNextVisitDate('');
setCart([]);
setShowModal(true);
};
const handleOpenEditModal = (sale: Sale) => {
if (!sale.id) {
toast.showToast('Invalid sale data.', 'error');
return;
}
setEditingMode(true);
setEditingSaleId(sale.id || null);
const saleCustomerName = sale.customerPos?.customer?.name ?? '';
const saleCustomerAddress = sale.customerPos?.address ?? '';
setCustomerFilter(saleCustomerAddress ? formatCustomerPosDisplay(saleCustomerName, saleCustomerAddress) : '');
setCustomerPosId(sale.customerPosId);
setPaymentMethod(sale.paymentMethod);
let paymentDueDateValue = '';
if (sale.paymentDueDate) {
const dueDate = new Date(sale.paymentDueDate);
paymentDueDateValue = dueDate.toISOString().slice(0, 10); // Format as YYYY-MM-DD for input value (UTC, timezone-stable)
}
setPaymentDueDate(paymentDueDateValue);
let paymentDateValue = '';
if (sale.paymentDate) {
const payDate = new Date(sale.paymentDate);
paymentDateValue = payDate.toISOString().slice(0, 10); // Format as YYYY-MM-DD for input value (UTC, timezone-stable)
}
setPaymentDate(paymentDateValue);
setComments(sale.comments || '');
let nextVisitDateValue = '';
if (sale.nextVisitDate) {
const nvDate = new Date(sale.nextVisitDate);
nextVisitDateValue = nvDate.toISOString().slice(0, 10);
}
setNextVisitDate(nextVisitDateValue);
setCart((sale.products || []).map((sp: SaleProduct) => ({
productId: sp.productId,
quantity: sp.quantity,
price: sp.product?.price || 0,
})));
setShowDetailsModal(false);
setShowModal(true);
};
const handleCloseModal = () => {
setCustomerFilter('');
setShowModal(false);
};
const handleAddCartItem = () => {
setCart([...cart, { productId: '', quantity: 1, price: 0 }]);
};
@@ -176,20 +227,28 @@ export default function Sales() {
paymentDueDate: paymentDueDate ? new Date(paymentDueDate).toISOString() : null,
paymentDate: paymentDate ? new Date(paymentDate).toISOString() : null,
comments,
nextVisitDate: nextVisitDate ? new Date(nextVisitDate).toISOString() : null,
products: cart.map(item => ({ productId: item.productId, quantity: item.quantity }))
};
await axios.post(`${apiBase}/api/sales`, payload, config);
setShowModal(false);
fetchData();
toast.showToast('Sale recorded successfully.', 'success');
if (editingMode && editingSaleId) {
await axios.put(`${apiBase}/api/sales/${editingSaleId}`, payload, config);
handleCloseModal();
fetchData();
toast.showToast('Sale updated successfully.', 'success');
} else {
await axios.post(`${apiBase}/api/sales`, payload, config);
handleCloseModal();
fetchData();
toast.showToast('Sale recorded successfully.', 'success');
}
} catch (err) {
if (axios.isAxiosError(err)) {
console.error('Error response:', err.response);
toast.showToast(err.response?.data?.error || 'Failed to record sale.', 'error');
toast.showToast(err.response?.data?.error || (editingMode ? 'Failed to update sale.' : 'Failed to record sale.'), 'error');
} else {
console.error('Unexpected error:', err);
toast.showToast('An unexpected error occurred while recording the sale.', 'error');
toast.showToast('An unexpected error occurred.', 'error');
}
} finally {
setLoading(false);
@@ -232,6 +291,67 @@ export default function Sales() {
}
};
const handleCloneSale = async (sale: Sale) => {
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const payload = {
customerPosId: sale.customerPosId,
paymentMethod: sale.paymentMethod,
paymentDueDate: sale.paymentDueDate || null,
paymentDate: sale.paymentDate || null,
comments: sale.comments || '',
products: (sale.products || []).map((sp: SaleProduct) => ({
productId: sp.productId,
quantity: sp.quantity,
})),
};
await axios.post(`${apiBase}/api/sales`, payload, config);
fetchData();
toast.showToast('Sale cloned successfully.', 'success');
} catch (err) {
if (axios.isAxiosError(err)) {
toast.showToast(err.response?.data?.error || 'Failed to clone sale.', 'error');
} else {
toast.showToast('Failed to clone sale.', 'error');
}
} finally {
setLoading(false);
}
};
const handleDeleteSale = async (sale: Sale) => {
if (!sale.id) {
toast.showToast('Invalid sale data.', 'error');
return;
}
const confirmed = await toast.confirm({
title: 'Delete Sale',
message: 'Are you sure you want to delete this sale? This action cannot be undone.',
confirmText: 'Delete',
cancelText: 'Cancel',
isDangerous: true,
});
if (!confirmed) return;
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
await axios.delete(`${apiBase}/api/sales/${sale.id}`, config);
setShowDetailsModal(false);
fetchData();
toast.showToast('Sale deleted successfully.', 'success');
} catch (err) {
if (axios.isAxiosError(err)) {
toast.showToast(err.response?.data?.error || 'Failed to delete sale.', 'error');
} else {
toast.showToast('Failed to delete sale.', 'error');
}
} finally {
setLoading(false);
}
};
const handleTogglePaymentStatus = async (sale: Sale) => {
if (!sale.id) {
toast.showToast('Invalid sale data.', 'error');
@@ -366,8 +486,18 @@ export default function Sales() {
</div>
</div>
<div className="mb-3">
<input
type="text"
className="form-control"
placeholder="Filter by customer name, person, or address..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
</div>
<div className="row g-3">
{sales.map((sale: Sale) => {
{filteredSales.map((sale: Sale) => {
const total = sale.products?.reduce((acc: number, sp: SaleProduct) => acc + (sp.quantity * (sp.product?.price ?? 0)), 0) || 0;
return (
<div key={sale.id} className="col-12 col-md-6">
@@ -391,29 +521,46 @@ export default function Sales() {
<i className="bi bi-calendar-event me-1"></i>Due: {new Date(sale.paymentDueDate).toLocaleDateString()}
</div>
)}
<div className="mt-auto d-flex justify-content-between align-items-center pt-2">
<strong className="text-success fs-5">{formatCurrency(total)}</strong>
<div className="d-flex gap-2">
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => handleCopySale(sale)}
>
<i className="bi bi-share me-1"></i>
Share
</button>
<button
type="button"
className="btn btn-sm btn-outline-light"
onClick={() => {
setSelectedSale(sale);
setShowDetailsModal(true);
}}
>
Details
</button>
<div className="mt-auto d-flex justify-content-between align-items-center pt-2">
<strong className="text-success fs-5">{formatCurrency(total)}</strong>
<div className="d-flex gap-2">
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => handleCopySale(sale)}
>
<i className="bi bi-share me-1"></i>
Share
</button>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => handleCloneSale(sale)}
disabled={loading}
>
<i className="bi bi-copy me-1"></i>
Copy
</button>
<button
type="button"
className="btn btn-sm btn-outline-warning"
onClick={() => handleOpenEditModal(sale)}
>
<i className="bi bi-pencil me-1"></i>
Edit
</button>
<button
type="button"
className="btn btn-sm btn-outline-light"
onClick={() => {
setSelectedSale(sale);
setShowDetailsModal(true);
}}
>
Details
</button>
</div>
</div>
</div>
</div>
</div>
);
@@ -432,8 +579,8 @@ export default function Sales() {
<div className="modal-dialog modal-dialog-centered modal-xl scrollable-modal">
<div className="modal-content glass-card">
<div className="modal-header border-bottom-0" style={{ borderColor: 'var(--glass-border)' }}>
<h5 className="modal-title text-white">Record New Sale</h5>
<button type="button" className="btn-close btn-close-white" onClick={() => setShowModal(false)}></button>
<h5 className="modal-title text-white">{editingMode ? 'Edit Sale' : 'Record New Sale'}</h5>
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
</div>
<form onSubmit={handleSaveSale}>
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
@@ -442,18 +589,21 @@ export default function Sales() {
<h6 className="text-secondary fw-bold mb-3">Customer & Payment</h6>
<div className="mb-3">
<label className="form-label text-secondary small">Customer Point of Sale</label>
<select className="form-select" value={customerPosId} onChange={e => setCustomerPosId(e.target.value)} required>
<option value="" disabled>Select POS...</option>
{customers.map((c: Customer) => (
c.pos && c.pos.length > 0 && (
<optgroup key={c.id} label={c.name}>
{c.pos.map((p: CustomerPOS) => (
<option key={p.id} value={p.id}>{p.address}</option>
))}
</optgroup>
)
))}
</select>
<CustomerPosCombobox
customers={customers}
selectedPosId={customerPosId}
filterText={customerFilter}
onFilterTextChange={(value) => {
setCustomerFilter(value);
if (customerPosId) {
setCustomerPosId('');
}
}}
onSelectPos={(posId, displayText) => {
setCustomerPosId(posId);
setCustomerFilter(displayText);
}}
/>
</div>
<div className="mb-3">
<label className="form-label text-secondary small">Payment Method</label>
@@ -479,6 +629,10 @@ export default function Sales() {
<label className="form-label text-secondary small">Comments / Notes</label>
<textarea className="form-control" rows={3} value={comments} onChange={e => setComments(e.target.value)} placeholder="Any specific requirements..."></textarea>
</div>
<div className="mb-3">
<label className="form-label text-secondary small">Next Visit Date (Optional)</label>
<input type="date" className="form-control" value={nextVisitDate} onChange={e => setNextVisitDate(e.target.value)} />
</div>
</div>
<div className="col-lg-8">
@@ -505,7 +659,7 @@ export default function Sales() {
<select className="form-select form-select-sm" value={item.productId} onChange={(e) => handleUpdateCartItem(idx, 'productId', e.target.value)} required>
<option value="" disabled>Select product...</option>
{products.map((p: Product) => (
<option key={p.id} value={p.id} disabled={p.stock <= 0}>
<option key={p.id} value={p.id} disabled={p.stock <= 0 && p.id !== item.productId}>
{p.name} {p.stock <= 0 ? '(Out of Stock)' : `(${p.stock} in stock)`}
</option>
))}
@@ -534,8 +688,8 @@ export default function Sales() {
</div>
</div>
<div className="modal-footer border-top-0" style={{ borderColor: 'var(--glass-border)' }}>
<button type="button" className="btn btn-outline-light" onClick={() => setShowModal(false)}>Cancel</button>
<button type="submit" className="btn btn-primary btn-lg px-4" disabled={loading || cart.length === 0}>{loading ? 'Processing...' : 'Complete Sale'}</button>
<button type="button" className="btn btn-outline-light" onClick={handleCloseModal}>Cancel</button>
<button type="submit" className="btn btn-primary btn-lg px-4" disabled={loading || cart.length === 0}>{loading ? 'Processing...' : editingMode ? 'Save Changes' : 'Complete Sale'}</button>
</div>
</form>
</div>
@@ -560,7 +714,7 @@ export default function Sales() {
<div className="col-md-6">
<h6 className="text-secondary fw-bold mb-2">Customer Info</h6>
<p className="mb-1"><strong>Name:</strong> {selectedSale.customerPos?.customer?.name || 'Unknown'}</p>
<p className="mb-1"><strong>Document:</strong> {selectedSale.customerPos?.customer?.document || 'N/A'}</p>
<p className="mb-1"><strong>Person:</strong> {selectedSale.customerPos?.personName || selectedSale.customerPos?.customer?.personName || 'N/A'}</p>
<p className="mb-1"><strong>POS Address:</strong> {selectedSale.customerPos?.address || 'Unknown'}</p>
<p className="mb-0"><strong>POS Phone:</strong> {selectedSale.customerPos?.phone || 'N/A'}</p>
</div>
@@ -608,6 +762,32 @@ export default function Sales() {
</div>
<div className="modal-footer border-top-0" style={{ borderColor: 'var(--glass-border)' }}>
<button
type="button"
className="btn btn-outline-danger me-auto"
onClick={() => handleDeleteSale(selectedSale)}
disabled={loading}
>
<i className="bi bi-trash me-1"></i>
Delete
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => handleCloneSale(selectedSale)}
disabled={loading}
>
<i className="bi bi-copy me-1"></i>
Copy
</button>
<button
type="button"
className="btn btn-outline-warning"
onClick={() => handleOpenEditModal(selectedSale)}
>
<i className="bi bi-pencil me-1"></i>
Edit
</button>
<button type="button" className="btn btn-outline-light" onClick={() => setShowDetailsModal(false)}>Close</button>
</div>
</div>
+1 -1
View File
@@ -13,7 +13,7 @@ export default function Users() {
const [loading, setLoading] = useState(false);
const [showDisabled, setShowDisabled] = useState<boolean>(false);
const token = localStorage.getItem('token');
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const fetchUsers = useCallback(async () => {
try {
+142
View File
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useState } from 'react';
import axios from 'axios';
import { useToast } from '../context/toast';
import type { Sale } from '../types';
const formatDate = (value: string | null) => {
if (value) {
const valueDate = new Date(value);
const formatted = valueDate.toLocaleDateString('pt-BR');
switch (valueDate.getDay()) {
case 0: return `${formatted} - Sunday`;
case 1: return `${formatted} - Monday`;
case 2: return `${formatted} - Tuesday`;
case 3: return `${formatted} - Wednesday`;
case 4: return `${formatted} - Thursday`;
case 5: return `${formatted} - Friday`;
case 6: return `${formatted} - Saturday`;
}
}
return 'N/A';
}
const formatDateTime = (value: string) =>
new Date(value).toLocaleString('pt-BR');
export default function Visits() {
const toast = useToast();
const [visits, setVisits] = useState<Sale[]>([]);
const [showVisited, setShowVisited] = useState(false);
const [togglingVisitId, setTogglingVisitId] = useState<string | null>(null);
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const token = localStorage.getItem('token');
const fetchVisits = useCallback(async () => {
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const res = await axios.get(`${apiBase}/api/visits?showVisited=${showVisited}`, config);
setVisits(res.data);
} catch (err) {
console.error('Failed to load visits', err);
toast.showToast('Failed to load visits.', 'error');
}
}, [token, apiBase, showVisited, toast]);
useEffect(() => {
fetchVisits();
}, [fetchVisits]);
const handleMarkVisited = async (visit: Sale) => {
if (!visit.id || togglingVisitId === visit.id) return;
setTogglingVisitId(visit.id);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const nextVisitedAt = visit.visitedAt ? null : new Date().toISOString();
await axios.put(`${apiBase}/api/sales/${visit.id}`, { visitedAt: nextVisitedAt }, config);
toast.showToast(nextVisitedAt ? 'Visit marked as completed.' : 'Visit marked as pending.', 'success');
await fetchVisits();
} catch (err) {
if (axios.isAxiosError(err)) {
toast.showToast(err.response?.data?.error || 'Failed to update visit status.', 'error');
} else {
toast.showToast('An unexpected error occurred.', 'error');
}
} finally {
setTogglingVisitId(null);
}
};
return (
<div className="animate-fade-in">
<div className="d-flex justify-content-between align-items-center mb-4">
<h2 className="fw-bold m-0">Visits</h2>
<div className="form-check form-switch m-0">
<input
id="show-visited"
className="form-check-input"
type="checkbox"
checked={showVisited}
onChange={(e) => setShowVisited(e.target.checked)}
/>
<label className="form-check-label text-secondary" htmlFor="show-visited">
Visited
</label>
</div>
</div>
<div className="row g-3">
{visits.map((visit: Sale) => {
const isUpdating = togglingVisitId === visit.id;
const isVisited = Boolean(visit.visitedAt);
const customerName = visit.customerPos?.customer?.name || 'Unknown';
const posPersonName = visit.customerPos?.personName || visit.customerPos?.customer?.personName || 'N/A';
const posAddress = visit.customerPos?.address || 'Unknown';
return (
<div key={visit.id} className="col-12 col-md-6 col-lg-4">
<div className="glass-card p-3 h-100 d-flex flex-column">
<div className="d-flex justify-content-between align-items-start mb-2">
<div>
<h6 className="fw-bold text-white m-0">{customerName}</h6>
<div className="text-secondary small">{posPersonName}</div>
</div>
<button
type="button"
className={`badge border-0 ${isVisited ? 'bg-success' : 'bg-warning text-dark'}`}
style={{ cursor: isUpdating ? 'wait' : 'pointer' }}
onClick={() => handleMarkVisited(visit)}
disabled={isUpdating}
title={isVisited ? 'Click to mark as pending' : 'Click to mark as visited'}
>
{isUpdating ? 'Updating...' : isVisited ? 'Visited' : 'Pending'}
</button>
</div>
<div className="text-secondary small mb-1">
<i className="bi bi-geo-alt me-1"></i>{posAddress}
</div>
<div className="text-secondary small mb-1">
<i className="bi bi-calendar-plus me-1"></i>Sale: {formatDateTime(visit.createdAt)}
</div>
<div className="text-secondary small mb-1">
<i className="bi bi-calendar-event me-1"></i>Next Visit: {formatDate(visit.nextVisitDate)}
</div>
{isVisited && visit.visitedAt && (
<div className="text-success small mt-auto pt-2">
<i className="bi bi-check-circle me-1"></i>Visited: {formatDateTime(visit.visitedAt)}
</div>
)}
</div>
</div>
);
})}
{visits.length === 0 && (
<div className="col-12 text-center text-secondary mt-4">
<p>{showVisited ? 'No completed visits found.' : 'No pending visits.'}</p>
</div>
)}
</div>
</div>
);
}
+17 -4
View File
@@ -6,11 +6,19 @@ export type SalesByCustomer = {
totalAmount: number;
}
export type SalesByRoute = {
routeId: string;
routeName: string;
export type SalesByProduct = {
productId: string;
productName: string;
totalQuantity: number;
totalAmount: number;
}
export type SalesSummary = {
totalSales: number;
totalAmount: number;
averageAmount: number;
totalCustomers: number;
totalFridges: number;
}
/* Users page types */
@@ -29,14 +37,17 @@ export type CustomerPOS = {
customerId?: string;
address: string;
phone: string;
personName?: string;
fridgeCount?: number;
customer?: Customer;
}
export type Customer = {
id?: string;
name: string;
document: string;
document?: string;
phone: string;
personName?: string;
pos?: CustomerPOS[];
disabledAt?: string | null;
};
@@ -81,6 +92,8 @@ export type Sale = {
paymentDueDate: string | null;
paymentDate: string | null;
comments: string;
nextVisitDate: string | null;
visitedAt: string | null;
createdAt: string;
customerPos?: CustomerPOS;
products?: SaleProduct[];
+2
View File
@@ -0,0 +1,2 @@
export const formatCustomerPosDisplay = (customerName: string, address: string) =>
`${customerName} - ${address}`;
+27
View File
@@ -0,0 +1,27 @@
events {}
http {
server {
listen 8080;
server_name _;
location /api {
# Handle double /api/api/ prefixing if it happens
rewrite ^/+api/+api/+(.*)$ /api/$1 break;
proxy_pass http://polpa_gestao_backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://polpa_gestao_frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# Stop and remove existing proxy if it exists
docker stop ngrok-proxy 2>/dev/null
docker rm ngrok-proxy 2>/dev/null
docker run -d \
--name ngrok-proxy \
-p 127.0.0.1:8080:8080 \
-v "$(pwd)/nginx/nginx.conf:/etc/nginx/nginx.conf:ro" \
--restart unless-stopped \
--network polpa-network \
nginx:stable
+22
View File
@@ -0,0 +1,22 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/kubernetes" {
version = "3.1.0"
constraints = ">= 2.0.0"
hashes = [
"h1:oodIAuFMikXNmEtil5MQgP4dfSctUBYQiGJfjbsF3NY=",
"zh:0215c5c60be62028c09a2f22458e89cda3ef5830a632299f1d401eb3538874b0",
"zh:09ebb9f442431e278a310a9423f32caf467cb4b3cad3fe59573ca71fa7b14e20",
"zh:0c4e5912f83bb35846ae0a9ae54fc320706ee61894cd21cc6b4181b1c5a2fa5c",
"zh:1678c982853ad461e65ccb5e79d585e13ed109dd47dab2a66d3a7a304faeef65",
"zh:1c050a5c15e330457a9c18caacf61a923c59d663e13f2962e4b32f04fef523a0",
"zh:2c55bcec83be58ec132c7cb0a1ac644758b800d794fdc636d53a0eada0358a3a",
"zh:a062bb0aa316c08d8460c66a5d68da71da40de5d3bc3b31abcf3a1a9a19650f1",
"zh:a26fdea0afaa9b247c73c0b42843ca51ba7db0ac2571f9d3d50dcabd20ca1b98",
"zh:c872c9385a78d502bf5823d61cd3bb0f9a0585030e025eb12585c83451beeaa1",
"zh:f180879af931182beee4c8c0d9dab62b81d86f17ddcbe3786ef4c7cec9163a4e",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
"zh:f70f5789264069e0eef06f9b5d5fde955ef7206f7d446d1ce51a4c37a3f3e02f",
]
}
+458
View File
@@ -0,0 +1,458 @@
terraform {
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.0.0"
}
}
backend "s3" {
bucket = "polpa-gestao"
key = "kubernetes/terraform.tfstate"
region = "auto"
endpoints = { s3 = "https://d17eb09b6bce2f90e16e800bb2a6baf9.r2.cloudflarestorage.com" }
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_s3_checksum = true
}
}
provider "kubernetes" {
config_path = "~/.kube/config"
}
variable "db_user" {
type = string
sensitive = true
}
variable "db_password" {
type = string
sensitive = true
}
variable "db_name" {
type = string
sensitive = true
}
variable "cpf_cnpj_api_token" {
type = string
sensitive = true
}
variable "r2_access_key" {
type = string
sensitive = true
}
variable "r2_secret_key" {
type = string
sensitive = true
}
variable "r2_bucket_name" {
type = string
default = "polpa-gestao-backups"
}
variable "r2_endpoint" {
type = string
default = "https://d17eb09b6bce2f90e16e800bb2a6baf9.r2.cloudflarestorage.com"
}
variable "backend_image" {
type = string
default = "ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.03.25.11"
}
variable "frontend_image" {
type = string
default = "ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.03.25.11"
}
resource "kubernetes_namespace_v1" "polpa_gestao" {
metadata {
name = "polpa-gestao"
}
}
resource "kubernetes_secret_v1" "polpa_gestao_secrets" {
metadata {
name = "polpa-gestao-secrets"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
}
data = {
postgres_user = var.db_user
postgres_password = var.db_password
postgres_db = var.db_name
cpf_cnpj_api_token = var.cpf_cnpj_api_token
}
}
resource "kubernetes_persistent_volume_claim_v1" "polpa_gestao_db_data" {
metadata {
name = "postgres-data-pvc"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
}
spec {
access_modes = ["ReadWriteOnce"]
resources {
requests = {
storage = "1Gi"
}
}
}
}
resource "kubernetes_deployment_v1" "polpa_gestao_db" {
metadata {
name = "polpa-gestao-db"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
}
spec {
replicas = 1
selector { match_labels = { app = "polpa-gestao-db" } }
template {
metadata { labels = { app = "polpa-gestao-db" } }
spec {
container {
image = "postgres:16-alpine"
name = "postgres"
volume_mount {
name = "postgres-storage"
mount_path = "/var/lib/postgresql/data"
}
env {
name = "POSTGRES_USER"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
key = "postgres_user"
}
}
}
env {
name = "POSTGRES_PASSWORD"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
key = "postgres_password"
}
}
}
env {
name = "POSTGRES_DB"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
key = "postgres_db"
}
}
}
port { container_port = 5432 }
}
volume {
name = "postgres-storage"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim_v1.polpa_gestao_db_data.metadata[0].name
}
}
}
}
}
}
resource "kubernetes_service_v1" "polpa_gestao_db_svc" {
metadata {
name = "polpa-gestao-db-svc"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
}
spec {
selector = { app = "polpa-gestao-db" }
port { port = 5432 }
type = "ClusterIP"
}
}
resource "kubernetes_deployment_v1" "polpa_gestao_backend" {
metadata {
name = "polpa-gestao-backend"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
}
spec {
replicas = 1
selector { match_labels = { app = "polpa-gestao-backend" } }
template {
metadata { labels = { app = "polpa-gestao-backend" } }
spec {
init_container {
name = "prisma-migrate"
image = "${var.backend_image}-prisma"
command = ["npx", "prisma", "db", "push"]
env {
name = "DATABASE_URL"
value = "postgresql://${var.db_user}:${var.db_password}@polpa-gestao-db-svc:5432/${var.db_name}?schema=public"
}
}
container {
image = var.backend_image
name = "app"
env {
name = "DATABASE_URL"
value = "postgresql://${var.db_user}:${var.db_password}@polpa-gestao-db-svc:5432/${var.db_name}?schema=public"
}
env {
name = "PORT"
value = "3000"
}
env {
name = "HOSTNAME"
value = "0.0.0.0"
}
resources {
limits = { memory = "512Mi", cpu = "500m" }
requests = { memory = "256Mi", cpu = "100m" }
}
readiness_probe {
exec {
command = ["node", "healthcheck.js"]
}
initial_delay_seconds = 10
period_seconds = 5
failure_threshold = 3
}
liveness_probe {
exec {
command = ["node", "healthcheck.js"]
}
initial_delay_seconds = 15
period_seconds = 10
failure_threshold = 3
}
}
}
}
}
}
resource "kubernetes_service_v1" "polpa_gestao_backend_svc" {
metadata {
name = "polpa-gestao-backend-svc"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
}
spec {
selector = { app = "polpa-gestao-backend" }
port {
port = 3000
target_port = 3000
}
}
}
resource "kubernetes_deployment_v1" "polpa_gestao_frontend" {
metadata {
name = "polpa-gestao-frontend"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
}
spec {
replicas = 1
selector { match_labels = { app = "polpa-gestao-frontend" } }
template {
metadata { labels = { app = "polpa-gestao-frontend" } }
spec {
container {
image = var.frontend_image
name = "frontend"
port { container_port = 80 }
resources {
limits = { memory = "128Mi", cpu = "150m" }
requests = { memory = "128Mi", cpu = "100m" }
}
}
}
}
}
}
resource "kubernetes_service_v1" "polpa_gestao_frontend_svc" {
metadata {
name = "polpa-gestao-frontend-svc"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
}
spec {
selector = { app = "polpa-gestao-frontend" }
port {
port = 80
target_port = 80
}
type = "ClusterIP"
}
}
# Unified Ingress for App and API
resource "kubernetes_ingress_v1" "polpa_gestao_ingress" {
metadata {
name = "polpa-gestao-ingress"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
annotations = {
"kubernetes.io/ingress.class" = "traefik"
"cert-manager.io/cluster-issuer" = "letsencrypt-prod"
}
}
spec {
tls {
hosts = ["polpa-gestao.darkroasted.vps-kinghost.net", "polpaapi-gestao.darkroasted.vps-kinghost.net"]
secret_name = "polpa-gestao-tls-certs"
}
rule {
host = "polpa-gestao.darkroasted.vps-kinghost.net"
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = kubernetes_service_v1.polpa_gestao_frontend_svc.metadata[0].name
port { number = 80 }
}
}
}
}
}
rule {
host = "polpaapi-gestao.darkroasted.vps-kinghost.net"
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = kubernetes_service_v1.polpa_gestao_backend_svc.metadata[0].name
port { number = 3000 }
}
}
}
}
}
}
}
resource "kubernetes_secret_v1" "r2_backup_secrets" {
metadata {
name = "r2-backup-secrets"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
}
data = {
access_key = var.r2_access_key
secret_key = var.r2_secret_key
}
}
resource "kubernetes_cron_job_v1" "polpa_gestao_db_backup" {
metadata {
name = "polpa-gestao-db-backup"
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
}
spec {
schedule = "0 0,12 * * *"
job_template {
metadata {
labels = {
app = "polpa-gestao-db-backup"
}
}
spec {
template {
metadata {
labels = {
app = "polpa-gestao-db-backup"
}
}
spec {
container {
name = "backup"
image = "postgres:16-alpine"
command = ["/bin/sh", "-c"]
args = [
<<-EOT
apk add --no-cache aws-cli
export PGPASSWORD=$POSTGRES_PASSWORD
FILENAME="backup-$(date +%Y%m%d%H%M%S).sql.gz"
echo "Starting backup of $POSTGRES_DB to $FILENAME..."
pg_dump -h $DB_HOST -U $POSTGRES_USER $POSTGRES_DB | gzip > /tmp/$FILENAME
echo "Uploading to R2..."
AWS_ACCESS_KEY_ID=$R2_ACCESS_KEY AWS_SECRET_ACCESS_KEY=$R2_SECRET_KEY \
aws s3 cp /tmp/$FILENAME s3://$R2_BUCKET/ --endpoint-url $R2_ENDPOINT
echo "Backup completed successfully."
EOT
]
env {
name = "DB_HOST"
value = "polpa-gestao-db-svc"
}
env {
name = "POSTGRES_USER"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
key = "postgres_user"
}
}
}
env {
name = "POSTGRES_PASSWORD"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
key = "postgres_password"
}
}
}
env {
name = "POSTGRES_DB"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
key = "postgres_db"
}
}
}
env {
name = "R2_ACCESS_KEY"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.r2_backup_secrets.metadata[0].name
key = "access_key"
}
}
}
env {
name = "R2_SECRET_KEY"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.r2_backup_secrets.metadata[0].name
key = "secret_key"
}
}
}
env {
name = "R2_BUCKET"
value = var.r2_bucket_name
}
env {
name = "R2_ENDPOINT"
value = var.r2_endpoint
}
}
restart_policy = "OnFailure"
}
}
}
}
}
}