Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3b6f7cba | ||
|
|
84ae5618f5 | ||
|
|
3956c49f6e | ||
|
|
32e7015123 | ||
|
|
aedabd33de | ||
|
|
bab13be673 | ||
|
|
65ed7046c2 | ||
|
|
b868e4ba39 | ||
|
|
4dbc2973cb | ||
|
|
e54cf0dee1 | ||
|
|
e0c0b45afd | ||
|
|
ebcaa975c7 | ||
|
|
9f1fabeadc | ||
|
|
5a0f7a3ab5 | ||
|
|
ab92fb2968 | ||
|
|
389f2d875a | ||
|
|
f21c0fb089 | ||
|
|
bf7a0b7208 | ||
|
|
f065a10562 | ||
|
|
2d2db29487 | ||
|
|
680bffad2f | ||
|
|
9295a7af26 | ||
|
|
7fd4c48b4f | ||
|
|
bb2a7085f4 | ||
|
|
815ae47138 | ||
|
|
fc1784cdc4 | ||
|
|
7ff632081f | ||
|
|
8590b6277c | ||
|
|
f0d45b7af8 | ||
|
|
6d1a9c8d8e | ||
|
|
e01b416d18 | ||
|
|
f2161e1adf | ||
|
|
14ab25dec9 | ||
|
|
d065da2cd2 | ||
|
|
dc06d1e0f6 | ||
|
|
e016ca8940 | ||
|
|
730501c268 | ||
|
|
01b34f62ad | ||
|
|
81df1a2369 | ||
|
|
2ed9969f33 | ||
|
|
1beca9f2be | ||
|
|
ed4e47021a | ||
|
|
952fe394db | ||
|
|
c1ce19d7fc | ||
|
|
5ed074bacf | ||
|
|
1ed67f981f | ||
|
|
75c367f700 | ||
|
|
35cc0e5d01 | ||
|
|
ab464cee56 |
@@ -1,11 +0,0 @@
|
||||
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 "..."
|
||||
"""
|
||||
@@ -10,11 +10,23 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: easynode-debian
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/backend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -23,69 +35,60 @@ jobs:
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: ./backend
|
||||
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- run: cd backend && npm i
|
||||
- run: cd backend && npx tsc
|
||||
- name: Run build
|
||||
run: npx tsc
|
||||
working-directory: ./backend
|
||||
env:
|
||||
BUILD_VERSION: ${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
run: docker buildx inspect --bootstrap
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker image
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/backend
|
||||
images: rmcampos/polpa-gestao-api
|
||||
tags: |
|
||||
type=raw,value=${{ steps.version.outputs.tag }}
|
||||
type=raw,value=latest,enable={{ is_default_branch }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=registry,ref=rmcampos/polpa-gestao-api:buildcache
|
||||
cache-to: type=registry,ref=rmcampos/polpa-gestao-api:buildcache,mode=max
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Build and push prisma image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
target: prisma
|
||||
tags: |
|
||||
ghcr.io/rmcampos/polpa-gestao/backend:${{ steps.version.outputs.tag }}-prisma
|
||||
ghcr.io/rmcampos/polpa-gestao/backend:latest-prisma
|
||||
provenance: false
|
||||
sbom: false
|
||||
docker.io/rmcampos/polpa-gestao-api:${{ steps.version.outputs.tag }}-prisma
|
||||
docker.io/rmcampos/polpa-gestao-api:latest-prisma
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
|
||||
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
name: Deploy to prod
|
||||
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -37,10 +41,15 @@ jobs:
|
||||
- name: Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Setup Doppler CLI
|
||||
uses: dopplerhq/cli-action@v4
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
env:
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
doppler run --config prd_secrets -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Validate cluster access
|
||||
@@ -80,9 +89,8 @@ jobs:
|
||||
- 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
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: doppler run --config prd_secrets -- terraform init -input=false
|
||||
|
||||
- name: Terraform Validate
|
||||
working-directory: terraform
|
||||
@@ -92,18 +100,23 @@ jobs:
|
||||
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 }}
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
BACKEND_IMAGE: ${{ steps.deploy-vars.outputs.backend_image }}
|
||||
FRONTEND_IMAGE: ${{ steps.deploy-vars.outputs.frontend_image }}
|
||||
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 }}"
|
||||
doppler run --config prd_secrets -- bash -c '
|
||||
export TF_VAR_db_user="$DB_USER"
|
||||
export TF_VAR_db_password="$DB_PASSWORD"
|
||||
export TF_VAR_db_name="$DB_NAME"
|
||||
export TF_VAR_cpf_cnpj_api_token="$CPF_CNPJ_API_TOKEN"
|
||||
export TF_VAR_google_maps_api_key="$GOOGLE_MAPS_API_KEY"
|
||||
export TF_VAR_jwt_secret="$JWT_SECRET"
|
||||
export TF_VAR_r2_access_key="$AWS_ACCESS_KEY_ID"
|
||||
export TF_VAR_r2_secret_key="$AWS_SECRET_ACCESS_KEY"
|
||||
export TF_VAR_backend_image="$BACKEND_IMAGE"
|
||||
export TF_VAR_frontend_image="$FRONTEND_IMAGE"
|
||||
timeout 1m terraform plan -input=false -out=tfplan
|
||||
'
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -144,23 +157,26 @@ jobs:
|
||||
name: tfplan
|
||||
path: terraform
|
||||
|
||||
- name: Setup Doppler CLI
|
||||
uses: dopplerhq/cli-action@v4
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
env:
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
doppler run --config prd_secrets -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: doppler run --config prd_secrets -- 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
|
||||
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
|
||||
run: doppler run --config prd_secrets -- timeout 2m terraform apply tfplan
|
||||
|
||||
|
||||
@@ -10,11 +10,37 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: easynode-debian
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
VITE_BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -24,63 +50,43 @@ jobs:
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
echo "Generated build number: ${BUILD_NUMBER}"
|
||||
echo "Generated build number: ${BUILD_NUMBER}"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- run: cd frontend && npm i
|
||||
- run: cd frontend && npm run lint
|
||||
- run: cd frontend && npm run build
|
||||
env:
|
||||
VITE_BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
|
||||
VITE_CPF_CNPJ_API_TOKEN: ${{ secrets.CPF_CNPJ_API_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
run: docker buildx inspect --bootstrap
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker image
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/frontend
|
||||
images: rmcampos/polpa-gestao-app
|
||||
tags: |
|
||||
type=raw,value=${{ steps.version.outputs.tag }}
|
||||
type=raw,value=latest,enable={{ is_default_branch }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=registry,ref=rmcampos/polpa-gestao-app:buildcache
|
||||
cache-to: type=registry,ref=rmcampos/polpa-gestao-app:buildcache,mode=max
|
||||
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 }}
|
||||
|
||||
VITE_BUILD=${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
|
||||
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
+3
-1
@@ -4,4 +4,6 @@ dist
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
.vscode
|
||||
|
||||
backend/validate-security-fixes.mjs
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# AGENTS.md
|
||||
|
||||
This document provides context and guidelines for AI agents interacting with the Polpa Gestão project.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Polpa Gestão is a full-stack business management web application designed for managing customers, products, sales, and delivery routes. It serves as a comprehensive platform for administrative tasks within a business.
|
||||
|
||||
## Architecture
|
||||
|
||||
The project has a monorepo-like structure with distinct `backend` and `frontend` components.
|
||||
|
||||
- **Backend**: Developed with Node.js 22, Fastify 5, and TypeScript, utilizing Prisma ORM for database interaction.
|
||||
- API Endpoints: Defined in `backend/src/routes/`.
|
||||
- Database Schema: Managed with Prisma in `backend/prisma/`.
|
||||
- **Frontend**: Built with React 19, TypeScript, Vite, and styled with Bootstrap 5.
|
||||
- Main Views: Located in `frontend/src/pages/`.
|
||||
- Reusable Components: Found in `frontend/src/components/`.
|
||||
- **Database**: PostgreSQL 15.
|
||||
- **Infrastructure**: Docker, Docker Compose for local orchestration.
|
||||
|
||||
## Local Development Setup for Agents
|
||||
|
||||
The recommended way for an AI agent to set up and run the project for local interaction is using **Taskfile + Doppler** (which wraps Docker Compose).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
- [Task](https://taskfile.dev/installation/)
|
||||
- [Doppler CLI](https://docs.doppler.com/docs/cli)
|
||||
|
||||
### Configure Doppler
|
||||
|
||||
Project defaults are defined in `doppler.yaml`:
|
||||
|
||||
- project: `polpa-gestao`
|
||||
- config: `dev_ricardo`
|
||||
|
||||
Authenticate and configure Doppler before running tasks:
|
||||
|
||||
```bash
|
||||
doppler login
|
||||
doppler setup --project polpa-gestao --config dev_ricardo
|
||||
```
|
||||
|
||||
### Run with Taskfile
|
||||
|
||||
To start all services in background:
|
||||
|
||||
```bash
|
||||
task dev-up
|
||||
```
|
||||
|
||||
To stop:
|
||||
|
||||
```bash
|
||||
task dev-down
|
||||
```
|
||||
|
||||
To stop and remove volumes/orphans:
|
||||
|
||||
```bash
|
||||
task dev-tier-down
|
||||
```
|
||||
|
||||
Once running, services are accessible at:
|
||||
|
||||
- **Frontend**: http://localhost:5173
|
||||
- **Backend API**: http://localhost:3000
|
||||
- **Database**: localhost:5432 (User: `admin`, Password: `adminpassword`, DB: `polpa_gestao`)
|
||||
|
||||
### Build images with Taskfile
|
||||
|
||||
Use these tasks for local image builds:
|
||||
|
||||
```bash
|
||||
task build-frontend
|
||||
task build-backend
|
||||
task build-prisma
|
||||
```
|
||||
|
||||
All build tasks run with `doppler run`, so required secrets (for example API tokens and maps keys) come from Doppler-managed environment values.
|
||||
|
||||
## Interaction Points for Agents
|
||||
|
||||
### Backend API
|
||||
|
||||
Agents can interact with the backend API via HTTP requests to `http://localhost:3000`.
|
||||
|
||||
- **Authentication**: Routes requiring authentication use JWT. Agents might need to obtain a token via a login endpoint (e.g., `/auth/login`) and include it in subsequent requests.
|
||||
- **Data Models**: Database schema and models are defined in `backend/prisma/schema.prisma`. Agents modifying data should be aware of these structures.
|
||||
- **Validation**: Backend endpoints often have schema validation. Agents should adhere to expected request body/query parameter formats.
|
||||
|
||||
### Database
|
||||
|
||||
Direct database interaction through Prisma commands can be performed within the `backend` directory.
|
||||
|
||||
- **Schema Changes**: Any changes to the database schema require updating `backend/prisma/schema.prisma` and running `npx prisma migrate dev`.
|
||||
- **Seeding**: The database can be seeded using `backend/run-seed.sh`.
|
||||
|
||||
## Development Conventions for Agents
|
||||
|
||||
- **Naming**: Follow `camelCase` for TypeScript variables, functions, and database fields.
|
||||
- **Types**: Adhere strictly to TypeScript. Shared types are often in `frontend/src/types.ts` or relevant backend models.
|
||||
- **Code Location**:
|
||||
- Backend routes: `backend/src/routes/`
|
||||
- Frontend pages: `frontend/src/pages/`
|
||||
- Frontend components: `frontend/src/components/`
|
||||
|
||||
## Testing
|
||||
|
||||
Currently, there is no formal test suite implemented. Agents making changes should consider adding basic tests for new functionalities in `backend/tests` or `frontend/src/__tests__` if appropriate.
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## 2026-06-13 #1
|
||||
|
||||
### Fixed
|
||||
- Missing Google Maps API Key and CPF CNPJ API Key integration in backend deployment.
|
||||
|
||||
### Docker images
|
||||
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.34](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.34)
|
||||
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.34-prisma](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.34)
|
||||
|
||||
## 2026-06-11 #3
|
||||
|
||||
### Fixed
|
||||
- Login issue due to jwt secrets missing in production.
|
||||
|
||||
## 2026-06-11 #2
|
||||
|
||||
### Added
|
||||
- Created `CustomerDeleted` audit database table and model to log details of deleted customers.
|
||||
- Created `/api/proxy/validator` and `/api/proxy/geocode` endpoints in backend to proxy third-party validation and mapping requests securely.
|
||||
- Added `/api/users/me` and `/api/users/logout` endpoints in backend for cookie session verification and cleanup.
|
||||
|
||||
### Changed
|
||||
- Configured explicit allowed origins for CORS from `ALLOWED_ORIGINS` environment variable, defaulting to `http://localhost:5173`.
|
||||
- Gated public closest POS endpoint `/api/public/pos/closest` with rate-limiting (max 15/min) and removed `lat`, `lng`, and `lastBuyingDate` fields from its response.
|
||||
- Configured `@fastify/jwt` to read tokens from `polpaAuth` HttpOnly cookie.
|
||||
- Overrode frontend `localStorage` to store `token` and `user` strictly in-memory (JS variables) instead of persistent disk storage.
|
||||
- Proxy validation and geocoding in frontend `Customers.tsx` through backend instead of using direct third-party calls.
|
||||
- Compiled production backend build without TS source maps or declarations using a specialized `tsconfig.prod.json`.
|
||||
- JWT token is no longer returned in login or `/api/users/me` response bodies — token is exclusively transported via the `polpaAuth` HttpOnly cookie and never exposed to JavaScript.
|
||||
- Removed all `Authorization: Bearer` headers from frontend API calls — authentication relies solely on the HttpOnly cookie sent automatically by the browser.
|
||||
- `Storage.prototype` override now only intercepts the `user` key (token interception removed as token no longer exists in JS context).
|
||||
- Cookie signing reverted to `signed: false` — JWT's own HMAC signature provides integrity; `@fastify/cookie` layer was redundant and non-functional in v11.
|
||||
|
||||
### Fixed
|
||||
- H1: Public endpoint leaks customer GPS + buying data.
|
||||
- H2: CORS reflects any origin.
|
||||
- H3: JWT in localStorage (XSS-accessible).
|
||||
- H4: API tokens baked into frontend Docker image.
|
||||
- H5: Hard delete of sales (no audit trail) mitigated by auditing customer deletes via `CustomerDeleted`.
|
||||
- H6: Source maps served in production.
|
||||
- Parameter injection vulnerability in `/api/proxy/validator`: `value` and `token` query parameters are now URL-encoded before being forwarded to the Invertexto API.
|
||||
- Session re-issue on every `/api/users/me` call removed — endpoint now returns user data only, without silently extending the session on each page load.
|
||||
- `Storage.prototype` override applied at prototype level instead of instance level, fixing Firefox compatibility where instance-level assignment was silently ignored.
|
||||
|
||||
### Docker images
|
||||
- [ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.06.11.52](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.11.52)
|
||||
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.34](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.34)
|
||||
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.34-prisma](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.34)
|
||||
-
|
||||
## 2026-06-11 #1
|
||||
|
||||
### Added
|
||||
- Scoped rate limiting using `@fastify/rate-limit` for `/api/users/login` endpoint keyed by IP + email.
|
||||
- In-memory login failure tracking with exponential backoff lockout after 3 consecutive failures.
|
||||
- Role-based authorization middleware via `requireAdmin` decorator on Fastify backend.
|
||||
- Enforced admin role requirement on Users management, Product modification, Customer/POS deletion, Sale deletion, and Dashboard routes.
|
||||
- Frontend role guards and conditional sidebar rendering to restrict non-admin users from accessing Users or Dashboard views.
|
||||
|
||||
### Changed
|
||||
- Removed hardcoded fallback JWT signature secret.
|
||||
- Seed script updated to read admin credentials from environment variables or generate a secure random password on first seed.
|
||||
- Docker compose configuration updated to forward `JWT_SECRET` to the backend container.
|
||||
|
||||
### Fixed
|
||||
- Hardcoded default admin user credentials security vulnerability (C2).
|
||||
- Zero role-based authorization model allowing non-admin users to reach admin endpoints (C3).
|
||||
- Potential authentication bypass due to fallback JWT secret when environment variable is missing (C1).
|
||||
- No brute-force protection on user login (C4).
|
||||
|
||||
### Docker images
|
||||
- [ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.06.11.50](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.11.50)
|
||||
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.33](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.33)
|
||||
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.33-prisma](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.33)
|
||||
|
||||
## 2026-06-10 #2
|
||||
|
||||
### Added
|
||||
- Option do delete customers and POSes, with a confirmation modal to prevent accidental deletions.
|
||||
|
||||
### Changed
|
||||
- Customer and POSes tables foreign keys recreating them with `ON DELETE CASCADE` to ensure related records are removed when a customer or POS is deleted.
|
||||
- Updates on a disabled customer make it enabled again.
|
||||
|
||||
### Fixed
|
||||
- Clearing up the phone number input on the customer modal.
|
||||
|
||||
### Docker images
|
||||
- [ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.06.10.49](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.10.49)
|
||||
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.10.32](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.10.32)
|
||||
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.10.32-prisma](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.10.32)
|
||||
|
||||
|
||||
## 2026-06-10 #1
|
||||
|
||||
### Added
|
||||
- Card to the dashboard page displaying customer POSes and their last buying date for those who haven't bought anything in 10 days or more.
|
||||
|
||||
### Docker images
|
||||
- [ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.06.10.48](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.10.48)
|
||||
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.10.31](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.10.31)
|
||||
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.10.31-prisma](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.10.31)
|
||||
|
||||
## [app-v2026.06.08.47](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.08.47) - 2026-06-08
|
||||
|
||||
### Added
|
||||
- Professional local development setup with Taskfile and Doppler for secure secret management.
|
||||
- Doppler integration in CI/CD workflows for streamlined secret handling.
|
||||
- POS by Region summary on the dashboard for better regional insights.
|
||||
- Dashboard drill-down for Total Fridges with POS-level modal and API.
|
||||
- POS Industry Summary endpoint and dashboard card.
|
||||
- Industry field to Customer POS for categorization.
|
||||
- Optional `region` field to POS for filtering and reporting.
|
||||
- Optional `notes` field for customers in Prisma schema and database migration.
|
||||
- Notes textarea in the customer create/edit modal with support for loading existing notes.
|
||||
- Optional notes snippet display on customer cards.
|
||||
|
||||
### Changed
|
||||
- Improved Terraform deployment plan with variables for better configurability.
|
||||
- Updated README with clearer setup instructions.
|
||||
- CI/CD workflows updated to use Doppler for environment secrets.
|
||||
- Frontend build CI improved with Doppler integration.
|
||||
- Container names and Docker flows updated for better naming consistency.
|
||||
- Quantity input in sales page changed to text type for better UX.
|
||||
- Closest page updated to include customer name for easier identification.
|
||||
- Customer POST/PUT API handlers now accept and persist optional `notes`.
|
||||
|
||||
### Fixed
|
||||
- Unable to type all 14 digits for enterprise documents on customer creation.
|
||||
- Wrong Doppler secret name in multiple workflow files.
|
||||
- Prevented duplicate Customer and POS creation.
|
||||
|
||||
## [[app-v2026.06.03.45]](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.03.45) - 2026-06-03
|
||||
|
||||
- Initial tagged release.
|
||||
@@ -30,16 +30,30 @@ A dashboard provides analytics and reporting. The application includes user mana
|
||||
| CI/CD | GitHub Actions → GitHub Container Registry |
|
||||
| Deployment | Terraform |
|
||||
|
||||
## Running Locally with Docker
|
||||
## Running Locally with Taskfile + Doppler
|
||||
|
||||
The entire application stack (database, backend API, and frontend) can be started with a single command using Docker Compose.
|
||||
The project uses `Taskfile.yml` to standardize local commands and `doppler.yaml` to define Doppler project/config for secrets.
|
||||
|
||||
**Prerequisites:** [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed.
|
||||
**Prerequisites:** [Docker](https://docs.docker.com/get-docker/), [Docker Compose](https://docs.docker.com/compose/install/), [Task](https://taskfile.dev/installation/), and [Doppler CLI](https://docs.doppler.com/docs/cli).
|
||||
|
||||
**Start all services:**
|
||||
### 1. Authenticate and configure Doppler
|
||||
|
||||
`doppler.yaml` defaults to:
|
||||
|
||||
- project: `polpa-gestao`
|
||||
- config: `dev_secrets`
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
doppler login
|
||||
doppler setup --project polpa-gestao --config dev_secrets
|
||||
|
||||
# PS: tokens will be handled directly in runs, injected as ENV VARS
|
||||
```
|
||||
|
||||
### 2. Start services
|
||||
|
||||
```bash
|
||||
task dev-up
|
||||
```
|
||||
|
||||
This command will:
|
||||
@@ -54,29 +68,57 @@ This command will:
|
||||
|---|---|
|
||||
| Frontend | http://localhost:5173 |
|
||||
| Backend API | http://localhost:3000 |
|
||||
| Database | `localhost:5432` (user: `admin`, password: `adminpassword`, db: `polpa_gestao`) |
|
||||
| Database | `localhost:5432` (Please run `task dev-db-access` to get DB credentials) |
|
||||
|
||||
**Optional environment variable:**
|
||||
### Optional ngrok stack
|
||||
|
||||
To enable CPF/CNPJ document validation, set the `VITE_CPF_CNPJ_API_TOKEN` variable before starting:
|
||||
To run the ngrok compose file:
|
||||
|
||||
```bash
|
||||
VITE_CPF_CNPJ_API_TOKEN=your_token_here docker compose up
|
||||
task dev-up-ngrok
|
||||
```
|
||||
|
||||
**Stop all services:**
|
||||
### Build Docker images
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
task build-all
|
||||
|
||||
# For ngrok, use
|
||||
task build-all-ngrok
|
||||
```
|
||||
|
||||
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`.
|
||||
Build tasks run with `doppler run`, which injects secrets as environment variables (for example `CPF_CNPJ_API_TOKEN` and `GOOGLE_MAPS_API_KEY`) during Docker builds.
|
||||
|
||||
### Stop services
|
||||
|
||||
```bash
|
||||
task dev-down
|
||||
|
||||
# For ngrok, use
|
||||
task dev-down-ngrok
|
||||
```
|
||||
|
||||
To also remove volumes/orphans:
|
||||
|
||||
```bash
|
||||
task dev-tier-down
|
||||
```
|
||||
|
||||
Database data is persisted in a Docker volume (`pgdata`) and survives regular restarts.
|
||||
|
||||
## Running Locally with Docker Compose (Alternative)
|
||||
|
||||
If you prefer not to use Taskfile, you can still start the stack directly:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
The application is deployed using **Terraform**. The infrastructure-as-code configuration can be found in the following public repository:
|
||||
The application is deployed using **Terraform**. The infrastructure-as-code configuration can be found in the terraform directory:
|
||||
|
||||
[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)
|
||||
[terraform/main.tf](terraform/main.tf)
|
||||
|
||||
Docker images are built and published to the GitHub Container Registry automatically via GitHub Actions on every push:
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# SECURITY-AUDIT: polpa-gestao
|
||||
|
||||
> Date: 2026-06-09
|
||||
> Scope: full repo (backend, frontend, infra, CI/CD)
|
||||
> Method: static analysis
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### C1 — Hardcoded JWT fallback secret
|
||||
|
||||
**File:** `backend/src/index.ts:14`
|
||||
**Risk:** if `JWT_SECRET` env missing, uses string `'supersecret'`. Anyone knows this can forge tokens = full system access.
|
||||
**Fix:** rm fallback; make `JWT_SECRET` required at startup + crash if unset.
|
||||
|
||||
### C2 — Default admin creds in seed file
|
||||
|
||||
**File:** `backend/src/seed.ts:7-15`
|
||||
**Risk:** user `admin@polpagestao.com` / `admin123` hardcoded. If seed runs in prod (possible via `run-seed.sh`), trivial brute force gives admin access.
|
||||
**Fix:** read creds from env or generate random password on first seed; log it to stdout only.
|
||||
|
||||
### C3 — Zero role-based authorization
|
||||
|
||||
**File:** all route files + `backend/src/index.ts:17-23`
|
||||
**Risk:** `authenticate` decorator only checks JWT validity. Never checks `user.role`. Any authed user (role `user`) can: create other users (`POST /api/users`), delete products, read all sales, etc. Flat permission model — no separation of concerns.
|
||||
**Fix:** add middleware that checks `request.user.role` against required role per route.
|
||||
|
||||
### C4 — No brute-force protection on login
|
||||
|
||||
**File:** `backend/src/routes/users.ts:6-21`
|
||||
**Risk:** `POST /api/users/login` has zero rate limiting, no CAPTCHA, no account lockout. Attacker can spray passwords indefinitely.
|
||||
**Fix:** rate-limit by IP + email (e.g., `@fastify/rate-limit`); add exponential backoff after N failures.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1 — Public endpoint leaks customer GPS + buying data
|
||||
|
||||
**File:** `backend/src/routes/public.ts:34-88` + `frontend/src/pages/ClosestPos.tsx`
|
||||
**Risk:** `GET /api/public/pos/closest` requires zero auth. Returns customer names, full addresses, GPS coords, and last buying dates. Anyone can scrape this to build customer profiles + location history.
|
||||
**Fix:** add basic auth or rate-limit heavily; strip `lastBuyingDate`; return only distance + address (not lat/lng).
|
||||
|
||||
### H2 — CORS reflects any origin
|
||||
|
||||
**File:** `backend/src/index.ts:8-11`
|
||||
**Risk:** `origin: true` = any website can call API from browser. Combined with localStorage JWT (H3), XSS on any subdomain = full account takeover.
|
||||
**Fix:** set explicit allowed origins (env var).
|
||||
|
||||
### H3 — JWT in localStorage (XSS-accessible)
|
||||
|
||||
**File:** `frontend/src/pages/Login.tsx:29`, `frontend/src/App.tsx:17`, all pages read from `localStorage`
|
||||
**Risk:** Any XSS (even minor) steals token permanently. No HttpOnly/SameSite cookie protection.
|
||||
**Fix:** use HttpOnly + Secure + SameSite cookies for token transport; keep JS token only in memory.
|
||||
|
||||
### H4 — API tokens baked into frontend Docker image
|
||||
|
||||
**File:** `frontend/Dockerfile:7-16` + `docker-compose.yml:64-68`
|
||||
**Risk:** `VITE_CPF_CNPJ_API_TOKEN` + `VITE_GOOGLE_MAPS_API_KEY` are build args embedded in final JS bundle. Anyone who pulls `ghcr.io/rmcampos/polpa-gestao/frontend` can extract them from `dist/assets/*.js`.
|
||||
**Fix:** proxy these API calls through backend (never expose tokens to client); or restrict token scopes to minimum.
|
||||
|
||||
### H5 — Hard delete of sales (no audit trail)
|
||||
|
||||
**File:** `backend/src/routes/sales.ts:254-275`
|
||||
**Risk:** `DELETE /api/sales/:id` does `prisma.sale.delete()` — irreversible data loss. No soft-delete, no audit log. Financial records vanish.
|
||||
**Fix:** add `disabledAt` column like other models; keep sale data but mark inactive.
|
||||
|
||||
### H6 — Source maps served in production
|
||||
|
||||
**File:** `backend/tsconfig.json:21-24`
|
||||
**Risk:** `sourceMap: true`, `declaration: true`, `declarationMap: true` — production `dist/` maps back to TypeScript sources. Anyone decompiling the image sees full source code.
|
||||
**Fix:** set `sourceMap: false`, `declaration: false`, `declarationMap: false` for production build.
|
||||
|
||||
### H7 — CI deploys to prod without manual approval
|
||||
|
||||
**File:** `.github/workflows/deploy.yml:20-22`
|
||||
**Risk:** `workflow_run` on any completed backend/frontend CD directly triggers Terraform apply. No human gate before prod changes. A broken build can take down production.
|
||||
**Fix:** require manual `workflow_dispatch` approval for production apply.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M1 — DB exposed on host port
|
||||
|
||||
**File:** `docker-compose.yml:12`
|
||||
**Risk:** `5432:5432` mapped to host. Anyone on same network can probe PostgreSQL.
|
||||
**Fix:** remove port mapping or bind to `127.0.0.1:5432:5432`.
|
||||
|
||||
### M2 — ngrok tunnels dev to public internet
|
||||
|
||||
**File:** `Taskfile.yml` (ngrok task)
|
||||
**Risk:** dev environment exposed via ngrok public URL. If dev DB has real data, it's exposed.
|
||||
**Fix:** use Tailscale/WireGuard instead of ngrok; or ensure dev DB has fake data.
|
||||
|
||||
### M3 — No input validation (everything cast `as any`)
|
||||
|
||||
**File:** all route files (e.g., `users.ts:7`, `products.ts:22`, `customers.ts`, `sales.ts`)
|
||||
**Risk:** request body typed `as any` everywhere. No Zod/Joi schema. Prisma catches type errors but not business logic validation. SQL injection unlikely (Prisma paramerizes) but unexpected types can crash or cause weird state.
|
||||
**Fix:** define Fastify JSON schemas per route; use Zod for runtime validation.
|
||||
|
||||
### M4 — Prisma errors leak schema details to client
|
||||
|
||||
**File:** `backend/src/routes/products.ts:28-34`
|
||||
**Risk:** `PrismaClientValidationError` message split and sent to client. Leaks field names, types, constraints.
|
||||
**Fix:** return generic error to client; log full error server-side only.
|
||||
|
||||
### M5 — No HTTPS in compose (plaintext traffic)
|
||||
|
||||
**File:** `docker-compose.yml` (all services), `nginx/nginx.conf`
|
||||
**Risk:** dev mode traffic is HTTP. If accessed over network (or via ngrok), credentials + tokens in plaintext.
|
||||
**Fix:** terminate TLS at nginx or use Traefik with self-signed cert in dev.
|
||||
|
||||
### M6 — Terraform R2 backend skips validation
|
||||
|
||||
**File:** `terraform/main.tf:9-19`
|
||||
**Risk:** `skip_credentials_validation`, `skip_region_validation`, `skip_s3_checksum` = misconfiguration-friendly. State may contain plaintext secrets.
|
||||
**Fix:** remove skips; configure proper AWS env vars; enable state encryption.
|
||||
|
||||
### M7 — No soft-delete stack for visits/routes
|
||||
|
||||
**File:** `backend/prisma/schema.prisma` (Route, CustomerPos have no `disabledAt`)
|
||||
**Risk:** Cascade deletes can destroy route data. No recovery.
|
||||
**Fix:** add `disabledAt` to all models; soft-delete everywhere.
|
||||
|
||||
### M8 — Broad CI permissions
|
||||
|
||||
**File:** `.github/workflows/backend-cd.yml:15`, `deploy.yml`
|
||||
**Risk:** `contents: write` + `packages: write`. Deploy workflow has access to production Kubeconfig via Doppler token.
|
||||
**Fix:** restrict to `contents: read`, `packages: write` on workflow scope; use OIDC instead of long-lived Doppler tokens.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### L1 — `strict: false` in backend TS
|
||||
|
||||
**File:** `backend/tsconfig.json:38`
|
||||
**Risk:** no strict null checks. Null pointer bugs slip to runtime.
|
||||
**Fix:** `strict: true` + fix type errors.
|
||||
|
||||
### L2 — `*.pem` in .dockerignore but not .gitignore
|
||||
|
||||
**File:** `backend/.dockerignore:7` — `*.pem` excluded from Docker build.
|
||||
**Risk:** if someone places a .pem key in repo root, git would track it.
|
||||
**Fix:** add `*.pem` to root `.gitignore`.
|
||||
|
||||
### L3 — Password stored as plain `String` in Prisma
|
||||
|
||||
**File:** `backend/prisma/schema.prisma:15`
|
||||
**Risk:** no DB-level constraint on password min length. An app bug could store short/empty hashes. (Mitigated by bcrypt at app level.)
|
||||
**Fix:** add `@db.VarChar(60)` to match bcrypt output length.
|
||||
|
||||
### L4 — No `enabledAt`/`disabledAt` on `Route` model
|
||||
|
||||
**File:** `backend/prisma/schema.prisma:96-104`
|
||||
**Risk:** routes can only be deleted, not soft-disabled.
|
||||
**Fix:** add `disabledAt DateTime?` to Route schema.
|
||||
|
||||
### L5 — `VITE_BUILD_NUMBER: snapshot` in compose
|
||||
|
||||
**File:** `docker-compose.yml:68`
|
||||
**Risk:** images built locally not traceable to any CI run or git commit.
|
||||
**Fix:** use `git describe --tags` or commit SHA.
|
||||
|
||||
### L6 — DB backups not encrypted client-side
|
||||
|
||||
**File:** `terraform/main.tf:385-390`
|
||||
**Risk:** backups uploaded to R2 without client-side encryption. R2 server-side encryption may not be enabled.
|
||||
**Fix:** encrypt with age/gpg before upload, or use `--sse aws:kms`.
|
||||
|
||||
### L7 — No dependency scanning/fuzzing
|
||||
|
||||
**File:** entire repo
|
||||
**Risk:** no Dependabot/Renovate/Snyk for `package.json` deps. Supply chain risk unmanaged.
|
||||
**Fix:** enable Dependabot or Renovate for npm + Docker.
|
||||
|
||||
---
|
||||
|
||||
## SUMMARY
|
||||
|
||||
| Severity | Count | Key issues |
|
||||
|----------|-------|------------|
|
||||
| CRITICAL | 4 | JWT secret fallback, default admin creds, zero RBAC, no login rate-limit |
|
||||
| HIGH | 7 | Public GPS leak, open CORS, localStorage JWT, API tokens in image, hard deletes, source maps in prod, no deploy approval gate |
|
||||
| MEDIUM | 8 | DB port exposed, ngrok tunnel, no validation, Prisma errors leaked, plaintext HTTP, Terraform skips, no soft-delete, broad CI permissions |
|
||||
| LOW | 7 | strict:false, .pem git tracking, password column type, missing disableAt, build snapshot, unencrypted backups, no dependabot |
|
||||
|
||||
**Top 3 priorities:**
|
||||
1. Remove JWT fallback secret (C1) — crash if `JWT_SECRET` unset
|
||||
2. Add role checks to `authenticate` middleware (C3) — `admin` vs `user` access
|
||||
3. Rate-limit login endpoint (C4) — `@fastify/rate-limit`
|
||||
@@ -0,0 +1,97 @@
|
||||
# https://taskfile.dev
|
||||
|
||||
version: '3'
|
||||
|
||||
silent: true
|
||||
|
||||
tasks:
|
||||
build-all:
|
||||
desc: Build frontend, backend and prisma Docker images using docker compose
|
||||
cmd: |
|
||||
export $(doppler secrets download --no-file --format env --config dev_tokens | sed 's/"//g' | xargs) && \
|
||||
doppler run --config dev_secrets -- docker compose -f docker-compose.yml build
|
||||
|
||||
build-all-ngrok:
|
||||
desc: Build frontend, backend and prisma Docker images using docker-compose.ngrok.yml
|
||||
cmd: |
|
||||
export $(doppler secrets download --no-file --format env --config dev_tokens | sed 's/"//g' | xargs) && \
|
||||
doppler run --config dev_secrets -- docker compose -f docker-compose.ngrok.yml build
|
||||
|
||||
dev-up:
|
||||
desc: Start the development environment using docker compose
|
||||
deps: [build-all]
|
||||
cmd: |
|
||||
export $(doppler secrets download --no-file --format env --config dev_tokens | sed 's/"//g' | xargs) && \
|
||||
doppler run --config dev_secrets -- docker compose -f docker-compose.yml up -d
|
||||
|
||||
dev-up-ngrok:
|
||||
desc: Start the development environment using docker compose and ngrok
|
||||
deps: [build-all-ngrok]
|
||||
cmds:
|
||||
- |
|
||||
export $(doppler secrets download --no-file --format env --config dev_tokens | sed 's/"//g' | xargs) && \
|
||||
doppler run --config dev_secrets -- docker compose -f docker-compose.ngrok.yml up -d
|
||||
- task: ngrok-up
|
||||
|
||||
dev-down:
|
||||
desc: Stop the development environment using docker compose
|
||||
cmd: docker compose -f docker-compose.yml down
|
||||
|
||||
dev-down-ngrok:
|
||||
desc: Stop the development environment using docker compose and ngrok
|
||||
cmds:
|
||||
- docker compose -f docker-compose.ngrok.yml down
|
||||
- task: ngrok-down
|
||||
|
||||
dev-db-access:
|
||||
desc: Print local DB credentials
|
||||
cmd: |
|
||||
doppler run --config dev_secrets -- sh -c '
|
||||
echo "Local database credentials:"
|
||||
echo "- Database name=$DB_NAME"
|
||||
echo "- Database user=$DB_USER"
|
||||
echo "- Database password=$DB_PASSWORD"
|
||||
'
|
||||
|
||||
dev-tier-down:
|
||||
desc: Stop the development environment using docker compose, removing volumes
|
||||
cmd: docker compose -f docker-compose.yml down -v --remove-orphans
|
||||
|
||||
ngrok-up:
|
||||
desc: Start ngrok tunnel in background
|
||||
cmds:
|
||||
- ./nginx/start-proxy.sh
|
||||
- |
|
||||
bash -c '
|
||||
if pgrep -f "ngrok http" | grep -v $$ > /dev/null; then
|
||||
echo "ngrok is already running, skipping..."
|
||||
else
|
||||
nohup ngrok http 8080 --log=stdout --log-format=logfmt --log-level=info > /tmp/ngrok.log 2>&1 &
|
||||
echo $! > /tmp/ngrok.pid
|
||||
echo "ngrok started with PID $(cat /tmp/ngrok.pid)"
|
||||
sleep 3
|
||||
if ! pgrep -f "ngrok http" > /dev/null; then
|
||||
echo "ngrok failed to start, log output:"
|
||||
cat /tmp/ngrok.log
|
||||
exit 1
|
||||
fi
|
||||
echo "ngrok is running"
|
||||
fi
|
||||
'
|
||||
- curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[0].public_url'
|
||||
|
||||
ngrok-down:
|
||||
desc: Stop ngrok tunnel
|
||||
cmds:
|
||||
- docker stop nginx-proxy 2> /dev/null || true
|
||||
- |
|
||||
if [ -f /tmp/ngrok.pid ]; then
|
||||
echo "Killing ngrok PID $(cat /tmp/ngrok.pid)"
|
||||
/bin/kill -9 $(cat /tmp/ngrok.pid)
|
||||
rm -rf /tmp/ngrok.pid
|
||||
else
|
||||
echo "No PID file found. trying pkill..."
|
||||
pkill -9 -f ngrok || true
|
||||
fi
|
||||
- rm -rf /tmp/ngrok.log
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
|
||||
+2
-2
@@ -17,12 +17,12 @@ 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 tsconfig.json tsconfig.prod.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY prisma.config.ts ./
|
||||
COPY src ./src
|
||||
|
||||
RUN npx tsc
|
||||
RUN npx tsc -p tsconfig.prod.json
|
||||
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
|
||||
|
||||
Generated
+43
@@ -9,8 +9,10 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/rate-limit": "^11.0.0",
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -92,6 +94,26 @@
|
||||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
|
||||
"integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.0",
|
||||
"fastify-plugin": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||
@@ -225,6 +247,27 @@
|
||||
"ipaddr.js": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/rate-limit": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.0.0.tgz",
|
||||
"integrity": "sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/rate-limit": "^11.0.0",
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CustomerPos" ADD COLUMN "banner" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "indiBanner" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CustomerPos"
|
||||
ADD COLUMN "lat" DOUBLE PRECISION,
|
||||
ADD COLUMN "lng" DOUBLE PRECISION;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CustomerPos"
|
||||
ADD COLUMN "industry" TEXT;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CustomerPos"
|
||||
ADD COLUMN "region" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Customer" ADD COLUMN "notes" TEXT;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CustomerPos" DROP CONSTRAINT "CustomerPos_customerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "RouteCustomerPos" DROP CONSTRAINT "RouteCustomerPos_customerPosId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Sale" DROP CONSTRAINT "Sale_customerPosId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SaleProduct" DROP CONSTRAINT "SaleProduct_saleId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerPos" ADD CONSTRAINT "CustomerPos_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Sale" ADD CONSTRAINT "Sale_customerPosId_fkey" FOREIGN KEY ("customerPosId") REFERENCES "CustomerPos"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SaleProduct" ADD CONSTRAINT "SaleProduct_saleId_fkey" FOREIGN KEY ("saleId") REFERENCES "Sale"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteCustomerPos" ADD CONSTRAINT "RouteCustomerPos_customerPosId_fkey" FOREIGN KEY ("customerPosId") REFERENCES "CustomerPos"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "CustomerDeleted" (
|
||||
"id" UUID NOT NULL,
|
||||
"customerId" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"document" TEXT,
|
||||
"phone" TEXT,
|
||||
"personName" VARCHAR(30),
|
||||
"notes" TEXT,
|
||||
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CustomerDeleted_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -25,6 +25,7 @@ model Customer {
|
||||
document String? @unique
|
||||
phone String?
|
||||
personName String? @db.VarChar(30)
|
||||
notes String?
|
||||
pos CustomerPos[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -34,10 +35,17 @@ model Customer {
|
||||
model CustomerPos {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
customerId String @db.Uuid
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
address String
|
||||
phone String
|
||||
industry String?
|
||||
personName String? @db.VarChar(30)
|
||||
fridgeCount Int @default(0)
|
||||
banner Boolean @default(false)
|
||||
indiBanner Boolean @default(false)
|
||||
region String?
|
||||
lat Float?
|
||||
lng Float?
|
||||
sales Sale[]
|
||||
routes RouteCustomerPos[]
|
||||
createdAt DateTime @default(now())
|
||||
@@ -60,12 +68,14 @@ model Product {
|
||||
model Sale {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
customerPosId String @db.Uuid
|
||||
customerPos CustomerPos @relation(fields: [customerPosId], references: [id])
|
||||
customerPos CustomerPos @relation(fields: [customerPosId], references: [id], onDelete: Cascade)
|
||||
delivered Boolean @default(false)
|
||||
paymentMethod String
|
||||
paymentDueDate DateTime?
|
||||
paymentDate DateTime?
|
||||
comments String?
|
||||
nextVisitDate DateTime?
|
||||
visitedAt DateTime?
|
||||
products SaleProduct[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -74,7 +84,7 @@ model Sale {
|
||||
model SaleProduct {
|
||||
saleId String @db.Uuid
|
||||
productId String @db.Uuid
|
||||
sale Sale @relation(fields: [saleId], references: [id])
|
||||
sale Sale @relation(fields: [saleId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
quantity Int
|
||||
createdAt DateTime @default(now())
|
||||
@@ -97,7 +107,19 @@ model RouteCustomerPos {
|
||||
routeId String @db.Uuid
|
||||
customerPosId String @db.Uuid
|
||||
route Route @relation(fields: [routeId], references: [id])
|
||||
customerPos CustomerPos @relation(fields: [customerPosId], references: [id])
|
||||
customerPos CustomerPos @relation(fields: [customerPosId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([routeId, customerPosId])
|
||||
}
|
||||
|
||||
model CustomerDeleted {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
customerId String @db.Uuid
|
||||
name String
|
||||
document String?
|
||||
phone String?
|
||||
personName String? @db.VarChar(30)
|
||||
notes String?
|
||||
deletedAt DateTime @default(now())
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker compose exec backend node dist/seed.js
|
||||
docker compose exec \
|
||||
-e SEED_ADMIN_EMAIL="$1" \
|
||||
-e SEED_ADMIN_PASSWORD="$2" \
|
||||
polpa_backend node dist/seed.js
|
||||
|
||||
# for prod
|
||||
# kubectl exec -n production -it polpa-gestao-backend-6d7d9f8c7b-abc12 -- node dist/seed.js
|
||||
Vendored
+1
@@ -3,5 +3,6 @@ import fastify from 'fastify';
|
||||
declare module 'fastify' {
|
||||
export interface FastifyInstance {
|
||||
authenticate: any;
|
||||
requireAdmin: any;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,3 +57,8 @@ export type Route = Prisma.RouteModel
|
||||
*
|
||||
*/
|
||||
export type RouteCustomerPos = Prisma.RouteCustomerPosModel
|
||||
/**
|
||||
* Model CustomerDeleted
|
||||
*
|
||||
*/
|
||||
export type CustomerDeleted = Prisma.CustomerDeletedModel
|
||||
|
||||
@@ -79,3 +79,8 @@ export type Route = Prisma.RouteModel
|
||||
*
|
||||
*/
|
||||
export type RouteCustomerPos = Prisma.RouteCustomerPosModel
|
||||
/**
|
||||
* Model CustomerDeleted
|
||||
*
|
||||
*/
|
||||
export type CustomerDeleted = Prisma.CustomerDeletedModel
|
||||
|
||||
@@ -162,17 +162,6 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedStringNullableFilter<$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 IntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
@@ -184,20 +173,20 @@ export type IntFilter<$PrismaModel = never> = {
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
export type BoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type FloatNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
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>
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
@@ -216,11 +205,6 @@ export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedIntFilter<$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
|
||||
@@ -229,6 +213,49 @@ export type BoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatNullableFilter<$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 NestedUuidFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
@@ -388,31 +415,20 @@ export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedStringNullableFilter<$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 NestedBoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
export type NestedFloatNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
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>
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
@@ -431,9 +447,15 @@ export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedBoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
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 NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
@@ -444,4 +466,36 @@ export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -391,7 +391,8 @@ export const ModelName = {
|
||||
Sale: 'Sale',
|
||||
SaleProduct: 'SaleProduct',
|
||||
Route: 'Route',
|
||||
RouteCustomerPos: 'RouteCustomerPos'
|
||||
RouteCustomerPos: 'RouteCustomerPos',
|
||||
CustomerDeleted: 'CustomerDeleted'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -407,7 +408,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "user" | "customer" | "customerPos" | "product" | "sale" | "saleProduct" | "route" | "routeCustomerPos"
|
||||
modelProps: "user" | "customer" | "customerPos" | "product" | "sale" | "saleProduct" | "route" | "routeCustomerPos" | "customerDeleted"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
@@ -1003,6 +1004,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
CustomerDeleted: {
|
||||
payload: Prisma.$CustomerDeletedPayload<ExtArgs>
|
||||
fields: Prisma.CustomerDeletedFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.CustomerDeletedFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.CustomerDeletedFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.CustomerDeletedFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.CustomerDeletedFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.CustomerDeletedFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.CustomerDeletedCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.CustomerDeletedCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.CustomerDeletedCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.CustomerDeletedDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.CustomerDeletedUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.CustomerDeletedDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.CustomerDeletedUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.CustomerDeletedUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.CustomerDeletedUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.CustomerDeletedAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateCustomerDeleted>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.CustomerDeletedGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.CustomerDeletedGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.CustomerDeletedCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.CustomerDeletedCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} & {
|
||||
other: {
|
||||
@@ -1062,6 +1137,7 @@ export const CustomerScalarFieldEnum = {
|
||||
document: 'document',
|
||||
phone: 'phone',
|
||||
personName: 'personName',
|
||||
notes: 'notes',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
disabledAt: 'disabledAt'
|
||||
@@ -1075,7 +1151,14 @@ export const CustomerPosScalarFieldEnum = {
|
||||
customerId: 'customerId',
|
||||
address: 'address',
|
||||
phone: 'phone',
|
||||
industry: 'industry',
|
||||
personName: 'personName',
|
||||
fridgeCount: 'fridgeCount',
|
||||
banner: 'banner',
|
||||
indiBanner: 'indiBanner',
|
||||
region: 'region',
|
||||
lat: 'lat',
|
||||
lng: 'lng',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
disabledAt: 'disabledAt'
|
||||
@@ -1106,6 +1189,8 @@ export const SaleScalarFieldEnum = {
|
||||
paymentDueDate: 'paymentDueDate',
|
||||
paymentDate: 'paymentDate',
|
||||
comments: 'comments',
|
||||
nextVisitDate: 'nextVisitDate',
|
||||
visitedAt: 'visitedAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
@@ -1144,6 +1229,20 @@ export const RouteCustomerPosScalarFieldEnum = {
|
||||
export type RouteCustomerPosScalarFieldEnum = (typeof RouteCustomerPosScalarFieldEnum)[keyof typeof RouteCustomerPosScalarFieldEnum]
|
||||
|
||||
|
||||
export const CustomerDeletedScalarFieldEnum = {
|
||||
id: 'id',
|
||||
customerId: 'customerId',
|
||||
name: 'name',
|
||||
document: 'document',
|
||||
phone: 'phone',
|
||||
personName: 'personName',
|
||||
notes: 'notes',
|
||||
deletedAt: 'deletedAt'
|
||||
} as const
|
||||
|
||||
export type CustomerDeletedScalarFieldEnum = (typeof CustomerDeletedScalarFieldEnum)[keyof typeof CustomerDeletedScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -1202,20 +1301,6 @@ export type ListDateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaM
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Float'
|
||||
*/
|
||||
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Float[]'
|
||||
*/
|
||||
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Int'
|
||||
*/
|
||||
@@ -1236,6 +1321,20 @@ export type ListIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel,
|
||||
export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Boolean'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Float'
|
||||
*/
|
||||
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Float[]'
|
||||
*/
|
||||
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
|
||||
|
||||
|
||||
/**
|
||||
* Batch Payload for updateMany & deleteMany & createMany
|
||||
*/
|
||||
@@ -1354,6 +1453,7 @@ export type GlobalOmitConfig = {
|
||||
saleProduct?: Prisma.SaleProductOmit
|
||||
route?: Prisma.RouteOmit
|
||||
routeCustomerPos?: Prisma.RouteCustomerPosOmit
|
||||
customerDeleted?: Prisma.CustomerDeletedOmit
|
||||
}
|
||||
|
||||
/* Types for Logging */
|
||||
|
||||
@@ -58,7 +58,8 @@ export const ModelName = {
|
||||
Sale: 'Sale',
|
||||
SaleProduct: 'SaleProduct',
|
||||
Route: 'Route',
|
||||
RouteCustomerPos: 'RouteCustomerPos'
|
||||
RouteCustomerPos: 'RouteCustomerPos',
|
||||
CustomerDeleted: 'CustomerDeleted'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -97,6 +98,7 @@ export const CustomerScalarFieldEnum = {
|
||||
document: 'document',
|
||||
phone: 'phone',
|
||||
personName: 'personName',
|
||||
notes: 'notes',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
disabledAt: 'disabledAt'
|
||||
@@ -110,7 +112,14 @@ export const CustomerPosScalarFieldEnum = {
|
||||
customerId: 'customerId',
|
||||
address: 'address',
|
||||
phone: 'phone',
|
||||
industry: 'industry',
|
||||
personName: 'personName',
|
||||
fridgeCount: 'fridgeCount',
|
||||
banner: 'banner',
|
||||
indiBanner: 'indiBanner',
|
||||
region: 'region',
|
||||
lat: 'lat',
|
||||
lng: 'lng',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
disabledAt: 'disabledAt'
|
||||
@@ -141,6 +150,8 @@ export const SaleScalarFieldEnum = {
|
||||
paymentDueDate: 'paymentDueDate',
|
||||
paymentDate: 'paymentDate',
|
||||
comments: 'comments',
|
||||
nextVisitDate: 'nextVisitDate',
|
||||
visitedAt: 'visitedAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
@@ -179,6 +190,20 @@ export const RouteCustomerPosScalarFieldEnum = {
|
||||
export type RouteCustomerPosScalarFieldEnum = (typeof RouteCustomerPosScalarFieldEnum)[keyof typeof RouteCustomerPosScalarFieldEnum]
|
||||
|
||||
|
||||
export const CustomerDeletedScalarFieldEnum = {
|
||||
id: 'id',
|
||||
customerId: 'customerId',
|
||||
name: 'name',
|
||||
document: 'document',
|
||||
phone: 'phone',
|
||||
personName: 'personName',
|
||||
notes: 'notes',
|
||||
deletedAt: 'deletedAt'
|
||||
} as const
|
||||
|
||||
export type CustomerDeletedScalarFieldEnum = (typeof CustomerDeletedScalarFieldEnum)[keyof typeof CustomerDeletedScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
|
||||
@@ -16,4 +16,5 @@ 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 './models/CustomerDeleted.js'
|
||||
export type * from './commonInputTypes.js'
|
||||
@@ -30,6 +30,7 @@ export type CustomerMinAggregateOutputType = {
|
||||
document: string | null
|
||||
phone: string | null
|
||||
personName: string | null
|
||||
notes: string | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
disabledAt: Date | null
|
||||
@@ -41,6 +42,7 @@ export type CustomerMaxAggregateOutputType = {
|
||||
document: string | null
|
||||
phone: string | null
|
||||
personName: string | null
|
||||
notes: string | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
disabledAt: Date | null
|
||||
@@ -52,6 +54,7 @@ export type CustomerCountAggregateOutputType = {
|
||||
document: number
|
||||
phone: number
|
||||
personName: number
|
||||
notes: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
disabledAt: number
|
||||
@@ -65,6 +68,7 @@ export type CustomerMinAggregateInputType = {
|
||||
document?: true
|
||||
phone?: true
|
||||
personName?: true
|
||||
notes?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
disabledAt?: true
|
||||
@@ -76,6 +80,7 @@ export type CustomerMaxAggregateInputType = {
|
||||
document?: true
|
||||
phone?: true
|
||||
personName?: true
|
||||
notes?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
disabledAt?: true
|
||||
@@ -87,6 +92,7 @@ export type CustomerCountAggregateInputType = {
|
||||
document?: true
|
||||
phone?: true
|
||||
personName?: true
|
||||
notes?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
disabledAt?: true
|
||||
@@ -171,6 +177,7 @@ export type CustomerGroupByOutputType = {
|
||||
document: string | null
|
||||
phone: string | null
|
||||
personName: string | null
|
||||
notes: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
disabledAt: Date | null
|
||||
@@ -203,6 +210,7 @@ export type CustomerWhereInput = {
|
||||
document?: Prisma.StringNullableFilter<"Customer"> | string | null
|
||||
phone?: Prisma.StringNullableFilter<"Customer"> | string | null
|
||||
personName?: Prisma.StringNullableFilter<"Customer"> | string | null
|
||||
notes?: Prisma.StringNullableFilter<"Customer"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Customer"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Customer"> | Date | string
|
||||
disabledAt?: Prisma.DateTimeNullableFilter<"Customer"> | Date | string | null
|
||||
@@ -215,6 +223,7 @@ export type CustomerOrderByWithRelationInput = {
|
||||
document?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
phone?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
personName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
notes?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
disabledAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -230,6 +239,7 @@ export type CustomerWhereUniqueInput = Prisma.AtLeast<{
|
||||
name?: Prisma.StringFilter<"Customer"> | string
|
||||
phone?: Prisma.StringNullableFilter<"Customer"> | string | null
|
||||
personName?: Prisma.StringNullableFilter<"Customer"> | string | null
|
||||
notes?: Prisma.StringNullableFilter<"Customer"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Customer"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Customer"> | Date | string
|
||||
disabledAt?: Prisma.DateTimeNullableFilter<"Customer"> | Date | string | null
|
||||
@@ -242,6 +252,7 @@ export type CustomerOrderByWithAggregationInput = {
|
||||
document?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
phone?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
personName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
notes?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
disabledAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -259,6 +270,7 @@ export type CustomerScalarWhereWithAggregatesInput = {
|
||||
document?: Prisma.StringNullableWithAggregatesFilter<"Customer"> | string | null
|
||||
phone?: Prisma.StringNullableWithAggregatesFilter<"Customer"> | string | null
|
||||
personName?: Prisma.StringNullableWithAggregatesFilter<"Customer"> | string | null
|
||||
notes?: Prisma.StringNullableWithAggregatesFilter<"Customer"> | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Customer"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Customer"> | Date | string
|
||||
disabledAt?: Prisma.DateTimeNullableWithAggregatesFilter<"Customer"> | Date | string | null
|
||||
@@ -270,6 +282,7 @@ export type CustomerCreateInput = {
|
||||
document?: string | null
|
||||
phone?: string | null
|
||||
personName?: string | null
|
||||
notes?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -282,6 +295,7 @@ export type CustomerUncheckedCreateInput = {
|
||||
document?: string | null
|
||||
phone?: string | null
|
||||
personName?: string | null
|
||||
notes?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -294,6 +308,7 @@ export type CustomerUpdateInput = {
|
||||
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -306,6 +321,7 @@ export type CustomerUncheckedUpdateInput = {
|
||||
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -318,6 +334,7 @@ export type CustomerCreateManyInput = {
|
||||
document?: string | null
|
||||
phone?: string | null
|
||||
personName?: string | null
|
||||
notes?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -329,6 +346,7 @@ export type CustomerUpdateManyMutationInput = {
|
||||
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -340,6 +358,7 @@ export type CustomerUncheckedUpdateManyInput = {
|
||||
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -351,6 +370,7 @@ export type CustomerCountOrderByAggregateInput = {
|
||||
document?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
personName?: Prisma.SortOrder
|
||||
notes?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
disabledAt?: Prisma.SortOrder
|
||||
@@ -362,6 +382,7 @@ export type CustomerMaxOrderByAggregateInput = {
|
||||
document?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
personName?: Prisma.SortOrder
|
||||
notes?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
disabledAt?: Prisma.SortOrder
|
||||
@@ -373,6 +394,7 @@ export type CustomerMinOrderByAggregateInput = {
|
||||
document?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
personName?: Prisma.SortOrder
|
||||
notes?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
disabledAt?: Prisma.SortOrder
|
||||
@@ -407,6 +429,7 @@ export type CustomerCreateWithoutPosInput = {
|
||||
document?: string | null
|
||||
phone?: string | null
|
||||
personName?: string | null
|
||||
notes?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -418,6 +441,7 @@ export type CustomerUncheckedCreateWithoutPosInput = {
|
||||
document?: string | null
|
||||
phone?: string | null
|
||||
personName?: string | null
|
||||
notes?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -445,6 +469,7 @@ export type CustomerUpdateWithoutPosInput = {
|
||||
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -456,6 +481,7 @@ export type CustomerUncheckedUpdateWithoutPosInput = {
|
||||
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -498,6 +524,7 @@ export type CustomerSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
|
||||
document?: boolean
|
||||
phone?: boolean
|
||||
personName?: boolean
|
||||
notes?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
disabledAt?: boolean
|
||||
@@ -511,6 +538,7 @@ export type CustomerSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Exte
|
||||
document?: boolean
|
||||
phone?: boolean
|
||||
personName?: boolean
|
||||
notes?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
disabledAt?: boolean
|
||||
@@ -522,6 +550,7 @@ export type CustomerSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Exte
|
||||
document?: boolean
|
||||
phone?: boolean
|
||||
personName?: boolean
|
||||
notes?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
disabledAt?: boolean
|
||||
@@ -533,12 +562,13 @@ export type CustomerSelectScalar = {
|
||||
document?: boolean
|
||||
phone?: boolean
|
||||
personName?: boolean
|
||||
notes?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
disabledAt?: boolean
|
||||
}
|
||||
|
||||
export type CustomerOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "document" | "phone" | "personName" | "createdAt" | "updatedAt" | "disabledAt", ExtArgs["result"]["customer"]>
|
||||
export type CustomerOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "document" | "phone" | "personName" | "notes" | "createdAt" | "updatedAt" | "disabledAt", ExtArgs["result"]["customer"]>
|
||||
export type CustomerInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
pos?: boolean | Prisma.Customer$posArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.CustomerCountOutputTypeDefaultArgs<ExtArgs>
|
||||
@@ -557,6 +587,7 @@ export type $CustomerPayload<ExtArgs extends runtime.Types.Extensions.InternalAr
|
||||
document: string | null
|
||||
phone: string | null
|
||||
personName: string | null
|
||||
notes: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
disabledAt: Date | null
|
||||
@@ -989,6 +1020,7 @@ export interface CustomerFieldRefs {
|
||||
readonly document: Prisma.FieldRef<"Customer", 'String'>
|
||||
readonly phone: Prisma.FieldRef<"Customer", 'String'>
|
||||
readonly personName: Prisma.FieldRef<"Customer", 'String'>
|
||||
readonly notes: Prisma.FieldRef<"Customer", 'String'>
|
||||
readonly createdAt: Prisma.FieldRef<"Customer", 'DateTime'>
|
||||
readonly updatedAt: Prisma.FieldRef<"Customer", 'DateTime'>
|
||||
readonly disabledAt: Prisma.FieldRef<"Customer", 'DateTime'>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,16 +20,37 @@ export type CustomerPosModel = runtime.Types.Result.DefaultSelection<Prisma.$Cus
|
||||
|
||||
export type AggregateCustomerPos = {
|
||||
_count: CustomerPosCountAggregateOutputType | null
|
||||
_avg: CustomerPosAvgAggregateOutputType | null
|
||||
_sum: CustomerPosSumAggregateOutputType | null
|
||||
_min: CustomerPosMinAggregateOutputType | null
|
||||
_max: CustomerPosMaxAggregateOutputType | null
|
||||
}
|
||||
|
||||
export type CustomerPosAvgAggregateOutputType = {
|
||||
fridgeCount: number | null
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
}
|
||||
|
||||
export type CustomerPosSumAggregateOutputType = {
|
||||
fridgeCount: number | null
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
}
|
||||
|
||||
export type CustomerPosMinAggregateOutputType = {
|
||||
id: string | null
|
||||
customerId: string | null
|
||||
address: string | null
|
||||
phone: string | null
|
||||
industry: string | null
|
||||
personName: string | null
|
||||
fridgeCount: number | null
|
||||
banner: boolean | null
|
||||
indiBanner: boolean | null
|
||||
region: string | null
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
disabledAt: Date | null
|
||||
@@ -40,7 +61,14 @@ export type CustomerPosMaxAggregateOutputType = {
|
||||
customerId: string | null
|
||||
address: string | null
|
||||
phone: string | null
|
||||
industry: string | null
|
||||
personName: string | null
|
||||
fridgeCount: number | null
|
||||
banner: boolean | null
|
||||
indiBanner: boolean | null
|
||||
region: string | null
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
disabledAt: Date | null
|
||||
@@ -51,7 +79,14 @@ export type CustomerPosCountAggregateOutputType = {
|
||||
customerId: number
|
||||
address: number
|
||||
phone: number
|
||||
industry: number
|
||||
personName: number
|
||||
fridgeCount: number
|
||||
banner: number
|
||||
indiBanner: number
|
||||
region: number
|
||||
lat: number
|
||||
lng: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
disabledAt: number
|
||||
@@ -59,12 +94,31 @@ export type CustomerPosCountAggregateOutputType = {
|
||||
}
|
||||
|
||||
|
||||
export type CustomerPosAvgAggregateInputType = {
|
||||
fridgeCount?: true
|
||||
lat?: true
|
||||
lng?: true
|
||||
}
|
||||
|
||||
export type CustomerPosSumAggregateInputType = {
|
||||
fridgeCount?: true
|
||||
lat?: true
|
||||
lng?: true
|
||||
}
|
||||
|
||||
export type CustomerPosMinAggregateInputType = {
|
||||
id?: true
|
||||
customerId?: true
|
||||
address?: true
|
||||
phone?: true
|
||||
industry?: true
|
||||
personName?: true
|
||||
fridgeCount?: true
|
||||
banner?: true
|
||||
indiBanner?: true
|
||||
region?: true
|
||||
lat?: true
|
||||
lng?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
disabledAt?: true
|
||||
@@ -75,7 +129,14 @@ export type CustomerPosMaxAggregateInputType = {
|
||||
customerId?: true
|
||||
address?: true
|
||||
phone?: true
|
||||
industry?: true
|
||||
personName?: true
|
||||
fridgeCount?: true
|
||||
banner?: true
|
||||
indiBanner?: true
|
||||
region?: true
|
||||
lat?: true
|
||||
lng?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
disabledAt?: true
|
||||
@@ -86,7 +147,14 @@ export type CustomerPosCountAggregateInputType = {
|
||||
customerId?: true
|
||||
address?: true
|
||||
phone?: true
|
||||
industry?: true
|
||||
personName?: true
|
||||
fridgeCount?: true
|
||||
banner?: true
|
||||
indiBanner?: true
|
||||
region?: true
|
||||
lat?: true
|
||||
lng?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
disabledAt?: true
|
||||
@@ -128,6 +196,18 @@ export type CustomerPosAggregateArgs<ExtArgs extends runtime.Types.Extensions.In
|
||||
* Count returned CustomerPos
|
||||
**/
|
||||
_count?: true | CustomerPosCountAggregateInputType
|
||||
/**
|
||||
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
|
||||
*
|
||||
* Select which fields to average
|
||||
**/
|
||||
_avg?: CustomerPosAvgAggregateInputType
|
||||
/**
|
||||
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
|
||||
*
|
||||
* Select which fields to sum
|
||||
**/
|
||||
_sum?: CustomerPosSumAggregateInputType
|
||||
/**
|
||||
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
|
||||
*
|
||||
@@ -161,6 +241,8 @@ export type CustomerPosGroupByArgs<ExtArgs extends runtime.Types.Extensions.Inte
|
||||
take?: number
|
||||
skip?: number
|
||||
_count?: CustomerPosCountAggregateInputType | true
|
||||
_avg?: CustomerPosAvgAggregateInputType
|
||||
_sum?: CustomerPosSumAggregateInputType
|
||||
_min?: CustomerPosMinAggregateInputType
|
||||
_max?: CustomerPosMaxAggregateInputType
|
||||
}
|
||||
@@ -170,11 +252,20 @@ export type CustomerPosGroupByOutputType = {
|
||||
customerId: string
|
||||
address: string
|
||||
phone: string
|
||||
industry: string | null
|
||||
personName: string | null
|
||||
fridgeCount: number
|
||||
banner: boolean
|
||||
indiBanner: boolean
|
||||
region: string | null
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
disabledAt: Date | null
|
||||
_count: CustomerPosCountAggregateOutputType | null
|
||||
_avg: CustomerPosAvgAggregateOutputType | null
|
||||
_sum: CustomerPosSumAggregateOutputType | null
|
||||
_min: CustomerPosMinAggregateOutputType | null
|
||||
_max: CustomerPosMaxAggregateOutputType | null
|
||||
}
|
||||
@@ -202,7 +293,14 @@ export type CustomerPosWhereInput = {
|
||||
customerId?: Prisma.UuidFilter<"CustomerPos"> | string
|
||||
address?: Prisma.StringFilter<"CustomerPos"> | string
|
||||
phone?: Prisma.StringFilter<"CustomerPos"> | string
|
||||
industry?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
|
||||
personName?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
|
||||
fridgeCount?: Prisma.IntFilter<"CustomerPos"> | number
|
||||
banner?: Prisma.BoolFilter<"CustomerPos"> | boolean
|
||||
indiBanner?: Prisma.BoolFilter<"CustomerPos"> | boolean
|
||||
region?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
|
||||
lat?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
|
||||
lng?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
|
||||
createdAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
|
||||
disabledAt?: Prisma.DateTimeNullableFilter<"CustomerPos"> | Date | string | null
|
||||
@@ -216,7 +314,14 @@ export type CustomerPosOrderByWithRelationInput = {
|
||||
customerId?: Prisma.SortOrder
|
||||
address?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
industry?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
personName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
fridgeCount?: Prisma.SortOrder
|
||||
banner?: Prisma.SortOrder
|
||||
indiBanner?: Prisma.SortOrder
|
||||
region?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
lat?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
lng?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
disabledAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -233,7 +338,14 @@ export type CustomerPosWhereUniqueInput = Prisma.AtLeast<{
|
||||
customerId?: Prisma.UuidFilter<"CustomerPos"> | string
|
||||
address?: Prisma.StringFilter<"CustomerPos"> | string
|
||||
phone?: Prisma.StringFilter<"CustomerPos"> | string
|
||||
industry?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
|
||||
personName?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
|
||||
fridgeCount?: Prisma.IntFilter<"CustomerPos"> | number
|
||||
banner?: Prisma.BoolFilter<"CustomerPos"> | boolean
|
||||
indiBanner?: Prisma.BoolFilter<"CustomerPos"> | boolean
|
||||
region?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
|
||||
lat?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
|
||||
lng?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
|
||||
createdAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
|
||||
disabledAt?: Prisma.DateTimeNullableFilter<"CustomerPos"> | Date | string | null
|
||||
@@ -247,13 +359,22 @@ export type CustomerPosOrderByWithAggregationInput = {
|
||||
customerId?: Prisma.SortOrder
|
||||
address?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
industry?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
personName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
fridgeCount?: Prisma.SortOrder
|
||||
banner?: Prisma.SortOrder
|
||||
indiBanner?: Prisma.SortOrder
|
||||
region?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
lat?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
lng?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
disabledAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
_count?: Prisma.CustomerPosCountOrderByAggregateInput
|
||||
_avg?: Prisma.CustomerPosAvgOrderByAggregateInput
|
||||
_max?: Prisma.CustomerPosMaxOrderByAggregateInput
|
||||
_min?: Prisma.CustomerPosMinOrderByAggregateInput
|
||||
_sum?: Prisma.CustomerPosSumOrderByAggregateInput
|
||||
}
|
||||
|
||||
export type CustomerPosScalarWhereWithAggregatesInput = {
|
||||
@@ -264,7 +385,14 @@ export type CustomerPosScalarWhereWithAggregatesInput = {
|
||||
customerId?: Prisma.UuidWithAggregatesFilter<"CustomerPos"> | string
|
||||
address?: Prisma.StringWithAggregatesFilter<"CustomerPos"> | string
|
||||
phone?: Prisma.StringWithAggregatesFilter<"CustomerPos"> | string
|
||||
industry?: Prisma.StringNullableWithAggregatesFilter<"CustomerPos"> | string | null
|
||||
personName?: Prisma.StringNullableWithAggregatesFilter<"CustomerPos"> | string | null
|
||||
fridgeCount?: Prisma.IntWithAggregatesFilter<"CustomerPos"> | number
|
||||
banner?: Prisma.BoolWithAggregatesFilter<"CustomerPos"> | boolean
|
||||
indiBanner?: Prisma.BoolWithAggregatesFilter<"CustomerPos"> | boolean
|
||||
region?: Prisma.StringNullableWithAggregatesFilter<"CustomerPos"> | string | null
|
||||
lat?: Prisma.FloatNullableWithAggregatesFilter<"CustomerPos"> | number | null
|
||||
lng?: Prisma.FloatNullableWithAggregatesFilter<"CustomerPos"> | number | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CustomerPos"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CustomerPos"> | Date | string
|
||||
disabledAt?: Prisma.DateTimeNullableWithAggregatesFilter<"CustomerPos"> | Date | string | null
|
||||
@@ -274,7 +402,14 @@ export type CustomerPosCreateInput = {
|
||||
id?: string
|
||||
address: string
|
||||
phone: string
|
||||
industry?: string | null
|
||||
personName?: string | null
|
||||
fridgeCount?: number
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -288,7 +423,14 @@ export type CustomerPosUncheckedCreateInput = {
|
||||
customerId: string
|
||||
address: string
|
||||
phone: string
|
||||
industry?: string | null
|
||||
personName?: string | null
|
||||
fridgeCount?: number
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -300,7 +442,14 @@ export type CustomerPosUpdateInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -314,7 +463,14 @@ export type CustomerPosUncheckedUpdateInput = {
|
||||
customerId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -327,7 +483,14 @@ export type CustomerPosCreateManyInput = {
|
||||
customerId: string
|
||||
address: string
|
||||
phone: string
|
||||
industry?: string | null
|
||||
personName?: string | null
|
||||
fridgeCount?: number
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -337,7 +500,14 @@ export type CustomerPosUpdateManyMutationInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -348,7 +518,14 @@ export type CustomerPosUncheckedUpdateManyInput = {
|
||||
customerId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -369,18 +546,38 @@ export type CustomerPosCountOrderByAggregateInput = {
|
||||
customerId?: Prisma.SortOrder
|
||||
address?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
industry?: Prisma.SortOrder
|
||||
personName?: Prisma.SortOrder
|
||||
fridgeCount?: Prisma.SortOrder
|
||||
banner?: Prisma.SortOrder
|
||||
indiBanner?: Prisma.SortOrder
|
||||
region?: Prisma.SortOrder
|
||||
lat?: Prisma.SortOrder
|
||||
lng?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
disabledAt?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type CustomerPosAvgOrderByAggregateInput = {
|
||||
fridgeCount?: Prisma.SortOrder
|
||||
lat?: Prisma.SortOrder
|
||||
lng?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type CustomerPosMaxOrderByAggregateInput = {
|
||||
id?: Prisma.SortOrder
|
||||
customerId?: Prisma.SortOrder
|
||||
address?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
industry?: Prisma.SortOrder
|
||||
personName?: Prisma.SortOrder
|
||||
fridgeCount?: Prisma.SortOrder
|
||||
banner?: Prisma.SortOrder
|
||||
indiBanner?: Prisma.SortOrder
|
||||
region?: Prisma.SortOrder
|
||||
lat?: Prisma.SortOrder
|
||||
lng?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
disabledAt?: Prisma.SortOrder
|
||||
@@ -391,12 +588,25 @@ export type CustomerPosMinOrderByAggregateInput = {
|
||||
customerId?: Prisma.SortOrder
|
||||
address?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
industry?: Prisma.SortOrder
|
||||
personName?: Prisma.SortOrder
|
||||
fridgeCount?: Prisma.SortOrder
|
||||
banner?: Prisma.SortOrder
|
||||
indiBanner?: Prisma.SortOrder
|
||||
region?: Prisma.SortOrder
|
||||
lat?: Prisma.SortOrder
|
||||
lng?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
disabledAt?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type CustomerPosSumOrderByAggregateInput = {
|
||||
fridgeCount?: Prisma.SortOrder
|
||||
lat?: Prisma.SortOrder
|
||||
lng?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type CustomerPosScalarRelationFilter = {
|
||||
is?: Prisma.CustomerPosWhereInput
|
||||
isNot?: Prisma.CustomerPosWhereInput
|
||||
@@ -444,6 +654,26 @@ export type CustomerPosUncheckedUpdateManyWithoutCustomerNestedInput = {
|
||||
deleteMany?: Prisma.CustomerPosScalarWhereInput | Prisma.CustomerPosScalarWhereInput[]
|
||||
}
|
||||
|
||||
export type IntFieldUpdateOperationsInput = {
|
||||
set?: number
|
||||
increment?: number
|
||||
decrement?: number
|
||||
multiply?: number
|
||||
divide?: number
|
||||
}
|
||||
|
||||
export type BoolFieldUpdateOperationsInput = {
|
||||
set?: boolean
|
||||
}
|
||||
|
||||
export type NullableFloatFieldUpdateOperationsInput = {
|
||||
set?: number | null
|
||||
increment?: number
|
||||
decrement?: number
|
||||
multiply?: number
|
||||
divide?: number
|
||||
}
|
||||
|
||||
export type CustomerPosCreateNestedOneWithoutSalesInput = {
|
||||
create?: Prisma.XOR<Prisma.CustomerPosCreateWithoutSalesInput, Prisma.CustomerPosUncheckedCreateWithoutSalesInput>
|
||||
connectOrCreate?: Prisma.CustomerPosCreateOrConnectWithoutSalesInput
|
||||
@@ -476,7 +706,14 @@ export type CustomerPosCreateWithoutCustomerInput = {
|
||||
id?: string
|
||||
address: string
|
||||
phone: string
|
||||
industry?: string | null
|
||||
personName?: string | null
|
||||
fridgeCount?: number
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -488,7 +725,14 @@ export type CustomerPosUncheckedCreateWithoutCustomerInput = {
|
||||
id?: string
|
||||
address: string
|
||||
phone: string
|
||||
industry?: string | null
|
||||
personName?: string | null
|
||||
fridgeCount?: number
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -530,7 +774,14 @@ export type CustomerPosScalarWhereInput = {
|
||||
customerId?: Prisma.UuidFilter<"CustomerPos"> | string
|
||||
address?: Prisma.StringFilter<"CustomerPos"> | string
|
||||
phone?: Prisma.StringFilter<"CustomerPos"> | string
|
||||
industry?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
|
||||
personName?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
|
||||
fridgeCount?: Prisma.IntFilter<"CustomerPos"> | number
|
||||
banner?: Prisma.BoolFilter<"CustomerPos"> | boolean
|
||||
indiBanner?: Prisma.BoolFilter<"CustomerPos"> | boolean
|
||||
region?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
|
||||
lat?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
|
||||
lng?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
|
||||
createdAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
|
||||
disabledAt?: Prisma.DateTimeNullableFilter<"CustomerPos"> | Date | string | null
|
||||
@@ -540,7 +791,14 @@ export type CustomerPosCreateWithoutSalesInput = {
|
||||
id?: string
|
||||
address: string
|
||||
phone: string
|
||||
industry?: string | null
|
||||
personName?: string | null
|
||||
fridgeCount?: number
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -553,7 +811,14 @@ export type CustomerPosUncheckedCreateWithoutSalesInput = {
|
||||
customerId: string
|
||||
address: string
|
||||
phone: string
|
||||
industry?: string | null
|
||||
personName?: string | null
|
||||
fridgeCount?: number
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -580,7 +845,14 @@ export type CustomerPosUpdateWithoutSalesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -593,7 +865,14 @@ export type CustomerPosUncheckedUpdateWithoutSalesInput = {
|
||||
customerId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -604,7 +883,14 @@ export type CustomerPosCreateWithoutRoutesInput = {
|
||||
id?: string
|
||||
address: string
|
||||
phone: string
|
||||
industry?: string | null
|
||||
personName?: string | null
|
||||
fridgeCount?: number
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -617,7 +903,14 @@ export type CustomerPosUncheckedCreateWithoutRoutesInput = {
|
||||
customerId: string
|
||||
address: string
|
||||
phone: string
|
||||
industry?: string | null
|
||||
personName?: string | null
|
||||
fridgeCount?: number
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -644,7 +937,14 @@ export type CustomerPosUpdateWithoutRoutesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -657,7 +957,14 @@ export type CustomerPosUncheckedUpdateWithoutRoutesInput = {
|
||||
customerId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -668,7 +975,14 @@ export type CustomerPosCreateManyCustomerInput = {
|
||||
id?: string
|
||||
address: string
|
||||
phone: string
|
||||
industry?: string | null
|
||||
personName?: string | null
|
||||
fridgeCount?: number
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
disabledAt?: Date | string | null
|
||||
@@ -678,7 +992,14 @@ export type CustomerPosUpdateWithoutCustomerInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -690,7 +1011,14 @@ export type CustomerPosUncheckedUpdateWithoutCustomerInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -702,7 +1030,14 @@ export type CustomerPosUncheckedUpdateManyWithoutCustomerInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
address?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
phone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
@@ -753,7 +1088,14 @@ export type CustomerPosSelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
customerId?: boolean
|
||||
address?: boolean
|
||||
phone?: boolean
|
||||
industry?: boolean
|
||||
personName?: boolean
|
||||
fridgeCount?: boolean
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: boolean
|
||||
lat?: boolean
|
||||
lng?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
disabledAt?: boolean
|
||||
@@ -768,7 +1110,14 @@ export type CustomerPosSelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
customerId?: boolean
|
||||
address?: boolean
|
||||
phone?: boolean
|
||||
industry?: boolean
|
||||
personName?: boolean
|
||||
fridgeCount?: boolean
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: boolean
|
||||
lat?: boolean
|
||||
lng?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
disabledAt?: boolean
|
||||
@@ -780,7 +1129,14 @@ export type CustomerPosSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
customerId?: boolean
|
||||
address?: boolean
|
||||
phone?: boolean
|
||||
industry?: boolean
|
||||
personName?: boolean
|
||||
fridgeCount?: boolean
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: boolean
|
||||
lat?: boolean
|
||||
lng?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
disabledAt?: boolean
|
||||
@@ -792,13 +1148,20 @@ export type CustomerPosSelectScalar = {
|
||||
customerId?: boolean
|
||||
address?: boolean
|
||||
phone?: boolean
|
||||
industry?: boolean
|
||||
personName?: boolean
|
||||
fridgeCount?: boolean
|
||||
banner?: boolean
|
||||
indiBanner?: boolean
|
||||
region?: boolean
|
||||
lat?: boolean
|
||||
lng?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
disabledAt?: boolean
|
||||
}
|
||||
|
||||
export type CustomerPosOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "customerId" | "address" | "phone" | "personName" | "createdAt" | "updatedAt" | "disabledAt", ExtArgs["result"]["customerPos"]>
|
||||
export type CustomerPosOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "customerId" | "address" | "phone" | "industry" | "personName" | "fridgeCount" | "banner" | "indiBanner" | "region" | "lat" | "lng" | "createdAt" | "updatedAt" | "disabledAt", ExtArgs["result"]["customerPos"]>
|
||||
export type CustomerPosInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
customer?: boolean | Prisma.CustomerDefaultArgs<ExtArgs>
|
||||
sales?: boolean | Prisma.CustomerPos$salesArgs<ExtArgs>
|
||||
@@ -824,7 +1187,14 @@ export type $CustomerPosPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
||||
customerId: string
|
||||
address: string
|
||||
phone: string
|
||||
industry: string | null
|
||||
personName: string | null
|
||||
fridgeCount: number
|
||||
banner: boolean
|
||||
indiBanner: boolean
|
||||
region: string | null
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
disabledAt: Date | null
|
||||
@@ -1258,7 +1628,14 @@ export interface CustomerPosFieldRefs {
|
||||
readonly customerId: Prisma.FieldRef<"CustomerPos", 'String'>
|
||||
readonly address: Prisma.FieldRef<"CustomerPos", 'String'>
|
||||
readonly phone: Prisma.FieldRef<"CustomerPos", 'String'>
|
||||
readonly industry: Prisma.FieldRef<"CustomerPos", 'String'>
|
||||
readonly personName: Prisma.FieldRef<"CustomerPos", 'String'>
|
||||
readonly fridgeCount: Prisma.FieldRef<"CustomerPos", 'Int'>
|
||||
readonly banner: Prisma.FieldRef<"CustomerPos", 'Boolean'>
|
||||
readonly indiBanner: Prisma.FieldRef<"CustomerPos", 'Boolean'>
|
||||
readonly region: Prisma.FieldRef<"CustomerPos", 'String'>
|
||||
readonly lat: Prisma.FieldRef<"CustomerPos", 'Float'>
|
||||
readonly lng: Prisma.FieldRef<"CustomerPos", 'Float'>
|
||||
readonly createdAt: Prisma.FieldRef<"CustomerPos", 'DateTime'>
|
||||
readonly updatedAt: Prisma.FieldRef<"CustomerPos", 'DateTime'>
|
||||
readonly disabledAt: Prisma.FieldRef<"CustomerPos", 'DateTime'>
|
||||
|
||||
@@ -447,14 +447,6 @@ export type FloatFieldUpdateOperationsInput = {
|
||||
divide?: number
|
||||
}
|
||||
|
||||
export type IntFieldUpdateOperationsInput = {
|
||||
set?: number
|
||||
increment?: number
|
||||
decrement?: number
|
||||
multiply?: number
|
||||
divide?: number
|
||||
}
|
||||
|
||||
export type ProductCreateNestedOneWithoutSalesInput = {
|
||||
create?: Prisma.XOR<Prisma.ProductCreateWithoutSalesInput, Prisma.ProductUncheckedCreateWithoutSalesInput>
|
||||
connectOrCreate?: Prisma.ProductCreateOrConnectWithoutSalesInput
|
||||
|
||||
@@ -32,6 +32,8 @@ export type SaleMinAggregateOutputType = {
|
||||
paymentDueDate: Date | null
|
||||
paymentDate: Date | null
|
||||
comments: string | null
|
||||
nextVisitDate: Date | null
|
||||
visitedAt: Date | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
}
|
||||
@@ -44,6 +46,8 @@ export type SaleMaxAggregateOutputType = {
|
||||
paymentDueDate: Date | null
|
||||
paymentDate: Date | null
|
||||
comments: string | null
|
||||
nextVisitDate: Date | null
|
||||
visitedAt: Date | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
}
|
||||
@@ -56,6 +60,8 @@ export type SaleCountAggregateOutputType = {
|
||||
paymentDueDate: number
|
||||
paymentDate: number
|
||||
comments: number
|
||||
nextVisitDate: number
|
||||
visitedAt: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
_all: number
|
||||
@@ -70,6 +76,8 @@ export type SaleMinAggregateInputType = {
|
||||
paymentDueDate?: true
|
||||
paymentDate?: true
|
||||
comments?: true
|
||||
nextVisitDate?: true
|
||||
visitedAt?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
}
|
||||
@@ -82,6 +90,8 @@ export type SaleMaxAggregateInputType = {
|
||||
paymentDueDate?: true
|
||||
paymentDate?: true
|
||||
comments?: true
|
||||
nextVisitDate?: true
|
||||
visitedAt?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
}
|
||||
@@ -94,6 +104,8 @@ export type SaleCountAggregateInputType = {
|
||||
paymentDueDate?: true
|
||||
paymentDate?: true
|
||||
comments?: true
|
||||
nextVisitDate?: true
|
||||
visitedAt?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
_all?: true
|
||||
@@ -179,6 +191,8 @@ export type SaleGroupByOutputType = {
|
||||
paymentDueDate: Date | null
|
||||
paymentDate: Date | null
|
||||
comments: string | null
|
||||
nextVisitDate: Date | null
|
||||
visitedAt: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
_count: SaleCountAggregateOutputType | null
|
||||
@@ -212,6 +226,8 @@ export type SaleWhereInput = {
|
||||
paymentDueDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
paymentDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
comments?: Prisma.StringNullableFilter<"Sale"> | string | null
|
||||
nextVisitDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
visitedAt?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
|
||||
customerPos?: Prisma.XOR<Prisma.CustomerPosScalarRelationFilter, Prisma.CustomerPosWhereInput>
|
||||
@@ -226,6 +242,8 @@ export type SaleOrderByWithRelationInput = {
|
||||
paymentDueDate?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
paymentDate?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
comments?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
nextVisitDate?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
visitedAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
customerPos?: Prisma.CustomerPosOrderByWithRelationInput
|
||||
@@ -243,6 +261,8 @@ export type SaleWhereUniqueInput = Prisma.AtLeast<{
|
||||
paymentDueDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
paymentDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
comments?: Prisma.StringNullableFilter<"Sale"> | string | null
|
||||
nextVisitDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
visitedAt?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
|
||||
customerPos?: Prisma.XOR<Prisma.CustomerPosScalarRelationFilter, Prisma.CustomerPosWhereInput>
|
||||
@@ -257,6 +277,8 @@ export type SaleOrderByWithAggregationInput = {
|
||||
paymentDueDate?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
paymentDate?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
comments?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
nextVisitDate?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
visitedAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
_count?: Prisma.SaleCountOrderByAggregateInput
|
||||
@@ -275,6 +297,8 @@ export type SaleScalarWhereWithAggregatesInput = {
|
||||
paymentDueDate?: Prisma.DateTimeNullableWithAggregatesFilter<"Sale"> | Date | string | null
|
||||
paymentDate?: Prisma.DateTimeNullableWithAggregatesFilter<"Sale"> | Date | string | null
|
||||
comments?: Prisma.StringNullableWithAggregatesFilter<"Sale"> | string | null
|
||||
nextVisitDate?: Prisma.DateTimeNullableWithAggregatesFilter<"Sale"> | Date | string | null
|
||||
visitedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"Sale"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Sale"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Sale"> | Date | string
|
||||
}
|
||||
@@ -286,6 +310,8 @@ export type SaleCreateInput = {
|
||||
paymentDueDate?: Date | string | null
|
||||
paymentDate?: Date | string | null
|
||||
comments?: string | null
|
||||
nextVisitDate?: Date | string | null
|
||||
visitedAt?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
customerPos: Prisma.CustomerPosCreateNestedOneWithoutSalesInput
|
||||
@@ -300,6 +326,8 @@ export type SaleUncheckedCreateInput = {
|
||||
paymentDueDate?: Date | string | null
|
||||
paymentDate?: Date | string | null
|
||||
comments?: string | null
|
||||
nextVisitDate?: Date | string | null
|
||||
visitedAt?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
products?: Prisma.SaleProductUncheckedCreateNestedManyWithoutSaleInput
|
||||
@@ -312,6 +340,8 @@ export type SaleUpdateInput = {
|
||||
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
customerPos?: Prisma.CustomerPosUpdateOneRequiredWithoutSalesNestedInput
|
||||
@@ -326,6 +356,8 @@ export type SaleUncheckedUpdateInput = {
|
||||
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
products?: Prisma.SaleProductUncheckedUpdateManyWithoutSaleNestedInput
|
||||
@@ -339,6 +371,8 @@ export type SaleCreateManyInput = {
|
||||
paymentDueDate?: Date | string | null
|
||||
paymentDate?: Date | string | null
|
||||
comments?: string | null
|
||||
nextVisitDate?: Date | string | null
|
||||
visitedAt?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
@@ -350,6 +384,8 @@ export type SaleUpdateManyMutationInput = {
|
||||
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
@@ -362,6 +398,8 @@ export type SaleUncheckedUpdateManyInput = {
|
||||
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
@@ -384,6 +422,8 @@ export type SaleCountOrderByAggregateInput = {
|
||||
paymentDueDate?: Prisma.SortOrder
|
||||
paymentDate?: Prisma.SortOrder
|
||||
comments?: Prisma.SortOrder
|
||||
nextVisitDate?: Prisma.SortOrder
|
||||
visitedAt?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
@@ -396,6 +436,8 @@ export type SaleMaxOrderByAggregateInput = {
|
||||
paymentDueDate?: Prisma.SortOrder
|
||||
paymentDate?: Prisma.SortOrder
|
||||
comments?: Prisma.SortOrder
|
||||
nextVisitDate?: Prisma.SortOrder
|
||||
visitedAt?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
@@ -408,6 +450,8 @@ export type SaleMinOrderByAggregateInput = {
|
||||
paymentDueDate?: Prisma.SortOrder
|
||||
paymentDate?: Prisma.SortOrder
|
||||
comments?: Prisma.SortOrder
|
||||
nextVisitDate?: Prisma.SortOrder
|
||||
visitedAt?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
@@ -459,10 +503,6 @@ export type SaleUncheckedUpdateManyWithoutCustomerPosNestedInput = {
|
||||
deleteMany?: Prisma.SaleScalarWhereInput | Prisma.SaleScalarWhereInput[]
|
||||
}
|
||||
|
||||
export type BoolFieldUpdateOperationsInput = {
|
||||
set?: boolean
|
||||
}
|
||||
|
||||
export type SaleCreateNestedOneWithoutProductsInput = {
|
||||
create?: Prisma.XOR<Prisma.SaleCreateWithoutProductsInput, Prisma.SaleUncheckedCreateWithoutProductsInput>
|
||||
connectOrCreate?: Prisma.SaleCreateOrConnectWithoutProductsInput
|
||||
@@ -484,6 +524,8 @@ export type SaleCreateWithoutCustomerPosInput = {
|
||||
paymentDueDate?: Date | string | null
|
||||
paymentDate?: Date | string | null
|
||||
comments?: string | null
|
||||
nextVisitDate?: Date | string | null
|
||||
visitedAt?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
products?: Prisma.SaleProductCreateNestedManyWithoutSaleInput
|
||||
@@ -496,6 +538,8 @@ export type SaleUncheckedCreateWithoutCustomerPosInput = {
|
||||
paymentDueDate?: Date | string | null
|
||||
paymentDate?: Date | string | null
|
||||
comments?: string | null
|
||||
nextVisitDate?: Date | string | null
|
||||
visitedAt?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
products?: Prisma.SaleProductUncheckedCreateNestedManyWithoutSaleInput
|
||||
@@ -538,6 +582,8 @@ export type SaleScalarWhereInput = {
|
||||
paymentDueDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
paymentDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
comments?: Prisma.StringNullableFilter<"Sale"> | string | null
|
||||
nextVisitDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
visitedAt?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
|
||||
}
|
||||
@@ -549,6 +595,8 @@ export type SaleCreateWithoutProductsInput = {
|
||||
paymentDueDate?: Date | string | null
|
||||
paymentDate?: Date | string | null
|
||||
comments?: string | null
|
||||
nextVisitDate?: Date | string | null
|
||||
visitedAt?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
customerPos: Prisma.CustomerPosCreateNestedOneWithoutSalesInput
|
||||
@@ -562,6 +610,8 @@ export type SaleUncheckedCreateWithoutProductsInput = {
|
||||
paymentDueDate?: Date | string | null
|
||||
paymentDate?: Date | string | null
|
||||
comments?: string | null
|
||||
nextVisitDate?: Date | string | null
|
||||
visitedAt?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
@@ -589,6 +639,8 @@ export type SaleUpdateWithoutProductsInput = {
|
||||
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
customerPos?: Prisma.CustomerPosUpdateOneRequiredWithoutSalesNestedInput
|
||||
@@ -602,6 +654,8 @@ export type SaleUncheckedUpdateWithoutProductsInput = {
|
||||
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
@@ -613,6 +667,8 @@ export type SaleCreateManyCustomerPosInput = {
|
||||
paymentDueDate?: Date | string | null
|
||||
paymentDate?: Date | string | null
|
||||
comments?: string | null
|
||||
nextVisitDate?: Date | string | null
|
||||
visitedAt?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
@@ -624,6 +680,8 @@ export type SaleUpdateWithoutCustomerPosInput = {
|
||||
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
products?: Prisma.SaleProductUpdateManyWithoutSaleNestedInput
|
||||
@@ -636,6 +694,8 @@ export type SaleUncheckedUpdateWithoutCustomerPosInput = {
|
||||
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
products?: Prisma.SaleProductUncheckedUpdateManyWithoutSaleNestedInput
|
||||
@@ -648,6 +708,8 @@ export type SaleUncheckedUpdateManyWithoutCustomerPosInput = {
|
||||
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
@@ -691,6 +753,8 @@ export type SaleSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
||||
paymentDueDate?: boolean
|
||||
paymentDate?: boolean
|
||||
comments?: boolean
|
||||
nextVisitDate?: boolean
|
||||
visitedAt?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
customerPos?: boolean | Prisma.CustomerPosDefaultArgs<ExtArgs>
|
||||
@@ -706,6 +770,8 @@ export type SaleSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
paymentDueDate?: boolean
|
||||
paymentDate?: boolean
|
||||
comments?: boolean
|
||||
nextVisitDate?: boolean
|
||||
visitedAt?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
customerPos?: boolean | Prisma.CustomerPosDefaultArgs<ExtArgs>
|
||||
@@ -719,6 +785,8 @@ export type SaleSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
paymentDueDate?: boolean
|
||||
paymentDate?: boolean
|
||||
comments?: boolean
|
||||
nextVisitDate?: boolean
|
||||
visitedAt?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
customerPos?: boolean | Prisma.CustomerPosDefaultArgs<ExtArgs>
|
||||
@@ -732,11 +800,13 @@ export type SaleSelectScalar = {
|
||||
paymentDueDate?: boolean
|
||||
paymentDate?: boolean
|
||||
comments?: boolean
|
||||
nextVisitDate?: boolean
|
||||
visitedAt?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type SaleOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "customerPosId" | "delivered" | "paymentMethod" | "paymentDueDate" | "paymentDate" | "comments" | "createdAt" | "updatedAt", ExtArgs["result"]["sale"]>
|
||||
export type SaleOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "customerPosId" | "delivered" | "paymentMethod" | "paymentDueDate" | "paymentDate" | "comments" | "nextVisitDate" | "visitedAt" | "createdAt" | "updatedAt", ExtArgs["result"]["sale"]>
|
||||
export type SaleInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
customerPos?: boolean | Prisma.CustomerPosDefaultArgs<ExtArgs>
|
||||
products?: boolean | Prisma.Sale$productsArgs<ExtArgs>
|
||||
@@ -763,6 +833,8 @@ export type $SalePayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
paymentDueDate: Date | null
|
||||
paymentDate: Date | null
|
||||
comments: string | null
|
||||
nextVisitDate: Date | null
|
||||
visitedAt: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}, ExtArgs["result"]["sale"]>
|
||||
@@ -1197,6 +1269,8 @@ export interface SaleFieldRefs {
|
||||
readonly paymentDueDate: Prisma.FieldRef<"Sale", 'DateTime'>
|
||||
readonly paymentDate: Prisma.FieldRef<"Sale", 'DateTime'>
|
||||
readonly comments: Prisma.FieldRef<"Sale", 'String'>
|
||||
readonly nextVisitDate: Prisma.FieldRef<"Sale", 'DateTime'>
|
||||
readonly visitedAt: Prisma.FieldRef<"Sale", 'DateTime'>
|
||||
readonly createdAt: Prisma.FieldRef<"Sale", 'DateTime'>
|
||||
readonly updatedAt: Prisma.FieldRef<"Sale", 'DateTime'>
|
||||
}
|
||||
|
||||
+60
-3
@@ -1,43 +1,100 @@
|
||||
import 'dotenv/config';
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import cookie from '@fastify/cookie';
|
||||
import { prisma } from './prisma';
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.error("CRITICAL ERROR: JWT_SECRET environment variable is not set. Exiting...");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
app.register(cookie);
|
||||
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
|
||||
: ['http://localhost:5173'];
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
origin: allowedOrigins,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.register(jwt, {
|
||||
secret: process.env.JWT_SECRET || 'supersecret'
|
||||
secret: process.env.JWT_SECRET,
|
||||
cookie: {
|
||||
cookieName: 'polpaAuth',
|
||||
signed: false
|
||||
}
|
||||
});
|
||||
|
||||
app.register(rateLimit, {
|
||||
global: false,
|
||||
});
|
||||
|
||||
app.decorate('authenticate', async (request: any, reply: any) => {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: (request.user as any).id },
|
||||
select: { disabledAt: true }
|
||||
});
|
||||
if (!dbUser || dbUser.disabledAt) {
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
} catch (err) {
|
||||
reply.send(err)
|
||||
return reply.send(err)
|
||||
}
|
||||
})
|
||||
|
||||
app.decorate('requireAdmin', async (request: any, reply: any) => {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: (request.user as any).id },
|
||||
select: { disabledAt: true, role: true }
|
||||
});
|
||||
if (!dbUser || dbUser.disabledAt) {
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
if (dbUser.role !== 'admin') {
|
||||
return reply.code(403).send({ error: 'Forbidden: Admin access required' })
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.send(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Register Routes
|
||||
import usersRoutes from './routes/users';
|
||||
import customersRoutes from './routes/customers';
|
||||
import customerPosRoutes from './routes/customer-pos';
|
||||
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';
|
||||
import publicRoutes from './routes/public';
|
||||
import proxyRoutes from './routes/proxy';
|
||||
|
||||
app.register(usersRoutes, { prefix: '/api/users' });
|
||||
app.register(customersRoutes, { prefix: '/api/customers' });
|
||||
app.register(customerPosRoutes, { prefix: '/api/customer-pos' });
|
||||
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' });
|
||||
app.register(publicRoutes, { prefix: '/api/public' });
|
||||
app.register(proxyRoutes, { prefix: '/api/proxy' });
|
||||
|
||||
|
||||
app.get('/health', async (request, reply) => {
|
||||
return { status: 'ok' };
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../prisma';
|
||||
|
||||
export default async function customerPosRoutes(app: FastifyInstance) {
|
||||
app.delete('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
try {
|
||||
await prisma.customerPos.delete({ where: { id } });
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2025') return reply.code(404).send({ error: 'Point of sale not found' });
|
||||
return reply.code(500).send({ error: 'Failed to delete point of sale' });
|
||||
}
|
||||
});
|
||||
}
|
||||
+306
-34
@@ -1,6 +1,119 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../prisma';
|
||||
|
||||
type PosCoordinates = {
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
};
|
||||
|
||||
const POS_INDUSTRIES = new Set([
|
||||
'Academia',
|
||||
'Conveniência',
|
||||
'Hamburgueria',
|
||||
'Lanchonete',
|
||||
'Mercado',
|
||||
'Panificadora',
|
||||
'Restaurante',
|
||||
'Sorveteria',
|
||||
'Verdureira/Frutaria',
|
||||
'Parque aquático',
|
||||
'Recanto',
|
||||
'Pesque e pague',
|
||||
'Arena de esporte'
|
||||
]);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const parseBooleanFlag = (value: unknown): boolean | undefined => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'boolean') return value;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseCoordinate = (value: unknown): number | null | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
const parsedValue = Number(value);
|
||||
if (!Number.isFinite(parsedValue)) return undefined;
|
||||
return parsedValue;
|
||||
};
|
||||
|
||||
const parseIndustry = (value: unknown): string | null | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalizedValue = value.trim();
|
||||
if (!normalizedValue) return null;
|
||||
if (!POS_INDUSTRIES.has(normalizedValue)) return undefined;
|
||||
return normalizedValue;
|
||||
};
|
||||
|
||||
const geocodeAddress = async (address: string): Promise<PosCoordinates | null> => {
|
||||
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey || !address?.trim()) return null;
|
||||
|
||||
const geocodeUrl = new URL('https://maps.googleapis.com/maps/api/geocode/json');
|
||||
geocodeUrl.searchParams.set('address', address.trim());
|
||||
geocodeUrl.searchParams.set('key', apiKey);
|
||||
|
||||
try {
|
||||
const response = await fetch(geocodeUrl.toString());
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as {
|
||||
status?: string;
|
||||
results?: Array<{ geometry?: { location?: { lat?: number; lng?: number } } }>;
|
||||
};
|
||||
if (data.status !== 'OK' || !data.results?.length) return null;
|
||||
const location = data.results[0]?.geometry?.location;
|
||||
if (typeof location?.lat !== 'number' || typeof location?.lng !== 'number') return null;
|
||||
return { lat: location.lat, lng: location.lng };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvePosCoordinates = async ({
|
||||
address,
|
||||
lat,
|
||||
lng
|
||||
}: {
|
||||
address?: string;
|
||||
lat: number | null | undefined;
|
||||
lng: number | null | undefined;
|
||||
}): Promise<PosCoordinates | undefined> => {
|
||||
const hasLat = lat !== undefined;
|
||||
const hasLng = lng !== undefined;
|
||||
|
||||
if (hasLat || hasLng) {
|
||||
if (!hasLat || !hasLng) return undefined;
|
||||
return { lat: lat ?? null, lng: lng ?? null };
|
||||
}
|
||||
|
||||
if (!address?.trim()) return undefined;
|
||||
|
||||
const geocoded = await geocodeAddress(address);
|
||||
if (!geocoded) return undefined;
|
||||
return geocoded;
|
||||
};
|
||||
|
||||
const conflictError = (message: string): Error & { statusCode: number } =>
|
||||
Object.assign(new Error(message), { statusCode: 409 });
|
||||
|
||||
const normalizeOptionalString = (value: unknown): string | null | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
};
|
||||
|
||||
export default async function customersRoutes(app: FastifyInstance) {
|
||||
// Get all customers (with pos optionally)
|
||||
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
@@ -23,33 +136,71 @@ export default async function customersRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Create customer
|
||||
app.post('/', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { name, document, phone, personName } = request.body as any;
|
||||
app.post('/', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { name, document, phone, personName, notes } = request.body as any;
|
||||
const docValue = document || null;
|
||||
const notesValue = normalizeOptionalString(notes);
|
||||
try {
|
||||
return await prisma.customer.create({ data: { name, document: docValue, phone, personName } });
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const existingByName = await tx.customer.findFirst({
|
||||
where: { name: { equals: name, mode: 'insensitive' }, disabledAt: null }
|
||||
});
|
||||
if (existingByName) throw conflictError('A customer with this name already exists');
|
||||
|
||||
if (phone) {
|
||||
const existingByPhone = await tx.customer.findFirst({
|
||||
where: { phone, disabledAt: null }
|
||||
});
|
||||
if (existingByPhone) throw conflictError('A customer with this phone number already exists');
|
||||
}
|
||||
|
||||
if (docValue) {
|
||||
const existingByDoc = await tx.customer.findFirst({
|
||||
where: { document: docValue, disabledAt: null }
|
||||
});
|
||||
if (existingByDoc) throw conflictError('A customer with this document already exists');
|
||||
}
|
||||
|
||||
return tx.customer.create({ data: { name, document: docValue, phone, personName, notes: notesValue ?? null } });
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.code === 'P2002') return reply.code(400).send({ error: 'Document already exists' });
|
||||
if (e.statusCode === 409) return reply.code(409).send({ error: e.message });
|
||||
if (e.code === 'P2002') return reply.code(409).send({ error: 'Document already exists' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// Update customer
|
||||
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.put('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
const { name, document, phone, personName } = request.body as any;
|
||||
const { name, document, phone, personName, notes } = request.body as any;
|
||||
const docValue = document || null;
|
||||
const notesValue = normalizeOptionalString(notes);
|
||||
try {
|
||||
if (docValue) {
|
||||
const existing = await prisma.customer.findFirst({
|
||||
where: { document: docValue, id: { not: id } }
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const existingByName = await tx.customer.findFirst({
|
||||
where: { name: { equals: name, mode: 'insensitive' }, disabledAt: null, id: { not: id } }
|
||||
});
|
||||
if (existing) {
|
||||
return reply.code(400).send({ error: 'Document already exists for another customer' });
|
||||
if (existingByName) throw conflictError('A customer with this name already exists');
|
||||
|
||||
if (phone) {
|
||||
const existingByPhone = await tx.customer.findFirst({
|
||||
where: { phone, disabledAt: null, id: { not: id } }
|
||||
});
|
||||
if (existingByPhone) throw conflictError('A customer with this phone number already exists');
|
||||
}
|
||||
}
|
||||
return await prisma.customer.update({ where: { id }, data: { name, document: docValue, phone, personName } });
|
||||
|
||||
if (docValue) {
|
||||
const existingByDoc = await tx.customer.findFirst({
|
||||
where: { document: docValue, disabledAt: null, id: { not: id } }
|
||||
});
|
||||
if (existingByDoc) throw conflictError('Document already exists for another customer');
|
||||
}
|
||||
|
||||
return tx.customer.update({ where: { id }, data: { name, document: docValue, phone, personName, notes: notesValue ?? null, disabledAt: null } });
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.statusCode === 409) return reply.code(409).send({ error: e.message });
|
||||
if (e && e.code === 'P2025') {
|
||||
return reply.code(404).send({ error: 'Customer not found' });
|
||||
}
|
||||
@@ -57,38 +208,159 @@ export default async function customersRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete customer (soft)
|
||||
app.delete('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
// Disable customer (soft)
|
||||
app.patch('/:id/disable', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
await prisma.customer.update({ where: { id }, data: { disabledAt: new Date() } });
|
||||
return { success: true };
|
||||
try {
|
||||
const existingCustomer = await prisma.customer.findUnique({ where: { id }, select: { disabledAt: true } });
|
||||
if (!existingCustomer) return reply.code(404).send({ error: 'Customer not found' });
|
||||
if (existingCustomer.disabledAt) return reply.code(409).send({ error: 'Customer is already disabled' });
|
||||
|
||||
await prisma.customer.update({ where: { id }, data: { disabledAt: new Date() } });
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
request.log.error({ err: e, customerId: id }, 'Failed to disable customer');
|
||||
return reply.code(500).send({ error: 'Failed to disable customer' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete customer (hard)
|
||||
app.delete('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
try {
|
||||
const existing = await prisma.customer.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
if (!existing) return reply.code(404).send({ error: 'Customer not found' });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.customerDeleted.create({
|
||||
data: {
|
||||
customerId: existing.id,
|
||||
name: existing.name,
|
||||
document: existing.document,
|
||||
phone: existing.phone,
|
||||
personName: existing.personName,
|
||||
notes: existing.notes
|
||||
}
|
||||
});
|
||||
await tx.customer.delete({ where: { id } });
|
||||
});
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2025') return reply.code(404).send({ error: 'Customer not found' });
|
||||
return reply.code(500).send({ error: 'Failed to delete customer' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- Point of Sales for Customer ---
|
||||
|
||||
app.post('/:customerId/pos', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { customerId } = request.params as any;
|
||||
const { address, phone, personName } = request.body as any;
|
||||
return prisma.customerPos.create({
|
||||
data: { customerId, address, phone, personName }
|
||||
});
|
||||
const { address, phone, personName, industry, fridgeCount, banner, indiBanner, lat, lng, region } = request.body as any;
|
||||
const normalizedIndustry = parseIndustry(industry);
|
||||
const normalizedRegion = normalizeOptionalString(region);
|
||||
const parsedLat = parseCoordinate(lat);
|
||||
const parsedLng = parseCoordinate(lng);
|
||||
|
||||
if (industry !== undefined && normalizedIndustry === undefined) return reply.code(400).send({ error: 'Invalid industry' });
|
||||
if (lat !== undefined && parsedLat === undefined) return reply.code(400).send({ error: 'Invalid lat coordinate' });
|
||||
if (lng !== undefined && parsedLng === undefined) return reply.code(400).send({ error: 'Invalid lng coordinate' });
|
||||
|
||||
const coordinates = await resolvePosCoordinates({ address, lat: parsedLat, lng: parsedLng });
|
||||
if ((parsedLat !== undefined || parsedLng !== undefined) && !coordinates) {
|
||||
return reply.code(400).send({ error: 'Both lat and lng must be provided together' });
|
||||
}
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
if (phone) {
|
||||
const existingByPhone = await tx.customerPos.findFirst({
|
||||
where: { phone, disabledAt: null }
|
||||
});
|
||||
if (existingByPhone) throw conflictError('A point of sale with this phone number already exists');
|
||||
}
|
||||
if (address) {
|
||||
const existingByAddress = await tx.customerPos.findFirst({
|
||||
where: { address: { equals: address, mode: 'insensitive' }, disabledAt: null }
|
||||
});
|
||||
if (existingByAddress) throw conflictError('A point of sale with this address already exists');
|
||||
}
|
||||
return tx.customerPos.create({
|
||||
data: {
|
||||
customerId,
|
||||
address,
|
||||
phone,
|
||||
industry: normalizedIndustry ?? null,
|
||||
personName,
|
||||
fridgeCount: parseFridgeCount(fridgeCount) ?? 0,
|
||||
banner: parseBooleanFlag(banner) ?? false,
|
||||
indiBanner: parseBooleanFlag(indiBanner) ?? false,
|
||||
region: normalizedRegion ?? null,
|
||||
...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {})
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.statusCode === 409) return reply.code(409).send({ error: e.message });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/pos/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
const { address, phone, personName } = request.body as any;
|
||||
return prisma.customerPos.update({
|
||||
where: { id },
|
||||
data: { address, phone, personName }
|
||||
});
|
||||
const { address, phone, personName, industry, fridgeCount, banner, indiBanner, lat, lng, region } = request.body as any;
|
||||
const normalizedIndustry = parseIndustry(industry);
|
||||
const normalizedFridgeCount = parseFridgeCount(fridgeCount);
|
||||
const normalizedBanner = parseBooleanFlag(banner);
|
||||
const normalizedIndiBanner = parseBooleanFlag(indiBanner);
|
||||
const parsedLat = parseCoordinate(lat);
|
||||
const parsedLng = parseCoordinate(lng);
|
||||
const normalizedRegion = normalizeOptionalString(region);
|
||||
|
||||
if (industry !== undefined && normalizedIndustry === undefined) return reply.code(400).send({ error: 'Invalid industry' });
|
||||
if (lat !== undefined && parsedLat === undefined) return reply.code(400).send({ error: 'Invalid lat coordinate' });
|
||||
if (lng !== undefined && parsedLng === undefined) return reply.code(400).send({ error: 'Invalid lng coordinate' });
|
||||
|
||||
const coordinates = await resolvePosCoordinates({ address, lat: parsedLat, lng: parsedLng });
|
||||
if ((parsedLat !== undefined || parsedLng !== undefined) && !coordinates) {
|
||||
return reply.code(400).send({ error: 'Both lat and lng must be provided together' });
|
||||
}
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
if (phone) {
|
||||
const existingByPhone = await tx.customerPos.findFirst({
|
||||
where: { phone, disabledAt: null, id: { not: id } }
|
||||
});
|
||||
if (existingByPhone) throw conflictError('A point of sale with this phone number already exists');
|
||||
}
|
||||
if (address) {
|
||||
const existingByAddress = await tx.customerPos.findFirst({
|
||||
where: { address: { equals: address, mode: 'insensitive' }, disabledAt: null, id: { not: id } }
|
||||
});
|
||||
if (existingByAddress) throw conflictError('A point of sale with this address already exists');
|
||||
}
|
||||
return tx.customerPos.update({
|
||||
where: { id },
|
||||
data: {
|
||||
address,
|
||||
phone,
|
||||
personName,
|
||||
...(normalizedIndustry !== undefined ? { industry: normalizedIndustry } : {}),
|
||||
...(normalizedFridgeCount !== undefined ? { fridgeCount: normalizedFridgeCount } : {}),
|
||||
...(normalizedBanner !== undefined ? { banner: normalizedBanner } : {}),
|
||||
...(normalizedIndiBanner !== undefined ? { indiBanner: normalizedIndiBanner } : {}),
|
||||
...(normalizedRegion !== undefined ? { region: normalizedRegion } : {}),
|
||||
...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {})
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.statusCode === 409) return reply.code(409).send({ error: e.message });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/pos/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
await prisma.customerPos.update({
|
||||
where: { id },
|
||||
data: { disabledAt: new Date() }
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
+127
-13
@@ -66,8 +66,12 @@ function getDateRange(range: string): { startDate: Date; endDate: Date | null }
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
const INACTIVE_THRESHOLD_DAYS = 10;
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
export default async function dashboardRoutes(app: FastifyInstance) {
|
||||
app.get('/sales-by-customer', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.addHook('preValidation', app.requireAdmin);
|
||||
app.get('/sales-by-customer', async (request, reply) => {
|
||||
const { range } = request.query as { range: string };
|
||||
const { startDate, endDate } = getDateRange(range);
|
||||
|
||||
@@ -114,7 +118,7 @@ export default async function dashboardRoutes(app: FastifyInstance) {
|
||||
.sort((a, b) => b.totalAmount - a.totalAmount);
|
||||
});
|
||||
|
||||
app.get('/sales-by-product', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.get('/sales-by-product', async (request, reply) => {
|
||||
const { range } = request.query as { range: string };
|
||||
const { startDate, endDate } = getDateRange(range);
|
||||
|
||||
@@ -144,21 +148,28 @@ export default async function dashboardRoutes(app: FastifyInstance) {
|
||||
.sort((a, b) => b.totalAmount - a.totalAmount);
|
||||
});
|
||||
|
||||
app.get('/sales-summary', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.get('/sales-summary', 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: {
|
||||
products: {
|
||||
select: {
|
||||
quantity: true,
|
||||
product: { select: { price: true } }
|
||||
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;
|
||||
@@ -173,6 +184,109 @@ export default async function dashboardRoutes(app: FastifyInstance) {
|
||||
|
||||
const averageAmount = totalSales > 0 ? totalAmount / totalSales : 0;
|
||||
|
||||
return { totalSales, totalAmount, averageAmount };
|
||||
return {
|
||||
totalSales,
|
||||
totalAmount,
|
||||
averageAmount,
|
||||
totalCustomers,
|
||||
totalFridges: totalFridges._sum.fridgeCount ?? 0
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/fridges', async () => {
|
||||
const fridges = await prisma.customerPos.findMany({
|
||||
where: {
|
||||
disabledAt: null,
|
||||
fridgeCount: { gt: 0 }
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
address: true,
|
||||
fridgeCount: true,
|
||||
customer: { select: { name: true } }
|
||||
}
|
||||
});
|
||||
|
||||
return fridges.sort((a, b) => {
|
||||
const byCustomer = (a.customer.name ?? '').localeCompare(b.customer.name ?? '');
|
||||
if (byCustomer !== 0) return byCustomer;
|
||||
return a.address.localeCompare(b.address);
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/industries-summary', async () => {
|
||||
const grouped = await prisma.customerPos.groupBy({
|
||||
by: ['industry'],
|
||||
_count: { _all: true },
|
||||
where: { disabledAt: null }
|
||||
});
|
||||
|
||||
const fallbackIndustry = 'Não Informado';
|
||||
const summaryMap = new Map<string, number>();
|
||||
|
||||
for (const item of grouped) {
|
||||
const normalizedIndustry = item.industry?.trim() || fallbackIndustry;
|
||||
summaryMap.set(normalizedIndustry, (summaryMap.get(normalizedIndustry) ?? 0) + item._count._all);
|
||||
}
|
||||
|
||||
return Array.from(summaryMap.entries())
|
||||
.map(([industry, count]) => ({ industry, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
});
|
||||
|
||||
app.get('/regions-summary', async () => {
|
||||
const grouped = await prisma.customerPos.groupBy({
|
||||
by: ['region'],
|
||||
_count: { _all: true },
|
||||
where: { disabledAt: null }
|
||||
});
|
||||
|
||||
const fallbackRegion = 'Não Informado';
|
||||
const summaryMap = new Map<string, number>();
|
||||
|
||||
for (const item of grouped) {
|
||||
const normalizedRegion = item.region?.trim() || fallbackRegion;
|
||||
summaryMap.set(normalizedRegion, (summaryMap.get(normalizedRegion) ?? 0) + item._count._all);
|
||||
}
|
||||
|
||||
return Array.from(summaryMap.entries())
|
||||
.map(([region, count]) => ({ region, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
});
|
||||
|
||||
app.get('/inactive-pos', async () => {
|
||||
const now = new Date();
|
||||
|
||||
const poses = await prisma.customerPos.findMany({
|
||||
where: { disabledAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
address: true,
|
||||
createdAt: true,
|
||||
customer: { select: { name: true } },
|
||||
sales: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: { createdAt: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return poses
|
||||
.map(pos => {
|
||||
const lastSale = pos.sales[0] ?? null;
|
||||
const lastBuyingDate = lastSale?.createdAt ?? null;
|
||||
const referenceDate = lastBuyingDate ?? pos.createdAt;
|
||||
const daysInactive = Math.floor((now.getTime() - referenceDate.getTime()) / MS_PER_DAY);
|
||||
return {
|
||||
posId: pos.id,
|
||||
customerName: pos.customer.name,
|
||||
posAddress: pos.address,
|
||||
lastBuyingDate: lastBuyingDate?.toISOString() ?? null,
|
||||
daysInactive
|
||||
};
|
||||
})
|
||||
.filter(item => item.daysInactive >= INACTIVE_THRESHOLD_DAYS)
|
||||
.sort((a, b) => b.daysInactive - a.daysInactive);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default async function productsRoutes(app: FastifyInstance) {
|
||||
return product;
|
||||
});
|
||||
|
||||
app.post('/', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.post('/', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { name, price, stock, cost } = request.body as any;
|
||||
try {
|
||||
return await prisma.product.create({
|
||||
@@ -37,7 +37,7 @@ export default async function productsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.put('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
const { name, price, stock, cost, disabledAt } = request.body as any;
|
||||
try {
|
||||
@@ -58,7 +58,7 @@ export default async function productsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.delete('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
await prisma.product.update({
|
||||
where: { id },
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
|
||||
export default async function proxyRoutes(app: FastifyInstance) {
|
||||
app.get('/validator', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { value } = request.query as { value?: string };
|
||||
if (!value) {
|
||||
return reply.code(400).send({ error: 'value query parameter is required' });
|
||||
}
|
||||
|
||||
const token = process.env.CPF_CNPJ_API_TOKEN;
|
||||
if (!token) {
|
||||
return reply.code(500).send({ error: 'CPF_CNPJ_API_TOKEN is not configured on the server' });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.invertexto.com/v1/validator?token=${encodeURIComponent(token)}&value=${encodeURIComponent(value)}`);
|
||||
if (!response.ok) {
|
||||
return reply.code(response.status).send({ error: 'Failed to validate document via proxy' });
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
return reply.code(500).send({ error: 'Error calling validation API' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/geocode', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { address } = request.query as { address?: string };
|
||||
if (!address) {
|
||||
return reply.code(400).send({ error: 'address query parameter is required' });
|
||||
}
|
||||
|
||||
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey) {
|
||||
return reply.code(500).send({ error: 'GOOGLE_MAPS_API_KEY is not configured on the server' });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`);
|
||||
if (!response.ok) {
|
||||
return reply.code(response.status).send({ error: 'Failed to geocode address via proxy' });
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
return reply.code(500).send({ error: 'Error calling geocoding API' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../prisma';
|
||||
|
||||
type PosClosestResponse = {
|
||||
id: string;
|
||||
customerName: string;
|
||||
address: string;
|
||||
distanceKm: number;
|
||||
};
|
||||
|
||||
const earthRadiusKm = 6371;
|
||||
|
||||
const degreesToRadians = (value: number): number => (value * Math.PI) / 180;
|
||||
|
||||
const calculateDistanceKm = (originLat: number, originLng: number, destinationLat: number, destinationLng: number): number => {
|
||||
const deltaLat = degreesToRadians(destinationLat - originLat);
|
||||
const deltaLng = degreesToRadians(destinationLng - originLng);
|
||||
const originLatRad = degreesToRadians(originLat);
|
||||
const destinationLatRad = degreesToRadians(destinationLat);
|
||||
|
||||
const haversineComponent = (
|
||||
Math.sin(deltaLat / 2) ** 2
|
||||
+ Math.cos(originLatRad) * Math.cos(destinationLatRad) * Math.sin(deltaLng / 2) ** 2
|
||||
);
|
||||
|
||||
const arc = 2 * Math.atan2(Math.sqrt(haversineComponent), Math.sqrt(1 - haversineComponent));
|
||||
return earthRadiusKm * arc;
|
||||
};
|
||||
|
||||
export default async function publicRoutes(app: FastifyInstance) {
|
||||
app.get('/pos/closest', {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 15,
|
||||
timeWindow: '1 minute',
|
||||
keyGenerator: (request: any) => request.ip
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const { lat, lng } = request.query as { lat?: string; lng?: string };
|
||||
if (lat === undefined || lng === undefined) {
|
||||
return reply.code(400).send({ error: 'lat and lng query parameters are required' });
|
||||
}
|
||||
|
||||
const referenceLat = Number(lat);
|
||||
const referenceLng = Number(lng);
|
||||
|
||||
if (!Number.isFinite(referenceLat) || !Number.isFinite(referenceLng)) {
|
||||
return reply.code(400).send({ error: 'lat and lng query parameters must be valid numbers' });
|
||||
}
|
||||
|
||||
const activePosList = await prisma.customerPos.findMany({
|
||||
where: {
|
||||
disabledAt: null,
|
||||
lat: { not: null },
|
||||
lng: { not: null },
|
||||
customer: { disabledAt: null }
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
address: true,
|
||||
lat: true,
|
||||
lng: true,
|
||||
customer: {
|
||||
select: { name: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const closestPos = activePosList
|
||||
.map((pos): PosClosestResponse => {
|
||||
const posLat = pos.lat as number;
|
||||
const posLng = pos.lng as number;
|
||||
return {
|
||||
id: pos.id,
|
||||
customerName: pos.customer.name,
|
||||
address: pos.address,
|
||||
distanceKm: calculateDistanceKm(referenceLat, referenceLng, posLat, posLng)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.distanceKm - b.distanceKm)
|
||||
.slice(0, 10);
|
||||
|
||||
return closestPos;
|
||||
});
|
||||
}
|
||||
@@ -42,13 +42,14 @@ export default async function salesRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Create sale
|
||||
app.post('/', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.post('/', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const {
|
||||
customerPosId,
|
||||
paymentMethod,
|
||||
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,
|
||||
@@ -103,7 +105,7 @@ export default async function salesRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Update sale (e.g. mark as paid, edit fields, replace products)
|
||||
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.put('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
const {
|
||||
customerPosId,
|
||||
@@ -112,6 +114,8 @@ export default async function salesRoutes(app: FastifyInstance) {
|
||||
paymentDueDate,
|
||||
paymentDate,
|
||||
comments,
|
||||
nextVisitDate,
|
||||
visitedAt,
|
||||
products
|
||||
} = request.body as any;
|
||||
|
||||
@@ -135,6 +139,8 @@ export default async function salesRoutes(app: FastifyInstance) {
|
||||
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)) {
|
||||
@@ -245,7 +251,7 @@ export default async function salesRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Delete sale (hard delete)
|
||||
app.delete('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.delete('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
try {
|
||||
const existing = await prisma.sale.findUnique({
|
||||
|
||||
+94
-12
@@ -2,25 +2,107 @@ import { FastifyInstance } from 'fastify';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { prisma } from '../prisma';
|
||||
|
||||
interface LoginFailure {
|
||||
consecutiveFailures: number;
|
||||
lockoutUntil?: number;
|
||||
lastAttempt: number;
|
||||
}
|
||||
|
||||
// NOTE: Process-local. In a multi-replica deployment, replace with a shared
|
||||
// store (e.g. Redis) to enforce lockout across all instances.
|
||||
const loginFailures = new Map<string, LoginFailure>();
|
||||
|
||||
const MAX_CONSECUTIVE_FAILURES = 3;
|
||||
const BASE_LOCKOUT_MS = 2000;
|
||||
const MAX_LOCKOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const FAILURE_ENTRY_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// Dummy hash for constant-time bcrypt comparison when user is missing/disabled,
|
||||
// preventing user enumeration via response timing.
|
||||
const DUMMY_HASH = bcrypt.hashSync('__timing_prevention__', 10);
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - FAILURE_ENTRY_TTL_MS;
|
||||
for (const [key, record] of loginFailures.entries()) {
|
||||
if (record.lastAttempt < cutoff) loginFailures.delete(key);
|
||||
}
|
||||
}, 10 * 60 * 1000).unref();
|
||||
|
||||
export default async function usersRoutes(app: FastifyInstance) {
|
||||
app.post('/login', async (request, reply) => {
|
||||
app.post('/login', {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: '1 minute',
|
||||
keyGenerator: (request: any) => request.ip
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const { email, password } = request.body as any;
|
||||
const clientIp = request.ip;
|
||||
const normalizedEmail = (email || '').toLowerCase().trim();
|
||||
const failureKey = `${clientIp}-${normalizedEmail || 'unknown'}`;
|
||||
|
||||
// Check if locked out
|
||||
const failureRecord = loginFailures.get(failureKey);
|
||||
if (failureRecord?.lockoutUntil && failureRecord.lockoutUntil > Date.now()) {
|
||||
const waitTime = Math.ceil((failureRecord.lockoutUntil - Date.now()) / 1000);
|
||||
return reply.code(429).send({
|
||||
error: `Too many failed login attempts. Please wait ${waitTime} seconds.`
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user || user.disabledAt) {
|
||||
// Always run bcrypt to prevent user enumeration via response timing
|
||||
const hashToCompare = (user && !user.disabledAt) ? user.password : DUMMY_HASH;
|
||||
const isValid = (await bcrypt.compare(password, hashToCompare)) && !!user && !user.disabledAt;
|
||||
|
||||
if (!isValid) {
|
||||
const record = failureRecord || { consecutiveFailures: 0, lastAttempt: 0 };
|
||||
record.consecutiveFailures += 1;
|
||||
record.lastAttempt = Date.now();
|
||||
|
||||
if (record.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
const rawBackoff = BASE_LOCKOUT_MS * Math.pow(2, record.consecutiveFailures - MAX_CONSECUTIVE_FAILURES);
|
||||
record.lockoutUntil = Date.now() + Math.min(rawBackoff, MAX_LOCKOUT_MS);
|
||||
}
|
||||
loginFailures.set(failureKey, record);
|
||||
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, user.password);
|
||||
if (!validPassword) {
|
||||
return reply.code(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
// Success: clear failures
|
||||
loginFailures.delete(failureKey);
|
||||
|
||||
const token = app.jwt.sign({ id: user.id, email: user.email, role: user.role });
|
||||
return { token, user: { id: user.id, name: user.name, email: user.email, role: user.role } };
|
||||
reply.setCookie('polpaAuth', app.jwt.sign({ id: user.id, email: user.email, role: user.role }, { expiresIn: '8h' }), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 8 * 60 * 60
|
||||
});
|
||||
|
||||
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } };
|
||||
});
|
||||
|
||||
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.get('/me', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const userPayload = request.user as any;
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userPayload.id }
|
||||
});
|
||||
if (!dbUser || dbUser.disabledAt) {
|
||||
return reply.code(401).send({ error: 'User not found or disabled' });
|
||||
}
|
||||
return { user: { id: dbUser.id, name: dbUser.name, email: dbUser.email, role: dbUser.role } };
|
||||
});
|
||||
|
||||
app.post('/logout', async (request, reply) => {
|
||||
reply.clearCookie('polpaAuth', { path: '/' });
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
|
||||
app.get('/', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const showDisabled = (request.query as any).showDisabled as string === 'true';
|
||||
const users = await prisma.user.findMany({
|
||||
where: showDisabled ? {} : { disabledAt: null },
|
||||
@@ -29,7 +111,7 @@ export default async function usersRoutes(app: FastifyInstance) {
|
||||
return users;
|
||||
});
|
||||
|
||||
app.post('/', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.post('/', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { name, email, password, role } = request.body as any;
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
@@ -45,7 +127,7 @@ export default async function usersRoutes(app: FastifyInstance) {
|
||||
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
||||
});
|
||||
|
||||
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.put('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
const { name, email, role, password } = request.body as any;
|
||||
|
||||
@@ -66,7 +148,7 @@ export default async function usersRoutes(app: FastifyInstance) {
|
||||
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
||||
});
|
||||
|
||||
app.delete('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
app.delete('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
});
|
||||
}
|
||||
+13
-3
@@ -1,19 +1,29 @@
|
||||
import 'dotenv/config';
|
||||
import { prisma } from './prisma';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
async function main() {
|
||||
const existing = await prisma.user.findFirst();
|
||||
if (!existing) {
|
||||
const hashedPassword = await bcrypt.hash('admin123', 10);
|
||||
const email = process.env.SEED_ADMIN_EMAIL || 'admin@polpagestao.com';
|
||||
const password = process.env.SEED_ADMIN_PASSWORD;
|
||||
|
||||
if (!password) {
|
||||
console.error('ERROR: SEED_ADMIN_PASSWORD environment variable is required. Set it and re-run the seed script.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name: 'Admin User',
|
||||
email: 'admin@polpagestao.com',
|
||||
email: email,
|
||||
password: hashedPassword,
|
||||
role: 'admin'
|
||||
}
|
||||
});
|
||||
console.log('Created default admin user: admin@polpagestao.com / admin123');
|
||||
|
||||
console.log(`Created default admin user: ${email}`);
|
||||
} else {
|
||||
console.log('Database already seeded with users.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"sourceMap": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
}
|
||||
}
|
||||
+21
-19
@@ -1,53 +1,56 @@
|
||||
---
|
||||
|
||||
services:
|
||||
postgres:
|
||||
polpa_db:
|
||||
image: postgres:15
|
||||
container_name: polpa_gestao_db
|
||||
container_name: polpa_db
|
||||
environment:
|
||||
POSTGRES_USER: admin
|
||||
POSTGRES_PASSWORD: adminpassword
|
||||
POSTGRES_DB: polpa_gestao
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d polpa_gestao"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME} -h localhost || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
prisma-push:
|
||||
polpa_prisma:
|
||||
build:
|
||||
context: ./backend
|
||||
target: prisma
|
||||
image: ghcr.io/rmcampos/polpa-gestao/backend-prisma:latest
|
||||
container_name: polpa_gestao_prisma_push
|
||||
container_name: polpa_prisma
|
||||
environment:
|
||||
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
|
||||
DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@polpa_db:5432/${DB_NAME}
|
||||
command: ["npx", "prisma", "migrate", "deploy"]
|
||||
depends_on:
|
||||
postgres:
|
||||
polpa_db:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
backend:
|
||||
polpa_backend:
|
||||
build: ./backend
|
||||
image: ghcr.io/rmcampos/polpa-gestao/backend:latest
|
||||
container_name: polpa_gestao_backend
|
||||
container_name: polpa_backend
|
||||
environment:
|
||||
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
|
||||
DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@polpa_db:5432/${DB_NAME}
|
||||
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
CPF_CNPJ_API_TOKEN: ${CPF_CNPJ_API_TOKEN}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
polpa_db:
|
||||
condition: service_healthy
|
||||
prisma-push:
|
||||
polpa_prisma:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "healthcheck.js"]
|
||||
@@ -57,19 +60,18 @@ services:
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
frontend:
|
||||
polpa_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
|
||||
container_name: polpa_frontend
|
||||
ports:
|
||||
- "5173:80"
|
||||
depends_on:
|
||||
- backend
|
||||
- polpa_backend
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
|
||||
+21
-19
@@ -1,53 +1,56 @@
|
||||
---
|
||||
|
||||
services:
|
||||
postgres:
|
||||
polpa_db:
|
||||
image: postgres:15
|
||||
container_name: polpa_gestao_db
|
||||
container_name: polpa_db
|
||||
environment:
|
||||
POSTGRES_USER: admin
|
||||
POSTGRES_PASSWORD: adminpassword
|
||||
POSTGRES_DB: polpa_gestao
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d polpa_gestao"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME} -h localhost || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
prisma-push:
|
||||
polpa_prisma:
|
||||
build:
|
||||
context: ./backend
|
||||
target: prisma
|
||||
image: ghcr.io/rmcampos/polpa-gestao/backend-prisma:latest
|
||||
container_name: polpa_gestao_prisma_push
|
||||
container_name: polpa_prisma
|
||||
environment:
|
||||
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
|
||||
DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@polpa_db:5432/${DB_NAME}
|
||||
command: ["npx", "prisma", "migrate", "deploy"]
|
||||
depends_on:
|
||||
postgres:
|
||||
polpa_db:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
backend:
|
||||
polpa_backend:
|
||||
build: ./backend
|
||||
image: ghcr.io/rmcampos/polpa-gestao/backend:latest
|
||||
container_name: polpa_gestao_backend
|
||||
container_name: polpa_backend
|
||||
environment:
|
||||
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
|
||||
DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@polpa_db:5432/${DB_NAME}
|
||||
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
CPF_CNPJ_API_TOKEN: ${CPF_CNPJ_API_TOKEN}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
polpa_db:
|
||||
condition: service_healthy
|
||||
prisma-push:
|
||||
polpa_prisma:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "healthcheck.js"]
|
||||
@@ -57,19 +60,18 @@ services:
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
frontend:
|
||||
polpa_frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
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
|
||||
container_name: polpa_frontend
|
||||
ports:
|
||||
- "5173:80"
|
||||
depends_on:
|
||||
- backend
|
||||
- polpa_backend
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
setup:
|
||||
project: polpa-gestao
|
||||
config: dev_secrets
|
||||
@@ -1,5 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_BACKEND_SERVER=http://localhost:3000
|
||||
CPF_CNPJ_API_TOKEN=your_api_key_here
|
||||
+1
-2
@@ -5,14 +5,13 @@ 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 ./
|
||||
|
||||
|
||||
+59
-9
@@ -7,32 +7,75 @@ 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 ClosestPos from './pages/ClosestPos';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import { Toast } from './components/Toast';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
export default function App() {
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
if (window.location.pathname !== '/login') {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${apiBase}/api/users/me`);
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user));
|
||||
setToken(response.data.user?.id ?? null);
|
||||
} catch {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setToken(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
initAuth();
|
||||
}, [apiBase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) {
|
||||
const isPublicPath = window.location.pathname === '/login' || window.location.pathname === '/closest';
|
||||
if (!isPublicPath) {
|
||||
navigate('/login');
|
||||
}
|
||||
}
|
||||
}, [token, navigate]);
|
||||
}, [token, loading, navigate]);
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await axios.post(`${apiBase}/api/users/logout`);
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
}
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: '100vh', width: '100vw', backgroundColor: '#0f172a', color: '#fff' }}>
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="m-0 text-white-50">Loading Polpa Gestão...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/closest" element={<ClosestPos />} />
|
||||
<Route path="/login" element={<Login setToken={setToken} />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
@@ -42,20 +85,27 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const isAdmin = user.role === 'admin';
|
||||
const defaultRoute = isAdmin ? "/dashboard" : "/routes";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-container">
|
||||
<Sidebar onLogout={handleLogout} />
|
||||
<main className="main-content animate-fade-in">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
<Route path="/dashboard" element={isAdmin ? <Dashboard /> : <Navigate to="/routes" replace />} />
|
||||
<Route path="/users" element={isAdmin ? <Users /> : <Navigate to="/routes" replace />} />
|
||||
<Route path="/customers" element={<Customers />} />
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/sales" element={<Sales />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/visits" element={<Visits />} />
|
||||
<Route path="/closest" element={<ClosestPos />} />
|
||||
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,9 @@ interface SidebarProps {
|
||||
|
||||
export default function Sidebar({ onLogout }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const userName = JSON.parse(localStorage.getItem('user') || '{}').name;
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const userName = user.name || 'User';
|
||||
const isAdmin = user.role === 'admin';
|
||||
const buildNumber = import.meta.env.VITE_BUILD_NUMBER || 'dev';
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@@ -43,20 +45,24 @@ export default function Sidebar({ onLogout }: SidebarProps) {
|
||||
</div>
|
||||
|
||||
<nav className="nav flex-column w-100 mb-auto">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className={`nav-link ${location.pathname === '/dashboard' ? 'active' : ''}`}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<i className="bi bi-speedometer2 me-2"></i> Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/users"
|
||||
className={`nav-link ${location.pathname === '/users' ? 'active' : ''}`}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<i className="bi bi-people me-2"></i> Users
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className={`nav-link ${location.pathname === '/dashboard' ? 'active' : ''}`}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<i className="bi bi-speedometer2 me-2"></i> Dashboard
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to="/users"
|
||||
className={`nav-link ${location.pathname === '/users' ? 'active' : ''}`}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<i className="bi bi-people me-2"></i> Users
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/customers"
|
||||
className={`nav-link ${location.pathname === '/customers' ? 'active' : ''}`}
|
||||
@@ -85,6 +91,13 @@ 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">
|
||||
|
||||
@@ -6,6 +6,37 @@ import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import axios from 'axios';
|
||||
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
let memoryUser: string | null = null;
|
||||
|
||||
const _origGetItem = Storage.prototype.getItem;
|
||||
const _origSetItem = Storage.prototype.setItem;
|
||||
const _origRemoveItem = Storage.prototype.removeItem;
|
||||
|
||||
Storage.prototype.getItem = function (key: string): string | null {
|
||||
if (this === localStorage) {
|
||||
if (key === 'user') return memoryUser;
|
||||
}
|
||||
return _origGetItem.call(this, key);
|
||||
};
|
||||
|
||||
Storage.prototype.setItem = function (key: string, value: string): void {
|
||||
if (this === localStorage) {
|
||||
if (key === 'user') { memoryUser = value; return; }
|
||||
}
|
||||
_origSetItem.call(this, key, value);
|
||||
};
|
||||
|
||||
Storage.prototype.removeItem = function (key: string): void {
|
||||
if (this === localStorage) {
|
||||
if (key === 'user') { memoryUser = null; return; }
|
||||
}
|
||||
_origRemoveItem.call(this, key);
|
||||
};
|
||||
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
type ClosestPos = {
|
||||
id: string;
|
||||
customerName: string;
|
||||
address: string;
|
||||
distanceKm: number;
|
||||
};
|
||||
|
||||
const formatDistance = (distanceKm: number): string => {
|
||||
const distanceMeters = Math.round(distanceKm * 1000);
|
||||
if (distanceMeters < 1000) return `${distanceMeters} m away`;
|
||||
return `${distanceKm.toFixed(2)} km away`;
|
||||
};
|
||||
|
||||
const openAddressInMaps = (address: string, preferredApp?: 'google' | 'apple') => {
|
||||
const encodedAddress = encodeURIComponent(address);
|
||||
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||
|
||||
if (isIOS) {
|
||||
if (preferredApp === 'google') {
|
||||
window.location.href = `comgooglemaps://?q=${encodedAddress}`;
|
||||
} else {
|
||||
window.location.href = `https://maps.apple.com/?q=${encodedAddress}`;
|
||||
}
|
||||
} else if (isAndroid) {
|
||||
window.location.href = `geo:0,0?q=${encodedAddress}`;
|
||||
} else {
|
||||
window.open(`https://www.google.com/maps/search/?api=1&query=${encodedAddress}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
export default function ClosestPos() {
|
||||
const [items, setItems] = useState<ClosestPos[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const fetchClosestPos = useCallback(async () => {
|
||||
if (!navigator.geolocation) {
|
||||
setError('Geolocation is not supported by this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setItems([]);
|
||||
|
||||
navigator.geolocation.getCurrentPosition(async ({ coords }) => {
|
||||
try {
|
||||
const response = await axios.get<ClosestPos[]>(
|
||||
`${apiBase}/api/public/pos/closest`,
|
||||
{ params: { lat: coords.latitude, lng: coords.longitude } }
|
||||
);
|
||||
setItems(response.data);
|
||||
} catch {
|
||||
setError('Failed to load closest points of sale.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, () => {
|
||||
setError('Location access is required to find closest points of sale.');
|
||||
setLoading(false);
|
||||
}, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000
|
||||
});
|
||||
}, [apiBase]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClosestPos();
|
||||
}, [fetchClosestPos]);
|
||||
|
||||
return (
|
||||
<div className="container py-4 py-md-5 animate-fade-in d-flex flex-column" style={{ minHeight: '100vh' }}>
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 className="fw-bold m-0">Closest Points of Sale</h2>
|
||||
<p className="text-secondary m-0">Using your current GPS location</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-outline-light" onClick={fetchClosestPos} disabled={loading}>
|
||||
{loading ? 'Locating...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" style={{ backgroundColor: 'rgba(239, 68, 68, 0.2)', border: '1px solid var(--danger-color)', color: '#fff' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex flex-column gap-3">
|
||||
{items.map((pos) => (
|
||||
<div key={pos.id} className="w-100">
|
||||
<div className="glass-card p-4 d-flex flex-column" style={{ minHeight: '140px' }}>
|
||||
<strong className="text-white fs-5">{pos.customerName}</strong>
|
||||
<div className="text-white-50 small mb-2">{pos.address}</div>
|
||||
<div className="text-secondary small mt-2">{formatDistance(pos.distanceKm)}</div>
|
||||
<div className="d-flex justify-content-end gap-2 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-light btn-sm"
|
||||
aria-label={`Open ${pos.address} in Google Maps`}
|
||||
onClick={() => openAddressInMaps(pos.address, 'google')}
|
||||
>
|
||||
Open in Google Maps
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-light btn-sm"
|
||||
aria-label={`Open ${pos.address} in Apple Maps`}
|
||||
onClick={() => openAddressInMaps(pos.address, 'apple')}
|
||||
>
|
||||
Open in Apple Maps
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<div className="text-secondary mt-4">No points of sale with coordinates were found.</div>
|
||||
)}
|
||||
|
||||
<footer className="text-center text-secondary small mt-auto pt-4">© 2026 Polpa Gestão</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,25 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '../context/toast';
|
||||
import type { Customer, CustomerPOS } from '../types';
|
||||
|
||||
const POS_INDUSTRY_OPTIONS = [
|
||||
'Academia',
|
||||
'Conveniência',
|
||||
'Hamburgueria',
|
||||
'Lanchonete',
|
||||
'Mercado',
|
||||
'Panificadora',
|
||||
'Restaurante',
|
||||
'Sorveteria',
|
||||
'Verdureira/Frutaria',
|
||||
'Parque aquático',
|
||||
'Recanto',
|
||||
'Pesque e pague',
|
||||
'Arena de esporte'
|
||||
] as const;
|
||||
|
||||
const toErrorMessage = (err: unknown, fallback: string): string => {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const data = err.response?.data as { message?: string; error?: string } | undefined;
|
||||
@@ -16,8 +32,8 @@ 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: '', personName: '' });
|
||||
const emptyPos: CustomerPOS = { address: '', phone: '', personName: '' };
|
||||
const [newCustomer, setNewCustomer] = useState<Customer>({ name: '', document: '', phone: '', personName: '', notes: '' });
|
||||
const emptyPos: CustomerPOS = { address: '', phone: '', industry: '', personName: '', fridgeCount: undefined, banner: false, indiBanner: false, lat: null, lng: null, region: null };
|
||||
const [editingCustomer, setEditingCustomer] = useState<string | null>(null);
|
||||
const [showPosModal, setShowPosModal] = useState(false);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
@@ -25,28 +41,46 @@ export default function Customers() {
|
||||
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 docAbortRef = useRef<AbortController | null>(null);
|
||||
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 || '/api';
|
||||
const cpfCnpjApiToken = import.meta.env.VITE_CPF_CNPJ_API_TOKEN || '';
|
||||
|
||||
|
||||
const fetchCustomers = useCallback(async () => {
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const res = await axios.get(`${apiBase}/api/customers?showDisabled=${showDisabled}`, config);
|
||||
const res = await axios.get(`${apiBase}/api/customers?showDisabled=${showDisabled}`);
|
||||
setCustomers(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load customers', err);
|
||||
}
|
||||
}, [token, apiBase, showDisabled]);
|
||||
}, [apiBase, showDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers();
|
||||
}, [fetchCustomers]);
|
||||
|
||||
const addressFilterMatches = (pos: CustomerPOS[] | undefined, text: string): boolean => {
|
||||
if (pos && Array.isArray(pos)) {
|
||||
for (let i = 0; i < pos.length; i++) {
|
||||
const normalizedAddress = pos[i].address
|
||||
.replace(/á/g, 'a')
|
||||
.replace(/ã/g, 'a')
|
||||
.replace(/â/g, 'a')
|
||||
.replace(/ê/g, 'e')
|
||||
.replace(/é/g, 'e')
|
||||
.replace(/ó/g, 'o')
|
||||
.replace(/ú/g, 'u')
|
||||
.replace(/ç/g, 'c')
|
||||
.toLowerCase();
|
||||
return normalizedAddress.includes(text.toLowerCase());
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
if (!filterText.trim()) return customers;
|
||||
const lower = filterText.toLowerCase();
|
||||
@@ -54,7 +88,8 @@ export default function Customers() {
|
||||
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))
|
||||
(digits && c.phone && c.phone.replace(/\D/g, '').includes(digits)) ||
|
||||
addressFilterMatches(c.pos, filterText)
|
||||
);
|
||||
}, [customers, filterText]);
|
||||
|
||||
@@ -62,44 +97,53 @@ export default function Customers() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
if (editingCustomer) {
|
||||
await axios.put(`${apiBase}/api/customers/${editingCustomer}`, newCustomer, config);
|
||||
await axios.put(`${apiBase}/api/customers/${editingCustomer}`, newCustomer);
|
||||
} else {
|
||||
await axios.post(`${apiBase}/api/customers`, newCustomer, config);
|
||||
await axios.post(`${apiBase}/api/customers`, newCustomer);
|
||||
}
|
||||
setShowModal(false);
|
||||
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
|
||||
setNewCustomer({ name: '', document: '', phone: '', personName: '', notes: '' });
|
||||
setEditingCustomer(null);
|
||||
fetchCustomers();
|
||||
toast.showToast(editingCustomer ? 'Customer updated successfully.' : 'Customer created successfully.', 'success');
|
||||
} catch (err) {
|
||||
toast.showToast(`Failed to save customer. Document (CNPJ/CPF) may already exist: ${toErrorMessage(err, 'Unknown error')}`, 'error');
|
||||
toast.showToast(`Failed to save customer: ${toErrorMessage(err, 'Unknown error')}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateDocument = async (doc: string) => {
|
||||
if (!doc || doc.length < 11) {
|
||||
if (!doc || (doc.length !== 11 && doc.length !== 14)) {
|
||||
setDocValidation({ valid: null, loading: false });
|
||||
return;
|
||||
}
|
||||
docAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
docAbortRef.current = controller;
|
||||
setDocValidation({ valid: null, loading: true });
|
||||
try {
|
||||
const res = await axios.get(`https://api.invertexto.com/v1/validator?token=${cpfCnpjApiToken}&value=${doc}`);
|
||||
setNewCustomer(prev => ({ ...prev, document: res.data.formatted }));
|
||||
const res = await axios.get(
|
||||
`${apiBase}/api/proxy/validator?value=${doc}`,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
if (res.data.valid) {
|
||||
setNewCustomer(prev => ({ ...prev, document: res.data.formatted }));
|
||||
}
|
||||
setDocValidation({ valid: res.data.valid, loading: false });
|
||||
} catch (err) {
|
||||
if (axios.isCancel(err)) return;
|
||||
setDocValidation({ valid: false, loading: false });
|
||||
console.error('Document validation failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDocumentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawVal = e.target.value.replace(/\D/g, '');
|
||||
const rawVal = e.target.value.replace(/\D/g, '').slice(0, 14);
|
||||
setNewCustomer({ ...newCustomer, document: rawVal });
|
||||
if (rawVal.length >= 11) {
|
||||
if (rawVal.length === 11 || rawVal.length === 14) {
|
||||
validateDocument(rawVal);
|
||||
} else {
|
||||
setDocValidation({ valid: null, loading: false });
|
||||
@@ -109,21 +153,29 @@ export default function Customers() {
|
||||
const formatPhone = (val: string) => {
|
||||
let r = val.replace(/\D/g, '');
|
||||
r = r.replace(/^0/, '');
|
||||
if (!r) return '';
|
||||
if (r.length > 10) {
|
||||
r = r.replace(/^(\d\d)(\d{5})(\d{4}).*/, '($1) $2-$3');
|
||||
const ddd = r.slice(0, 2);
|
||||
const part1 = r.slice(2, 7);
|
||||
const part2 = r.slice(7, 11);
|
||||
return `(${ddd}) ${part1}-${part2}`;
|
||||
} else if (r.length > 5) {
|
||||
r = r.replace(/^(\d\d)(\d{4})(\d{0,4}).*/, '($1) $2-$3');
|
||||
const ddd = r.slice(0, 2);
|
||||
const part1 = r.slice(2, 6);
|
||||
const part2 = r.slice(6);
|
||||
return `(${ddd}) ${part1}` + (part2 ? `-${part2}` : '');
|
||||
} else if (r.length > 2) {
|
||||
r = r.replace(/^(\d\d)(\d{0,5})/, '($1) $2');
|
||||
const ddd = r.slice(0, 2);
|
||||
const part = r.slice(2);
|
||||
return `(${ddd}) ${part}`;
|
||||
} else {
|
||||
r = r.replace(/^(\d*)/, '($1');
|
||||
return `(${r}`;
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
const openNewModal = () => {
|
||||
setEditingCustomer(null);
|
||||
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
|
||||
setNewCustomer({ name: '', document: '', phone: '', personName: '', notes: '' });
|
||||
setDocValidation({ valid: null, loading: false });
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -139,7 +191,7 @@ export default function Customers() {
|
||||
return;
|
||||
}
|
||||
setEditingCustomer(c.id);
|
||||
setNewCustomer({ name: c.name, document: c.document || '', phone: c.phone || '', personName: c.personName || '' });
|
||||
setNewCustomer({ name: c.name, document: c.document || '', phone: c.phone || '', personName: c.personName || '', notes: c.notes || '' });
|
||||
setDocValidation({ valid: null, loading: false });
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -163,7 +215,18 @@ export default function Customers() {
|
||||
return;
|
||||
}
|
||||
setEditingPos(p.id);
|
||||
setNewPos({ address: p.address, phone: p.phone, personName: p.personName || '' });
|
||||
setNewPos({
|
||||
address: p.address,
|
||||
phone: p.phone,
|
||||
industry: p.industry || '',
|
||||
personName: p.personName || '',
|
||||
fridgeCount: p.fridgeCount ?? undefined,
|
||||
banner: p.banner ?? false,
|
||||
indiBanner: p.indiBanner ?? false,
|
||||
lat: p.lat ?? null,
|
||||
lng: p.lng ?? null,
|
||||
region: p.region || null
|
||||
});
|
||||
};
|
||||
|
||||
const cancelEditPos = () => {
|
||||
@@ -176,19 +239,18 @@ export default function Customers() {
|
||||
if (!selectedCustomer) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
if (editingPos) {
|
||||
await axios.put(`${apiBase}/api/customers/pos/${editingPos}`, newPos, config);
|
||||
await axios.put(`${apiBase}/api/customers/pos/${editingPos}`, newPos);
|
||||
toast.showToast('Point of sale updated successfully.', 'success');
|
||||
} else {
|
||||
await axios.post(`${apiBase}/api/customers/${selectedCustomer.id}/pos`, newPos, config);
|
||||
await axios.post(`${apiBase}/api/customers/${selectedCustomer.id}/pos`, newPos);
|
||||
toast.showToast('Point of sale added successfully.', 'success');
|
||||
}
|
||||
setNewPos(emptyPos);
|
||||
setEditingPos(null);
|
||||
fetchCustomers();
|
||||
|
||||
const res = await axios.get(`${apiBase}/api/customers/${selectedCustomer.id}`, config);
|
||||
const res = await axios.get(`${apiBase}/api/customers/${selectedCustomer.id}`);
|
||||
setSelectedCustomer(res.data);
|
||||
} catch (err) {
|
||||
toast.showToast(`Failed to save POS: ${toErrorMessage(err, 'Unknown error')}`, 'error');
|
||||
@@ -197,11 +259,37 @@ export default function Customers() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeocodePos = async () => {
|
||||
if (!newPos.address?.trim()) {
|
||||
toast.showToast('Enter an address before geocoding.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`${apiBase}/api/proxy/geocode`, { params: { address: newPos.address.trim() } });
|
||||
|
||||
const location = response.data?.results?.[0]?.geometry?.location;
|
||||
if (response.data?.status !== 'OK' || typeof location?.lat !== 'number' || typeof location?.lng !== 'number') {
|
||||
toast.showToast('Address not found for geocoding.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setNewPos((prev) => ({ ...prev, lat: location.lat, lng: location.lng }));
|
||||
toast.showToast('Geocode set successfully.', 'success');
|
||||
|
||||
} catch (err) {
|
||||
toast.showToast(`Failed to geocode address: ${toErrorMessage(err, 'Unknown error')}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePos = async (posId: string | undefined) => {
|
||||
if (!posId) return;
|
||||
const confirmed = await toast.confirm({
|
||||
title: 'Delete POS',
|
||||
message: 'Are you sure you want to delete this POS?',
|
||||
message: 'Are you sure you want to permanently delete this POS? All related sales history and route assignments will be permanently deleted.',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
isDangerous: true
|
||||
@@ -209,12 +297,11 @@ export default function Customers() {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
await axios.delete(`${apiBase}/api/customers/pos/${posId}`, config);
|
||||
await axios.delete(`${apiBase}/api/customer-pos/${posId}`);
|
||||
fetchCustomers();
|
||||
|
||||
if (selectedCustomer && selectedCustomer.id) {
|
||||
const res = await axios.get(`${apiBase}/api/customers/${selectedCustomer.id}`, config);
|
||||
const res = await axios.get(`${apiBase}/api/customers/${selectedCustomer.id}`);
|
||||
setSelectedCustomer(res.data);
|
||||
}
|
||||
toast.showToast('Point of sale deleted successfully.', 'success');
|
||||
@@ -236,10 +323,9 @@ export default function Customers() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
await axios.delete(`${apiBase}/api/customers/${editingCustomer}`, config);
|
||||
await axios.patch(`${apiBase}/api/customers/${editingCustomer}/disable`, {});
|
||||
setShowModal(false);
|
||||
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
|
||||
setNewCustomer({ name: '', document: '', phone: '', personName: '', notes: '' });
|
||||
setEditingCustomer(null);
|
||||
fetchCustomers();
|
||||
toast.showToast('Customer disabled successfully.', 'success');
|
||||
@@ -250,6 +336,32 @@ export default function Customers() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCustomer = async () => {
|
||||
if (!editingCustomer) return;
|
||||
const confirmed = await toast.confirm({
|
||||
title: 'Delete Customer',
|
||||
message: 'Are you sure you want to permanently delete this customer? All associated POSes, sales history, and route assignments will be permanently deleted.',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
isDangerous: true
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await axios.delete(`${apiBase}/api/customers/${editingCustomer}`);
|
||||
setShowModal(false);
|
||||
setNewCustomer({ name: '', document: '', phone: '', personName: '', notes: '' });
|
||||
setEditingCustomer(null);
|
||||
fetchCustomers();
|
||||
toast.showToast('Customer deleted successfully.', 'success');
|
||||
} catch (err) {
|
||||
toast.showToast(`Failed to delete customer: ${toErrorMessage(err, 'Unknown error')}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDocument = (doc: string) => {
|
||||
const cleaned = doc.replace(/\D/g, '');
|
||||
if (cleaned.length === 11) {
|
||||
@@ -308,6 +420,11 @@ export default function Customers() {
|
||||
<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>
|
||||
{c.notes?.trim() && (
|
||||
<div className="text-secondary small mt-1">
|
||||
<i className="bi bi-journal-text me-1"></i>{c.notes.trim().slice(0, 80)}{c.notes.trim().length > 80 ? '...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex flex-column gap-1 text-secondary small mb-3">
|
||||
<div>
|
||||
@@ -368,7 +485,7 @@ export default function Customers() {
|
||||
<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..." />
|
||||
<input type="text" className="form-control" value={newCustomer.document || ''} onChange={handleDocumentChange} placeholder="Type numbers only..." maxLength={18} />
|
||||
{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>}
|
||||
@@ -378,11 +495,24 @@ export default function Customers() {
|
||||
<label className="form-label text-secondary">Phone (Optional)</label>
|
||||
<input type="text" className="form-control" value={newCustomer.phone} onChange={e => setNewCustomer({ ...newCustomer, phone: formatPhone(e.target.value) })} placeholder="(11) 99999-9999" maxLength={15} />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-secondary">Notes (Optional)</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
value={newCustomer.notes || ''}
|
||||
onChange={e => setNewCustomer({ ...newCustomer, notes: e.target.value })}
|
||||
placeholder="Add notes/observations about this customer..."
|
||||
/>
|
||||
</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>
|
||||
{editingCustomer && (
|
||||
<button type="button" className="btn btn-outline-danger" onClick={handleDisableCustomer}>Disable</button>
|
||||
<button type="button" className="btn btn-outline-warning" onClick={handleDisableCustomer}>Disable</button>
|
||||
)}
|
||||
{editingCustomer && (
|
||||
<button type="button" className="btn btn-outline-danger" onClick={handleDeleteCustomer}>Delete</button>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Saving...' : 'Save Customer'}</button>
|
||||
</div>
|
||||
@@ -427,10 +557,25 @@ export default function Customers() {
|
||||
</button>
|
||||
) : 'N/A'}
|
||||
</div>
|
||||
<div className="text-secondary small">
|
||||
Industry: {p.industry || 'N/A'}
|
||||
</div>
|
||||
<div className="text-secondary small">
|
||||
Region: {p.region || 'N/A'}
|
||||
</div>
|
||||
<div className="text-secondary small">
|
||||
Fridges: {p.fridgeCount ?? 0}
|
||||
</div>
|
||||
<div className="text-secondary small">
|
||||
Banner: {p.banner ? 'Yes' : 'No'} | Indi Banner: {p.indiBanner ? 'Yes' : 'No'}
|
||||
</div>
|
||||
<div className="text-secondary small">
|
||||
Geocode: {p.lat != null && p.lng != null ? 'Set' : 'Missing'}
|
||||
</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>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => handleDeletePos(p.id)}>Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -470,10 +615,108 @@ export default function Customers() {
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-5">
|
||||
<label className="form-label text-secondary small">Industry (Optional)</label>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={newPos.industry || ''}
|
||||
onChange={e =>
|
||||
setNewPos({
|
||||
...newPos,
|
||||
industry: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Select an industry...</option>
|
||||
{POS_INDUSTRY_OPTIONS.map((industryOption) => (
|
||||
<option key={industryOption} value={industryOption}>{industryOption}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-7">
|
||||
<label className="form-label text-secondary small">Region (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={newPos.region || ''}
|
||||
onChange={e =>
|
||||
setNewPos({
|
||||
...newPos,
|
||||
region: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g. North, Downtown..."
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-5">
|
||||
<label className="form-label text-secondary small">Fridges</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className="form-control form-control-sm"
|
||||
value={String(newPos.fridgeCount ?? '')}
|
||||
onChange={e => {
|
||||
const raw = e.target.value;
|
||||
|
||||
if (raw === '') {
|
||||
setNewPos({
|
||||
...newPos,
|
||||
fridgeCount: undefined,
|
||||
})
|
||||
return;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
setNewPos({
|
||||
...newPos,
|
||||
fridgeCount: parsed,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-7">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="pos-banner"
|
||||
checked={newPos.banner}
|
||||
onChange={e =>
|
||||
setNewPos({
|
||||
...newPos,
|
||||
banner: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label className="form-check-label text-secondary small" htmlFor="pos-banner">
|
||||
Banner
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="pos-indi-banner"
|
||||
checked={newPos.indiBanner}
|
||||
onChange={e =>
|
||||
setNewPos({
|
||||
...newPos,
|
||||
indiBanner: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label className="form-check-label text-secondary small" htmlFor="pos-indi-banner">
|
||||
Wind Banner
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-5 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="button" className="btn btn-sm btn-outline-info w-100" onClick={handleGeocodePos} disabled={loading}>Geocode</button>
|
||||
<button type="submit" className="btn btn-sm btn-success w-100" disabled={loading}>{loading ? '...' : editingPos ? 'Save' : 'Add'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import axios from 'axios';
|
||||
import type { SalesByCustomer, SalesByProduct, SalesSummary } from '../types';
|
||||
import type { FridgePosSummary, InactivePosSummary, IndustriesSummary, RegionsSummary, SalesByCustomer, SalesByProduct, SalesSummary } from '../types';
|
||||
|
||||
export default function Dashboard() {
|
||||
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 [industriesSummary, setIndustriesSummary] = useState<IndustriesSummary[]>([]);
|
||||
const [regionsSummary, setRegionsSummary] = useState<RegionsSummary[]>([]);
|
||||
const [fridgePoses, setFridgePoses] = useState<FridgePosSummary[]>([]);
|
||||
const [showFridgesModal, setShowFridgesModal] = useState(false);
|
||||
const [loadingFridgePoses, setLoadingFridgePoses] = useState(false);
|
||||
const [inactivePoses, setInactivePoses] = useState<InactivePosSummary[]>([]);
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const ranges = [
|
||||
@@ -26,23 +32,26 @@ export default function Dashboard() {
|
||||
useEffect(() => {
|
||||
const fetchDashboard = async () => {
|
||||
try {
|
||||
const config = {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: { range }
|
||||
};
|
||||
const config = { 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);
|
||||
const resIndustries = await axios.get(`${apiBase}/api/dashboard/industries-summary`, config);
|
||||
const resRegions = await axios.get(`${apiBase}/api/dashboard/regions-summary`, config);
|
||||
const resInactivePoses = await axios.get(`${apiBase}/api/dashboard/inactive-pos`, config);
|
||||
|
||||
setSalesByProduct(resProducts.data);
|
||||
setSalesByCustomer(resCustomers.data);
|
||||
setSalesSummary(resSummary.data);
|
||||
setIndustriesSummary(resIndustries.data);
|
||||
setRegionsSummary(resRegions.data);
|
||||
setInactivePoses(resInactivePoses.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard', err);
|
||||
}
|
||||
};
|
||||
fetchDashboard();
|
||||
}, [token, apiBase, range]);
|
||||
}, [apiBase, range]);
|
||||
|
||||
const top3Products = [...salesByProduct]
|
||||
.sort((a, b) => b.totalQuantity - a.totalQuantity)
|
||||
@@ -52,6 +61,22 @@ export default function Dashboard() {
|
||||
.sort((a, b) => b.totalAmount - a.totalAmount)
|
||||
.slice(0, 3);
|
||||
|
||||
const { totalCustomers, totalFridges } = salesSummary ?? { totalCustomers: 0, totalFridges: 0 };
|
||||
|
||||
const openFridgesModal = async () => {
|
||||
setShowFridgesModal(true);
|
||||
setLoadingFridgePoses(true);
|
||||
try {
|
||||
const response = await axios.get(`${apiBase}/api/dashboard/fridges`);
|
||||
setFridgePoses(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load fridge POS details', err);
|
||||
setFridgePoses([]);
|
||||
} finally {
|
||||
setLoadingFridgePoses(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
@@ -96,6 +121,36 @@ export default function Dashboard() {
|
||||
</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"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={openFridgesModal}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openFridgesModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
@@ -135,6 +190,44 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* POS by Industry */}
|
||||
<div className="col-12 col-sm-6 col-lg-6">
|
||||
<div className="glass-card p-4 h-100">
|
||||
<h6 className="text-secondary mb-2">POS by Industry</h6>
|
||||
{industriesSummary.length === 0 ? (
|
||||
<p className="text-secondary mb-0 small">No data available.</p>
|
||||
) : (
|
||||
<ul className="list-group list-group-flush" style={{ background: 'transparent' }}>
|
||||
{industriesSummary.map((item) => (
|
||||
<li key={item.industry} className="list-group-item d-flex justify-content-between align-items-center text-white px-0" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
|
||||
<span>{item.industry}</span>
|
||||
<span className="badge bg-secondary rounded-pill">{item.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* POS by Region */}
|
||||
<div className="col-12 col-sm-6 col-lg-6">
|
||||
<div className="glass-card p-4 h-100">
|
||||
<h6 className="text-secondary mb-2">POS by Region</h6>
|
||||
{regionsSummary.length === 0 ? (
|
||||
<p className="text-secondary mb-0 small">No data available.</p>
|
||||
) : (
|
||||
<ul className="list-group list-group-flush" style={{ background: 'transparent' }}>
|
||||
{regionsSummary.map((item) => (
|
||||
<li key={item.region} className="list-group-item d-flex justify-content-between align-items-center text-white px-0" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
|
||||
<span>{item.region}</span>
|
||||
<span className="badge bg-secondary rounded-pill">{item.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-4">
|
||||
@@ -178,6 +271,98 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inactive Customer POSes */}
|
||||
<div className="row g-4 mt-0">
|
||||
<div className="col-12">
|
||||
<div className="glass-card p-4">
|
||||
<h4 className="mb-3">Inactive Customer POSes (10+ days)</h4>
|
||||
{inactivePoses.length === 0 ? (
|
||||
<p className="text-secondary mb-0">No inactive POSes found.</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-dark table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>POS Address</th>
|
||||
<th>Last Buying Date</th>
|
||||
<th className="text-end">Days Inactive</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inactivePoses.map((item: InactivePosSummary) => (
|
||||
<tr key={item.posId}>
|
||||
<td>{item.customerName}</td>
|
||||
<td>{item.posAddress}</td>
|
||||
<td>{item.lastBuyingDate ? new Date(item.lastBuyingDate).toLocaleDateString() : '—'}</td>
|
||||
<td className="text-end">
|
||||
<span className="badge bg-danger rounded-pill">{item.daysInactive}d</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFridgesModal && createPortal(
|
||||
<>
|
||||
<div className="modal-backdrop fade show" style={{ zIndex: 1040 }}></div>
|
||||
<div
|
||||
className="modal fade show d-block"
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="fridges-modal-title"
|
||||
style={{ zIndex: 1050 }}
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div className="modal-content glass-card">
|
||||
<div className="modal-header border-bottom-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<h5 id="fridges-modal-title" className="modal-title text-white">POS with Fridges</h5>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={() => setShowFridgesModal(false)}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{loadingFridgePoses ? (
|
||||
<p className="text-secondary mb-0">Loading...</p>
|
||||
) : fridgePoses.length === 0 ? (
|
||||
<p className="text-secondary mb-0">No active POS with fridges found.</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-dark table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Address</th>
|
||||
<th className="text-end">Fridge Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fridgePoses.map((pos) => (
|
||||
<tr key={pos.id}>
|
||||
<td>{pos.customer.name}</td>
|
||||
<td>{pos.address}</td>
|
||||
<td className="text-end">{pos.fridgeCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer border-top-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<button type="button" className="btn btn-outline-light" onClick={() => setShowFridgesModal(false)}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,10 +25,9 @@ export default function Login({ setToken }: LoginProps) {
|
||||
password
|
||||
});
|
||||
|
||||
const { token, user } = response.data;
|
||||
localStorage.setItem('token', token);
|
||||
const { user } = response.data;
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
setToken(token);
|
||||
setToken(user.id);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
|
||||
@@ -12,18 +12,16 @@ export default function Products() {
|
||||
const [editingProduct, setEditingProduct] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDisabled, setShowDisabled] = useState<boolean>(false);
|
||||
const token = localStorage.getItem('token');
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const fetchProducts = useCallback(async () => {
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const res = await axios.get(`${apiBase}/api/products?showDisabled=${showDisabled}`, config);
|
||||
const res = await axios.get(`${apiBase}/api/products?showDisabled=${showDisabled}`);
|
||||
setProducts(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load products', err);
|
||||
}
|
||||
}, [token, apiBase, showDisabled]);
|
||||
}, [apiBase, showDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
@@ -33,11 +31,10 @@ export default function Products() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
if (editingProduct) {
|
||||
await axios.put(`${apiBase}/api/products/${editingProduct}`, newProduct, config);
|
||||
await axios.put(`${apiBase}/api/products/${editingProduct}`, newProduct);
|
||||
} else {
|
||||
await axios.post(`${apiBase}/api/products`, newProduct, config);
|
||||
await axios.post(`${apiBase}/api/products`, newProduct);
|
||||
}
|
||||
setShowModal(false);
|
||||
setNewProduct({ id: '', name: '', price: 0, stock: 0, cost: 0 });
|
||||
@@ -86,9 +83,8 @@ export default function Products() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const disabledProduct: Product = { ...newProduct, disabledAt: new Date().toISOString() };
|
||||
await axios.put(`${apiBase}/api/products/${editingProduct}`, disabledProduct, config);
|
||||
await axios.put(`${apiBase}/api/products/${editingProduct}`, disabledProduct);
|
||||
setShowModal(false);
|
||||
setNewProduct({ id: '', name: '', price: 0, stock: 0, cost: 0 });
|
||||
setEditingProduct(null);
|
||||
|
||||
@@ -58,28 +58,25 @@ export default function RoutesPage() {
|
||||
const [newRoute, setNewRoute] = useState<Route>({ name: '', completed: false, dayOfWeek: 0, customerPos: [] });
|
||||
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 || '/api';
|
||||
|
||||
const fetchRoutes = useCallback(async () => {
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const res = await axios.get<RouteApiResponse[]>(`${apiBase}/api/routes`, config);
|
||||
const res = await axios.get<RouteApiResponse[]>(`${apiBase}/api/routes`);
|
||||
setRoutesData(res.data.map(normalizeRoute));
|
||||
} catch (err) {
|
||||
console.error('Failed to load routes', err);
|
||||
}
|
||||
}, [token, apiBase]);
|
||||
}, [apiBase]);
|
||||
|
||||
const fetchCustomers = useCallback(async () => {
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const res = await axios.get<Customer[]>(`${apiBase}/api/customers`, config);
|
||||
const res = await axios.get<Customer[]>(`${apiBase}/api/customers`);
|
||||
setCustomers(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load customers', err);
|
||||
}
|
||||
}, [token, apiBase]);
|
||||
}, [apiBase]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoutes();
|
||||
@@ -90,14 +87,13 @@ export default function RoutesPage() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
if (editingRoute) {
|
||||
const updatePayload = {
|
||||
name: newRoute.name,
|
||||
dayOfWeek: newRoute.dayOfWeek,
|
||||
completed: newRoute.completed,
|
||||
};
|
||||
await axios.put(`${apiBase}/api/routes/${editingRoute}`, updatePayload, config);
|
||||
await axios.put(`${apiBase}/api/routes/${editingRoute}`, updatePayload);
|
||||
} else {
|
||||
const createPayload = {
|
||||
name: newRoute.name,
|
||||
@@ -106,7 +102,7 @@ export default function RoutesPage() {
|
||||
.map((cp: CustomerPOS) => cp.id)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
};
|
||||
await axios.post(`${apiBase}/api/routes`, createPayload, config);
|
||||
await axios.post(`${apiBase}/api/routes`, createPayload);
|
||||
}
|
||||
setShowModal(false);
|
||||
setNewRoute({ name: '', completed: false, dayOfWeek: 0, customerPos: [] });
|
||||
@@ -122,8 +118,7 @@ export default function RoutesPage() {
|
||||
|
||||
const toggleRouteCompleted = async (route: Route) => {
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
await axios.put(`${apiBase}/api/routes/${route.id}`, { completed: !route.completed }, config);
|
||||
await axios.put(`${apiBase}/api/routes/${route.id}`, { completed: !route.completed });
|
||||
fetchRoutes();
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
@@ -162,7 +157,6 @@ export default function RoutesPage() {
|
||||
if (!selectedRoute || !selectedPosIdToAdd) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const existingPosIds = (selectedRoute.customerPos || [])
|
||||
.map((cp: CustomerPOS) => cp.id)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
@@ -171,13 +165,13 @@ export default function RoutesPage() {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const newPosIds = [...existingPosIds, selectedPosIdToAdd];
|
||||
await axios.put(`${apiBase}/api/routes/${selectedRoute.id}`, { customerPosIds: newPosIds }, config);
|
||||
|
||||
await axios.put(`${apiBase}/api/routes/${selectedRoute.id}`, { customerPosIds: newPosIds });
|
||||
|
||||
setSelectedPosIdToAdd('');
|
||||
fetchRoutes();
|
||||
const res = await axios.get<RouteApiResponse>(`${apiBase}/api/routes/${selectedRoute.id}`, config);
|
||||
const res = await axios.get<RouteApiResponse>(`${apiBase}/api/routes/${selectedRoute.id}`);
|
||||
setSelectedRoute(normalizeRoute(res.data));
|
||||
toast.showToast('Stop added successfully', 'success');
|
||||
} catch (err) {
|
||||
@@ -200,7 +194,6 @@ export default function RoutesPage() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
if (!selectedRoute.customerPos || selectedRoute.customerPos.length === 0) {
|
||||
toast.showToast('No stops to remove.', 'warning');
|
||||
setLoading(false);
|
||||
@@ -216,11 +209,11 @@ export default function RoutesPage() {
|
||||
return;
|
||||
}
|
||||
const newPosIds: string[] = existingPosIds.filter((id: string) => id !== posIdToRemove);
|
||||
|
||||
await axios.put(`${apiBase}/api/routes/${selectedRoute.id}`, { customerPosIds: newPosIds }, config);
|
||||
|
||||
|
||||
await axios.put(`${apiBase}/api/routes/${selectedRoute.id}`, { customerPosIds: newPosIds });
|
||||
|
||||
fetchRoutes();
|
||||
const res = await axios.get<RouteApiResponse>(`${apiBase}/api/routes/${selectedRoute.id}`, config);
|
||||
const res = await axios.get<RouteApiResponse>(`${apiBase}/api/routes/${selectedRoute.id}`);
|
||||
setSelectedRoute(normalizeRoute(res.data));
|
||||
toast.showToast('Stop removed successfully', 'success');
|
||||
} catch (err) {
|
||||
@@ -243,8 +236,7 @@ export default function RoutesPage() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
await axios.delete(`${apiBase}/api/routes/${editingRoute}`, config);
|
||||
await axios.delete(`${apiBase}/api/routes/${editingRoute}`);
|
||||
setShowModal(false);
|
||||
setNewRoute({ name: '', completed: false, dayOfWeek: 0, customerPos: [] });
|
||||
setEditingRoute(null);
|
||||
|
||||
@@ -89,6 +89,7 @@ export default function Sales() {
|
||||
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>('');
|
||||
@@ -96,15 +97,12 @@ export default function Sales() {
|
||||
const [editingMode, setEditingMode] = useState(false);
|
||||
const [editingSaleId, setEditingSaleId] = useState<string | null>(null);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const [salesRes, custRes, prodRes] = await Promise.all([
|
||||
axios.get(`${apiBase}/api/sales?showDelivered=${showDelivered}`, config),
|
||||
axios.get(`${apiBase}/api/customers`, config),
|
||||
axios.get(`${apiBase}/api/products`, config)
|
||||
axios.get(`${apiBase}/api/sales?showDelivered=${showDelivered}`),
|
||||
axios.get(`${apiBase}/api/customers`),
|
||||
axios.get(`${apiBase}/api/products`)
|
||||
]);
|
||||
setSales(salesRes.data);
|
||||
setCustomers(custRes.data);
|
||||
@@ -112,7 +110,7 @@ export default function Sales() {
|
||||
} catch (err) {
|
||||
console.error('Failed to load sales data', err);
|
||||
}
|
||||
}, [token, apiBase, showDelivered]);
|
||||
}, [apiBase, showDelivered]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -138,6 +136,7 @@ export default function Sales() {
|
||||
setPaymentDueDate('');
|
||||
setPaymentDate('');
|
||||
setComments('');
|
||||
setNextVisitDate('');
|
||||
setCart([]);
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -167,6 +166,12 @@ export default function Sales() {
|
||||
}
|
||||
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,
|
||||
@@ -212,23 +217,23 @@ export default function Sales() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const payload = {
|
||||
customerPosId,
|
||||
paymentMethod,
|
||||
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 }))
|
||||
};
|
||||
|
||||
if (editingMode && editingSaleId) {
|
||||
await axios.put(`${apiBase}/api/sales/${editingSaleId}`, payload, config);
|
||||
await axios.put(`${apiBase}/api/sales/${editingSaleId}`, payload);
|
||||
handleCloseModal();
|
||||
fetchData();
|
||||
toast.showToast('Sale updated successfully.', 'success');
|
||||
} else {
|
||||
await axios.post(`${apiBase}/api/sales`, payload, config);
|
||||
await axios.post(`${apiBase}/api/sales`, payload);
|
||||
handleCloseModal();
|
||||
fetchData();
|
||||
toast.showToast('Sale recorded successfully.', 'success');
|
||||
@@ -285,7 +290,6 @@ 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,
|
||||
@@ -297,7 +301,7 @@ export default function Sales() {
|
||||
quantity: sp.quantity,
|
||||
})),
|
||||
};
|
||||
await axios.post(`${apiBase}/api/sales`, payload, config);
|
||||
await axios.post(`${apiBase}/api/sales`, payload);
|
||||
fetchData();
|
||||
toast.showToast('Sale cloned successfully.', 'success');
|
||||
} catch (err) {
|
||||
@@ -327,8 +331,7 @@ export default function Sales() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
await axios.delete(`${apiBase}/api/sales/${sale.id}`, config);
|
||||
await axios.delete(`${apiBase}/api/sales/${sale.id}`);
|
||||
setShowDetailsModal(false);
|
||||
fetchData();
|
||||
toast.showToast('Sale deleted successfully.', 'success');
|
||||
@@ -356,9 +359,8 @@ export default function Sales() {
|
||||
setTogglingSaleId(sale.id);
|
||||
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const nextPaymentDate = sale.paymentDate ? null : new Date().toISOString();
|
||||
const response = await axios.put(`${apiBase}/api/sales/${sale.id}`, { paymentDate: nextPaymentDate }, config);
|
||||
const response = await axios.put(`${apiBase}/api/sales/${sale.id}`, { paymentDate: nextPaymentDate });
|
||||
const updatedSale = response.data as Sale;
|
||||
|
||||
setSales((currentSales) => currentSales.map((currentSale) => (
|
||||
@@ -395,8 +397,7 @@ export default function Sales() {
|
||||
setTogglingDeliverySaleId(sale.id);
|
||||
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const response = await axios.put(`${apiBase}/api/sales/${sale.id}`, { delivered: !sale.delivered }, config);
|
||||
const response = await axios.put(`${apiBase}/api/sales/${sale.id}`, { delivered: !sale.delivered });
|
||||
const updatedSale = response.data as Sale;
|
||||
|
||||
setSales((currentSales) => currentSales.map((currentSale) => (
|
||||
@@ -620,6 +621,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">
|
||||
@@ -655,7 +660,26 @@ export default function Sales() {
|
||||
<div className="d-flex gap-2 align-items-end">
|
||||
<div style={{ width: '90px' }}>
|
||||
<label className="form-label text-secondary small mb-1">Qty</label>
|
||||
<input type="number" className="form-control form-control-sm" min="1" value={item.quantity} onChange={(e) => handleUpdateCartItem(idx, 'quantity', parseInt(e.target.value) || 1)} required />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className="form-control form-control-sm"
|
||||
value={String(item.quantity)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
|
||||
if (raw === '') {
|
||||
handleUpdateCartItem(idx, 'quantity', '' as unknown as number);
|
||||
return;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
handleUpdateCartItem(idx, 'quantity', parsed);
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="text-end ms-auto">
|
||||
<div className="text-secondary small">Unit: R$ {item.price.toFixed(2)}</div>
|
||||
|
||||
@@ -12,18 +12,16 @@ export default function Users() {
|
||||
const [editingUser, setEditingUser] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDisabled, setShowDisabled] = useState<boolean>(false);
|
||||
const token = localStorage.getItem('token');
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const res = await axios.get(`${apiBase}/api/users?showDisabled=${showDisabled}`, config);
|
||||
const res = await axios.get(`${apiBase}/api/users?showDisabled=${showDisabled}`);
|
||||
setUsers(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load users', err);
|
||||
}
|
||||
}, [token, apiBase, showDisabled]);
|
||||
}, [apiBase, showDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
@@ -33,16 +31,15 @@ export default function Users() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const payload: User = { ...newUser };
|
||||
if (editingUser && !payload.password) {
|
||||
delete payload.password;
|
||||
}
|
||||
|
||||
if (editingUser) {
|
||||
await axios.put(`${apiBase}/api/users/${editingUser}`, payload, config);
|
||||
await axios.put(`${apiBase}/api/users/${editingUser}`, payload);
|
||||
} else {
|
||||
await axios.post(`${apiBase}/api/users`, payload, config);
|
||||
await axios.post(`${apiBase}/api/users`, payload);
|
||||
}
|
||||
setShowModal(false);
|
||||
setNewUser({ name: '', email: '', password: '', role: 'user' });
|
||||
@@ -91,8 +88,7 @@ export default function Users() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
await axios.delete(`${apiBase}/api/users/${editingUser}`, { ...config });
|
||||
await axios.delete(`${apiBase}/api/users/${editingUser}`);
|
||||
setShowModal(false);
|
||||
setNewUser({ name: '', email: '', password: '', role: 'user' });
|
||||
setEditingUser(null);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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 fetchVisits = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get(`${apiBase}/api/visits?showVisited=${showVisited}`);
|
||||
setVisits(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load visits', err);
|
||||
toast.showToast('Failed to load visits.', 'error');
|
||||
}
|
||||
}, [apiBase, showVisited, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVisits();
|
||||
}, [fetchVisits]);
|
||||
|
||||
const handleMarkVisited = async (visit: Sale) => {
|
||||
if (!visit.id || togglingVisitId === visit.id) return;
|
||||
|
||||
setTogglingVisitId(visit.id);
|
||||
try {
|
||||
const nextVisitedAt = visit.visitedAt ? null : new Date().toISOString();
|
||||
await axios.put(`${apiBase}/api/sales/${visit.id}`, { visitedAt: nextVisitedAt });
|
||||
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,6 +17,35 @@ export type SalesSummary = {
|
||||
totalSales: number;
|
||||
totalAmount: number;
|
||||
averageAmount: number;
|
||||
totalCustomers: number;
|
||||
totalFridges: number;
|
||||
}
|
||||
|
||||
export type IndustriesSummary = {
|
||||
industry: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type RegionsSummary = {
|
||||
region: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type FridgePosSummary = {
|
||||
id: string;
|
||||
address: string;
|
||||
fridgeCount: number;
|
||||
customer: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type InactivePosSummary = {
|
||||
posId: string;
|
||||
customerName: string;
|
||||
posAddress: string;
|
||||
lastBuyingDate: string | null;
|
||||
daysInactive: number;
|
||||
}
|
||||
|
||||
/* Users page types */
|
||||
@@ -35,7 +64,14 @@ export type CustomerPOS = {
|
||||
customerId?: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
industry?: string | null;
|
||||
personName?: string;
|
||||
fridgeCount?: number;
|
||||
banner: boolean;
|
||||
indiBanner: boolean;
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
region?: string | null;
|
||||
customer?: Customer;
|
||||
}
|
||||
|
||||
@@ -45,6 +81,7 @@ export type Customer = {
|
||||
document?: string;
|
||||
phone: string;
|
||||
personName?: string;
|
||||
notes?: string;
|
||||
pos?: CustomerPOS[];
|
||||
disabledAt?: string | null;
|
||||
};
|
||||
@@ -89,6 +126,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
-2
@@ -8,7 +8,7 @@ http {
|
||||
# Handle double /api/api/ prefixing if it happens
|
||||
rewrite ^/+api/+api/+(.*)$ /api/$1 break;
|
||||
|
||||
proxy_pass http://polpa_gestao_backend:3000;
|
||||
proxy_pass http://polpa_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;
|
||||
@@ -16,7 +16,7 @@ http {
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://polpa_gestao_frontend:80;
|
||||
proxy_pass http://polpa_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;
|
||||
|
||||
@@ -1,13 +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 stop nginx-proxy 2>/dev/null
|
||||
#docker rm nginx-proxy 2>/dev/null
|
||||
|
||||
docker run -d \
|
||||
--name ngrok-proxy \
|
||||
docker run -d --rm \
|
||||
--name nginx-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
|
||||
|
||||
|
||||
+49
-6
@@ -43,6 +43,16 @@ variable "cpf_cnpj_api_token" {
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "google_maps_api_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "jwt_secret" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "r2_access_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
@@ -65,12 +75,12 @@ variable "r2_endpoint" {
|
||||
|
||||
variable "backend_image" {
|
||||
type = string
|
||||
default = "ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.03.25.11"
|
||||
default = "ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.34"
|
||||
}
|
||||
|
||||
variable "frontend_image" {
|
||||
type = string
|
||||
default = "ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.03.25.11"
|
||||
default = "ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.06.11.52"
|
||||
}
|
||||
|
||||
resource "kubernetes_namespace_v1" "polpa_gestao" {
|
||||
@@ -86,10 +96,12 @@ resource "kubernetes_secret_v1" "polpa_gestao_secrets" {
|
||||
}
|
||||
|
||||
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
|
||||
postgres_user = var.db_user
|
||||
postgres_password = var.db_password
|
||||
postgres_db = var.db_name
|
||||
cpf_cnpj_api_token = var.cpf_cnpj_api_token
|
||||
jwt_secret = var.jwt_secret
|
||||
google_maps_api_key = var.google_maps_api_key
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +225,37 @@ resource "kubernetes_deployment_v1" "polpa_gestao_backend" {
|
||||
name = "HOSTNAME"
|
||||
value = "0.0.0.0"
|
||||
}
|
||||
env {
|
||||
name = "ALLOWED_ORIGINS"
|
||||
value = "https://polpa-gestao.darkroasted.vps-kinghost.net"
|
||||
}
|
||||
env {
|
||||
name = "JWT_SECRET"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
|
||||
key = "jwt_secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "CPF_CNPJ_API_TOKEN"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
|
||||
key = "cpf_cnpj_api_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "GOOGLE_MAPS_API_KEY"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
|
||||
key = "google_maps_api_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
resources {
|
||||
limits = { memory = "512Mi", cpu = "500m" }
|
||||
requests = { memory = "256Mi", cpu = "100m" }
|
||||
|
||||
Reference in New Issue
Block a user