Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c367f700 | ||
|
|
35cc0e5d01 | ||
|
|
ab464cee56 | ||
|
|
39f2fffcc0 | ||
|
|
9e8aff62c3 | ||
|
|
65fb7c0b3a | ||
|
|
eedfaf6aa7 | ||
|
|
cb2d4534fc | ||
|
|
111a8f6333 | ||
|
|
cfd139666d | ||
|
|
429a4bacf6 | ||
|
|
5d550e92a2 | ||
|
|
1cd71ccd7a | ||
|
|
5ad13a0783 | ||
|
|
e5129a18ca | ||
|
|
9ac0f58f0a | ||
|
|
d2109218a5 | ||
|
|
2ab79f3886 | ||
|
|
0e4b58513f | ||
|
|
d8ac1ed064 | ||
|
|
9c24ae1d85 | ||
|
|
e006cbccba | ||
|
|
8ec79378a8 | ||
|
|
c12aeaabf3 | ||
|
|
5538221546 | ||
|
|
2a4c964c42 | ||
|
|
aad97f075c | ||
|
|
20b4feef2b |
@@ -0,0 +1,11 @@
|
||||
description = "Create a formatted GitHub issue"
|
||||
prompt = """
|
||||
Act as a project manager. Based on the following input: {{args}}
|
||||
Create a GitHub issue using the `gh` CLI tool with this exact structure:
|
||||
- Title: [Feat/Bug] <short summary>
|
||||
- Body: A detailed description, technical requirements, and acceptance criteria.
|
||||
- Label: Automatically determine if it's 'bug', 'enhencement', or 'task'.
|
||||
|
||||
Once the user confirms the plan, execute the command:
|
||||
`gh issue create --title "[Type] Title" --body "..." --label "..."
|
||||
"""
|
||||
@@ -0,0 +1,166 @@
|
||||
name: Deploy to prod
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
backend_image:
|
||||
description: "Backend image tag (full image reference)"
|
||||
required: false
|
||||
frontend_image:
|
||||
description: "Frontend image tag (full image reference)"
|
||||
required: false
|
||||
apply:
|
||||
description: "Apply changes after plan"
|
||||
required: false
|
||||
default: "true"
|
||||
workflow_run:
|
||||
workflows: [ "Backend CD", "Frontend CD" ]
|
||||
types: [ completed ]
|
||||
|
||||
jobs:
|
||||
terraform-plan:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Validate cluster access
|
||||
run: |
|
||||
kubectl cluster-info
|
||||
kubectl get namespace polpa-gestao
|
||||
|
||||
- name: Determine deployment values
|
||||
id: deploy-vars
|
||||
run: |
|
||||
backend_image="${{ github.event.inputs.backend_image }}"
|
||||
frontend_image="${{ github.event.inputs.frontend_image }}"
|
||||
|
||||
latest_backend_tag="$(git tag --list 'api-v*' | sort -V | tail -n1)"
|
||||
echo "latest backend tag=$latest_backend_tag"
|
||||
|
||||
latest_frontend_tag="$(git tag --list 'app-v*' | sort -V | tail -n1)"
|
||||
echo "latest frontend tag=$latest_frontend_tag"
|
||||
|
||||
if [ -z "$backend_image" ]; then
|
||||
backend_image="ghcr.io/rmcampos/polpa-gestao/backend:$latest_backend_tag"
|
||||
fi
|
||||
if [ -z "$frontend_image" ]; then
|
||||
frontend_image="ghcr.io/rmcampos/polpa-gestao/frontend:$latest_frontend_tag"
|
||||
fi
|
||||
|
||||
echo "Resolved backend_image=$backend_image"
|
||||
echo "Resolved frontend_image=$frontend_image"
|
||||
|
||||
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Terraform Fmt -check -diff
|
||||
working-directory: terraform
|
||||
run: terraform fmt -check -diff
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Validate
|
||||
working-directory: terraform
|
||||
run: terraform validate
|
||||
|
||||
- name: Terraform Plan
|
||||
id: check-changes
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
timeout 1m terraform plan -input=false -out=tfplan \
|
||||
-var="db_user=${{ secrets.DB_USER }}" \
|
||||
-var="db_password=${{ secrets.DB_PASSWORD }}" \
|
||||
-var="db_name=${{ secrets.DB_NAME }}" \
|
||||
-var="cpf_cnpj_api_token=${{ secrets.CPF_CNPJ_API_TOKEN }}" \
|
||||
-var="r2_access_key=${{ secrets.R2_ACCESS_KEY_ID }}" \
|
||||
-var="r2_secret_key=${{ secrets.R2_SECRET_ACCESS_KEY }}" \
|
||||
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
|
||||
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: ubuntu-latest
|
||||
needs: terraform-plan
|
||||
if: >
|
||||
(github.event_name == 'push' || github.event_name == 'workflow_run' || inputs.apply == 'true')
|
||||
&& needs.terraform-plan.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: production
|
||||
url: https://polpa-gestao.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Apply
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: timeout 1m terraform apply tfplan
|
||||
|
||||
@@ -20,8 +20,11 @@ jobs:
|
||||
run: |
|
||||
DATE=$(date +'%Y.%m.%d')
|
||||
TAG="app-v${DATE}.${{ github.run_number }}"
|
||||
BUILD_NUMBER="${DATE}.${{ github.run_id }}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
echo "Generated build number: ${BUILD_NUMBER}"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -38,7 +41,7 @@ jobs:
|
||||
- run: cd frontend && npm run lint
|
||||
- run: cd frontend && npm run build
|
||||
env:
|
||||
VITE_BUILD_VERSION: ${{ steps.version.outputs.tag }}
|
||||
VITE_BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
|
||||
VITE_CPF_CNPJ_API_TOKEN: ${{ secrets.CPF_CNPJ_API_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -72,6 +75,7 @@ jobs:
|
||||
build-args: |
|
||||
VITE_BACKEND_SERVER=${{ vars.BACKEND_SERVER_URL }}
|
||||
VITE_CPF_CNPJ_API_TOKEN=${{ secrets.CPF_CNPJ_API_TOKEN }}
|
||||
VITE_BUILD_NUMBER=${{ steps.version.outputs.build_number }}
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
@@ -79,4 +83,4 @@ jobs:
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
@@ -0,0 +1,83 @@
|
||||
# Gemini Context: Polpa Gestão
|
||||
|
||||
This project is a full-stack business management web application designed for managing customers, products, sales, and delivery routes.
|
||||
|
||||
## Project Overview
|
||||
|
||||
- **Architecture:** Monorepo-like structure with separate `backend` and `frontend` directories.
|
||||
- **Backend:** Node.js 22, Fastify 5, TypeScript, Prisma ORM.
|
||||
- **Frontend:** React 19, TypeScript, Vite, Bootstrap 5.
|
||||
- **Database:** PostgreSQL 15.
|
||||
- **Infrastructure:** Docker, Docker Compose, GitHub Actions, Terraform.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `backend/`: Fastify API server and Prisma schema.
|
||||
- `src/routes/`: API endpoint definitions.
|
||||
- `prisma/`: Database schema and migrations.
|
||||
- `frontend/`: React application.
|
||||
- `src/pages/`: Main view components.
|
||||
- `src/components/`: Reusable UI components.
|
||||
- `src/context/`: React context providers (e.g., Toast).
|
||||
- `docker-compose.yml`: Orchestrates the database, backend, and frontend for local development.
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Local Development (Recommended)
|
||||
|
||||
The easiest way to run the full stack is using Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
- **Frontend:** http://localhost:5173
|
||||
- **Backend API:** http://localhost:3000
|
||||
- **Database:** localhost:5432 (User: `admin`, Password: `adminpassword`, DB: `polpa_gestao`)
|
||||
|
||||
### Manual Setup
|
||||
|
||||
#### Backend
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
# Ensure DATABASE_URL is set in .env
|
||||
npx prisma migrate dev
|
||||
npm run server # or ./run-server.sh which uses ts-node
|
||||
```
|
||||
|
||||
#### Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Development Conventions
|
||||
|
||||
### Backend
|
||||
- **Routing:** Register new routes in `backend/src/index.ts` from the `backend/src/routes/` directory.
|
||||
- **Authentication:** Use the `authenticate` decorator on routes requiring protection. Authentication is JWT-based.
|
||||
- **Database:** Always update `prisma/schema.prisma` and run `npx prisma migrate dev` for schema changes.
|
||||
- **Validation:** Use Fastify's built-in schema validation where possible.
|
||||
|
||||
### Frontend
|
||||
- **Styling:** Primarily Bootstrap 5 with custom CSS in `.css` files.
|
||||
- **State Management:** React Hooks (useState, useEffect) and Context API for global UI state (like notifications).
|
||||
- **API Calls:** Use `axios`. Environment variables (like `VITE_BACKEND_SERVER`) are used for configuration.
|
||||
|
||||
### General
|
||||
- **Naming:** Use `camelCase` for TypeScript variables, functions, and database fields (as per Prisma schema).
|
||||
- **Types:** Strictly adhere to TypeScript. Define shared types in `frontend/src/types.ts` or relevant backend models.
|
||||
- **Testing:** No formal test suite is currently implemented (placeholder in `package.json`). Add tests to `backend/tests` or `frontend/src/__tests__` as needed.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- **Seeding the Database:**
|
||||
Run `backend/run-seed.sh` or `node dist/seed.js` inside the backend container.
|
||||
- **Adding a new Data Model:**
|
||||
1. Edit `backend/prisma/schema.prisma`.
|
||||
2. Run `npx prisma migrate dev --name <migration_name>`.
|
||||
3. Regenerate Prisma client (done automatically by migrate).
|
||||
- **Updating Frontend PWA:**
|
||||
Vite PWA plugin is configured in `frontend/vite.config.ts`.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Polpa Gestão
|
||||
|
||||
A full-stack business management web application for managing customers, points-of-sale (POS), products, sales, and delivery routes.
|
||||
|
||||
## Features
|
||||
|
||||
### Customer & POS Management
|
||||
Manage your customer database including personal and business details (CPF/CNPJ), phone numbers, and multiple points-of-sale per customer. Each customer can have one or more POS locations, making it easy to track deliveries to different addresses.
|
||||
|
||||
### Products
|
||||
Maintain a product catalog with pricing, cost, and stock information. Products are linked to sales so inventory levels are always up to date.
|
||||
|
||||
### Sales
|
||||
Record and track sales transactions with support for delivery status, payment methods, due dates, and additional comments. Each sale can include multiple products, and the status can be updated as orders are processed and delivered.
|
||||
|
||||
### Routes
|
||||
Define delivery routes organized by day of the week and assign customer POS locations to them. This makes it easy to plan and track distribution for each day.
|
||||
|
||||
### Dashboard & Authentication
|
||||
A dashboard provides analytics and reporting. The application includes user management with role-based access control secured by JWT authentication.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Frontend | React 19, TypeScript, Vite, Bootstrap 5 |
|
||||
| Backend | Node.js 22, Fastify 5, TypeScript |
|
||||
| Database | PostgreSQL 15 with Prisma ORM |
|
||||
| Containerization | Docker, Docker Compose |
|
||||
| CI/CD | GitHub Actions → GitHub Container Registry |
|
||||
| Deployment | Terraform |
|
||||
|
||||
## Running Locally with Docker
|
||||
|
||||
The entire application stack (database, backend API, and frontend) can be started with a single command using Docker Compose.
|
||||
|
||||
**Prerequisites:** [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed.
|
||||
|
||||
**Start all services:**
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Start a **PostgreSQL 15** database on port `5432`
|
||||
2. Run **Prisma migrations** automatically to set up the database schema
|
||||
3. Start the **Fastify backend API** on port `3000`
|
||||
4. Start the **React frontend** (via Nginx) on port `5173`
|
||||
|
||||
**Access the application:**
|
||||
|
||||
| Service | URL |
|
||||
|---|---|
|
||||
| Frontend | http://localhost:5173 |
|
||||
| Backend API | http://localhost:3000 |
|
||||
| Database | `localhost:5432` (user: `admin`, password: `adminpassword`, db: `polpa_gestao`) |
|
||||
|
||||
**Optional environment variable:**
|
||||
|
||||
To enable CPF/CNPJ document validation, set the `VITE_CPF_CNPJ_API_TOKEN` variable before starting:
|
||||
|
||||
```bash
|
||||
VITE_CPF_CNPJ_API_TOKEN=your_token_here docker compose up
|
||||
```
|
||||
|
||||
**Stop all services:**
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Database data is persisted in a Docker volume (`pgdata`) and will survive container restarts. To also remove the volume when stopping, run `docker compose down -v`.
|
||||
|
||||
## Deployment
|
||||
|
||||
The application is deployed using **Terraform**. The infrastructure-as-code configuration can be found in the following public repository:
|
||||
|
||||
[https://github.com/RMCampos/personal-projects-iaac/blob/main/polpa-gestao/main.tf](https://github.com/RMCampos/personal-projects-iaac/blob/main/polpa-gestao/main.tf)
|
||||
|
||||
Docker images are built and published to the GitHub Container Registry automatically via GitHub Actions on every push:
|
||||
|
||||
- `ghcr.io/rmcampos/polpa-gestao/backend:latest`
|
||||
- `ghcr.io/rmcampos/polpa-gestao/frontend:latest`
|
||||
@@ -4,8 +4,9 @@ WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY prisma.config.ts ./
|
||||
|
||||
RUN npm ci && npx prisma generate
|
||||
RUN npm ci && DATABASE_URL=postgres://dummy npx prisma generate
|
||||
|
||||
FROM deps AS prisma
|
||||
|
||||
@@ -14,9 +15,11 @@ FROM node:22-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/src/generated/prisma ./src/generated/prisma
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY prisma.config.ts ./
|
||||
COPY src ./src
|
||||
|
||||
RUN npx tsc
|
||||
@@ -31,8 +34,9 @@ ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY prisma.config.ts ./
|
||||
|
||||
RUN npm ci --omit=dev && npx prisma generate && npm cache clean --force
|
||||
RUN npm ci --omit=dev && DATABASE_URL=postgres://dummy npx prisma generate && npm cache clean --force
|
||||
|
||||
COPY healthcheck.js ./
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
@@ -13,16 +13,20 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"fastify": "^5.8.2",
|
||||
"fastify-plugin": "^5.1.0"
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"pg": "^8.13.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"prisma": "^6.19.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/pg": "^8.11.11",
|
||||
"prisma": "^7.7.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Customer" ADD COLUMN "personName" VARCHAR(30);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "CustomerPos" ADD COLUMN "personName" VARCHAR(30);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Customer" ALTER COLUMN "document" DROP NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CustomerPos" ADD COLUMN "fridgeCount" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Sale" ADD COLUMN "nextVisitDate" TIMESTAMP(3),
|
||||
ADD COLUMN "visitedAt" TIMESTAMP(3);
|
||||
@@ -1,10 +1,11 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
moduleFormat = "cjs"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -21,8 +22,9 @@ model User {
|
||||
model Customer {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
document String @unique
|
||||
document String? @unique
|
||||
phone String?
|
||||
personName String? @db.VarChar(30)
|
||||
pos CustomerPos[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -35,6 +37,8 @@ model CustomerPos {
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
address String
|
||||
phone String
|
||||
personName String? @db.VarChar(30)
|
||||
fridgeCount Int @default(0)
|
||||
sales Sale[]
|
||||
routes RouteCustomerPos[]
|
||||
createdAt DateTime @default(now())
|
||||
@@ -63,6 +67,8 @@ model Sale {
|
||||
paymentDueDate DateTime?
|
||||
paymentDate DateTime?
|
||||
comments String?
|
||||
nextVisitDate DateTime?
|
||||
visitedAt DateTime?
|
||||
products SaleProduct[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma-related types and utilities in a browser.
|
||||
* Use it to get access to models, enums, and input types.
|
||||
*
|
||||
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
|
||||
* See `client.ts` for the standard, server-side entry point.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as Prisma from './internal/prismaNamespaceBrowser.js'
|
||||
export { Prisma }
|
||||
export * as $Enums from './enums.js'
|
||||
export * from './enums.js';
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel
|
||||
/**
|
||||
* Model Customer
|
||||
*
|
||||
*/
|
||||
export type Customer = Prisma.CustomerModel
|
||||
/**
|
||||
* Model CustomerPos
|
||||
*
|
||||
*/
|
||||
export type CustomerPos = Prisma.CustomerPosModel
|
||||
/**
|
||||
* Model Product
|
||||
*
|
||||
*/
|
||||
export type Product = Prisma.ProductModel
|
||||
/**
|
||||
* Model Sale
|
||||
*
|
||||
*/
|
||||
export type Sale = Prisma.SaleModel
|
||||
/**
|
||||
* Model SaleProduct
|
||||
*
|
||||
*/
|
||||
export type SaleProduct = Prisma.SaleProductModel
|
||||
/**
|
||||
* Model Route
|
||||
*
|
||||
*/
|
||||
export type Route = Prisma.RouteModel
|
||||
/**
|
||||
* Model RouteCustomerPos
|
||||
*
|
||||
*/
|
||||
export type RouteCustomerPos = Prisma.RouteCustomerPosModel
|
||||
@@ -0,0 +1,81 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
|
||||
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as process from 'node:process'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums.js"
|
||||
import * as $Class from "./internal/class.js"
|
||||
import * as Prisma from "./internal/prismaNamespace.js"
|
||||
|
||||
export * as $Enums from './enums.js'
|
||||
export * from "./enums.js"
|
||||
/**
|
||||
* ## Prisma Client
|
||||
*
|
||||
* Type-safe database client for TypeScript
|
||||
* @example
|
||||
* ```
|
||||
* const prisma = new PrismaClient({
|
||||
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||
* })
|
||||
* // Fetch zero or more Users
|
||||
* const users = await prisma.user.findMany()
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://pris.ly/d/client).
|
||||
*/
|
||||
export const PrismaClient = $Class.getPrismaClientClass()
|
||||
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
|
||||
export { Prisma }
|
||||
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel
|
||||
/**
|
||||
* Model Customer
|
||||
*
|
||||
*/
|
||||
export type Customer = Prisma.CustomerModel
|
||||
/**
|
||||
* Model CustomerPos
|
||||
*
|
||||
*/
|
||||
export type CustomerPos = Prisma.CustomerPosModel
|
||||
/**
|
||||
* Model Product
|
||||
*
|
||||
*/
|
||||
export type Product = Prisma.ProductModel
|
||||
/**
|
||||
* Model Sale
|
||||
*
|
||||
*/
|
||||
export type Sale = Prisma.SaleModel
|
||||
/**
|
||||
* Model SaleProduct
|
||||
*
|
||||
*/
|
||||
export type SaleProduct = Prisma.SaleProductModel
|
||||
/**
|
||||
* Model Route
|
||||
*
|
||||
*/
|
||||
export type Route = Prisma.RouteModel
|
||||
/**
|
||||
* Model RouteCustomerPos
|
||||
*
|
||||
*/
|
||||
export type RouteCustomerPos = Prisma.RouteCustomerPosModel
|
||||
@@ -0,0 +1,447 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import type * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums.js"
|
||||
import type * as Prisma from "./internal/prismaNamespace.js"
|
||||
|
||||
|
||||
export type UuidFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedUuidFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type StringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type DateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type SortOrderInput = {
|
||||
sort: Prisma.SortOrder
|
||||
nulls?: Prisma.NullsOrder
|
||||
}
|
||||
|
||||
export type UuidWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedUuidWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type StringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type StringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type IntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type FloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type BoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedUuidFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedUuidFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type NestedStringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type NestedDateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type NestedUuidWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedUuidWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedBoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports all enum related types from the schema.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
// This file is empty because there are no enums in the schema.
|
||||
export {}
|
||||
@@ -0,0 +1,207 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* WARNING: This is an internal file that is subject to change!
|
||||
*
|
||||
* 🛑 Under no circumstances should you import this file directly! 🛑
|
||||
*
|
||||
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
|
||||
* While this enables partial backward compatibility, it is not part of the stable public API.
|
||||
*
|
||||
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
|
||||
* model files in the `model` directory!
|
||||
*/
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/index-browser"
|
||||
|
||||
export type * from '../models.js'
|
||||
export type * from './prismaNamespace.js'
|
||||
|
||||
export const Decimal = runtime.Decimal
|
||||
|
||||
|
||||
export const NullTypes = {
|
||||
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
|
||||
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
|
||||
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
|
||||
}
|
||||
/**
|
||||
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const DbNull = runtime.DbNull
|
||||
|
||||
/**
|
||||
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const JsonNull = runtime.JsonNull
|
||||
|
||||
/**
|
||||
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const AnyNull = runtime.AnyNull
|
||||
|
||||
|
||||
export const ModelName = {
|
||||
User: 'User',
|
||||
Customer: 'Customer',
|
||||
CustomerPos: 'CustomerPos',
|
||||
Product: 'Product',
|
||||
Sale: 'Sale',
|
||||
SaleProduct: 'SaleProduct',
|
||||
Route: 'Route',
|
||||
RouteCustomerPos: 'RouteCustomerPos'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
|
||||
/*
|
||||
* Enums
|
||||
*/
|
||||
|
||||
export const TransactionIsolationLevel = runtime.makeStrictEnum({
|
||||
ReadUncommitted: 'ReadUncommitted',
|
||||
ReadCommitted: 'ReadCommitted',
|
||||
RepeatableRead: 'RepeatableRead',
|
||||
Serializable: 'Serializable'
|
||||
} as const)
|
||||
|
||||
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
|
||||
|
||||
|
||||
export const UserScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
role: 'role',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
disabledAt: 'disabledAt'
|
||||
} as const
|
||||
|
||||
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
|
||||
|
||||
|
||||
export const CustomerScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
document: 'document',
|
||||
phone: 'phone',
|
||||
personName: 'personName',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
disabledAt: 'disabledAt'
|
||||
} as const
|
||||
|
||||
export type CustomerScalarFieldEnum = (typeof CustomerScalarFieldEnum)[keyof typeof CustomerScalarFieldEnum]
|
||||
|
||||
|
||||
export const CustomerPosScalarFieldEnum = {
|
||||
id: 'id',
|
||||
customerId: 'customerId',
|
||||
address: 'address',
|
||||
phone: 'phone',
|
||||
personName: 'personName',
|
||||
fridgeCount: 'fridgeCount',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
disabledAt: 'disabledAt'
|
||||
} as const
|
||||
|
||||
export type CustomerPosScalarFieldEnum = (typeof CustomerPosScalarFieldEnum)[keyof typeof CustomerPosScalarFieldEnum]
|
||||
|
||||
|
||||
export const ProductScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
price: 'price',
|
||||
stock: 'stock',
|
||||
cost: 'cost',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
disabledAt: 'disabledAt'
|
||||
} as const
|
||||
|
||||
export type ProductScalarFieldEnum = (typeof ProductScalarFieldEnum)[keyof typeof ProductScalarFieldEnum]
|
||||
|
||||
|
||||
export const SaleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
customerPosId: 'customerPosId',
|
||||
delivered: 'delivered',
|
||||
paymentMethod: 'paymentMethod',
|
||||
paymentDueDate: 'paymentDueDate',
|
||||
paymentDate: 'paymentDate',
|
||||
comments: 'comments',
|
||||
nextVisitDate: 'nextVisitDate',
|
||||
visitedAt: 'visitedAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type SaleScalarFieldEnum = (typeof SaleScalarFieldEnum)[keyof typeof SaleScalarFieldEnum]
|
||||
|
||||
|
||||
export const SaleProductScalarFieldEnum = {
|
||||
saleId: 'saleId',
|
||||
productId: 'productId',
|
||||
quantity: 'quantity',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type SaleProductScalarFieldEnum = (typeof SaleProductScalarFieldEnum)[keyof typeof SaleProductScalarFieldEnum]
|
||||
|
||||
|
||||
export const RouteScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
completed: 'completed',
|
||||
dayOfWeek: 'dayOfWeek',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type RouteScalarFieldEnum = (typeof RouteScalarFieldEnum)[keyof typeof RouteScalarFieldEnum]
|
||||
|
||||
|
||||
export const RouteCustomerPosScalarFieldEnum = {
|
||||
routeId: 'routeId',
|
||||
customerPosId: 'customerPosId'
|
||||
} as const
|
||||
|
||||
export type RouteCustomerPosScalarFieldEnum = (typeof RouteCustomerPosScalarFieldEnum)[keyof typeof RouteCustomerPosScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
} as const
|
||||
|
||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||
|
||||
|
||||
export const QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
} as const
|
||||
|
||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||
|
||||
|
||||
export const NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const
|
||||
|
||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This is a barrel export file for all models and their related types.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
export type * from './models/User.js'
|
||||
export type * from './models/Customer.js'
|
||||
export type * from './models/CustomerPos.js'
|
||||
export type * from './models/Product.js'
|
||||
export type * from './models/Sale.js'
|
||||
export type * from './models/SaleProduct.js'
|
||||
export type * from './models/Route.js'
|
||||
export type * from './models/RouteCustomerPos.js'
|
||||
export type * from './commonInputTypes.js'
|
||||
@@ -28,6 +28,7 @@ import customersRoutes from './routes/customers';
|
||||
import productsRoutes from './routes/products';
|
||||
import routesApi from './routes/routes';
|
||||
import salesRoutes from './routes/sales';
|
||||
import visitsRoutes from './routes/visits';
|
||||
import dashboardRoutes from './routes/dashboard';
|
||||
import healthRoutes from './routes/health';
|
||||
|
||||
@@ -36,6 +37,7 @@ app.register(customersRoutes, { prefix: '/api/customers' });
|
||||
app.register(productsRoutes, { prefix: '/api/products' });
|
||||
app.register(routesApi, { prefix: '/api/routes' });
|
||||
app.register(salesRoutes, { prefix: '/api/sales' });
|
||||
app.register(visitsRoutes, { prefix: '/api/visits' });
|
||||
app.register(dashboardRoutes, { prefix: '/api/dashboard' });
|
||||
app.register(healthRoutes, { prefix: '/api/health' });
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient } from './generated/prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
|
||||
const pool = new Pool({ connectionString });
|
||||
const adapter = new PrismaPg(pool);
|
||||
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../prisma';
|
||||
|
||||
const parseFridgeCount = (value: unknown): number | undefined => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
|
||||
const parsedValue = Number(value);
|
||||
if (!Number.isInteger(parsedValue) || parsedValue < 0) return 0;
|
||||
|
||||
return parsedValue;
|
||||
};
|
||||
|
||||
export default async function customersRoutes(app: FastifyInstance) {
|
||||
// Get all customers (with pos optionally)
|
||||
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
@@ -24,9 +33,10 @@ export default async function customersRoutes(app: FastifyInstance) {
|
||||
|
||||
// Create customer
|
||||
app.post('/', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { name, document, phone } = request.body as any;
|
||||
const { name, document, phone, personName } = request.body as any;
|
||||
const docValue = document || null;
|
||||
try {
|
||||
return await prisma.customer.create({ data: { name, document, phone } });
|
||||
return await prisma.customer.create({ data: { name, document: docValue, phone, personName } });
|
||||
} catch (e: any) {
|
||||
if (e.code === 'P2002') return reply.code(400).send({ error: 'Document already exists' });
|
||||
throw e;
|
||||
@@ -36,19 +46,23 @@ export default async function customersRoutes(app: FastifyInstance) {
|
||||
// Update customer
|
||||
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
const { name, document, phone } = request.body as any;
|
||||
const { name, document, phone, personName } = request.body as any;
|
||||
const docValue = document || null;
|
||||
try {
|
||||
if (document) {
|
||||
if (docValue) {
|
||||
const existing = await prisma.customer.findFirst({
|
||||
where: { document, id: { not: id } }
|
||||
where: { document: docValue, id: { not: id } }
|
||||
});
|
||||
if (existing) {
|
||||
return reply.code(400).send({ error: 'Document already exists for another customer' });
|
||||
}
|
||||
}
|
||||
return await prisma.customer.update({ where: { id }, data: { name, document, phone } });
|
||||
} catch (e) {
|
||||
return reply.code(404).send({ error: 'Customer not found' });
|
||||
return await prisma.customer.update({ where: { id }, data: { name, document: docValue, phone, personName } });
|
||||
} catch (e: any) {
|
||||
if (e && e.code === 'P2025') {
|
||||
return reply.code(404).send({ error: 'Customer not found' });
|
||||
}
|
||||
return reply.code(400).send({ error: 'Invalid customer data' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -63,18 +77,24 @@ export default async function customersRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post('/:customerId/pos', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { customerId } = request.params as any;
|
||||
const { address, phone } = request.body as any;
|
||||
const { address, phone, personName, fridgeCount } = request.body as any;
|
||||
return prisma.customerPos.create({
|
||||
data: { customerId, address, phone }
|
||||
data: { customerId, address, phone, personName, fridgeCount: parseFridgeCount(fridgeCount) ?? 0 }
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/pos/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
const { address, phone } = request.body as any;
|
||||
const { address, phone, personName, fridgeCount } = request.body as any;
|
||||
const normalizedFridgeCount = parseFridgeCount(fridgeCount);
|
||||
return prisma.customerPos.update({
|
||||
where: { id },
|
||||
data: { address, phone }
|
||||
data: {
|
||||
address,
|
||||
phone,
|
||||
personName,
|
||||
...(normalizedFridgeCount !== undefined ? { fridgeCount: normalizedFridgeCount } : {})
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,71 +1,191 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../prisma';
|
||||
|
||||
function getDateRange(range: string): { startDate: Date; endDate: Date | null } {
|
||||
const now = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
let endDate: Date | null = null;
|
||||
|
||||
switch (range) {
|
||||
case 'this-week': {
|
||||
const day = now.getDay();
|
||||
const diff = now.getDate() - day + (day === 0 ? -6 : 1);
|
||||
startDate.setDate(diff);
|
||||
break;
|
||||
}
|
||||
case 'this-month':
|
||||
startDate.setDate(1);
|
||||
break;
|
||||
case 'this-year':
|
||||
startDate.setMonth(0, 1);
|
||||
break;
|
||||
case 'last-7-days':
|
||||
startDate.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'last-14-days':
|
||||
startDate.setDate(now.getDate() - 14);
|
||||
break;
|
||||
case 'last-30-days':
|
||||
startDate.setDate(now.getDate() - 30);
|
||||
break;
|
||||
case 'last-90-days':
|
||||
startDate.setDate(now.getDate() - 90);
|
||||
break;
|
||||
case 'past-week': {
|
||||
const day = now.getDay();
|
||||
const daysSinceMonday = day === 0 ? 6 : day - 1;
|
||||
const lastMonday = new Date(now);
|
||||
lastMonday.setDate(now.getDate() - daysSinceMonday - 7);
|
||||
lastMonday.setHours(0, 0, 0, 0);
|
||||
startDate.setTime(lastMonday.getTime());
|
||||
endDate = new Date(lastMonday);
|
||||
endDate.setDate(lastMonday.getDate() + 6);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
}
|
||||
case 'past-month': {
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
firstDay.setHours(0, 0, 0, 0);
|
||||
startDate.setTime(firstDay.getTime());
|
||||
endDate = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
}
|
||||
case 'past-year': {
|
||||
const firstDay = new Date(now.getFullYear() - 1, 0, 1);
|
||||
firstDay.setHours(0, 0, 0, 0);
|
||||
startDate.setTime(firstDay.getTime());
|
||||
endDate = new Date(now.getFullYear() - 1, 11, 31);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
startDate.setDate(now.getDate() - 30);
|
||||
}
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
export default async function dashboardRoutes(app: FastifyInstance) {
|
||||
app.get('/sales-by-route', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
// Basic aggregation: total sales per route
|
||||
const routes = await prisma.route.findMany({
|
||||
include: {
|
||||
app.get('/sales-by-customer', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { range } = request.query as { range: string };
|
||||
const { startDate, endDate } = getDateRange(range);
|
||||
|
||||
const sales = await prisma.sale.findMany({
|
||||
where: { createdAt: { gte: startDate, ...(endDate ? { lte: endDate } : {}) } },
|
||||
select: {
|
||||
id: true,
|
||||
customerPos: {
|
||||
include: { customerPos: true }
|
||||
select: {
|
||||
customerId: true,
|
||||
customer: { select: { id: true, name: true } }
|
||||
}
|
||||
},
|
||||
products: {
|
||||
select: {
|
||||
quantity: true,
|
||||
product: { select: { price: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// To get sales by route, we need to find sales matching the POS included in each route
|
||||
// Note: a sale is tied to a POS, not directly a Route.
|
||||
const result = await Promise.all(routes.map(async (r) => {
|
||||
const posIds = r.customerPos.map(cp => cp.customerPosId);
|
||||
const sales = await prisma.sale.findMany({
|
||||
where: { customerPosId: { in: posIds } },
|
||||
include: { products: { include: { product: true } } }
|
||||
});
|
||||
|
||||
let totalAmount = 0;
|
||||
sales.forEach(sale => {
|
||||
sale.products.forEach(sp => {
|
||||
totalAmount += sp.quantity * sp.product.price;
|
||||
});
|
||||
});
|
||||
const customerMap = new Map<string, { customerId: string; customerName: string; totalSales: number; totalAmount: number }>();
|
||||
|
||||
return {
|
||||
routeId: r.id,
|
||||
routeName: r.name,
|
||||
totalSales: sales.length,
|
||||
totalAmount
|
||||
};
|
||||
}));
|
||||
for (const sale of sales) {
|
||||
const customerId = sale.customerPos.customerId;
|
||||
const customerName = sale.customerPos.customer.name;
|
||||
|
||||
return result.sort((a, b) => b.totalAmount - a.totalAmount);
|
||||
let saleAmount = 0;
|
||||
for (const sp of sale.products) {
|
||||
saleAmount += sp.quantity * sp.product.price;
|
||||
}
|
||||
|
||||
if (!customerMap.has(customerId)) {
|
||||
customerMap.set(customerId, { customerId, customerName, totalSales: 0, totalAmount: 0 });
|
||||
}
|
||||
|
||||
const entry = customerMap.get(customerId)!;
|
||||
entry.totalSales += 1;
|
||||
entry.totalAmount += saleAmount;
|
||||
}
|
||||
|
||||
return Array.from(customerMap.values())
|
||||
.sort((a, b) => b.totalAmount - a.totalAmount);
|
||||
});
|
||||
|
||||
app.get('/sales-by-customer', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const customers = await prisma.customer.findMany({
|
||||
include: { pos: true }
|
||||
});
|
||||
app.get('/sales-by-product', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { range } = request.query as { range: string };
|
||||
const { startDate, endDate } = getDateRange(range);
|
||||
|
||||
const result = await Promise.all(customers.map(async (c) => {
|
||||
const posIds = c.pos.map(p => p.id);
|
||||
const sales = await prisma.sale.findMany({
|
||||
where: { customerPosId: { in: posIds } },
|
||||
include: { products: { include: { product: true } } }
|
||||
});
|
||||
|
||||
let totalAmount = 0;
|
||||
sales.forEach(sale => {
|
||||
sale.products.forEach(sp => {
|
||||
totalAmount += sp.quantity * sp.product.price;
|
||||
});
|
||||
});
|
||||
const [grouped, products] = await Promise.all([
|
||||
prisma.saleProduct.groupBy({
|
||||
by: ['productId'],
|
||||
_sum: { quantity: true },
|
||||
where: { sale: { createdAt: { gte: startDate, ...(endDate ? { lte: endDate } : {}) } } }
|
||||
}),
|
||||
prisma.product.findMany({ select: { id: true, name: true, price: true } })
|
||||
]);
|
||||
|
||||
return {
|
||||
customerId: c.id,
|
||||
customerName: c.name,
|
||||
totalSales: sales.length,
|
||||
totalAmount
|
||||
};
|
||||
}));
|
||||
const productMap = new Map(products.map(p => [p.id, p]));
|
||||
|
||||
return result.sort((a, b) => b.totalAmount - a.totalAmount);
|
||||
return grouped
|
||||
.map(g => {
|
||||
const p = productMap.get(g.productId);
|
||||
const totalQuantity = g._sum.quantity ?? 0;
|
||||
return {
|
||||
productId: g.productId,
|
||||
productName: p?.name ?? '',
|
||||
totalQuantity,
|
||||
totalAmount: totalQuantity * (p?.price ?? 0)
|
||||
};
|
||||
})
|
||||
.filter(item => item.totalQuantity > 0)
|
||||
.sort((a, b) => b.totalAmount - a.totalAmount);
|
||||
});
|
||||
|
||||
app.get('/sales-summary', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { range } = request.query as { range: string };
|
||||
const { startDate, endDate } = getDateRange(range);
|
||||
|
||||
const [sales, totalCustomers, totalFridges] = await Promise.all([
|
||||
prisma.sale.findMany({
|
||||
where: { createdAt: { gte: startDate, ...(endDate ? { lte: endDate } : {}) } },
|
||||
select: {
|
||||
products: {
|
||||
select: {
|
||||
quantity: true,
|
||||
product: { select: { price: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.customer.count({ where: { disabledAt: null } }),
|
||||
prisma.customerPos.aggregate({
|
||||
_sum: { fridgeCount: true },
|
||||
where: { disabledAt: null }
|
||||
})
|
||||
]);
|
||||
|
||||
const totalSales = sales.length;
|
||||
let totalAmount = 0;
|
||||
|
||||
for (const sale of sales) {
|
||||
let saleAmount = 0;
|
||||
for (const sp of sale.products) {
|
||||
saleAmount += sp.quantity * sp.product.price;
|
||||
}
|
||||
totalAmount += saleAmount;
|
||||
}
|
||||
|
||||
const averageAmount = totalSales > 0 ? totalAmount / totalSales : 0;
|
||||
|
||||
return {
|
||||
totalSales,
|
||||
totalAmount,
|
||||
averageAmount,
|
||||
totalCustomers,
|
||||
totalFridges: totalFridges._sum.fridgeCount ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma } from '../generated/prisma/client';
|
||||
|
||||
export default async function productsRoutes(app: FastifyInstance) {
|
||||
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
|
||||
@@ -49,6 +49,7 @@ export default async function salesRoutes(app: FastifyInstance) {
|
||||
paymentDueDate,
|
||||
paymentDate,
|
||||
comments,
|
||||
nextVisitDate,
|
||||
products // Array of { productId, quantity }
|
||||
} = request.body as any;
|
||||
|
||||
@@ -72,6 +73,7 @@ export default async function salesRoutes(app: FastifyInstance) {
|
||||
paymentDueDate: paymentDueDate ? new Date(paymentDueDate) : null,
|
||||
paymentDate: paymentDate ? new Date(paymentDate) : null,
|
||||
comments,
|
||||
nextVisitDate: nextVisitDate ? new Date(nextVisitDate) : null,
|
||||
products: {
|
||||
create: products.map((p: any) => ({
|
||||
productId: p.productId,
|
||||
@@ -102,28 +104,134 @@ export default async function salesRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
// Update sale (e.g. mark as paid)
|
||||
// Update sale (e.g. mark as paid, edit fields, replace products)
|
||||
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
const {
|
||||
customerPosId,
|
||||
delivered,
|
||||
paymentMethod,
|
||||
paymentDueDate,
|
||||
paymentDate,
|
||||
comments
|
||||
comments,
|
||||
nextVisitDate,
|
||||
visitedAt,
|
||||
products
|
||||
} = request.body as any;
|
||||
|
||||
try {
|
||||
const existing = await prisma.sale.findUnique({ where: { id } });
|
||||
const existing = await prisma.sale.findUnique({
|
||||
where: { id },
|
||||
include: { products: true }
|
||||
});
|
||||
if (!existing) return reply.code(404).send({ error: 'Sale not found' });
|
||||
|
||||
if (customerPosId !== undefined) {
|
||||
const pos = await prisma.customerPos.findUnique({ where: { id: customerPosId } });
|
||||
if (!pos || pos.disabledAt) return reply.code(400).send({ error: 'Point of sale not found or disabled' });
|
||||
}
|
||||
|
||||
let data: any = {};
|
||||
const parsedDelivered = parseBoolean(delivered);
|
||||
if (parsedDelivered !== undefined) data.delivered = parsedDelivered;
|
||||
if (customerPosId !== undefined) data.customerPosId = customerPosId;
|
||||
if (paymentMethod !== undefined) data.paymentMethod = paymentMethod;
|
||||
if (paymentDueDate !== undefined) data.paymentDueDate = paymentDueDate ? new Date(paymentDueDate) : null;
|
||||
if (paymentDate !== undefined) data.paymentDate = paymentDate ? new Date(paymentDate) : null;
|
||||
if (comments !== undefined) data.comments = comments;
|
||||
if (nextVisitDate !== undefined) data.nextVisitDate = nextVisitDate ? new Date(nextVisitDate) : null;
|
||||
if (visitedAt !== undefined) data.visitedAt = visitedAt ? new Date(visitedAt) : null;
|
||||
|
||||
// If products are provided, replace the entire product list with stock management
|
||||
if (products !== undefined && !Array.isArray(products)) {
|
||||
return reply.code(400).send({ error: 'products must be an array' });
|
||||
}
|
||||
if (Array.isArray(products)) {
|
||||
// Aggregate quantities by productId to handle duplicates correctly
|
||||
const aggregatedMap = new Map<string, number>();
|
||||
for (const p of products) {
|
||||
if (!p.productId || typeof p.quantity !== 'number' || p.quantity <= 0) {
|
||||
return reply.code(400).send({ error: 'Invalid product entry' });
|
||||
}
|
||||
aggregatedMap.set(p.productId, (aggregatedMap.get(p.productId) || 0) + p.quantity);
|
||||
}
|
||||
const aggregatedProducts = Array.from(aggregatedMap.entries()).map(([productId, quantity]) => ({ productId, quantity }));
|
||||
|
||||
// Validate new products exist and are enabled in a single query
|
||||
const productIds = aggregatedProducts.map(p => p.productId);
|
||||
const foundProducts = await prisma.product.findMany({ where: { id: { in: productIds } } });
|
||||
for (const p of aggregatedProducts) {
|
||||
const prod = foundProducts.find(fp => fp.id === p.productId);
|
||||
if (!prod || prod.disabledAt) return reply.code(400).send({ error: `Product ${p.productId} not found or disabled` });
|
||||
}
|
||||
|
||||
const sale = await prisma.$transaction(async (tx) => {
|
||||
const lockProductIds = Array.from(
|
||||
new Set([
|
||||
...existing.products.map((sp) => sp.productId),
|
||||
...productIds
|
||||
])
|
||||
).sort();
|
||||
|
||||
if (lockProductIds.length > 0) {
|
||||
// Lock all affected product rows in a deterministic order.
|
||||
await tx.$queryRaw`
|
||||
SELECT id
|
||||
FROM "Product"
|
||||
WHERE id = ANY(${lockProductIds}::uuid[])
|
||||
FOR UPDATE
|
||||
`;
|
||||
}
|
||||
|
||||
// Restore stock for all existing products
|
||||
for (const sp of existing.products) {
|
||||
await tx.product.update({
|
||||
where: { id: sp.productId },
|
||||
data: { stock: { increment: sp.quantity } }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate stock after restoring by fetching all updated products in one query
|
||||
const updatedProds = await tx.product.findMany({ where: { id: { in: productIds } } });
|
||||
for (const p of aggregatedProducts) {
|
||||
const prod = updatedProds.find(up => up.id === p.productId);
|
||||
if (!prod || prod.stock < p.quantity) {
|
||||
throw new Error(`Not enough stock for product ${p.productId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete old sale products and create new ones
|
||||
await tx.saleProduct.deleteMany({ where: { saleId: id } });
|
||||
if (aggregatedProducts.length > 0) {
|
||||
await tx.saleProduct.createMany({
|
||||
data: aggregatedProducts.map((p) => ({
|
||||
saleId: id,
|
||||
productId: p.productId,
|
||||
quantity: p.quantity
|
||||
}))
|
||||
});
|
||||
|
||||
// Deduct stock for new products
|
||||
for (const p of aggregatedProducts) {
|
||||
await tx.product.update({
|
||||
where: { id: p.productId },
|
||||
data: { stock: { decrement: p.quantity } }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.sale.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
customerPos: { include: { customer: true } },
|
||||
products: { include: { product: true } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return sale;
|
||||
}
|
||||
|
||||
const sale = await prisma.sale.update({
|
||||
where: { id },
|
||||
@@ -134,8 +242,41 @@ export default async function salesRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
return sale;
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e?.message?.startsWith('Not enough stock')) {
|
||||
return reply.code(400).send({ error: e.message });
|
||||
}
|
||||
return reply.code(500).send({ error: 'Failed to update sale' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete sale (hard delete)
|
||||
app.delete('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
try {
|
||||
const existing = await prisma.sale.findUnique({
|
||||
where: { id },
|
||||
include: { products: true }
|
||||
});
|
||||
if (!existing) return reply.code(404).send({ error: 'Sale not found' });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Restore stock for all products in the sale
|
||||
for (const sp of existing.products) {
|
||||
await tx.product.update({
|
||||
where: { id: sp.productId },
|
||||
data: { stock: { increment: sp.quantity } }
|
||||
});
|
||||
}
|
||||
|
||||
// Delete sale products then the sale itself
|
||||
await tx.saleProduct.deleteMany({ where: { saleId: id } });
|
||||
await tx.sale.delete({ where: { id } });
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return reply.code(500).send({ error: 'Failed to delete sale' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { Prisma } from '../generated/prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
|
||||
function parseBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true') return true;
|
||||
if (normalized === 'false') return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface VisitsQuery {
|
||||
showVisited?: string;
|
||||
}
|
||||
|
||||
export default async function visitsRoutes(app: FastifyInstance) {
|
||||
// Get all visits (sales with nextVisitDate set)
|
||||
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
|
||||
const { showVisited } = request.query as VisitsQuery;
|
||||
const parsedShowVisited = parseBoolean(showVisited);
|
||||
|
||||
const where: Prisma.SaleWhereInput = {
|
||||
nextVisitDate: { not: null },
|
||||
visitedAt: parsedShowVisited === true ? { not: null } : null
|
||||
};
|
||||
|
||||
return prisma.sale.findMany({
|
||||
where,
|
||||
include: {
|
||||
customerPos: { include: { customer: true } },
|
||||
products: { include: { product: true } }
|
||||
},
|
||||
orderBy: { nextVisitDate: 'asc' }
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { prisma } from './prisma';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const existing = await prisma.user.findFirst();
|
||||
if (!existing) {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
// Environment Settings
|
||||
// See also https://aka.ms/tsconfig/module
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "es2022",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: polpa_gestao_db
|
||||
environment:
|
||||
POSTGRES_USER: admin
|
||||
POSTGRES_PASSWORD: adminpassword
|
||||
POSTGRES_DB: polpa_gestao
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d polpa_gestao"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
prisma-push:
|
||||
build:
|
||||
context: ./backend
|
||||
target: prisma
|
||||
image: ghcr.io/rmcampos/polpa-gestao/backend-prisma:latest
|
||||
container_name: polpa_gestao_prisma_push
|
||||
environment:
|
||||
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
|
||||
command: ["npx", "prisma", "migrate", "deploy"]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
image: ghcr.io/rmcampos/polpa-gestao/backend:latest
|
||||
container_name: polpa_gestao_backend
|
||||
environment:
|
||||
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
prisma-push:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "healthcheck.js"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_BACKEND_SERVER: "/api/.."
|
||||
VITE_CPF_CNPJ_API_TOKEN: ${VITE_CPF_CNPJ_API_TOKEN}
|
||||
VITE_BUILD_NUMBER: snapshot
|
||||
image: ghcr.io/rmcampos/polpa-gestao/frontend:latest
|
||||
container_name: polpa_gestao_frontend
|
||||
ports:
|
||||
- "5173:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- polpa-network
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
networks:
|
||||
polpa-network:
|
||||
external: true
|
||||
@@ -63,6 +63,7 @@ services:
|
||||
args:
|
||||
VITE_BACKEND_SERVER: http://localhost:3000
|
||||
VITE_CPF_CNPJ_API_TOKEN: ${VITE_CPF_CNPJ_API_TOKEN}
|
||||
VITE_BUILD_NUMBER: snapshot
|
||||
image: ghcr.io/rmcampos/polpa-gestao/frontend:latest
|
||||
container_name: polpa_gestao_frontend
|
||||
ports:
|
||||
@@ -76,4 +77,4 @@ volumes:
|
||||
pgdata:
|
||||
networks:
|
||||
polpa-network:
|
||||
driver: bridge
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
@@ -6,10 +6,12 @@ WORKDIR /app
|
||||
# Accept build arguments
|
||||
ARG VITE_BACKEND_SERVER
|
||||
ARG VITE_CPF_CNPJ_API_TOKEN
|
||||
ARG VITE_BUILD_NUMBER
|
||||
|
||||
# Set as environment variables for the build
|
||||
ENV VITE_BACKEND_SERVER=$VITE_BACKEND_SERVER
|
||||
ENV VITE_CPF_CNPJ_API_TOKEN=$VITE_CPF_CNPJ_API_TOKEN
|
||||
ENV VITE_BUILD_NUMBER=$VITE_BUILD_NUMBER
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>Polpa Gestão - Gestão de Polpas</title>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
|
||||
|
||||
@@ -10,27 +10,32 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"axios": "^1.15.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1",
|
||||
"globals": "^17.5.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
"vite-plugin-pwa": {
|
||||
"vite": "$vite"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 263 KiB |
@@ -7,6 +7,7 @@ import Customers from './pages/Customers';
|
||||
import Products from './pages/Products';
|
||||
import RoutesPage from './pages/Routes';
|
||||
import Sales from './pages/Sales';
|
||||
import Visits from './pages/Visits';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import { Toast } from './components/Toast';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
@@ -55,6 +56,7 @@ export default function App() {
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/sales" element={<Sales />} />
|
||||
<Route path="/visits" element={<Visits />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -23,12 +23,10 @@
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: var(--card-bg);
|
||||
background: #1e293b;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
@@ -97,7 +95,7 @@
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
background-color: rgba(15, 23, 42, 0.45);
|
||||
background-color: #0f172a;
|
||||
border-bottom-left-radius: 11px;
|
||||
border-bottom-right-radius: 11px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Customer } from '../types';
|
||||
import { formatCustomerPosDisplay } from '../utils/customerPos';
|
||||
|
||||
type CustomerPosOption = {
|
||||
id: string;
|
||||
customerName: string;
|
||||
customerPersonName: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
type CustomerPosComboboxProps = {
|
||||
customers: Customer[];
|
||||
selectedPosId: string;
|
||||
filterText: string;
|
||||
onFilterTextChange: (value: string) => void;
|
||||
onSelectPos: (posId: string, displayText: string) => void;
|
||||
};
|
||||
|
||||
const dropdownBaseColor = '#1e293b';
|
||||
const dropdownFocusColor = '#334155';
|
||||
const dropdownTextColor = '#f8fafc';
|
||||
|
||||
export function CustomerPosCombobox({
|
||||
customers,
|
||||
selectedPosId,
|
||||
filterText,
|
||||
onFilterTextChange,
|
||||
onSelectPos,
|
||||
}: CustomerPosComboboxProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return customers.flatMap((customer) => (customer.pos || [])
|
||||
.filter((pos) => Boolean(pos.id))
|
||||
.map((pos) => ({
|
||||
id: pos.id as string,
|
||||
customerName: customer.name,
|
||||
customerPersonName: customer.personName || '',
|
||||
address: pos.address,
|
||||
})));
|
||||
}, [customers]);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const normalized = filterText.trim().toLowerCase();
|
||||
if (!normalized) return options;
|
||||
|
||||
return options.filter((option) =>
|
||||
option.customerName.toLowerCase().includes(normalized)
|
||||
|| option.customerPersonName.toLowerCase().includes(normalized)
|
||||
|| option.address.toLowerCase().includes(normalized)
|
||||
);
|
||||
}, [options, filterText]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getOptionDisplay = (option: CustomerPosOption) =>
|
||||
formatCustomerPosDisplay(option.customerName, option.address);
|
||||
|
||||
const handleSelectOption = (option: CustomerPosOption) => {
|
||||
onSelectPos(option.id, getOptionDisplay(option));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const normalizedActiveIndex = isOpen && filteredOptions.length > 0
|
||||
? (activeIndex >= 0 && activeIndex < filteredOptions.length ? activeIndex : 0)
|
||||
: -1;
|
||||
const activeDescendantId = isOpen && normalizedActiveIndex >= 0 && filteredOptions[normalizedActiveIndex]
|
||||
? `customer-pos-option-${filteredOptions[normalizedActiveIndex].id}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="position-relative" ref={containerRef}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Search and select customer/POS..."
|
||||
value={filterText}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onChange={(e) => {
|
||||
onFilterTextChange(e.target.value);
|
||||
setIsOpen(true);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (filteredOptions.length === 0) return;
|
||||
setIsOpen(true);
|
||||
setActiveIndex((current) => (current + 1) % filteredOptions.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (filteredOptions.length === 0) return;
|
||||
setIsOpen(true);
|
||||
setActiveIndex((current) => (current <= 0 ? filteredOptions.length - 1 : current - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && isOpen && normalizedActiveIndex >= 0 && filteredOptions[normalizedActiveIndex]) {
|
||||
e.preventDefault();
|
||||
handleSelectOption(filteredOptions[normalizedActiveIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="customer-pos-combobox-options"
|
||||
aria-activedescendant={activeDescendantId}
|
||||
/>
|
||||
{isOpen && (
|
||||
<div
|
||||
id="customer-pos-combobox-options"
|
||||
role="listbox"
|
||||
className="list-group position-absolute w-100 mt-1"
|
||||
style={{
|
||||
zIndex: 1060,
|
||||
maxHeight: '260px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: dropdownBaseColor,
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option, index) => {
|
||||
const isSelected = selectedPosId === option.id;
|
||||
const isFocused = normalizedActiveIndex === index;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
id={`customer-pos-option-${option.id}`}
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action text-start ${isSelected ? 'active' : ''}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => {
|
||||
handleSelectOption(option);
|
||||
}}
|
||||
style={!isSelected ? { backgroundColor: isFocused ? dropdownFocusColor : dropdownBaseColor, color: dropdownTextColor } : undefined}
|
||||
>
|
||||
<div className="fw-semibold">{option.customerName}</div>
|
||||
<div className="small text-secondary">{option.address}</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="list-group-item text-secondary" style={{ backgroundColor: dropdownBaseColor }}>No matches found.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ interface SidebarProps {
|
||||
export default function Sidebar({ onLogout }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const userName = JSON.parse(localStorage.getItem('user') || '{}').name;
|
||||
const buildNumber = import.meta.env.VITE_BUILD_NUMBER || 'dev';
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const closeMenu = () => setIsOpen(false);
|
||||
@@ -84,9 +85,17 @@ export default function Sidebar({ onLogout }: SidebarProps) {
|
||||
>
|
||||
<i className="bi bi-cart-check me-2"></i> Sales
|
||||
</Link>
|
||||
<Link
|
||||
to="/visits"
|
||||
className={`nav-link ${location.pathname === '/visits' ? 'active' : ''}`}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<i className="bi bi-calendar-event me-2"></i> Visits
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto">
|
||||
<div className="text-muted small text-center mb-2">Build {buildNumber}</div>
|
||||
<button className="btn btn-outline-danger w-100" onClick={onLogout}>
|
||||
<i className="bi bi-box-arrow-right me-2"></i> Logout
|
||||
</button>
|
||||
|
||||
@@ -115,6 +115,12 @@ body {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.modal-content.glass-card {
|
||||
background-color: #1e293b; /* Solid Slate 800 */
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* Mobile top bar */
|
||||
.mobile-topbar {
|
||||
display: none;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '../context/toast';
|
||||
@@ -16,18 +16,21 @@ export default function Customers() {
|
||||
const toast = useToast();
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [newCustomer, setNewCustomer] = useState<Customer>({ name: '', document: '', phone: '' });
|
||||
const [newCustomer, setNewCustomer] = useState<Customer>({ name: '', document: '', phone: '', personName: '' });
|
||||
const emptyPos: CustomerPOS = { address: '', phone: '', personName: '', fridgeCount: 0 };
|
||||
const [editingCustomer, setEditingCustomer] = useState<string | null>(null);
|
||||
const [showPosModal, setShowPosModal] = useState(false);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
const [newPos, setNewPos] = useState<CustomerPOS>({ address: '', phone: '' });
|
||||
const [newPos, setNewPos] = useState<CustomerPOS>(emptyPos);
|
||||
const [editingPos, setEditingPos] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [docValidation, setDocValidation] = useState<{ valid: boolean | null, loading: boolean }>({ valid: null, loading: false });
|
||||
const [selectedPhone, setSelectedPhone] = useState<{ number: string, name: string } | null>(null);
|
||||
const [showPhoneModal, setShowPhoneModal] = useState(false);
|
||||
const [showDisabled, setShowDisabled] = useState<boolean>(false);
|
||||
const [filterText, setFilterText] = useState<string>('');
|
||||
const token = localStorage.getItem('token');
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
const cpfCnpjApiToken = import.meta.env.VITE_CPF_CNPJ_API_TOKEN || '';
|
||||
|
||||
const fetchCustomers = useCallback(async () => {
|
||||
@@ -44,6 +47,17 @@ export default function Customers() {
|
||||
fetchCustomers();
|
||||
}, [fetchCustomers]);
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
if (!filterText.trim()) return customers;
|
||||
const lower = filterText.toLowerCase();
|
||||
const digits = filterText.replace(/\D/g, '');
|
||||
return customers.filter((c: Customer) =>
|
||||
c.name.toLowerCase().includes(lower) ||
|
||||
(c.personName && c.personName.toLowerCase().includes(lower)) ||
|
||||
(digits && c.phone && c.phone.replace(/\D/g, '').includes(digits))
|
||||
);
|
||||
}, [customers, filterText]);
|
||||
|
||||
const handleSaveCustomer = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -55,7 +69,7 @@ export default function Customers() {
|
||||
await axios.post(`${apiBase}/api/customers`, newCustomer, config);
|
||||
}
|
||||
setShowModal(false);
|
||||
setNewCustomer({ name: '', document: '', phone: '' });
|
||||
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
|
||||
setEditingCustomer(null);
|
||||
fetchCustomers();
|
||||
toast.showToast(editingCustomer ? 'Customer updated successfully.' : 'Customer created successfully.', 'success');
|
||||
@@ -109,7 +123,7 @@ export default function Customers() {
|
||||
|
||||
const openNewModal = () => {
|
||||
setEditingCustomer(null);
|
||||
setNewCustomer({ name: '', document: '', phone: '' });
|
||||
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
|
||||
setDocValidation({ valid: null, loading: false });
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -125,32 +139,59 @@ export default function Customers() {
|
||||
return;
|
||||
}
|
||||
setEditingCustomer(c.id);
|
||||
setNewCustomer({ name: c.name, document: c.document, phone: c.phone || '' });
|
||||
setNewCustomer({ name: c.name, document: c.document || '', phone: c.phone || '', personName: c.personName || '' });
|
||||
setDocValidation({ valid: null, loading: false });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openPosModal = (c: Customer) => {
|
||||
setSelectedCustomer(c);
|
||||
setNewPos({ address: '', phone: '' });
|
||||
setNewPos(emptyPos);
|
||||
setEditingPos(null);
|
||||
setShowPosModal(true);
|
||||
};
|
||||
|
||||
const handleAddPos = async (e: React.FormEvent) => {
|
||||
const closePosModal = () => {
|
||||
setShowPosModal(false);
|
||||
setEditingPos(null);
|
||||
setNewPos(emptyPos);
|
||||
};
|
||||
|
||||
const openEditPos = (p: CustomerPOS) => {
|
||||
if (!p.id) {
|
||||
toast.showToast('POS ID is missing. Cannot edit this point of sale.', 'error');
|
||||
return;
|
||||
}
|
||||
setEditingPos(p.id);
|
||||
setNewPos({ address: p.address, phone: p.phone, personName: p.personName || '', fridgeCount: p.fridgeCount ?? 0 });
|
||||
};
|
||||
|
||||
const cancelEditPos = () => {
|
||||
setEditingPos(null);
|
||||
setNewPos(emptyPos);
|
||||
};
|
||||
|
||||
const handleSavePos = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedCustomer) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
await axios.post(`${apiBase}/api/customers/${selectedCustomer.id}/pos`, newPos, config);
|
||||
setNewPos({ address: '', phone: '' });
|
||||
if (editingPos) {
|
||||
await axios.put(`${apiBase}/api/customers/pos/${editingPos}`, newPos, config);
|
||||
toast.showToast('Point of sale updated successfully.', 'success');
|
||||
} else {
|
||||
await axios.post(`${apiBase}/api/customers/${selectedCustomer.id}/pos`, newPos, config);
|
||||
toast.showToast('Point of sale added successfully.', 'success');
|
||||
}
|
||||
setNewPos(emptyPos);
|
||||
setEditingPos(null);
|
||||
fetchCustomers();
|
||||
|
||||
const res = await axios.get(`${apiBase}/api/customers/${selectedCustomer.id}`, config);
|
||||
setSelectedCustomer(res.data);
|
||||
toast.showToast('Point of sale added successfully.', 'success');
|
||||
} catch (err) {
|
||||
toast.showToast(`Failed to add POS: ${toErrorMessage(err, 'Unknown error')}`, 'error');
|
||||
toast.showToast(`Failed to save POS: ${toErrorMessage(err, 'Unknown error')}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -198,7 +239,7 @@ export default function Customers() {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
await axios.delete(`${apiBase}/api/customers/${editingCustomer}`, config);
|
||||
setShowModal(false);
|
||||
setNewCustomer({ name: '', document: '', phone: '' });
|
||||
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
|
||||
setEditingCustomer(null);
|
||||
fetchCustomers();
|
||||
toast.showToast('Customer disabled successfully.', 'success');
|
||||
@@ -240,38 +281,57 @@ export default function Customers() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Filter by name, person, or phone..."
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row g-3">
|
||||
{customers.map((c: Customer) => (
|
||||
<div key={c.id} className="col-12 col-md-6 col-lg-4">
|
||||
<div className="glass-card p-3 h-100 d-flex flex-column">
|
||||
<div className="mb-2">
|
||||
<h5 className={`fw-bold m-0 ${c.disabledAt ? 'text-secondary' : 'text-white'}`}>{c.name}</h5>
|
||||
<div className="text-secondary small mt-1">
|
||||
<i className="bi bi-file-earmark-text me-1"></i>{formatDocument(c.document)}
|
||||
{filteredCustomers.map((c: Customer) => {
|
||||
const customerPersonName = c.personName?.trim();
|
||||
|
||||
return (
|
||||
<div key={c.id} className="col-12 col-md-6 col-lg-4">
|
||||
<div className="glass-card p-3 h-100 d-flex flex-column">
|
||||
<div className="mb-2">
|
||||
<h5 className={`fw-bold m-0 ${c.disabledAt ? 'text-secondary' : 'text-white'}`}>{c.name}</h5>
|
||||
{customerPersonName && (
|
||||
<div className="text-secondary small mt-1">
|
||||
<i className="bi bi-person me-1"></i>{customerPersonName}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-secondary small mt-1">
|
||||
<i className="bi bi-file-earmark-text me-1"></i>{c.document ? formatDocument(c.document) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex flex-column gap-1 text-secondary small mb-3">
|
||||
<div>
|
||||
<i className="bi bi-telephone me-1"></i>
|
||||
{c.phone ? (
|
||||
<button className="btn btn-link p-0 text-info fw-bold text-decoration-none align-baseline small" onClick={() => handlePhoneClick(c.phone, c.name)}>
|
||||
{formatPhone(c.phone)}
|
||||
</button>
|
||||
) : 'N/A'}
|
||||
<div className="d-flex flex-column gap-1 text-secondary small mb-3">
|
||||
<div>
|
||||
<i className="bi bi-telephone me-1"></i>
|
||||
{c.phone ? (
|
||||
<button className="btn btn-link p-0 text-info fw-bold text-decoration-none align-baseline small" onClick={() => handlePhoneClick(c.phone, c.name)}>
|
||||
{formatPhone(c.phone)}
|
||||
</button>
|
||||
) : 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<i className="bi bi-geo-alt me-1"></i>
|
||||
{c.pos?.length || 0} point{c.pos?.length !== 1 ? 's' : ''} of sale
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<i className="bi bi-geo-alt me-1"></i>
|
||||
{c.pos?.length || 0} point{c.pos?.length !== 1 ? 's' : ''} of sale
|
||||
<div className="mt-auto d-flex gap-2">
|
||||
<button className="btn btn-sm btn-outline-light flex-grow-1" onClick={() => openEditModal(c)}>Edit</button>
|
||||
<button className="btn btn-sm btn-outline-primary flex-grow-1" onClick={() => openPosModal(c)}>Manage POS</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto d-flex gap-2">
|
||||
<button className="btn btn-sm btn-outline-light flex-grow-1" onClick={() => openEditModal(c)}>Edit</button>
|
||||
<button className="btn btn-sm btn-outline-primary flex-grow-1" onClick={() => openPosModal(c)}>Manage POS</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{customers.length === 0 && (
|
||||
);
|
||||
})}
|
||||
{filteredCustomers.length === 0 && (
|
||||
<div className="col-12 text-center text-secondary mt-4">
|
||||
<p>No customers found.</p>
|
||||
</div>
|
||||
@@ -295,9 +355,20 @@ export default function Customers() {
|
||||
<input type="text" className="form-control" value={newCustomer.name} onChange={e => setNewCustomer({ ...newCustomer, name: e.target.value })} required />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-secondary">Document (CNPJ/CPF)</label>
|
||||
<label className="form-label text-secondary">Person Name (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={newCustomer.personName || ''}
|
||||
onChange={e => setNewCustomer({ ...newCustomer, personName: e.target.value })}
|
||||
placeholder="Responsible person name..."
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-secondary">Document (CNPJ/CPF) (Optional)</label>
|
||||
<div className="input-group">
|
||||
<input type="text" className="form-control" value={newCustomer.document} onChange={handleDocumentChange} placeholder="Type numbers only..." required />
|
||||
<input type="text" className="form-control" value={newCustomer.document || ''} onChange={handleDocumentChange} placeholder="Type numbers only..." />
|
||||
{docValidation.loading && <span className="input-group-text bg-secondary text-white border-secondary">...</span>}
|
||||
{!docValidation.loading && docValidation.valid === true && <span className="input-group-text bg-success text-white border-success">Valid</span>}
|
||||
{!docValidation.loading && docValidation.valid === false && <span className="input-group-text bg-danger text-white border-danger">Invalid</span>}
|
||||
@@ -331,7 +402,7 @@ export default function Customers() {
|
||||
<div className="modal-content glass-card">
|
||||
<div className="modal-header border-bottom-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<h5 className="modal-title text-white">Manage POS - {selectedCustomer.name}</h5>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={() => setShowPosModal(false)}></button>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={closePosModal}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
|
||||
@@ -341,6 +412,11 @@ export default function Customers() {
|
||||
<li key={p.id} className="list-group-item d-flex justify-content-between align-items-center bg-dark text-white border-secondary">
|
||||
<div>
|
||||
<strong>{p.address}</strong>
|
||||
{p.personName && (
|
||||
<div className="text-secondary small">
|
||||
Contact: {p.personName}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-secondary small">
|
||||
Phone: {p.phone ? (
|
||||
<button
|
||||
@@ -351,8 +427,14 @@ export default function Customers() {
|
||||
</button>
|
||||
) : 'N/A'}
|
||||
</div>
|
||||
<div className="text-secondary small">
|
||||
Fridges: {p.fridgeCount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button className="btn btn-sm btn-outline-primary" onClick={() => openEditPos(p)}>Edit</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => handleDeletePos(p.id)}>Remove</button>
|
||||
</div>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => handleDeletePos(p.id)}>Remove</button>
|
||||
</li>
|
||||
))}
|
||||
{(!selectedCustomer.pos || selectedCustomer.pos.length === 0) && (
|
||||
@@ -362,10 +444,10 @@ export default function Customers() {
|
||||
|
||||
<div className="card bg-transparent border-secondary">
|
||||
<div className="card-header border-secondary text-white fw-bold bg-dark bg-opacity-50">
|
||||
Add New POS
|
||||
{editingPos ? 'Edit POS' : 'Add New POS'}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={handleAddPos}>
|
||||
<form onSubmit={handleSavePos}>
|
||||
<div className="row g-3">
|
||||
<div className="col-md-7">
|
||||
<label className="form-label text-secondary small">Address</label>
|
||||
@@ -373,10 +455,44 @@ export default function Customers() {
|
||||
</div>
|
||||
<div className="col-md-5">
|
||||
<label className="form-label text-secondary small">Phone (Optional)</label>
|
||||
<div className="d-flex gap-2">
|
||||
<input type="text" className="form-control form-control-sm" value={newPos.phone} onChange={e => setNewPos({ ...newPos, phone: formatPhone(e.target.value) })} placeholder="(11) 99999-9999" maxLength={15} />
|
||||
<button type="submit" className="btn btn-sm btn-success" disabled={loading}>{loading ? '...' : 'Add'}</button>
|
||||
</div>
|
||||
<input type="text" className="form-control form-control-sm" value={newPos.phone} onChange={e => setNewPos({ ...newPos, phone: formatPhone(e.target.value) })} placeholder="(11) 99999-9999" maxLength={15} />
|
||||
</div>
|
||||
<div className="col-md-7">
|
||||
<label className="form-label text-secondary small">Person Name (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={newPos.personName || ''}
|
||||
onChange={e =>
|
||||
setNewPos({
|
||||
...newPos,
|
||||
personName: e.target.value.slice(0, 30),
|
||||
})
|
||||
}
|
||||
placeholder="Contact person name..."
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label text-secondary small">Fridges</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control-sm"
|
||||
value={newPos.fridgeCount ?? 0}
|
||||
onChange={e =>
|
||||
setNewPos({
|
||||
...newPos,
|
||||
fridgeCount: Math.max(0, Number.parseInt(e.target.value || '0', 10) || 0),
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3 d-flex align-items-end gap-2">
|
||||
{editingPos && (
|
||||
<button type="button" className="btn btn-sm btn-outline-secondary w-100" onClick={cancelEditPos}>Cancel</button>
|
||||
)}
|
||||
<button type="submit" className="btn btn-sm btn-success w-100" disabled={loading}>{loading ? '...' : editingPos ? 'Save' : 'Add'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -385,7 +501,7 @@ export default function Customers() {
|
||||
|
||||
</div>
|
||||
<div className="modal-footer border-top-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<button type="button" className="btn btn-outline-light" onClick={() => setShowPosModal(false)}>Close</button>
|
||||
<button type="button" className="btn btn-outline-light" onClick={closePosModal}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,175 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import type { SalesByCustomer, SalesByRoute } from '../types';
|
||||
import type { SalesByCustomer, SalesByProduct, SalesSummary } from '../types';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [salesByRoute, setSalesByRoute] = useState<SalesByRoute[]>([]);
|
||||
const [range, setRange] = useState('last-30-days');
|
||||
const [salesByProduct, setSalesByProduct] = useState<SalesByProduct[]>([]);
|
||||
const [salesByCustomer, setSalesByCustomer] = useState<SalesByCustomer[]>([]);
|
||||
const [salesSummary, setSalesSummary] = useState<SalesSummary | null>(null);
|
||||
const token = localStorage.getItem('token');
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const ranges = [
|
||||
{ label: 'This Week', value: 'this-week' },
|
||||
{ label: 'This Month', value: 'this-month' },
|
||||
{ label: 'This Year', value: 'this-year' },
|
||||
{ label: 'Past Week', value: 'past-week' },
|
||||
{ label: 'Past Month', value: 'past-month' },
|
||||
{ label: 'Past Year', value: 'past-year' },
|
||||
{ label: 'Last 7 Days', value: 'last-7-days' },
|
||||
{ label: 'Last 14 Days', value: 'last-14-days' },
|
||||
{ label: 'Last 30 Days', value: 'last-30-days' },
|
||||
{ label: 'Last 90 Days', value: 'last-90-days' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDashboard = async () => {
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const resRoutes = await axios.get(`${apiBase}/api/dashboard/sales-by-route`, config);
|
||||
const config = {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: { range }
|
||||
};
|
||||
const resProducts = await axios.get(`${apiBase}/api/dashboard/sales-by-product`, config);
|
||||
const resCustomers = await axios.get(`${apiBase}/api/dashboard/sales-by-customer`, config);
|
||||
const resSummary = await axios.get(`${apiBase}/api/dashboard/sales-summary`, config);
|
||||
|
||||
setSalesByRoute(resRoutes.data);
|
||||
setSalesByProduct(resProducts.data);
|
||||
setSalesByCustomer(resCustomers.data);
|
||||
setSalesSummary(resSummary.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard', err);
|
||||
}
|
||||
};
|
||||
fetchDashboard();
|
||||
}, [token, apiBase]);
|
||||
}, [token, apiBase, range]);
|
||||
|
||||
const top3Products = [...salesByProduct]
|
||||
.sort((a, b) => b.totalQuantity - a.totalQuantity)
|
||||
.slice(0, 3);
|
||||
|
||||
const top3Customers = [...salesByCustomer]
|
||||
.sort((a, b) => b.totalAmount - a.totalAmount)
|
||||
.slice(0, 3);
|
||||
|
||||
const { totalCustomers, totalFridges } = salesSummary ?? { totalCustomers: 0, totalFridges: 0 };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-4 fw-bold">Dashboard</h2>
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 className="m-0 fw-bold">Dashboard</h2>
|
||||
<div style={{ width: '200px' }}>
|
||||
<select
|
||||
className="form-select bg-dark text-white border-secondary"
|
||||
aria-label="Date range"
|
||||
value={range}
|
||||
onChange={(e) => setRange(e.target.value)}
|
||||
>
|
||||
{ranges.map((r) => (
|
||||
<option key={r.value} value={r.value}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="row g-4 mb-4">
|
||||
{/* Total Sales Value */}
|
||||
<div className="col-12 col-sm-6 col-lg-3">
|
||||
<div className="glass-card p-4 h-100">
|
||||
<h6 className="text-secondary mb-2">Total Sales Value</h6>
|
||||
<h3 className="fw-bold text-success mb-0">
|
||||
R$ {salesSummary ? salesSummary.totalAmount.toFixed(2) : '—'}
|
||||
</h3>
|
||||
<small className="text-secondary">
|
||||
{salesSummary ? `${salesSummary.totalSales} sale(s)` : ''}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Average Sale Value */}
|
||||
<div className="col-12 col-sm-6 col-lg-3">
|
||||
<div className="glass-card p-4 h-100">
|
||||
<h6 className="text-secondary mb-2">Average Sale Value</h6>
|
||||
<h3 className="fw-bold text-primary mb-0">
|
||||
R$ {salesSummary ? salesSummary.averageAmount.toFixed(2) : '—'}
|
||||
</h3>
|
||||
<small className="text-secondary">per sale</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Customers */}
|
||||
<div className="col-12 col-sm-6 col-lg-3">
|
||||
<div className="glass-card p-4 h-100">
|
||||
<h6 className="text-secondary mb-2">Total Customers</h6>
|
||||
<h3 className="fw-bold text-info mb-0">{salesSummary ? totalCustomers : '—'}</h3>
|
||||
<small className="text-secondary">active customers</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Fridges */}
|
||||
<div className="col-12 col-sm-6 col-lg-3">
|
||||
<div className="glass-card p-4 h-100">
|
||||
<h6 className="text-secondary mb-2">Total Fridges</h6>
|
||||
<h3 className="fw-bold text-warning mb-0">{salesSummary ? totalFridges : '—'}</h3>
|
||||
<small className="text-secondary">active POS total</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top 3 Products */}
|
||||
<div className="col-12 col-sm-6 col-lg-3">
|
||||
<div className="glass-card p-4 h-100">
|
||||
<h6 className="text-secondary mb-2">Top 3 Products</h6>
|
||||
{top3Products.length === 0 ? (
|
||||
<p className="text-secondary mb-0 small">No data available.</p>
|
||||
) : (
|
||||
<ol className="mb-0 ps-3">
|
||||
{top3Products.map((item) => (
|
||||
<li key={item.productId} className="text-white mb-1">
|
||||
<span>{item.productName}</span>
|
||||
<br />
|
||||
<small className="text-secondary">{item.totalQuantity} items sold</small>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top 3 Customers */}
|
||||
<div className="col-12 col-sm-6 col-lg-3">
|
||||
<div className="glass-card p-4 h-100">
|
||||
<h6 className="text-secondary mb-2">Top 3 Customers</h6>
|
||||
{top3Customers.length === 0 ? (
|
||||
<p className="text-secondary mb-0 small">No data available.</p>
|
||||
) : (
|
||||
<ol className="mb-0 ps-3">
|
||||
{top3Customers.map((item) => (
|
||||
<li key={item.customerId} className="text-white mb-1">
|
||||
<span>{item.customerName}</span>
|
||||
<br />
|
||||
<small className="text-secondary">R$ {item.totalAmount.toFixed(2)}</small>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-4">
|
||||
{/* Sales by Route */}
|
||||
{/* Sales by Product */}
|
||||
<div className="col-12 col-md-6">
|
||||
<div className="glass-card p-4 h-100">
|
||||
<h4 className="mb-3">Sales by Route</h4>
|
||||
{salesByRoute.length === 0 ? <p className="text-secondary">No data available.</p> : (
|
||||
<h4 className="mb-3">Sales by Product</h4>
|
||||
{salesByProduct.length === 0 ? <p className="text-secondary">No data available.</p> : (
|
||||
<ul className="list-group list-group-flush" style={{ background: 'transparent' }}>
|
||||
{salesByRoute.map((item: SalesByRoute) => (
|
||||
<li key={item.routeId} className="list-group-item d-flex justify-content-between align-items-center text-white" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
|
||||
{item.routeName}
|
||||
{salesByProduct.map((item: SalesByProduct) => (
|
||||
<li key={item.productId} className="list-group-item d-flex justify-content-between align-items-center text-white" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
|
||||
<div>
|
||||
<div>{item.productName}</div>
|
||||
<small className="text-secondary">{item.totalQuantity} items sold</small>
|
||||
</div>
|
||||
<span className="badge bg-primary rounded-pill">R$ {item.totalAmount.toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -54,7 +186,10 @@ export default function Dashboard() {
|
||||
<ul className="list-group list-group-flush" style={{ background: 'transparent' }}>
|
||||
{salesByCustomer.map((item: SalesByCustomer) => (
|
||||
<li key={item.customerId} className="list-group-item d-flex justify-content-between align-items-center text-white" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
|
||||
{item.customerName}
|
||||
<div>
|
||||
<div>{item.customerName}</div>
|
||||
<small className="text-secondary">{item.totalSales} purchases</small>
|
||||
</div>
|
||||
<span className="badge bg-success rounded-pill">R$ {item.totalAmount.toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function Login({ setToken }: LoginProps) {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function Products() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDisabled, setShowDisabled] = useState<boolean>(false);
|
||||
const token = localStorage.getItem('token');
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const fetchProducts = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function RoutesPage() {
|
||||
const [editingRoute, setEditingRoute] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const token = localStorage.getItem('token');
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const fetchRoutes = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '../context/toast';
|
||||
import type { Customer, CustomerPOS, Product, Sale, SaleProduct } from '../types';
|
||||
import { CustomerPosCombobox } from '../components/CustomerPosCombobox';
|
||||
import type { Customer, Product, Sale, SaleProduct } from '../types';
|
||||
import { formatCustomerPosDisplay } from '../utils/customerPos';
|
||||
|
||||
type ProductCart = {
|
||||
productId: string;
|
||||
@@ -12,19 +14,14 @@ type ProductCart = {
|
||||
|
||||
const formatCurrency = (value: number) => `R$ ${value.toFixed(2)}`;
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleDateString('pt-BR');
|
||||
|
||||
const formatDateTime = (value: string) => new Date(value).toLocaleString('pt-BR');
|
||||
|
||||
const buildSaleCopyText = (sale: Sale) => {
|
||||
const customerName = sale.customerPos?.customer?.name || 'Unknown';
|
||||
const customerDocument = sale.customerPos?.customer?.document || 'N/A';
|
||||
const customerPerson = sale.customerPos?.personName || sale.customerPos?.customer?.personName || 'N/A';
|
||||
const customerPhone = sale.customerPos?.customer?.phone || 'N/A';
|
||||
const posAddress = sale.customerPos?.address || 'Unknown';
|
||||
const posPhone = sale.customerPos?.phone || 'N/A';
|
||||
const paymentStatus = sale.paymentDate ? 'Pago' : 'Pendente';
|
||||
const saleDate = formatDateTime(sale.createdAt);
|
||||
const dueDate = !sale.paymentDate && sale.paymentDueDate ? formatDate(sale.paymentDueDate) : null;
|
||||
|
||||
const products = (sale.products || []).map((sp: SaleProduct) => {
|
||||
const productName = sp.product?.name || 'Unknown Product';
|
||||
@@ -54,32 +51,21 @@ const buildSaleCopyText = (sale: Sale) => {
|
||||
const lines: string[] = [
|
||||
'Novo pedido gerado com sucesso! 🚀',
|
||||
`Data: ${saleDate}`,
|
||||
'',
|
||||
'*CLIENTE*',
|
||||
`- Nome: ${customerName}`,
|
||||
`- CPF/CNPJ: ${customerDocument}`,
|
||||
`- Responsável: ${customerPerson}`,
|
||||
`- Telefone: ${customerPhone}`,
|
||||
'',
|
||||
'*ENTREGA* 📦',
|
||||
`- Endereço: ${posAddress}`,
|
||||
`- Telefone do Ponto de Venda: ${posPhone}`,
|
||||
'',
|
||||
'*ITENS* 🛒',
|
||||
...(products.length > 0 ? products : ['- Nenhum item']),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
`Total 💰: ${formatCurrency(total)}`,
|
||||
`Pagamento: ${translatePaymentMethod(sale.paymentMethod)}`,
|
||||
`Status: ${paymentStatus}`,
|
||||
];
|
||||
|
||||
if (dueDate) {
|
||||
lines.push(`Data de Vencimento: ${dueDate}`);
|
||||
}
|
||||
|
||||
if (sale.comments) {
|
||||
lines.push('', `Observações: ${sale.comments}`);
|
||||
lines.push(`Observações: ${sale.comments}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
@@ -96,15 +82,20 @@ export default function Sales() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [togglingSaleId, setTogglingSaleId] = useState<string | null>(null);
|
||||
const [togglingDeliverySaleId, setTogglingDeliverySaleId] = useState<string | null>(null);
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const [customerPosId, setCustomerPosId] = useState('');
|
||||
const [paymentMethod, setPaymentMethod] = useState('Cash');
|
||||
const [paymentDueDate, setPaymentDueDate] = useState('');
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [comments, setComments] = useState('');
|
||||
const [nextVisitDate, setNextVisitDate] = useState('');
|
||||
const [cart, setCart] = useState<ProductCart[]>([]);
|
||||
const [showDelivered, setShowDelivered] = useState(false);
|
||||
const [filterText, setFilterText] = useState<string>('');
|
||||
const [customerFilter, setCustomerFilter] = useState('');
|
||||
const [editingMode, setEditingMode] = useState(false);
|
||||
const [editingSaleId, setEditingSaleId] = useState<string | null>(null);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
@@ -128,16 +119,76 @@ export default function Sales() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const filteredSales = useMemo(() => {
|
||||
if (!filterText.trim()) return sales;
|
||||
const lower = filterText.toLowerCase();
|
||||
return sales.filter((sale: Sale) =>
|
||||
(sale.customerPos?.customer?.name || '').toLowerCase().includes(lower) ||
|
||||
(sale.customerPos?.personName || '').toLowerCase().includes(lower) ||
|
||||
(sale.customerPos?.customer?.personName || '').toLowerCase().includes(lower) ||
|
||||
(sale.customerPos?.address || '').toLowerCase().includes(lower)
|
||||
);
|
||||
}, [sales, filterText]);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setEditingMode(false);
|
||||
setEditingSaleId(null);
|
||||
setCustomerFilter('');
|
||||
setCustomerPosId('');
|
||||
setPaymentMethod('Cash');
|
||||
setPaymentDueDate('');
|
||||
setPaymentDate('');
|
||||
setComments('');
|
||||
setNextVisitDate('');
|
||||
setCart([]);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (sale: Sale) => {
|
||||
if (!sale.id) {
|
||||
toast.showToast('Invalid sale data.', 'error');
|
||||
return;
|
||||
}
|
||||
setEditingMode(true);
|
||||
setEditingSaleId(sale.id || null);
|
||||
const saleCustomerName = sale.customerPos?.customer?.name ?? '';
|
||||
const saleCustomerAddress = sale.customerPos?.address ?? '';
|
||||
setCustomerFilter(saleCustomerAddress ? formatCustomerPosDisplay(saleCustomerName, saleCustomerAddress) : '');
|
||||
setCustomerPosId(sale.customerPosId);
|
||||
setPaymentMethod(sale.paymentMethod);
|
||||
let paymentDueDateValue = '';
|
||||
if (sale.paymentDueDate) {
|
||||
const dueDate = new Date(sale.paymentDueDate);
|
||||
paymentDueDateValue = dueDate.toISOString().slice(0, 10); // Format as YYYY-MM-DD for input value (UTC, timezone-stable)
|
||||
}
|
||||
setPaymentDueDate(paymentDueDateValue);
|
||||
let paymentDateValue = '';
|
||||
if (sale.paymentDate) {
|
||||
const payDate = new Date(sale.paymentDate);
|
||||
paymentDateValue = payDate.toISOString().slice(0, 10); // Format as YYYY-MM-DD for input value (UTC, timezone-stable)
|
||||
}
|
||||
setPaymentDate(paymentDateValue);
|
||||
setComments(sale.comments || '');
|
||||
let nextVisitDateValue = '';
|
||||
if (sale.nextVisitDate) {
|
||||
const nvDate = new Date(sale.nextVisitDate);
|
||||
nextVisitDateValue = nvDate.toISOString().slice(0, 10);
|
||||
}
|
||||
setNextVisitDate(nextVisitDateValue);
|
||||
setCart((sale.products || []).map((sp: SaleProduct) => ({
|
||||
productId: sp.productId,
|
||||
quantity: sp.quantity,
|
||||
price: sp.product?.price || 0,
|
||||
})));
|
||||
setShowDetailsModal(false);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setCustomerFilter('');
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleAddCartItem = () => {
|
||||
setCart([...cart, { productId: '', quantity: 1, price: 0 }]);
|
||||
};
|
||||
@@ -176,20 +227,28 @@ export default function Sales() {
|
||||
paymentDueDate: paymentDueDate ? new Date(paymentDueDate).toISOString() : null,
|
||||
paymentDate: paymentDate ? new Date(paymentDate).toISOString() : null,
|
||||
comments,
|
||||
nextVisitDate: nextVisitDate ? new Date(nextVisitDate).toISOString() : null,
|
||||
products: cart.map(item => ({ productId: item.productId, quantity: item.quantity }))
|
||||
};
|
||||
|
||||
await axios.post(`${apiBase}/api/sales`, payload, config);
|
||||
setShowModal(false);
|
||||
fetchData();
|
||||
toast.showToast('Sale recorded successfully.', 'success');
|
||||
if (editingMode && editingSaleId) {
|
||||
await axios.put(`${apiBase}/api/sales/${editingSaleId}`, payload, config);
|
||||
handleCloseModal();
|
||||
fetchData();
|
||||
toast.showToast('Sale updated successfully.', 'success');
|
||||
} else {
|
||||
await axios.post(`${apiBase}/api/sales`, payload, config);
|
||||
handleCloseModal();
|
||||
fetchData();
|
||||
toast.showToast('Sale recorded successfully.', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
console.error('Error response:', err.response);
|
||||
toast.showToast(err.response?.data?.error || 'Failed to record sale.', 'error');
|
||||
toast.showToast(err.response?.data?.error || (editingMode ? 'Failed to update sale.' : 'Failed to record sale.'), 'error');
|
||||
} else {
|
||||
console.error('Unexpected error:', err);
|
||||
toast.showToast('An unexpected error occurred while recording the sale.', 'error');
|
||||
toast.showToast('An unexpected error occurred.', 'error');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -232,6 +291,67 @@ export default function Sales() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneSale = async (sale: Sale) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const payload = {
|
||||
customerPosId: sale.customerPosId,
|
||||
paymentMethod: sale.paymentMethod,
|
||||
paymentDueDate: sale.paymentDueDate || null,
|
||||
paymentDate: sale.paymentDate || null,
|
||||
comments: sale.comments || '',
|
||||
products: (sale.products || []).map((sp: SaleProduct) => ({
|
||||
productId: sp.productId,
|
||||
quantity: sp.quantity,
|
||||
})),
|
||||
};
|
||||
await axios.post(`${apiBase}/api/sales`, payload, config);
|
||||
fetchData();
|
||||
toast.showToast('Sale cloned successfully.', 'success');
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
toast.showToast(err.response?.data?.error || 'Failed to clone sale.', 'error');
|
||||
} else {
|
||||
toast.showToast('Failed to clone sale.', 'error');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSale = async (sale: Sale) => {
|
||||
if (!sale.id) {
|
||||
toast.showToast('Invalid sale data.', 'error');
|
||||
return;
|
||||
}
|
||||
const confirmed = await toast.confirm({
|
||||
title: 'Delete Sale',
|
||||
message: 'Are you sure you want to delete this sale? This action cannot be undone.',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
isDangerous: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
await axios.delete(`${apiBase}/api/sales/${sale.id}`, config);
|
||||
setShowDetailsModal(false);
|
||||
fetchData();
|
||||
toast.showToast('Sale deleted successfully.', 'success');
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
toast.showToast(err.response?.data?.error || 'Failed to delete sale.', 'error');
|
||||
} else {
|
||||
toast.showToast('Failed to delete sale.', 'error');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePaymentStatus = async (sale: Sale) => {
|
||||
if (!sale.id) {
|
||||
toast.showToast('Invalid sale data.', 'error');
|
||||
@@ -366,8 +486,18 @@ export default function Sales() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Filter by customer name, person, or address..."
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row g-3">
|
||||
{sales.map((sale: Sale) => {
|
||||
{filteredSales.map((sale: Sale) => {
|
||||
const total = sale.products?.reduce((acc: number, sp: SaleProduct) => acc + (sp.quantity * (sp.product?.price ?? 0)), 0) || 0;
|
||||
return (
|
||||
<div key={sale.id} className="col-12 col-md-6">
|
||||
@@ -391,29 +521,46 @@ export default function Sales() {
|
||||
<i className="bi bi-calendar-event me-1"></i>Due: {new Date(sale.paymentDueDate).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto d-flex justify-content-between align-items-center pt-2">
|
||||
<strong className="text-success fs-5">{formatCurrency(total)}</strong>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => handleCopySale(sale)}
|
||||
>
|
||||
<i className="bi bi-share me-1"></i>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-light"
|
||||
onClick={() => {
|
||||
setSelectedSale(sale);
|
||||
setShowDetailsModal(true);
|
||||
}}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<div className="mt-auto d-flex justify-content-between align-items-center pt-2">
|
||||
<strong className="text-success fs-5">{formatCurrency(total)}</strong>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => handleCopySale(sale)}
|
||||
>
|
||||
<i className="bi bi-share me-1"></i>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => handleCloneSale(sale)}
|
||||
disabled={loading}
|
||||
>
|
||||
<i className="bi bi-copy me-1"></i>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-warning"
|
||||
onClick={() => handleOpenEditModal(sale)}
|
||||
>
|
||||
<i className="bi bi-pencil me-1"></i>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-light"
|
||||
onClick={() => {
|
||||
setSelectedSale(sale);
|
||||
setShowDetailsModal(true);
|
||||
}}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -432,8 +579,8 @@ export default function Sales() {
|
||||
<div className="modal-dialog modal-dialog-centered modal-xl scrollable-modal">
|
||||
<div className="modal-content glass-card">
|
||||
<div className="modal-header border-bottom-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<h5 className="modal-title text-white">Record New Sale</h5>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={() => setShowModal(false)}></button>
|
||||
<h5 className="modal-title text-white">{editingMode ? 'Edit Sale' : 'Record New Sale'}</h5>
|
||||
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
|
||||
</div>
|
||||
<form onSubmit={handleSaveSale}>
|
||||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
@@ -442,18 +589,21 @@ export default function Sales() {
|
||||
<h6 className="text-secondary fw-bold mb-3">Customer & Payment</h6>
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-secondary small">Customer Point of Sale</label>
|
||||
<select className="form-select" value={customerPosId} onChange={e => setCustomerPosId(e.target.value)} required>
|
||||
<option value="" disabled>Select POS...</option>
|
||||
{customers.map((c: Customer) => (
|
||||
c.pos && c.pos.length > 0 && (
|
||||
<optgroup key={c.id} label={c.name}>
|
||||
{c.pos.map((p: CustomerPOS) => (
|
||||
<option key={p.id} value={p.id}>{p.address}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)
|
||||
))}
|
||||
</select>
|
||||
<CustomerPosCombobox
|
||||
customers={customers}
|
||||
selectedPosId={customerPosId}
|
||||
filterText={customerFilter}
|
||||
onFilterTextChange={(value) => {
|
||||
setCustomerFilter(value);
|
||||
if (customerPosId) {
|
||||
setCustomerPosId('');
|
||||
}
|
||||
}}
|
||||
onSelectPos={(posId, displayText) => {
|
||||
setCustomerPosId(posId);
|
||||
setCustomerFilter(displayText);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-secondary small">Payment Method</label>
|
||||
@@ -479,6 +629,10 @@ export default function Sales() {
|
||||
<label className="form-label text-secondary small">Comments / Notes</label>
|
||||
<textarea className="form-control" rows={3} value={comments} onChange={e => setComments(e.target.value)} placeholder="Any specific requirements..."></textarea>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label text-secondary small">Next Visit Date (Optional)</label>
|
||||
<input type="date" className="form-control" value={nextVisitDate} onChange={e => setNextVisitDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-8">
|
||||
@@ -505,7 +659,7 @@ export default function Sales() {
|
||||
<select className="form-select form-select-sm" value={item.productId} onChange={(e) => handleUpdateCartItem(idx, 'productId', e.target.value)} required>
|
||||
<option value="" disabled>Select product...</option>
|
||||
{products.map((p: Product) => (
|
||||
<option key={p.id} value={p.id} disabled={p.stock <= 0}>
|
||||
<option key={p.id} value={p.id} disabled={p.stock <= 0 && p.id !== item.productId}>
|
||||
{p.name} {p.stock <= 0 ? '(Out of Stock)' : `(${p.stock} in stock)`}
|
||||
</option>
|
||||
))}
|
||||
@@ -534,8 +688,8 @@ export default function Sales() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer border-top-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<button type="button" className="btn btn-outline-light" onClick={() => setShowModal(false)}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary btn-lg px-4" disabled={loading || cart.length === 0}>{loading ? 'Processing...' : 'Complete Sale'}</button>
|
||||
<button type="button" className="btn btn-outline-light" onClick={handleCloseModal}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary btn-lg px-4" disabled={loading || cart.length === 0}>{loading ? 'Processing...' : editingMode ? 'Save Changes' : 'Complete Sale'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -560,7 +714,7 @@ export default function Sales() {
|
||||
<div className="col-md-6">
|
||||
<h6 className="text-secondary fw-bold mb-2">Customer Info</h6>
|
||||
<p className="mb-1"><strong>Name:</strong> {selectedSale.customerPos?.customer?.name || 'Unknown'}</p>
|
||||
<p className="mb-1"><strong>Document:</strong> {selectedSale.customerPos?.customer?.document || 'N/A'}</p>
|
||||
<p className="mb-1"><strong>Person:</strong> {selectedSale.customerPos?.personName || selectedSale.customerPos?.customer?.personName || 'N/A'}</p>
|
||||
<p className="mb-1"><strong>POS Address:</strong> {selectedSale.customerPos?.address || 'Unknown'}</p>
|
||||
<p className="mb-0"><strong>POS Phone:</strong> {selectedSale.customerPos?.phone || 'N/A'}</p>
|
||||
</div>
|
||||
@@ -608,6 +762,32 @@ export default function Sales() {
|
||||
|
||||
</div>
|
||||
<div className="modal-footer border-top-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger me-auto"
|
||||
onClick={() => handleDeleteSale(selectedSale)}
|
||||
disabled={loading}
|
||||
>
|
||||
<i className="bi bi-trash me-1"></i>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => handleCloneSale(selectedSale)}
|
||||
disabled={loading}
|
||||
>
|
||||
<i className="bi bi-copy me-1"></i>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-warning"
|
||||
onClick={() => handleOpenEditModal(selectedSale)}
|
||||
>
|
||||
<i className="bi bi-pencil me-1"></i>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline-light" onClick={() => setShowDetailsModal(false)}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function Users() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDisabled, setShowDisabled] = useState<boolean>(false);
|
||||
const token = localStorage.getItem('token');
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || 'http://localhost:3000';
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '../context/toast';
|
||||
import type { Sale } from '../types';
|
||||
|
||||
const formatDate = (value: string | null) => {
|
||||
if (value) {
|
||||
const valueDate = new Date(value);
|
||||
const formatted = valueDate.toLocaleDateString('pt-BR');
|
||||
switch (valueDate.getDay()) {
|
||||
case 0: return `${formatted} - Sunday`;
|
||||
case 1: return `${formatted} - Monday`;
|
||||
case 2: return `${formatted} - Tuesday`;
|
||||
case 3: return `${formatted} - Wednesday`;
|
||||
case 4: return `${formatted} - Thursday`;
|
||||
case 5: return `${formatted} - Friday`;
|
||||
case 6: return `${formatted} - Saturday`;
|
||||
}
|
||||
}
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
const formatDateTime = (value: string) =>
|
||||
new Date(value).toLocaleString('pt-BR');
|
||||
|
||||
export default function Visits() {
|
||||
const toast = useToast();
|
||||
const [visits, setVisits] = useState<Sale[]>([]);
|
||||
const [showVisited, setShowVisited] = useState(false);
|
||||
const [togglingVisitId, setTogglingVisitId] = useState<string | null>(null);
|
||||
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const fetchVisits = useCallback(async () => {
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const res = await axios.get(`${apiBase}/api/visits?showVisited=${showVisited}`, config);
|
||||
setVisits(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load visits', err);
|
||||
toast.showToast('Failed to load visits.', 'error');
|
||||
}
|
||||
}, [token, apiBase, showVisited, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVisits();
|
||||
}, [fetchVisits]);
|
||||
|
||||
const handleMarkVisited = async (visit: Sale) => {
|
||||
if (!visit.id || togglingVisitId === visit.id) return;
|
||||
|
||||
setTogglingVisitId(visit.id);
|
||||
try {
|
||||
const config = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const nextVisitedAt = visit.visitedAt ? null : new Date().toISOString();
|
||||
await axios.put(`${apiBase}/api/sales/${visit.id}`, { visitedAt: nextVisitedAt }, config);
|
||||
toast.showToast(nextVisitedAt ? 'Visit marked as completed.' : 'Visit marked as pending.', 'success');
|
||||
await fetchVisits();
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
toast.showToast(err.response?.data?.error || 'Failed to update visit status.', 'error');
|
||||
} else {
|
||||
toast.showToast('An unexpected error occurred.', 'error');
|
||||
}
|
||||
} finally {
|
||||
setTogglingVisitId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 className="fw-bold m-0">Visits</h2>
|
||||
<div className="form-check form-switch m-0">
|
||||
<input
|
||||
id="show-visited"
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={showVisited}
|
||||
onChange={(e) => setShowVisited(e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label text-secondary" htmlFor="show-visited">
|
||||
Visited
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-3">
|
||||
{visits.map((visit: Sale) => {
|
||||
const isUpdating = togglingVisitId === visit.id;
|
||||
const isVisited = Boolean(visit.visitedAt);
|
||||
const customerName = visit.customerPos?.customer?.name || 'Unknown';
|
||||
const posPersonName = visit.customerPos?.personName || visit.customerPos?.customer?.personName || 'N/A';
|
||||
const posAddress = visit.customerPos?.address || 'Unknown';
|
||||
|
||||
return (
|
||||
<div key={visit.id} className="col-12 col-md-6 col-lg-4">
|
||||
<div className="glass-card p-3 h-100 d-flex flex-column">
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h6 className="fw-bold text-white m-0">{customerName}</h6>
|
||||
<div className="text-secondary small">{posPersonName}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`badge border-0 ${isVisited ? 'bg-success' : 'bg-warning text-dark'}`}
|
||||
style={{ cursor: isUpdating ? 'wait' : 'pointer' }}
|
||||
onClick={() => handleMarkVisited(visit)}
|
||||
disabled={isUpdating}
|
||||
title={isVisited ? 'Click to mark as pending' : 'Click to mark as visited'}
|
||||
>
|
||||
{isUpdating ? 'Updating...' : isVisited ? 'Visited' : 'Pending'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-secondary small mb-1">
|
||||
<i className="bi bi-geo-alt me-1"></i>{posAddress}
|
||||
</div>
|
||||
<div className="text-secondary small mb-1">
|
||||
<i className="bi bi-calendar-plus me-1"></i>Sale: {formatDateTime(visit.createdAt)}
|
||||
</div>
|
||||
<div className="text-secondary small mb-1">
|
||||
<i className="bi bi-calendar-event me-1"></i>Next Visit: {formatDate(visit.nextVisitDate)}
|
||||
</div>
|
||||
{isVisited && visit.visitedAt && (
|
||||
<div className="text-success small mt-auto pt-2">
|
||||
<i className="bi bi-check-circle me-1"></i>Visited: {formatDateTime(visit.visitedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visits.length === 0 && (
|
||||
<div className="col-12 text-center text-secondary mt-4">
|
||||
<p>{showVisited ? 'No completed visits found.' : 'No pending visits.'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,19 @@ export type SalesByCustomer = {
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export type SalesByRoute = {
|
||||
routeId: string;
|
||||
routeName: string;
|
||||
export type SalesByProduct = {
|
||||
productId: string;
|
||||
productName: string;
|
||||
totalQuantity: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export type SalesSummary = {
|
||||
totalSales: number;
|
||||
totalAmount: number;
|
||||
averageAmount: number;
|
||||
totalCustomers: number;
|
||||
totalFridges: number;
|
||||
}
|
||||
|
||||
/* Users page types */
|
||||
@@ -29,14 +37,17 @@ export type CustomerPOS = {
|
||||
customerId?: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
personName?: string;
|
||||
fridgeCount?: number;
|
||||
customer?: Customer;
|
||||
}
|
||||
|
||||
export type Customer = {
|
||||
id?: string;
|
||||
name: string;
|
||||
document: string;
|
||||
document?: string;
|
||||
phone: string;
|
||||
personName?: string;
|
||||
pos?: CustomerPOS[];
|
||||
disabledAt?: string | null;
|
||||
};
|
||||
@@ -81,6 +92,8 @@ export type Sale = {
|
||||
paymentDueDate: string | null;
|
||||
paymentDate: string | null;
|
||||
comments: string;
|
||||
nextVisitDate: string | null;
|
||||
visitedAt: string | null;
|
||||
createdAt: string;
|
||||
customerPos?: CustomerPOS;
|
||||
products?: SaleProduct[];
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const formatCustomerPosDisplay = (customerName: string, address: string) =>
|
||||
`${customerName} - ${address}`;
|
||||
@@ -0,0 +1,27 @@
|
||||
events {}
|
||||
http {
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
location /api {
|
||||
# Handle double /api/api/ prefixing if it happens
|
||||
rewrite ^/+api/+api/+(.*)$ /api/$1 break;
|
||||
|
||||
proxy_pass http://polpa_gestao_backend:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://polpa_gestao_frontend:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Stop and remove existing proxy if it exists
|
||||
docker stop ngrok-proxy 2>/dev/null
|
||||
docker rm ngrok-proxy 2>/dev/null
|
||||
|
||||
docker run -d \
|
||||
--name ngrok-proxy \
|
||||
-p 127.0.0.1:8080:8080 \
|
||||
-v "$(pwd)/nginx/nginx.conf:/etc/nginx/nginx.conf:ro" \
|
||||
--restart unless-stopped \
|
||||
--network polpa-network \
|
||||
nginx:stable
|
||||
@@ -0,0 +1,22 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/kubernetes" {
|
||||
version = "3.1.0"
|
||||
constraints = ">= 2.0.0"
|
||||
hashes = [
|
||||
"h1:oodIAuFMikXNmEtil5MQgP4dfSctUBYQiGJfjbsF3NY=",
|
||||
"zh:0215c5c60be62028c09a2f22458e89cda3ef5830a632299f1d401eb3538874b0",
|
||||
"zh:09ebb9f442431e278a310a9423f32caf467cb4b3cad3fe59573ca71fa7b14e20",
|
||||
"zh:0c4e5912f83bb35846ae0a9ae54fc320706ee61894cd21cc6b4181b1c5a2fa5c",
|
||||
"zh:1678c982853ad461e65ccb5e79d585e13ed109dd47dab2a66d3a7a304faeef65",
|
||||
"zh:1c050a5c15e330457a9c18caacf61a923c59d663e13f2962e4b32f04fef523a0",
|
||||
"zh:2c55bcec83be58ec132c7cb0a1ac644758b800d794fdc636d53a0eada0358a3a",
|
||||
"zh:a062bb0aa316c08d8460c66a5d68da71da40de5d3bc3b31abcf3a1a9a19650f1",
|
||||
"zh:a26fdea0afaa9b247c73c0b42843ca51ba7db0ac2571f9d3d50dcabd20ca1b98",
|
||||
"zh:c872c9385a78d502bf5823d61cd3bb0f9a0585030e025eb12585c83451beeaa1",
|
||||
"zh:f180879af931182beee4c8c0d9dab62b81d86f17ddcbe3786ef4c7cec9163a4e",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:f70f5789264069e0eef06f9b5d5fde955ef7206f7d446d1ce51a4c37a3f3e02f",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
version = ">= 2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "s3" {
|
||||
bucket = "polpa-gestao"
|
||||
key = "kubernetes/terraform.tfstate"
|
||||
region = "auto"
|
||||
endpoints = { s3 = "https://d17eb09b6bce2f90e16e800bb2a6baf9.r2.cloudflarestorage.com" }
|
||||
skip_credentials_validation = true
|
||||
skip_region_validation = true
|
||||
skip_requesting_account_id = true
|
||||
skip_metadata_api_check = true
|
||||
skip_s3_checksum = true
|
||||
}
|
||||
}
|
||||
|
||||
provider "kubernetes" {
|
||||
config_path = "~/.kube/config"
|
||||
}
|
||||
|
||||
variable "db_user" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "db_password" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "db_name" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "cpf_cnpj_api_token" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "r2_access_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "r2_secret_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "r2_bucket_name" {
|
||||
type = string
|
||||
default = "polpa-gestao-backups"
|
||||
}
|
||||
|
||||
variable "r2_endpoint" {
|
||||
type = string
|
||||
default = "https://d17eb09b6bce2f90e16e800bb2a6baf9.r2.cloudflarestorage.com"
|
||||
}
|
||||
|
||||
variable "backend_image" {
|
||||
type = string
|
||||
default = "ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.03.25.11"
|
||||
}
|
||||
|
||||
variable "frontend_image" {
|
||||
type = string
|
||||
default = "ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.03.25.11"
|
||||
}
|
||||
|
||||
resource "kubernetes_namespace_v1" "polpa_gestao" {
|
||||
metadata {
|
||||
name = "polpa-gestao"
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_secret_v1" "polpa_gestao_secrets" {
|
||||
metadata {
|
||||
name = "polpa-gestao-secrets"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
}
|
||||
|
||||
data = {
|
||||
postgres_user = var.db_user
|
||||
postgres_password = var.db_password
|
||||
postgres_db = var.db_name
|
||||
cpf_cnpj_api_token = var.cpf_cnpj_api_token
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_persistent_volume_claim_v1" "polpa_gestao_db_data" {
|
||||
metadata {
|
||||
name = "postgres-data-pvc"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
access_modes = ["ReadWriteOnce"]
|
||||
resources {
|
||||
requests = {
|
||||
storage = "1Gi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_deployment_v1" "polpa_gestao_db" {
|
||||
metadata {
|
||||
name = "polpa-gestao-db"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
replicas = 1
|
||||
selector { match_labels = { app = "polpa-gestao-db" } }
|
||||
template {
|
||||
metadata { labels = { app = "polpa-gestao-db" } }
|
||||
spec {
|
||||
container {
|
||||
image = "postgres:16-alpine"
|
||||
name = "postgres"
|
||||
volume_mount {
|
||||
name = "postgres-storage"
|
||||
mount_path = "/var/lib/postgresql/data"
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_USER"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
|
||||
key = "postgres_user"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_PASSWORD"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
|
||||
key = "postgres_password"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_DB"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
|
||||
key = "postgres_db"
|
||||
}
|
||||
}
|
||||
}
|
||||
port { container_port = 5432 }
|
||||
}
|
||||
volume {
|
||||
name = "postgres-storage"
|
||||
persistent_volume_claim {
|
||||
claim_name = kubernetes_persistent_volume_claim_v1.polpa_gestao_db_data.metadata[0].name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_service_v1" "polpa_gestao_db_svc" {
|
||||
metadata {
|
||||
name = "polpa-gestao-db-svc"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
selector = { app = "polpa-gestao-db" }
|
||||
port { port = 5432 }
|
||||
type = "ClusterIP"
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_deployment_v1" "polpa_gestao_backend" {
|
||||
metadata {
|
||||
name = "polpa-gestao-backend"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
replicas = 1
|
||||
selector { match_labels = { app = "polpa-gestao-backend" } }
|
||||
template {
|
||||
metadata { labels = { app = "polpa-gestao-backend" } }
|
||||
spec {
|
||||
init_container {
|
||||
name = "prisma-migrate"
|
||||
image = "${var.backend_image}-prisma"
|
||||
command = ["npx", "prisma", "db", "push"]
|
||||
env {
|
||||
name = "DATABASE_URL"
|
||||
value = "postgresql://${var.db_user}:${var.db_password}@polpa-gestao-db-svc:5432/${var.db_name}?schema=public"
|
||||
}
|
||||
}
|
||||
container {
|
||||
image = var.backend_image
|
||||
name = "app"
|
||||
env {
|
||||
name = "DATABASE_URL"
|
||||
value = "postgresql://${var.db_user}:${var.db_password}@polpa-gestao-db-svc:5432/${var.db_name}?schema=public"
|
||||
}
|
||||
env {
|
||||
name = "PORT"
|
||||
value = "3000"
|
||||
}
|
||||
env {
|
||||
name = "HOSTNAME"
|
||||
value = "0.0.0.0"
|
||||
}
|
||||
resources {
|
||||
limits = { memory = "512Mi", cpu = "500m" }
|
||||
requests = { memory = "256Mi", cpu = "100m" }
|
||||
}
|
||||
readiness_probe {
|
||||
exec {
|
||||
command = ["node", "healthcheck.js"]
|
||||
}
|
||||
initial_delay_seconds = 10
|
||||
period_seconds = 5
|
||||
failure_threshold = 3
|
||||
}
|
||||
liveness_probe {
|
||||
exec {
|
||||
command = ["node", "healthcheck.js"]
|
||||
}
|
||||
initial_delay_seconds = 15
|
||||
period_seconds = 10
|
||||
failure_threshold = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_service_v1" "polpa_gestao_backend_svc" {
|
||||
metadata {
|
||||
name = "polpa-gestao-backend-svc"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
selector = { app = "polpa-gestao-backend" }
|
||||
port {
|
||||
port = 3000
|
||||
target_port = 3000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_deployment_v1" "polpa_gestao_frontend" {
|
||||
metadata {
|
||||
name = "polpa-gestao-frontend"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
replicas = 1
|
||||
selector { match_labels = { app = "polpa-gestao-frontend" } }
|
||||
template {
|
||||
metadata { labels = { app = "polpa-gestao-frontend" } }
|
||||
spec {
|
||||
container {
|
||||
image = var.frontend_image
|
||||
name = "frontend"
|
||||
port { container_port = 80 }
|
||||
resources {
|
||||
limits = { memory = "128Mi", cpu = "150m" }
|
||||
requests = { memory = "128Mi", cpu = "100m" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_service_v1" "polpa_gestao_frontend_svc" {
|
||||
metadata {
|
||||
name = "polpa-gestao-frontend-svc"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
selector = { app = "polpa-gestao-frontend" }
|
||||
port {
|
||||
port = 80
|
||||
target_port = 80
|
||||
}
|
||||
type = "ClusterIP"
|
||||
}
|
||||
}
|
||||
|
||||
# Unified Ingress for App and API
|
||||
resource "kubernetes_ingress_v1" "polpa_gestao_ingress" {
|
||||
metadata {
|
||||
name = "polpa-gestao-ingress"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
annotations = {
|
||||
"kubernetes.io/ingress.class" = "traefik"
|
||||
"cert-manager.io/cluster-issuer" = "letsencrypt-prod"
|
||||
}
|
||||
}
|
||||
spec {
|
||||
tls {
|
||||
hosts = ["polpa-gestao.darkroasted.vps-kinghost.net", "polpaapi-gestao.darkroasted.vps-kinghost.net"]
|
||||
secret_name = "polpa-gestao-tls-certs"
|
||||
}
|
||||
rule {
|
||||
host = "polpa-gestao.darkroasted.vps-kinghost.net"
|
||||
http {
|
||||
path {
|
||||
path = "/"
|
||||
path_type = "Prefix"
|
||||
backend {
|
||||
service {
|
||||
name = kubernetes_service_v1.polpa_gestao_frontend_svc.metadata[0].name
|
||||
port { number = 80 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rule {
|
||||
host = "polpaapi-gestao.darkroasted.vps-kinghost.net"
|
||||
http {
|
||||
path {
|
||||
path = "/"
|
||||
path_type = "Prefix"
|
||||
backend {
|
||||
service {
|
||||
name = kubernetes_service_v1.polpa_gestao_backend_svc.metadata[0].name
|
||||
port { number = 3000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_secret_v1" "r2_backup_secrets" {
|
||||
metadata {
|
||||
name = "r2-backup-secrets"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
}
|
||||
|
||||
data = {
|
||||
access_key = var.r2_access_key
|
||||
secret_key = var.r2_secret_key
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_cron_job_v1" "polpa_gestao_db_backup" {
|
||||
metadata {
|
||||
name = "polpa-gestao-db-backup"
|
||||
namespace = kubernetes_namespace_v1.polpa_gestao.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
schedule = "0 0,12 * * *"
|
||||
job_template {
|
||||
metadata {
|
||||
labels = {
|
||||
app = "polpa-gestao-db-backup"
|
||||
}
|
||||
}
|
||||
spec {
|
||||
template {
|
||||
metadata {
|
||||
labels = {
|
||||
app = "polpa-gestao-db-backup"
|
||||
}
|
||||
}
|
||||
spec {
|
||||
container {
|
||||
name = "backup"
|
||||
image = "postgres:16-alpine"
|
||||
command = ["/bin/sh", "-c"]
|
||||
args = [
|
||||
<<-EOT
|
||||
apk add --no-cache aws-cli
|
||||
export PGPASSWORD=$POSTGRES_PASSWORD
|
||||
FILENAME="backup-$(date +%Y%m%d%H%M%S).sql.gz"
|
||||
echo "Starting backup of $POSTGRES_DB to $FILENAME..."
|
||||
pg_dump -h $DB_HOST -U $POSTGRES_USER $POSTGRES_DB | gzip > /tmp/$FILENAME
|
||||
echo "Uploading to R2..."
|
||||
AWS_ACCESS_KEY_ID=$R2_ACCESS_KEY AWS_SECRET_ACCESS_KEY=$R2_SECRET_KEY \
|
||||
aws s3 cp /tmp/$FILENAME s3://$R2_BUCKET/ --endpoint-url $R2_ENDPOINT
|
||||
echo "Backup completed successfully."
|
||||
EOT
|
||||
]
|
||||
env {
|
||||
name = "DB_HOST"
|
||||
value = "polpa-gestao-db-svc"
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_USER"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
|
||||
key = "postgres_user"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_PASSWORD"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
|
||||
key = "postgres_password"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_DB"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
|
||||
key = "postgres_db"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "R2_ACCESS_KEY"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.r2_backup_secrets.metadata[0].name
|
||||
key = "access_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "R2_SECRET_KEY"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret_v1.r2_backup_secrets.metadata[0].name
|
||||
key = "secret_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "R2_BUCKET"
|
||||
value = var.r2_bucket_name
|
||||
}
|
||||
env {
|
||||
name = "R2_ENDPOINT"
|
||||
value = var.r2_endpoint
|
||||
}
|
||||
}
|
||||
restart_policy = "OnFailure"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||