This commit is contained in:
JuruSysadmin
2025-06-17 13:41:48 -03:00
commit af154c3f7f
197 changed files with 50658 additions and 0 deletions

8
.babelrc.js Normal file
View File

@@ -0,0 +1,8 @@
// This Babel configuration is used only for Jest tests
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
['@babel/preset-react', { runtime: 'automatic' }],
],
};

26
.dockerignore Normal file
View File

@@ -0,0 +1,26 @@
# Dependências
node_modules
npm-debug.log
yarn-debug.log
yarn-error.log
# Next.js
.next
out
# Arquivos de desenvolvimento
.git
.gitignore
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.idea
.vscode
# Outros
README.md
*.md
.DS_Store

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

12
.idea/conceito-335.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/conceito-335.iml" filepath="$PROJECT_DIR$/.idea/conceito-335.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

114
.idea/workspace.xml generated Normal file
View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="c3170dda-5ac5-478f-9665-66c5f59a70f5" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/orders/OrderDetail.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/orders/OrderDetail.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/orders/OrderInfoCard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/orders/OrderInfoCard.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/orders/OrderItemsTable.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/orders/OrderItemsTable.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/orders/OrderRowExpandable.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/orders/OrderRowExpandable.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/orders/OrdersTable.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/orders/OrdersTable.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/types.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/types.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/constants/status-options.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/constants/status-options.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 8
}</component>
<component name="ProjectId" id="2w8VwZI8V1ZiF86I9ohOaePeoEH" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/react-data-grid-orders&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;js.debugger.nextJs.config.created.client&quot;: &quot;true&quot;,
&quot;js.debugger.nextJs.config.created.server&quot;: &quot;true&quot;,
&quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;C:/Users/Joelson/Desktop/conceito-335&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;npm.Next.js: server-side.executor&quot;: &quot;Run&quot;,
&quot;ts.external.directory.path&quot;: &quot;C:\\Users\\Joelson\\Desktop\\conceito-335\\node_modules\\typescript\\lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RunManager" selected="npm.Next.js: server-side">
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
<method v="2" />
</configuration>
<configuration name="Next.js: server-side" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-f27c65a3e318-JavaScript-WS-251.23774.424" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="c3170dda-5ac5-478f-9665-66c5f59a70f5" name="Changes" comment="" />
<created>1745423427238</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1745423427238</updated>
<workItem from="1745423428034" duration="1216000" />
<workItem from="1745434488280" duration="2733000" />
<workItem from="1745437286504" duration="90000" />
<workItem from="1745437605646" duration="221000" />
<workItem from="1745437861205" duration="5077000" />
<workItem from="1745444505364" duration="11900000" />
<workItem from="1745526495428" duration="121000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
<option name="exactExcludedFiles">
<list>
<option value="$PROJECT_DIR$/next.config.mjs" />
</list>
</option>
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<line-breakpoint enabled="true" type="javascript">
<url>file://$PROJECT_DIR$/src/components/types.ts</url>
<line>164</line>
<option name="timeStamp" value="1" />
</line-breakpoint>
</breakpoints>
</breakpoint-manager>
</component>
</project>

12
.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

50
Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
# Copiar arquivos de configuração
COPY package.json package-lock.json ./
# Instalar dependências
RUN npm ci
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Copiar dependências do stage anterior
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build da aplicação
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Criar usuário não-root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copiar arquivos necessários do builder
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Mudar para usuário não-root
USER nextjs
# Expor porta
EXPOSE 3000
# Definir variáveis de ambiente
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# Comando para iniciar a aplicação
CMD ["node", "server.js"]

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
BSD 2-Clause License
Copyright (c) 2025, JuruSysadmin
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

163
README.md Normal file
View File

@@ -0,0 +1,163 @@
Portalweb
O código-fonte deste software é confidencial e não será fornecido.
É estritamente proibido realizar engenharia reversa, descompilar ou desmontar o software.
É proibido copiar, redistribuir ou sublicenciar este software sem autorização por escrito.
A violação destes termos poderá resultar em responsabilização civil e criminal.
## Project Structure
src/
├── app/ # Diretório de app do Next.js 13+ (roteamento)
├── components/ # Componentes React
│ ├── ui/ # Componentes de UI reutilizáveis
│ ├── layout/ # Componentes de layout
│ └── features/ # Componentes específicos de funcionalidades
├── hooks/ # Hooks personalizados do React
│ ├── useNavigation.ts # Hook para abstrair navegação da aplicação
│ └── ... # Outros hooks personalizados
├── utils/ # Funções utilitárias
├── types/ # Tipos e interfaces TypeScript
├── styles/ # Estilos globais e configuração de tema
├── lib/ # Configurações de bibliotecas de terceiros
├── constants/ # Constantes da aplicação
└── public/ # Arquivos estáticos
## Directory Details
### `/src/app`
Contém a estrutura de diretório app do Next.js 13+
Responsável pelo roteamento e componentes de página
Inclui layout.tsx para layouts compartilhados
Inclui page.tsx para a página inicial
### `/src/components`
- **ui/**: Componentes de UI reutilizáveis (ex: Botão, Input, Card)
- **layout/**: Componentes de layout (ex: Cabeçalho, Rodapé, Barra Lateral)
- **features/**: Componentes específicos de funcionalidades, que podem não ser reutilizáveis
### `/src/hooks`
- Hooks personalizados do React para lógica compartilhada
- Exemplo: useTheme para gerenciamento de tema
- **useNavigation**: Abstrai a lógica de navegação comum, fornecendo métodos como:
- `goToHome()`: Navega para a página inicial
- `goToLogin()`: Navega para a página de login
- `goToOrdersFind()`: Navega para a busca de pedidos
- `goToOrderDetail(id)`: Navega para o detalhe de um pedido
- `goBack()`: Volta para a página anterior
- `navigateTo(url)`: Navega para uma URL específica
### `/src/utils`
-Funções utilitárias para operações comuns
-Exemplo: format.ts para formatação de datas e moedas
### `/src/types`
- Interfaces e tipos TypeScript
- Tipos comuns usados em toda a aplicação
### `/src/constants`
- Constantes globais da aplicação
- Rotas, breakpoints e outros valores estáticos
### `/src/styles`
- Estilos globais e configuração de tema
- Configuração do Tailwind CSS (se estiver sendo utilizado)
### `/src/lib`
- Configurações de bibliotecas de terceiros
- Clientes de API e outras integrações externas
### `/public`
Arquivos estáticos (imagens, fontes, etc.)
Favicon e outros arquivos públicos
### Componentes
Mantenha os componentes pequenos e com uma única responsabilidade
Use TypeScript para segurança de tipos
Siga o padrão de design atômico quando apropriado
Defina corretamente os tipos de props e valores padrão
### Hooks
Crie hooks personalizados para lógica reutilizável
Mantenha os hooks focados em uma funcionalidade específica
Tipifique corretamente os parâmetros e retornos com TypeScript
### Styling
Use Tailwind CSS para estilização
Siga uma convenção de nomes consistente
Mantenha os estilos modulares e reutilizáveis
### TypeScript
Utilize uma configuração estrita de TypeScript
Defina interfaces apropriadas para todos os componentes e funções
Use inferência de tipos sempre que possível
### Code Organization
Mantenha arquivos relacionados próximos uns dos outros
Use arquivos index.ts para importações mais limpas
Siga o princípio do menor privilégio
## Getting Started
1. Install dependencies:
```bash
npm install
```
2. Run the development server:
```bash
npm run dev
```
3. Build for production:
```bash
npm run build
```
## Development Guidelines
1. ### Criação de Componentes
Crie os componentes no diretório apropriado
Use TypeScript para segurança de tipos
Adicione documentação apropriada
2. ### Gerenciamento de Estado
Use hooks do React para estado local
Considere contextos para estado global
Mantenha o estado o mais próximo possível de onde é usado
3. ### Estilização
Use classes do Tailwind CSS
Mantenha os estilos consistentes entre os componentes
Use variáveis CSS para valores de tema

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/src/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3

30
jest.config.js Normal file
View File

@@ -0,0 +1,30 @@
module.exports = {
testEnvironment: 'jsdom',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
moduleNameMapper: {
'^@/src/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|sass|scss)$': 'identity-obj-proxy',
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/pages/_app.tsx',
'!src/pages/_document.tsx',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
transformIgnorePatterns: [
'/node_modules/(?!(@testing-library|react-dom|next)/)'
]
};

54
jest.setup.js Normal file
View File

@@ -0,0 +1,54 @@
// Configuração para adicionar suporte a testes com React e hooks
require('@testing-library/jest-dom');
// Mock para evitar erros relacionados ao Next.js Image
jest.mock('next/image', () => ({
__esModule: true,
default: (props) => {
// eslint-disable-next-line jsx-a11y/alt-text
return { type: 'img', props };
},
}));
// Mock do localStorage e sessionStorage para testes
class LocalStorageMock {
constructor() {
this.store = {};
}
clear() {
this.store = {};
}
getItem(key) {
return this.store[key] || null;
}
setItem(key, value) {
this.store[key] = String(value);
}
removeItem(key) {
delete this.store[key];
}
}
global.localStorage = new LocalStorageMock();
global.sessionStorage = new LocalStorageMock();
// Mock global fetch
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
);
// Suprimir warnings do console nos testes
global.console = {
...console,
// Uncomment to ignore specific console methods during tests
// log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
};

17
next.config.mjs Normal file
View File

@@ -0,0 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
experimental: {
forceSwcTransforms: true,
},
}
export default nextConfig

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

16676
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

131
package.json Normal file
View File

@@ -0,0 +1,131 @@
{
"name": "portal",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 9603",
"build": "next build",
"start": "next start --port 9602",
"lint": "next lint",
"test": "jest src/tests/utils src/tests/components src/tests/api-helpers.test.ts",
"test:all": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"nginx-export": "next export"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@hookform/resolvers": "^3.9.1",
"@material-tailwind/react": "^2.1.10",
"@mui/material": "^7.0.2",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-aspect-ratio": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-context-menu": "^2.2.4",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-menubar": "^1.1.4",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@tanstack/react-query": "^5.74.11",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.6",
"@types/crypto-js": "^4.2.2",
"@types/nprogress": "^0.2.3",
"@types/react-resizable": "^3.0.8",
"@types/react-window": "^1.8.8",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"ag-grid-community": "^33.2.4",
"ag-grid-react": "^33.2.4",
"antd": "^5.24.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"embla-carousel-react": "8.5.1",
"framer-motion": "^12.7.4",
"i18next": "^25.0.1",
"input-otp": "1.4.1",
"lucide-react": "^0.454.0",
"next": "^15.2.4",
"next-themes": "^0.4.4",
"nprogress": "^0.2.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-data-grid": "^7.0.0-beta.52",
"react-day-picker": "8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.1.0",
"react-hook-form": "^7.54.1",
"react-i18next": "^15.4.1",
"react-icons": "^5.5.0",
"react-resizable": "^3.0.5",
"react-resizable-panels": "^2.1.7",
"react-tsparticles": "^2.12.2",
"react-window": "^1.8.11",
"react-window-infinite-loader": "^1.0.10",
"recharts": "2.15.0",
"rxjs": "latest",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"tsparticles": "^3.8.1",
"tsparticles-slim": "^2.12.0",
"tw-animate-css": "^1.2.5",
"vaul": "^0.9.6",
"zod": "^3.24.3"
},
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@tailwindcss/postcss": "^4.1.4",
"@testing-library/cypress": "^10.0.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^14.3.1",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^29.5.14",
"@types/node": "^22",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"autoprefixer": "^10.4.21",
"babel-jest": "^29.7.0",
"cypress": "^14.3.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.4",
"ts-jest": "^29.3.2",
"typescript": "^5",
"vitest": "^3.1.2"
}
}

12507
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

8
postcss.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
// Função simples para gerar um token mock
function generateMockToken() {
return 'mock_token_' +
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15) +
Date.now().toString(36);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password } = body;
// Validação simples
if (!username || !password) {
return NextResponse.json(
{ success: false, message: 'Usuário e senha são obrigatórios' },
{ status: 400 }
);
}
// Simulação de credenciais - em produção isso estaria em um banco de dados
const validCredentials = [
{ username: 'admin', password: 'admin', role: 'ADMIN' },
{ username: 'user', password: 'user', role: 'USER' },
{ username: 'test', password: 'test', role: 'USER' },
];
const user = validCredentials.find(
(u) => u.username === username && u.password === password
);
if (!user) {
return NextResponse.json(
{ success: false, message: 'Credenciais inválidas' },
{ status: 401 }
);
}
// Gerar um token mock simples
const token = generateMockToken();
// Simular a resposta do seu backend
return NextResponse.json({
id: `${Math.floor(Math.random() * 1000)}`,
sellerId: '123',
name: username,
username: username,
storeId: '1',
email: `${username}@exemplo.com`,
role: user.role,
token: token,
});
} catch (error) {
console.error('Erro na API de login:', error);
return NextResponse.json(
{
success: false,
message: error instanceof Error ? error.message : 'Erro desconhecido',
},
{ status: 500 }
);
}
}

59
src/app/globals.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
border-color: hsl(var(--border));
}
body {
@apply bg-background text-foreground;
}
}

35
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,35 @@
import React from "react";
import type { Metadata } from "next/types";
import { Inter } from "next/font/google";
import "../styles/globals.css";
import { ThemeProvider } from "@/src/components/theme-provider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "PORTAL WEB",
description: "Portal web",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt-BR" suppressHydrationWarning>
<body className={`${inter.className} h-full`}>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
>
<div className="flex h-screen">
<main className="flex-1 overflow-auto">{children}</main>
</div>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,112 @@
import { useState, useEffect } from "react";
import { Input } from "@/src/components/ui/input";
import { dataConsultApi } from "@/src/lib/api";
interface Seller {
id: number;
name: string;
code?: string;
}
interface SellerSearchInputProps {
value: string;
onChange: (value: string) => void;
onSelect?: (seller: Seller) => void;
label?: string;
id?: string;
placeholder?: string;
}
export function SellerSearchInput({
value,
onChange,
onSelect,
label = "Vendedor",
id = "sellerSearch",
placeholder = "Digite o nome do vendedor"
}: SellerSearchInputProps) {
const [suggestions, setSuggestions] = useState<Seller[]>([]);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
const loadSuggestions = async () => {
if (!value || value.length < 2) return setSuggestions([]);
try {
const sellers = await dataConsultApi.getSellers();
if (Array.isArray(sellers)) {
// Filtrar vendedores que correspondem ao termo de busca (ignorando case)
const filtered = sellers.filter((s: any) =>
s.name.toLowerCase().includes(value.toLowerCase())
).map((s: any) => {
// Extrair o código numérico do vendedor se estiver no formato XXX-NOME
let code = "";
if (s.name && s.name.includes("-")) {
const parts = s.name.split("-");
if (parts.length > 0 && parts[0].trim()) {
const match = parts[0].trim().match(/\d+/);
if (match) {
code = match[0];
}
}
}
return {
...s,
code: code || ""
};
});
setSuggestions(filtered);
}
} catch (err) {
console.error("Erro ao buscar vendedores:", err);
}
};
const debounce = setTimeout(loadSuggestions, 300);
return () => clearTimeout(debounce);
}, [value]);
// Quando o usuário altera o texto na caixa de pesquisa
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onChange(newValue);
// Se o usuário apagou tudo, limpar também as sugestões
if (!newValue) {
setSuggestions([]);
}
};
return (
<div className="relative">
{label && <label htmlFor={id} className="block text-sm font-medium mb-1">{label}</label>}
<Input
id={id}
placeholder={placeholder}
value={value}
onChange={handleInputChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setTimeout(() => setIsFocused(false), 200)}
autoComplete="off"
/>
{isFocused && suggestions.length > 0 && (
<ul className="absolute z-10 w-full bg-white border rounded shadow max-h-48 overflow-auto mt-1">
{suggestions.map((seller) => (
<li
key={seller.id}
onClick={() => {
onChange(seller.name);
onSelect?.(seller);
}}
className="cursor-pointer px-3 py-1 hover:bg-gray-100"
>
{seller.name}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import Link from "next/link";
import { Button } from "../../../components/ui/button";
export default function OrderNotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] p-4">
<h1 className="text-3xl font-bold mb-4">Pedido não encontrado</h1>
<p className="text-gray-600 mb-6">O pedido que você está procurando não existe ou foi removido.</p>
<Link href="/orders/find" passHref>
<Button>
Voltar para lista de pedidos
</Button>
</Link>
</div>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { use } from "react";
import { OrderDetail } from "../../../components/orders/OrderDetail";
import { Order, TimelineEvent, leadtime } from "../../../components/types";
import { ordersApi } from "../../../lib/api";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
interface OrderPageProps {
params: Promise<{ id: string }>;
}
// Função para mapear leadtime para TimelineEvent
function mapLeadtimeToTimelineEvent(item: leadtime): TimelineEvent {
return {
id: `${item.orderId}-${item.descricaoEtapa || Math.random()}`,
description: item.descricao || "",
date: item.data || new Date().toISOString(),
status: item.status || "default",
icon: item.icon,
color: item.color,
user: item.nomeFuncionario
};
}
/**
* Página de detalhe do pedido com timeline
*/
export default function OrderPage({ params }: OrderPageProps) {
const { id } = use(params);
const [order, setOrder] = useState<Order | null>(null);
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadOrderData() {
if (!id) {
setError("ID do pedido não encontrado");
setLoading(false);
return;
}
setLoading(true);
try {
const response = await ordersApi.findOrders({ orderId: id });
if (!response || !response.data || !Array.isArray(response.data) || response.data.length === 0) {
setError("Pedido não encontrado");
setLoading(false);
return;
}
setOrder(response.data[0] as Order);
try {
const leadtimeResponse = await ordersApi.getLeadtime(id);
// Converter leadtime para TimelineEvent
const mappedEvents = leadtimeResponse.map(mapLeadtimeToTimelineEvent);
setTimelineEvents(mappedEvents);
} catch (timelineError) {
console.error("Erro ao carregar timeline:", timelineError);
setTimelineEvents([]);
}
} catch (orderError) {
console.error("Erro ao carregar pedido:", orderError);
setError("Erro ao carregar dados do pedido");
} finally {
setLoading(false);
}
}
loadOrderData();
}, [id]);
if (loading) {
return (
<div className="w-full h-screen flex items-center justify-center">
<div className="flex flex-col items-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="mt-2 text-sm text-muted-foreground">Carregando detalhes do pedido...</p>
</div>
</div>
);
}
if (error || !order) {
return (
<div className="w-full h-screen flex items-center justify-center">
<div className="bg-white p-6 rounded-lg border shadow-sm max-w-md mx-auto text-center">
<h2 className="text-xl font-semibold mb-2">Pedido não encontrado</h2>
<p className="text-muted-foreground mb-4">{error || "Não foi possível encontrar o pedido solicitado."}</p>
<a href="/orders/find" className="text-primary hover:underline">
Voltar para busca de pedidos
</a>
</div>
</div>
);
}
return <OrderDetail order={order} timelineEvents={timelineEvents} />;
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -0,0 +1,432 @@
"use client";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/src/components/ui/card";
import { Button } from "@/src/components/ui/button";
import { Search, RefreshCw, ChevronUp, ChevronDown } from "lucide-react";
import { OrdersTable } from "@/src/components/orders/OrdersTable";
import { ErrorMessage } from "@/src/components/ui/error-message";
import { useStores } from "@/src/hooks/useStores";
import { useOrderSearch } from "@/src/hooks/useOrderSearch";
import { FilterInput } from "@/src/components/orders/FilterInput";
import { FilterSelect } from "@/src/components/orders/FilterSelect";
import { STATUS_OPTIONS } from "@/src/constants/status-options";
import { DELIVERY_STATUS_OPTIONS } from "@/src/constants/delivery-status-options";
import { Spinner } from "@/src/components/ui/spinner";
import { DateRangeFilter } from "@/src/components/orders/DateRangeFilter";
import { MultiFilterSelect } from "@/src/components/orders/MultiFilterSelect";
import { CustomerSearchInput } from "@/src/components/orders/CustomerSearchInput";
import { useCallback, useState, useEffect } from "react";
import { User, useAuthValidation } from "../../../hooks/useAuthValidation";
import { SellerSearchInput } from "@/src/components/orders/SellerSearchInput";
/**
* Página de Consulta de Pedidos
* Permite ao usuário pesquisar pedidos com base em vários filtros
*
* @returns Componente da página de consulta de pedidos
*/
export default function FindOrdersPage() {
const {
user,
isAuthenticated,
isLoading: authLoading,
error: authError,
decodedToken,
} = useAuthValidation({
authServiceUrl: process.env.NEXT_PUBLIC_AUTH_SERVICE_URL!,
token: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMzg1IiwidXNlcm5hbWUiOiJKT0VMU09OLlIiLCJyb2xlIjpudWxsLCJpYXQiOjE3NDg4MjQzNDEsImV4cCI6MTc1MzIzMDc0MX0.H4nRp72NTEzHBKij67BgW4NO8Pot9AqPKlaynne694c",
autoRedirect: false,
});
console.log("User:", user);
console.log("Decoded Token:", decodedToken);
console.log("Auth Loading:", authLoading);
console.log("Auth Error:", authError);
console.log("Is Authenticated:", isAuthenticated);
const [isFiltersExpanded, setIsFiltersExpanded] = useState(true);
const { storeOptions, loading: loadingStores, stores } = useStores(true);
const [usePreloadClients, setUsePreloadClients] = useState(false);
const [status, setStatus] = useState("");
const [leadtime, setLeadtime] = useState("");
const {
orders,
loading,
dateError,
hasSearched,
selectedOrderId,
orderItems,
cutitens,
loadingItems,
itemsError,
currentPage,
totalPages,
currentOrders,
indexOfFirstOrder,
indexOfLastOrder,
searchParams,
setSearchParams,
handleSearch,
handleRowClick,
goToNextPage,
goToPreviousPage,
goToPage,
loadMoreOrders,
visibleOrdersCount,
handleInputChange,
handleCustomerId,
handleCustomerFilter,
handleCustomerSelect,
handleMultiInputChange,
} = useOrderSearch(8);
/**
* Limpa todos os filtros de pesquisa
* @returns {void}
*/
const handleClearFilters = useCallback(() => {
setSearchParams({});
}, [setSearchParams]);
/**
* Alterna o modo de pré-carregamento de clientes
* Quando ativado, carrega clientes antecipadamente para melhorar o desempenho da busca
* @returns {void}
*/
const togglePreloadMode = useCallback(() => {
setUsePreloadClients((prev) => !prev);
}, []);
return (
<div className="space-y-1">
<Card className="overflow-hidden">
<CardHeader
className="flex flex-row items-center justify-between space-y-0 py-1 px-4 white-slate-80 cursor-pointer"
onClick={() => setIsFiltersExpanded(!isFiltersExpanded)}
>
<CardTitle className="text-lg font-bold">
Consulta de Pedidos
</CardTitle>
<Button variant="ghost" size="sm" className="h-6 px-1">
{isFiltersExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</CardHeader>
{isFiltersExpanded && (
<CardContent className="p-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-2 gap-y-2 items-start p-2">
<div className="flex flex-col space-y-4">
<div className="filter-field">
<FilterSelect
id="codfilial"
label="Filial de Venda"
value={searchParams.codfilial}
onValueChange={(value) =>
handleInputChange("codfilial", value)
}
placeholder="Selecione a filial"
disabled={loadingStores}
options={storeOptions}
loading={loadingStores}
loadingText="Carregando filiais..."
className="w-full"
aria-label="Filial de Venda"
/>
</div>
<div className="filter-field">
<CustomerSearchInput
id="customerSearch"
label="Cliente"
placeholder="Buscar por codigo, nome ou CPF/CNPJ"
value={
searchParams.name ||
searchParams.customerId ||
searchParams.document ||
""
}
onChange={handleCustomerFilter}
onSelect={handleCustomerSelect}
selectedCustomer={searchParams.selectedCustomer || null}
preloadClients={usePreloadClients}
maxPreloadedClients={2000}
aria-label="Buscar Cliente"
/>
</div>
<div className="flex gap-4">
<div className="filter-small-field">
<FilterInput
id="orderId"
label="Número do Pedido"
type="number"
placeholder="Número do pedido"
value={searchParams.orderId}
onChange={(value) => handleInputChange("orderId", value)}
className="w-full"
aria-label="Número do Pedido"
/>
</div>
<div className="filter-small-field">
<FilterInput
id="invoiceNumber"
label="Nota Fiscal"
placeholder="Número da NF"
value={searchParams.invoiceNumber || ""}
onChange={(value) =>
handleInputChange("invoiceNumber", value)
}
className="w-full"
aria-label="Número da Nota Fiscal"
/>
</div>
<div className="filter-small-field">
<FilterInput
id="shippimentId"
label=" Carregamento"
placeholder="Carregamento"
value={searchParams.shippimentId || ""}
onChange={(value) =>
handleInputChange("shippimentId", value)
}
className="w-full"
aria-label="Carregamento"
/>
</div>
</div>
<div className="flex gap-4">
<div className="filter-small-field">
<MultiFilterSelect
id="orderType"
label="Tipo de Venda"
placeholder="Tipo de venda"
values={
searchParams.type
? Array.isArray(searchParams.type)
? searchParams.type
: [searchParams.type]
: []
}
onValuesChange={(values) =>
handleMultiInputChange("type", values)
}
options={[
{ label: "TV1 - Retira Imediata", value: "1" },
{ label: "TV7 - Faturamento", value: "7" },
{ label: "TV8 - Entrega", value: "8" },
{ label: "TV10 - Transferência", value: "10" },
]}
className="w-full"
aria-label="Tipo de Venda"
/>
</div>
<div className="filter-small-field">
<MultiFilterSelect
id="deliveryType"
label="Tipo de Entrega"
placeholder="Tipo de entrega"
values={
searchParams.deliveryType
? Array.isArray(searchParams.deliveryType)
? searchParams.deliveryType
: [searchParams.deliveryType]
: []
}
onValuesChange={(values) =>
handleMultiInputChange("deliveryType", values)
}
options={[
{ label: "Retira Imediata", value: "RI" },
{ label: "Entrega", value: "EN" },
{ label: "Entrega Futura", value: "EF" },
{ label: "Retira Posterior", value: "RP" },
]}
className="w-full"
aria-label="Tipo de Entrega"
/>
</div>
<div className="filter-small-field">
<MultiFilterSelect
id="status"
label="Situação"
values={
Array.isArray(searchParams.status)
? searchParams.status
: searchParams.status
? [searchParams.status]
: []
}
onValuesChange={(values) =>
handleMultiInputChange("status", values)
}
placeholder=" Situação"
options={STATUS_OPTIONS}
className="w-full"
aria-label="Situação do Pedido"
/>
</div>
</div>
</div>
<div className="flex flex-col space-y-4">
<div className="filter-field">
<FilterSelect
id="stockId"
label="Filial de Estoque"
value={searchParams.stockId}
onValueChange={(value) =>
handleInputChange("stockId", value)
}
placeholder="Filial de estoque"
options={storeOptions}
loading={loadingStores}
loadingText="Carregando filiais..."
className="w-full"
aria-label="Filial de Estoque"
/>
</div>
<div className="filter-field">
<SellerSearchInput
id="sellerSearch"
label="Nome do Vendedor"
placeholder="Buscar por nome ou código do vendedor"
value={searchParams.sellerName || ""}
onChange={(value) => handleInputChange("sellerName", value)}
onSelect={(seller) => {
if (seller) {
if (seller.code) {
handleInputChange("sellerId", seller.code);
} else {
handleInputChange("sellerId", seller.id.toString());
}
handleInputChange("sellerName", seller.name);
} else {
handleInputChange("sellerId", "");
handleInputChange("sellerName", "");
}
}}
selectedSeller={searchParams.sellerName ? {
id: parseInt(String(searchParams.sellerId || "0"), 10) || 0,
name: searchParams.sellerName,
code: (searchParams.sellerId || "").toString()
} : undefined}
aria-label="Buscar Vendedor"
/>
</div>
<div className="filter-field">
<DateRangeFilter
startDateId="createDateIni"
endDateId="createDateEnd"
label="Período (Obrigatório)"
startValue={searchParams.createDateIni}
endValue={searchParams.createDateEnd}
onStartChange={(value) =>
handleInputChange("createDateIni", value)
}
onEndChange={(value) =>
handleInputChange("createDateEnd", value)
}
aria-label="Período"
/>
</div>
</div>
<div className="flex gap-4">
<div className="flex flex-col space-y-4"></div>
</div>
</div>
<div className="flex items-center justify-end gap-2 mx-4 pb-3">
<Button
variant="outline"
size="sm"
onClick={handleClearFilters}
title="Limpar filtros"
aria-label="Limpar todos os filtros de pesquisa"
className="h-8"
>
<RefreshCw className="mr-2 h-4 w-4" />
Limpar
</Button>
<Button
variant={"blue"}
onClick={handleSearch}
disabled={loading}
className="h-8 px-4"
aria-label="Pesquisar pedidos com os filtros selecionados"
>
{loading ? (
<Spinner size="sm" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
Pesquisar
</Button>
</div>
{dateError && <ErrorMessage message={dateError} />}
</CardContent>
)}
</Card>
{loading ? (
<Card className="overflow-hidden">
<CardContent className="flex h-40 items-center justify-center p-4">
<div className="flex flex-col items-center gap-2">
<Spinner size="md" />
<p className="text-sm text-muted-foreground">
Buscando pedidos...
</p>
</div>
</CardContent>
</Card>
) : hasSearched ? (
<Card className="overflow-hidden">
<CardContent className="p-0">
<OrdersTable
orders={orders}
currentOrders={currentOrders}
selectedOrderId={selectedOrderId}
orderItems={orderItems}
cutitens={cutitens}
loadingItems={loadingItems}
itemsError={itemsError}
handleRowClick={handleRowClick}
currentPage={currentPage}
totalPages={totalPages}
indexOfFirstOrder={indexOfFirstOrder}
indexOfLastOrder={indexOfLastOrder}
goToPreviousPage={goToPreviousPage}
goToNextPage={goToNextPage}
goToPage={goToPage}
loadMoreOrders={loadMoreOrders}
visibleOrdersCount={visibleOrdersCount}
stores={stores}
transfers={[]}
status={status}
leadtime={leadtime}
/>
</CardContent>
</Card>
) : null}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -0,0 +1,16 @@
import Link from "next/link";
import { Button } from "../../components/ui/button";
export default function OrdersNotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] p-4">
<h1 className="text-3xl font-bold mb-4">Pedido não encontrado</h1>
<p className="text-gray-600 mb-6">O pedido que você está procurando não existe ou foi removido.</p>
<Link href="/orders/find" passHref>
<Button>
Voltar para lista de pedidos
</Button>
</Link>
</div>
);
}

6
src/app/orders/page.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function OrdersPage() {
redirect("/orders/find");
}

15
src/app/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
"use client"
export default function Home() {
return (
<div>
<h1>Home</h1>
</div>
)
}

View File

@@ -0,0 +1,208 @@
"use client";
import { OrderSearchParams, orderSearchParamsSchema } from "@/src/schemas";
import { useZodForm } from "@/src/hooks";
interface OrderSearchFormProps {
onSearch: (params: OrderSearchParams) => void;
isLoading?: boolean;
}
export function OrderSearchForm({ onSearch, isLoading = false }: OrderSearchFormProps) {
const {
values,
errors,
handleChange,
handleSubmit,
resetForm
} = useZodForm(orderSearchParamsSchema, {}, onSearch);
return (
<form onSubmit={handleSubmit} className="space-y-5 bg-white p-5 rounded-lg shadow" aria-label="Formulário de busca de pedidos">
<h2 className="text-lg font-semibold text-gray-700 mb-3 border-b pb-2">Busca de Pedidos</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-5">
{/* Número do Pedido */}
<div className="space-y-1">
<label htmlFor="orderId" className="text-sm font-medium text-gray-700">
Número do Pedido
</label>
<input
type="number"
id="orderId"
name="orderId"
value={values.orderId || ""}
onChange={handleChange}
min="1"
inputMode="numeric"
className={`w-full rounded border p-2 text-sm ${
errors.orderId ? "border-red-500" : "border-gray-300"
}`}
placeholder="Número do pedido"
/>
{errors.orderId && (
<p className="text-red-500 text-xs">{errors.orderId}</p>
)}
</div>
{/* Cliente ID */}
<div className="space-y-1">
<label htmlFor="customerId" className="text-sm font-medium text-gray-700">
ID do Cliente
</label>
<input
type="number"
id="customerId"
name="customerId"
value={values.customerId || ""}
onChange={handleChange}
min="1"
inputMode="numeric"
className={`w-full rounded border p-2 text-sm ${
errors.customerId ? "border-red-500" : "border-gray-300"
}`}
placeholder="ID do cliente"
/>
{errors.customerId && (
<p className="text-red-500 text-xs">{errors.customerId}</p>
)}
</div>
{/* Número da Nota Fiscal */}
<div className="space-y-1">
<label htmlFor="invoiceNumber" className="text-sm font-medium text-gray-700">
Número da Nota Fiscal
</label>
<input
type="text"
id="invoiceNumber"
name="invoiceNumber"
value={values.invoiceNumber || ""}
onChange={handleChange}
className={`w-full rounded border p-2 text-sm ${
errors.invoiceNumber ? "border-red-500" : "border-gray-300"
}`}
placeholder="Número da NF"
/>
{errors.invoiceNumber && (
<p className="text-red-500 text-xs">{errors.invoiceNumber}</p>
)}
</div>
{/* Status */}
<div className="space-y-1">
<label htmlFor="status" className="text-sm font-medium text-gray-700">
Status
</label>
<select
id="status"
name="status"
value={values.status || ""}
onChange={handleChange}
className={`w-full rounded border p-2 text-sm ${
errors.status ? "border-red-500" : "border-gray-300"
}`}
>
<option value="">Selecione o status</option>
<option value="Aberto">Aberto</option>
<option value="Em Processamento">Em Processamento</option>
<option value="Faturado">Faturado</option>
<option value="Entregue">Entregue</option>
<option value="Cancelado">Cancelado</option>
</select>
{errors.status && (
<p className="text-red-500 text-xs">{errors.status}</p>
)}
</div>
{/* Data Inicial */}
<div className="space-y-1">
<label htmlFor="createDateIni" className="text-sm font-medium text-gray-700">
Data Inicial
</label>
<input
type="date"
id="createDateIni"
name="createDateIni"
value={values.createDateIni || ""}
onChange={handleChange}
max={values.createDateEnd || undefined}
className={`w-full rounded border p-2 text-sm ${
errors.createDateIni ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.createDateIni && (
<p className="text-red-500 text-xs">{errors.createDateIni}</p>
)}
</div>
{/* Data Final */}
<div className="space-y-1">
<label htmlFor="createDateEnd" className="text-sm font-medium text-gray-700">
Data Final
</label>
<input
type="date"
id="createDateEnd"
name="createDateEnd"
value={values.createDateEnd || ""}
onChange={handleChange}
min={values.createDateIni || undefined}
className={`w-full rounded border p-2 text-sm ${
errors.createDateEnd ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.createDateEnd && (
<p className="text-red-500 text-xs">{errors.createDateEnd}</p>
)}
</div>
{/* Local de Entrega */}
<div className="space-y-1">
<label htmlFor="deliveryLocal" className="text-sm font-medium text-gray-700">
Local de Entrega
</label>
<input
type="text"
id="deliveryLocal"
name="deliveryLocal"
value={values.deliveryLocal || ""}
onChange={handleChange}
className={`w-full rounded border p-2 text-sm ${
errors.deliveryLocal ? "border-red-500" : "border-gray-300"
}`}
placeholder="Local de entrega"
/>
{errors.deliveryLocal && (
<p className="text-red-500 text-xs">{errors.deliveryLocal}</p>
)}
</div>
</div>
<div className="flex flex-col sm:flex-row items-center justify-between border-t pt-4 mt-2">
<p className="text-xs text-gray-500 mb-3 sm:mb-0">Todos os campos são opcionais</p>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={resetForm}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition-colors"
disabled={isLoading}
>
Limpar
</button>
<button
type="submit"
disabled={isLoading}
className={`px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors flex items-center justify-center min-w-[100px] ${
isLoading ? "opacity-70 cursor-not-allowed" : ""
}`}
aria-busy={isLoading}
>
{isLoading ? "Buscando..." : "Buscar"}
</button>
</div>
</div>
</form>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,75 @@
import { Button } from "@/src/components/ui/button";
import { Settings } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from "@/src/components/ui/dropdown-menu";
interface ColumnConfigProps {
allColumns: string[];
visibleColumns: string[];
isColumnVisible: (column: string) => boolean;
toggleColumn: (column: string) => void;
selectAllColumns: () => void;
unselectAllColumns: () => void;
}
export function ColumnConfig({
allColumns,
visibleColumns,
isColumnVisible,
toggleColumn,
selectAllColumns,
unselectAllColumns,
}: ColumnConfigProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Settings className="h-3.5 w-3.5 mr-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="flex items-center justify-between px-2 py-1.5">
<Button
variant="ghost"
size="sm"
onClick={selectAllColumns}
className="text-xs hover:text-foreground transition-colors px-2 h-7"
>
Todas
</Button>
<div className="h-4 w-px bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={unselectAllColumns}
className="text-xs hover:text-foreground transition-colors px-2 h-7"
>
Nenhuma
</Button>
</div>
<DropdownMenuSeparator />
<div className="max-h-[300px] overflow-y-auto">
{allColumns.map((column) => (
<DropdownMenuCheckboxItem
key={column}
checked={isColumnVisible(column)}
onCheckedChange={() => toggleColumn(column)}
className="text-xs"
>
{column}
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,14 @@
import { Settings } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { DropdownMenuTrigger } from "@/src/components/ui/dropdown-menu";
export function ColumnConfigTrigger() {
return (
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="flex items-center gap-1">
<Settings className="h-4 w-4" />
<span>Configurar Colunas</span>
</Button>
</DropdownMenuTrigger>
);
}

View File

@@ -0,0 +1,26 @@
import { ColumnVisibilityItem } from "./ColumnVisibilityItem";
interface ColumnListProps {
allColumns: string[];
isColumnVisible: (column: string) => boolean;
toggleColumn: (column: string) => void;
}
export function ColumnList({
allColumns,
isColumnVisible,
toggleColumn,
}: ColumnListProps) {
return (
<div className="max-h-[300px] overflow-y-auto">
{allColumns.map((column) => (
<ColumnVisibilityItem
key={column}
column={column}
checked={isColumnVisible(column)}
onToggle={toggleColumn}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Button } from "@/src/components/ui/button";
interface ColumnToggleActionsProps {
onSelectAll: () => void;
onUnselectAll: () => void;
}
export function ColumnToggleActions({
onSelectAll,
onUnselectAll,
}: ColumnToggleActionsProps) {
return (
<div className="flex justify-between mb-2 border-b pb-2">
<Button variant="ghost" size="sm" onClick={onSelectAll} className="text-xs h-7">
Marcar Todas
</Button>
<Button variant="ghost" size="sm" onClick={onUnselectAll} className="text-xs h-7">
Desmarcar Todas
</Button>
</div>
);
}

View File

@@ -0,0 +1,51 @@
// src/components/ColumnConfig/ColumnVisibilityItem.tsx
import { DropdownMenuCheckboxItem } from "@/src/components/ui/dropdown-menu";
interface Props {
column: string;
checked: boolean;
onToggle: (column: string) => void;
}
export function ColumnVisibilityItem({ column, checked, onToggle }: Props) {
return (
<DropdownMenuCheckboxItem
key={column}
checked={checked}
onCheckedChange={() => onToggle(column)}
className="flex items-center space-x-2 cursor-pointer py-1.5 hover:bg-gray-100 px-2 rounded"
>
<div className="flex items-center gap-2 text-sm">
{checked ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-blue-600"
>
<rect width="16" height="16" x="4" y="4" rx="2" />
<path d="m9 12 2 2 4-4" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-gray-400"
>
<rect width="16" height="16" x="4" y="4" rx="2" />
</svg>
)}
{column}
</div>
</DropdownMenuCheckboxItem>
);
}

View File

@@ -0,0 +1,694 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, User, Loader2, CloudOff, DownloadCloud, X } from "lucide-react";
import { cn } from "@/src/lib/utils";
import { Button } from "@/src/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/src/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { clientesApi } from "@/src/lib/api";
import { Cliente } from "@/src/components/types";
import { Label } from "@/src/components/ui/label";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/src/components/ui/tooltip";
import { Badge } from "@/src/components/ui/badge";
import { useLocalStorage } from "../../hooks/useLocalStorage";
interface CustomerSearchInputProps {
id: string;
label: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
onSelect: (customer: Cliente | null) => void;
selectedCustomer: Cliente | null;
preloadClients?: boolean;
maxPreloadedClients?: number;
recentLimit?: number;
disabled?: boolean;
}
// Função para normalizar texto para comparações (remover acentos, case insensitive)
const normalizeText = (text: string): string => {
return text
? text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
: "";
};
export function CustomerSearchInput({
id,
label,
placeholder,
value,
onChange,
onSelect,
selectedCustomer,
preloadClients = false,
maxPreloadedClients = 10,
recentLimit = 5,
disabled = false,
}: CustomerSearchInputProps) {
const [open, setOpen] = React.useState(false);
const [customers, setCustomers] = React.useState<Cliente[]>([]);
const [allCustomers, setAllCustomers] = React.useState<Cliente[]>([]);
const [isPreloaded, setIsPreloaded] = React.useState(false);
const [preloadLoading, setPreloadLoading] = React.useState(false);
const [preloadError, setPreloadError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState("");
const [debouncedQuery, setDebouncedQuery] = React.useState("");
const [searchError, setSearchError] = React.useState<string | null>(null);
const [useOfflineSearch, setUseOfflineSearch] = React.useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const [recentCustomers, setRecentCustomers] = useLocalStorage<Cliente[]>(
"recent-customers",
[]
);
// Memorized index for offline search
const searchIndex = React.useMemo(() => {
if (!allCustomers.length) return {};
// Criar um índice para busca mais rápida
const index: Record<string, Set<number>> = {};
allCustomers.forEach((customer, idx) => {
// Processar ID
if (customer.id) {
const idStr = customer.id.toString();
for (let i = 1; i <= idStr.length; i++) {
const partial = idStr.substring(0, i);
if (!index[partial]) index[partial] = new Set();
index[partial].add(idx);
}
}
// Processar nome
if (customer.name) {
const normalizedName = normalizeText(customer.name);
const words = normalizedName.split(/\s+/);
// Indexar palavras completas
words.forEach(word => {
if (!index[word]) index[word] = new Set();
index[word].add(idx);
});
// Indexar prefixos de palavras (até 4 letras)
words.forEach(word => {
for (let i = 1; i <= Math.min(word.length, 4); i++) {
const prefix = word.substring(0, i);
if (!index[prefix]) index[prefix] = new Set();
index[prefix].add(idx);
}
});
}
// Processar documento
if (customer.document) {
const doc = customer.document.replace(/[^\w]/g, "");
for (let i = 1; i <= doc.length; i++) {
const partial = doc.substring(0, i);
if (!index[partial]) index[partial] = new Set();
index[partial].add(idx);
}
}
});
return index;
}, [allCustomers]);
const preloadAllCustomers = React.useCallback(async () => {
try {
setPreloadLoading(true);
setPreloadError(null);
const allClients = await clientesApi.getAllClientes(maxPreloadedClients);
if (!allClients || allClients.length === 0) {
throw new Error("Nenhum cliente retornado");
}
setAllCustomers(allClients || []);
setIsPreloaded(true);
setUseOfflineSearch(true);
try {
localStorage.setItem("cached-customers", JSON.stringify(allClients));
localStorage.setItem("cached-customers-timestamp", Date.now().toString());
} catch (storageError) {
console.warn("Não foi possível armazenar clientes no cache:", storageError);
}
} catch (error) {
console.error("Erro ao pré-carregar clientes:", error);
setPreloadError("Falha ao carregar banco de clientes.");
setUseOfflineSearch(false);
// Tentar carregar do cache se disponível
try {
const cachedData = localStorage.getItem("cached-customers");
if (cachedData) {
const cachedClients = JSON.parse(cachedData);
setAllCustomers(cachedClients);
setIsPreloaded(true);
setUseOfflineSearch(true);
setPreloadError("Usando dados em cache. Falha na atualização online.");
}
} catch (cacheError) {
console.warn("Erro ao carregar clientes do cache:", cacheError);
}
} finally {
setPreloadLoading(false);
}
}, [maxPreloadedClients]);
// Tentar carregar clientes do cache ao inicializar
React.useEffect(() => {
if (preloadClients && !isPreloaded) {
try {
const cachedData = localStorage.getItem("cached-customers");
const timestamp = localStorage.getItem("cached-customers-timestamp");
if (cachedData && timestamp) {
const cachedTime = parseInt(timestamp, 10);
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
// Só usar cache se for mais recente que 1 dia
if (now - cachedTime < oneDay) {
const cachedClients = JSON.parse(cachedData);
setAllCustomers(cachedClients);
setIsPreloaded(true);
setUseOfflineSearch(true);
// Ainda assim, atualizar em segundo plano
preloadAllCustomers().catch(console.error);
return;
}
}
// Se não tiver cache ou for antigo, carregar normalmente
preloadAllCustomers();
} catch (error) {
console.warn("Erro ao verificar cache:", error);
preloadAllCustomers();
}
}
}, [preloadClients, isPreloaded, preloadAllCustomers]);
// Alternar entre modo online e offline de busca
const toggleSearchMode = () => {
if (!isPreloaded && !useOfflineSearch) {
preloadAllCustomers();
} else {
setUseOfflineSearch(!useOfflineSearch);
setSearchQuery("");
setCustomers([]);
}
};
// Busca local otimizada quando está usando modo offline
const searchOffline = React.useCallback((query: string) => {
if (!query || query.length < 2) {
if (recentCustomers.length > 0) {
setCustomers(recentCustomers.slice(0, recentLimit));
} else {
setCustomers([]);
}
return;
}
const normalizedQuery = normalizeText(query);
const queryTerms = normalizedQuery.split(/\s+/).filter(term => term.length > 0);
if (queryTerms.length === 0) {
setCustomers([]);
return;
}
// Usar o índice para encontrar matches potenciais
let matchSets: Set<number>[] = [];
queryTerms.forEach(term => {
// Verificar se o termo existe no índice
if (searchIndex[term]) {
matchSets.push(searchIndex[term]);
} else {
// Procurar por termos parciais
const matchingKeys = Object.keys(searchIndex).filter(key => key.includes(term));
if (matchingKeys.length > 0) {
const combinedSet = new Set<number>();
matchingKeys.forEach(key => {
searchIndex[key].forEach(idx => combinedSet.add(idx));
});
if (combinedSet.size > 0) {
matchSets.push(combinedSet);
}
}
}
});
if (matchSets.length === 0) {
setCustomers([]);
return;
}
// Intersecção de todos os conjuntos para encontrar registros que correspondam a todos os termos
let resultIndexes: number[] = [];
if (matchSets.length === 1) {
resultIndexes = Array.from(matchSets[0]);
} else {
// Intersecção de conjuntos (AND lógico)
const intersection = new Set(matchSets[0]);
for (let i = 1; i < matchSets.length; i++) {
const currentSet = matchSets[i];
for (const idx of intersection) {
if (!currentSet.has(idx)) {
intersection.delete(idx);
}
}
}
resultIndexes = Array.from(intersection);
}
// Limitar a 100 resultados para melhor performance e remover duplicatas por ID
const results = resultIndexes.slice(0, 150).map(idx => allCustomers[idx]);
// Remover duplicatas (por ID)
const uniqueResults = Array.from(
new Map(results.map(item => [item.id, item])).values()
).slice(0, 100);
setCustomers(uniqueResults);
}, [allCustomers, searchIndex, recentCustomers, recentLimit]);
// Debounce search query
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(searchQuery);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
// Search customers when debounced query changes
React.useEffect(() => {
if (useOfflineSearch) {
searchOffline(debouncedQuery);
return;
}
async function searchCustomers() {
if (!debouncedQuery || debouncedQuery.length < 2) {
if (recentCustomers.length > 0) {
setCustomers(recentCustomers.slice(0, recentLimit));
} else {
setCustomers([]);
}
return;
}
try {
setLoading(true);
setSearchError(null);
const results = await clientesApi.search(debouncedQuery);
// Remover duplicatas (pode haver clientes com o mesmo ID nos resultados)
const uniqueResults = results ? Array.from(
new Map(results.map(item => [item.id, item])).values()
) : [];
setCustomers(uniqueResults);
} catch (error) {
console.error("Erro ao buscar clientes:", error);
setSearchError("Erro ao buscar clientes. Tente novamente.");
// Fallback para busca offline se tiver clientes pré-carregados
if (isPreloaded && allCustomers.length > 0) {
setSearchError("Falha na busca online. Usando resultados locais.");
searchOffline(debouncedQuery);
} else {
setCustomers([]);
}
} finally {
setLoading(false);
}
}
searchCustomers();
}, [debouncedQuery, useOfflineSearch, searchOffline, recentCustomers, recentLimit, isPreloaded, allCustomers]);
// Adicionar cliente selecionado aos recentes
const addToRecentCustomers = (customer: Cliente) => {
if (!customer) return;
// Remover duplicatas e adicionar no início
const updatedRecents = [
customer,
...recentCustomers.filter(c => c.id !== customer.id)
].slice(0, 10); // Manter apenas os 10 mais recentes
setRecentCustomers(updatedRecents);
};
// Selecionar um cliente
const handleSelectCustomer = (customer: Cliente) => {
onSelect(customer);
addToRecentCustomers(customer);
setOpen(false);
};
// Clear results and selection when input is cleared
const handleClearSelection = () => {
onSelect(null);
onChange("");
setSearchQuery("");
setCustomers(recentCustomers.slice(0, recentLimit));
};
// Focus input when popover opens
React.useEffect(() => {
if (open && searchInputRef.current) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
}
}, [open]);
// Keyboard handler for the input
const handleKeyDown = (e: React.KeyboardEvent) => {
// Fechar com Escape
if (e.key === "Escape") {
setOpen(false);
}
// Limpar seleção com Delete ou Backspace quando input está vazio
if ((e.key === "Delete" || e.key === "Backspace") && !searchQuery && selectedCustomer) {
handleClearSelection();
}
};
return (
<div className="flex flex-col space-y-1">
<div className="flex justify-between items-center">
<Label
htmlFor={id}
className="text-xs font-medium"
>
{label}
{selectedCustomer && (
<Badge variant="outline" className="ml-2 text-xs py-0">
ID: {selectedCustomer.id}
</Badge>
)}
</Label>
<div className="flex items-center space-x-1">
{isPreloaded && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-xs"
onClick={toggleSearchMode}
disabled={disabled}
>
{useOfflineSearch ? (
<CloudOff className="h-3 w-3 mr-1" />
) : (
<DownloadCloud className="h-3 w-3 mr-1" />
)}
{useOfflineSearch ? "Online" : "Offline"}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{useOfflineSearch
? "Alternar para busca online (via)"
: "Alternar para busca offline (dados pré-carregados)"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!isPreloaded && preloadClients && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-xs"
onClick={preloadAllCustomers}
disabled={preloadLoading || disabled}
>
{preloadLoading ? (
<>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
<span className="sr-only">Carregando</span>
</>
) : (
<>
<DownloadCloud className="h-3 w-3 mr-1" />
<span>Carregar</span>
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Pré-carregar base de clientes para busca rápida offline</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
{preloadError && (
<p className="text-xs text-amber-600 mt-0.5">{preloadError}</p>
)}
<div className="relative">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-label="Selecione um cliente"
disabled={disabled}
className={cn(
"w-full justify-between h-8 transition-all",
selectedCustomer ? "pr-8" : "text-muted-foreground",
disabled && "opacity-50 cursor-not-allowed"
)}
>
{selectedCustomer ? (
<div className="flex items-center gap-2 overflow-hidden">
<User className="h-4 w-4 shrink-0" />
<span className="truncate">
{selectedCustomer.name}
{selectedCustomer.document ? ` (${selectedCustomer.document})` : ""}
</span>
</div>
) : (
<span className="truncate">{placeholder}</span>
)}
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{/* Botão de limpar seleção */}
{selectedCustomer && !disabled && (
<Button
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-2 text-muted-foreground hover:text-foreground"
onClick={handleClearSelection}
aria-label="Limpar seleção"
>
<X className="h-4 w-4" />
</Button>
)}
<PopoverContent
className="w-full p-0"
align="start"
sideOffset={4}
onEscapeKeyDown={() => setOpen(false)}
>
<Command
filter={(value: string, search: string) => {
// Use o filtro apenas para navegação com teclado, a filtragem real é feita pela API
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
onKeyDown={handleKeyDown}
>
<div className="flex items-center border-b px-3">
<CommandInput
ref={searchInputRef}
placeholder={placeholder}
value={searchQuery}
onValueChange={(value: string) => {
setSearchQuery(value);
onChange(value);
}}
className="h-9 flex-1"
/>
{isPreloaded && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 ml-1"
onClick={toggleSearchMode}
>
{useOfflineSearch ? (
<CloudOff className="h-4 w-4" />
) : (
<DownloadCloud className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{useOfflineSearch
? "Usando busca offline (local)"
: "Usando busca online (API)"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{searchError && (
<p className="px-3 py-2 text-xs text-amber-600">{searchError}</p>
)}
{loading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="ml-2 text-sm">Buscando clientes...</span>
</div>
)}
<CommandList>
<CommandEmpty>
{searchQuery.length > 0 ? (
"Nenhum cliente encontrado."
) : (
"Digite para buscar clientes."
)}
</CommandEmpty>
{/* Clientes recentes */}
{!searchQuery && recentCustomers.length > 0 && (
<CommandGroup heading="Clientes recentes">
{recentCustomers.slice(0, recentLimit).map((customer) => (
<CommandItem
key={`recent-${customer.id}`}
value={`recent-${customer.id || ""} ${customer.name || ""} ${customer.document || ""}`}
onSelect={() => handleSelectCustomer(customer)}
className="flex items-center gap-2"
>
<User className="h-4 w-4 text-muted-foreground" />
<span>{customer.name}</span>
{customer.document && (
<span className="ml-1 text-sm text-muted-foreground">
({customer.document})
</span>
)}
{selectedCustomer?.id === customer.id && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Resultados da busca */}
{customers.length > 0 && (
<CommandGroup
heading={
searchQuery.length > 0
? `Resultados (${customers.length}${customers.length >= 100 ? '+' : ''})`
: "Clientes"
}
>
{customers
// Filtrar clientes que já estão nos recentes se não houver busca
.filter(customer =>
searchQuery.length > 0 ||
!recentCustomers.slice(0, recentLimit).some(rc => rc.id === customer.id)
)
.map((customer) => (
<CommandItem
key={customer.id}
value={`${customer.id || ""} ${customer.name || ""} ${customer.document || ""}`}
onSelect={() => handleSelectCustomer(customer)}
className="flex items-center gap-2"
>
<User className="h-4 w-4" />
<div className="flex flex-col">
<div className="flex items-center">
<span className="font-medium">{customer.name}</span>
{selectedCustomer?.id === customer.id && (
<Check className="ml-2 h-4 w-4 text-green-500" />
)}
</div>
<div className="flex text-xs text-muted-foreground">
<span className="mr-2">ID: {customer.id}</span>
{customer.document && (
<span>{customer.document}</span>
)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
{/* Rodapé com status */}
<div className="p-2 text-xs text-center text-muted-foreground border-t flex justify-between items-center">
<span>
{useOfflineSearch
? `Busca local - ${allCustomers.length} clientes`
: ""}
</span>
{selectedCustomer && (
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={handleClearSelection}
>
Limpar seleção
</Button>
)}
</div>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { Input } from "@/src/components/ui/input";
import { Label } from "@/src/components/ui/label";
import { CalendarIcon } from "lucide-react";
interface DateRangeFilterProps {
startDateId: string;
endDateId: string;
label: string;
startValue: string | undefined;
endValue: string | undefined;
onStartChange: (value: any) => void;
onEndChange: (value: any) => void;
}
/**
* Componente para filtros de intervalo de datas, exibindo data inicial e final em uma única linha
*/
export function DateRangeFilter({
startDateId,
endDateId,
label,
startValue,
endValue,
onStartChange,
onEndChange,
}: DateRangeFilterProps) {
return (
<div className="flex flex-col space-y-1">
{label && (
<div className="flex items-center gap-1">
<CalendarIcon className="h-3 w-3 text-muted-foreground" />
<Label className="text-xs font-medium">{label}</Label>
</div>
)}
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
id={startDateId}
type="date"
value={startValue || ""}
onChange={(e) => onStartChange(e.target.value)}
className="h-8 text-xs"
placeholder="Data inicial"
/>
</div>
<span className="text-xs text-muted-foreground font-medium">até</span>
<div className="flex-1">
<Input
id={endDateId}
type="date"
value={endValue || ""}
onChange={(e) => onEndChange(e.target.value)}
className="h-8 text-xs"
placeholder="Data final"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Input } from "@/src/components/ui/input";
import { Label } from "@/src/components/ui/label";
interface FilterInputProps {
id: string;
label: string;
type?: string;
placeholder: string;
value: string | number | undefined;
onChange: (value: any) => void;
min?: string;
className?: string;
max?: string;
}
/**
* Componente de input para filtros de pesquisa
*/
export function FilterInput({
id,
label,
type = "text",
placeholder,
value,
onChange,
min,
max,
}: FilterInputProps) {
return (
<div className="flex flex-col space-y-1">
<div className="flex items-center gap-2 text-xs">
<Label htmlFor={id} className="text-xs font-medium">
{label}
</Label>
</div>
<Input
id={id}
type={type}
placeholder={placeholder}
value={value || ""}
onChange={(e) =>
onChange(
type === "number" && e.target.value
? Number(e.target.value)
: e.target.value,
)
}
min={min}
max={max}
className="h-8"
/>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { Label } from "@/src/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { useMemo } from "react";
import { cn } from "@/src/lib/utils";
interface FilterSelectProps {
id: string;
label: string;
value: string | undefined;
onValueChange: (value: string) => void;
placeholder: string;
disabled?: boolean;
options: Array<{ value: string; label: string }>;
loading?: boolean;
loadingText?: string;
className?: string;
}
/**
* Componente de select para filtros de pesquisa
* @param props FilterSelectProps - Propriedades do componente
* @returns JSX.Element
*/
export function FilterSelect({
id,
label,
value,
onValueChange,
placeholder = "Selecione.....",
disabled = false,
options = [],
loading = false,
loadingText,
className,
}: FilterSelectProps) {
// Garante que as opções sejam sempre um array válido e único
const safeOptions = useMemo(() => {
if (!Array.isArray(options)) return [];
return options.filter(option =>
option &&
typeof option === 'object' &&
'value' in option &&
'label' in option &&
typeof option.value === 'string' &&
typeof option.label === 'string'
);
}, [options]);
// Garante que o valor seja uma string válida e exista nas opções
const selectedValue = useMemo(() => {
try {
const strValue = value?.toString();
if (!strValue) return undefined;
// Verifica se o valor existe nas opções
const valueExists = safeOptions.some(opt => opt.value === strValue);
return valueExists ? strValue : undefined;
} catch (error) {
console.warn(`FilterSelect: Error processing value: ${error}`);
return undefined;
}
}, [value, safeOptions]);
// Handler seguro para mudanças
const handleValueChange = (newValue: string) => {
try {
if (typeof onValueChange === 'function') {
onValueChange(newValue);
}
} catch (error) {
console.error(`FilterSelect: Error in onValueChange: ${error}`);
}
};
return (
<div className="h-12 w-full">
<div className="flex flex-col space-y-1 h-full w-full">
<div className="flex items-center gap-2">
<Label htmlFor={id} className="text-xs font-medium">
{label || ""}
</Label>
</div>
<Select
defaultValue={selectedValue}
onValueChange={handleValueChange}
disabled={disabled || loading || safeOptions.length === 0}
>
<SelectTrigger
id={id}
className={cn(
"flex h-15 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{safeOptions.length === 0 ? (
<SelectItem value="empty" disabled>
Nenhuma opção disponível
</SelectItem>
) : (
safeOptions.map((option) => (
<SelectItem
key={`${id}-${option.value}`}
value={option.value}
>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{loading && loadingText && (
<p className="text-xs text-muted-foreground mt-0.5">{loadingText}</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import React from "react";
import { cn } from "@/src/lib/utils";
/**
* Componente InfoCard para exibir informações de forma consistente
* Utilizado para mostrar dados de pedido, cliente, entrega, etc.
*/
export function InfoCard({
title,
value,
subtext,
icon,
className,
customBadge
}: {
title: string;
value: string | React.ReactNode;
subtext?: string;
icon?: React.ReactNode;
className?: string;
customBadge?: React.ReactNode;
}) {
return (
<div className="space-y-1">
<div className="flex items-center gap-1">
{icon}
<h4 className="text-xs font-medium text-muted-foreground">{title}</h4>
</div>
<div>
{customBadge || (
<p className={cn("text-sm font-medium truncate", className)}>
{value}
</p>
)}
{subtext && (
<p className="text-xs text-muted-foreground mt-0.5">
{subtext}
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import * as React from "react";
import { Label } from "@/src/components/ui/label";
import { X, ChevronDown, Check } from "lucide-react";
import { Badge } from "@/src/components/ui/badge";
import { cn } from "@/src/lib/utils";
import { Button } from "@/src/components/ui/button";
import { Checkbox } from "@/src/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
interface Option {
value: string;
label: string;
}
interface MultiFilterSelectProps {
id: string;
label: string;
values: string[];
onValuesChange: (values: string[]) => void;
placeholder: string;
disabled?: boolean;
options: Option[];
loading?: boolean;
loadingText?: string;
className?: string;
}
/**
* Componente de select para filtros de pesquisa com suporte a múltipla seleção
*/
export function MultiFilterSelect({
id,
label,
values = [],
onValuesChange,
placeholder,
disabled,
options,
loading,
loadingText,
className,
}: MultiFilterSelectProps) {
const [open, setOpen] = React.useState(false);
const handleSelect = (value: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const newValues = values.includes(value)
? values.filter((v) => v !== value)
: [...values, value];
onValuesChange(newValues);
};
const clearValues = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onValuesChange([]);
};
const selectedLabels = options
.filter((option) => values.includes(option.value))
.map((option) => option.label);
return (
<div className={cn("flex flex-col space-y-1", className)}>
<div className="flex items-center gap-2">
<Label htmlFor={id} className="text-xs font-medium">
{label}
</Label>
</div>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild disabled={disabled}>
<div
id={id}
className={cn(
"flex h-8 items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
{ "cursor-not-allowed opacity-50": disabled }
)}
>
<div className="flex flex-1 items-center gap-1 overflow-hidden">
{values.length > 0 ? (
<div className="flex flex-1 flex-nowrap gap-1 overflow-hidden">
{values.length === 1 ? (
<span className="truncate">{selectedLabels[0]}</span>
) : (
<Badge variant="secondary" className="px-1.5 py-0 h-5 font-normal text-xs bg-blue-100 text-blue-800 hover:bg-blue-200">
{values.length} selecionados
</Badge>
)}
</div>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{values.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-4 w-4 p-0 opacity-70 hover:opacity-100"
onClick={clearValues}
>
<X className="h-3 w-3" />
</Button>
)}
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[var(--radix-dropdown-menu-trigger-width)]" align="start">
<DropdownMenuLabel className="flex items-center justify-between text-xs">
<span>Selecione as opções</span>
{values.length > 0 && (
<Badge variant="secondary" className="px-1.5 py-0 h-10 text-xs bg-blue-50 text-blue-600 hover:bg-blue-100">
{values.length} selecionados
</Badge>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className="max-h-[200px] overflow-y-auto">
{options.map((option) => (
<DropdownMenuItem
key={option.value}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
values.includes(option.value) && "bg-blue-50"
)}
onSelect={(e) => e.preventDefault()}
onClick={(e) => handleSelect(option.value, e)}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Checkbox
id={`${id}-${option.value}`}
checked={values.includes(option.value)}
className={cn("h-4 w-4", values.includes(option.value) && "border-blue-500 bg-blue-500")}
onClick={(e) => e.stopPropagation()}
/>
</span>
<span>{option.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<div className="p-1 flex justify-between">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={clearValues}
>
Limpar
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs bg-blue-50 text-blue-600 border-blue-200 hover:bg-blue-100"
onClick={() => setOpen(false)}
>
<Check className="mr-1 h-3 w-3" />
Confirmar
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
{loading && loadingText && (
<p className="text-xs text-muted-foreground mt-0.5">{loadingText}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,296 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowLeft } from "lucide-react";
import { useNavigation } from "../../hooks";
import { Button } from "../../components/ui/button";
import { OrderInfoCard } from "./OrderInfoCard";
import Timeline from "./OrderTimeline";
import { QRCodeRastreamento } from "../../components/qr-code-rastreamento";
import { ordersApi } from "../../lib/api";
import { createSearchParams, buildUrl } from "../../utils/url-helpers";
import {
Order,
OrderItem,
OrderDelivery,
cutitens,
transfer,
status,
TimelineEvent,
leadtime,
} from "../types";
/**
* Interface que define a estrutura de um evento na timeline
*/
export interface TimelineDisplayEvent {
id: string;
codigoFuncionario: string;
description: string;
date: string;
status: string;
icon: any;
color: string;
}
interface OrderDetailProps {
order: Order;
timelineEvents: leadtime[];
}
export function mapTimelineEvents(events: leadtime[]): TimelineEvent[] {
return events.map(event => ({
id: event.descricaoEtapa,
title: event.descricaoEtapa,
description: event.descricao || "Status updated",
date: event.data || new Date().toISOString(),
status: event.descricaoEtapa,
user: event.codigoFuncionario,
}));
}
export function OrderDetail({ order, timelineEvents }: OrderDetailProps) {
const { searchParams, navigateTo, goBack, goToOrdersFind } = useNavigation();
const from = searchParams.get("from");
const returnPath = searchParams.get("returnPath") || "/orders/find";
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
const [loadingItems, setLoadingItems] = useState(false);
const [itemsError, setItemsError] = useState<string | null>(null);
const [cutItems, setCutItems] = useState<cutitens[]>([]);
const [transfers, setTransfers] = useState<transfer[]>([]);
const [statusData, setStatusData] = useState<status[]>([]);
const [orderDelivery, setOrderDelivery] = useState<OrderDelivery>({
placeId: 0,
placeName: "",
street: "",
addressNumber: "",
bairro: "",
city: "",
state: "",
addressComplement: "",
cep: "",
commentOrder1: "",
commentOrder2: "",
commentDelivery1: order.commentDelivery1 || "",
commentDelivery2: "",
commentDelivery3: "",
commentDelivery4: "",
shippimentId: 0,
shippimentDate: new Date(),
shippimentComment: "",
place: order.deliveryLocal || "",
driver: order.driver || "",
car: order.carDescription || "",
closeDate: new Date(),
separatorName: "",
confName: "",
releaseDate: new Date(),
});
useEffect(() => {
async function fetchOrderItems() {
if (!order.orderId) return;
setLoadingItems(true);
setItemsError(null);
try {
const response = await ordersApi.getOrderItems(order.orderId);
const items = Array.isArray(response?.data)
? response.data
: Array.isArray(response)
? response
: [];
setOrderItems(items);
} catch {
setItemsError("Could not load order items.");
} finally {
setLoadingItems(false);
}
}
fetchOrderItems();
}, [order.orderId]);
useEffect(() => {
async function fetchDeliveryInfo() {
if (!order.orderId) return;
try {
const response = await ordersApi.getOrderDelivery(order.orderId);
const data = response?.data || response;
setOrderDelivery(prev => ({
...prev,
...data,
place: data.place || order.deliveryLocal || "",
driver: data.driver || order.driver || "",
car: data.car || order.carDescription || "",
}));
} catch {}
}
fetchDeliveryInfo();
}, [order.orderId]);
useEffect(() => {
async function fetchCutItems() {
if (!order.orderId) return;
try {
const response = await ordersApi.getCutItems(order.orderId);
const items = Array.isArray(response?.data)
? response.data
: Array.isArray(response)
? response
: [];
setCutItems(items);
} catch {}
}
fetchCutItems();
}, [order.orderId]);
useEffect(() => {
async function fetchTransfers() {
if (!order.orderId) return;
try {
const id = parseInt(order.orderId);
if (isNaN(id)) return;
const response = await ordersApi.getTransfer(id);
const items = Array.isArray(response?.data)
? response.data
: Array.isArray(response)
? response
: [];
setTransfers(
items.map(item => ({
...item,
oldShipmentId: Number(item.oldShipmentId ?? 0),
newShipmentId: Number(item.newShipmentId ?? 0),
}))
);
} catch {}
}
fetchTransfers();
}, [order.orderId]);
useEffect(() => {
async function fetchStatusData() {
if (!order.orderId) return;
try {
const id = parseInt(order.orderId);
if (isNaN(id)) return;
const response = await ordersApi.getStatusOrder(id);
const items = Array.isArray(response?.data)
? response.data
: Array.isArray(response)
? response
: [];
setStatusData(items);
} catch {}
}
fetchStatusData();
}, [order.orderId]);
const returnParams = createSearchParams(
searchParams,
["from", "returnPath"],
{ origem: "detail", noRefresh: "true", preserveState: "true" }
);
const handleBack = () => {
if (from === "lista") {
const baseUrl = returnPath;
const params: Record<string, string> = {};
searchParams.forEach((value, key) => {
params[key] = value;
});
const url = buildUrl(baseUrl, {
...params,
origem: "detail",
noRefresh: "true",
preserveState: "true",
});
navigateTo(url);
} else if (window.history.length > 2) {
goBack();
} else {
goToOrdersFind();
}
};
const orderWithDefaults = {
...order,
paymentMethod: order.paymentMethod || "Not provided",
paymentPlan: order.paymentPlan || "Not provided",
paymentName: order.paymentName || "Not provided",
driver: order.driver || "Not provided",
carDescription: order.carDescription || "Not provided",
deliveryLocal: order.deliveryLocal || "Not provided",
deliveryType: order.deliveryType || "Not provided",
};
const store = {
storeId: order.storeId,
store: order.storeName,
};
return (
<div className="pl-0 w-full">
<div className="bg-white border-b p-1 sm:p-2 flex justify-between items-center">
<div className="flex items-center gap-1 sm:gap-3">
<Button variant="ghost" size="sm" onClick={handleBack} className="p-1 sm:p-2">
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground" />
<span className="ml-1 sm:ml-2 text-xs sm:text-sm hidden xs:inline">Voltar</span>
</Button>
<div>
<h1 className="text-sm sm:text-lg font-semibold text-gray-800">
Pedido {order.orderId}
</h1>
<p className="text-xs sm:text-sm text-muted-foreground hidden sm:block">
Detalhes completos do pedido
</p>
</div>
</div>
<div className="flex justify-center p-2">
<QRCodeRastreamento orderId={order.orderId} /> </div>
</div>
<div className="p-2 sm:p-6 space-y-4 sm:space-y-6 mx-auto">
<div className="grid grid-cols-1 xl:grid-cols-4 gap-4 sm:gap-6">
<div className="xl:col-span-3">
<OrderInfoCard
order={orderWithDefaults}
delivery={orderDelivery}
loja={store}
orderItems={orderItems}
cutitens={cutItems}
transfers={transfers}
status={statusData}
/>
</div>
<div className="hidden sm:block xl:col-span-1">
<div className="bg-white border rounded-lg p-4 shadow-sm">
<Timeline orderId={order.orderId} />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../components/ui/dialog";
import { Order } from "../types";
import { formatCurrency, formatDateToDisplay } from "../../utils/format-helpers";
interface OrderDetailModalProps {
order: Order | null;
isOpen: boolean;
onClose: () => void;
}
export function OrderDetailModal({ order, isOpen, onClose }: OrderDetailModalProps) {
if (!order) return null;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Order Details: {order.orderId}</DialogTitle>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-medium text-gray-500">Customer</h3>
<p className="text-sm">{order.clientName}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Store</h3>
<p className="text-sm">{order.storeName}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Order Date</h3>
<p className="text-sm">{formatDateToDisplay(order.createDate)}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Status</h3>
<p className="text-sm">{order.status}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Total Value</h3>
<p className="text-sm font-semibold text-emerald-600">
{formatCurrency(order.totalValue)}
</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Payment Status</h3>
<p className="text-sm">{order.paymentStatus}</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,342 @@
"use client";
import { useState, useEffect } from "react";
import { Order, OrderDelivery, Store, OrderItem, cutitens, transfer, status, OrderDeliveryDto } from "../types";
import { STATUS_COLORS, DELIVERY_TYPE_MAP } from "../../constants/status-options";
import { ordersApi } from "../../lib/api";
import {
Info,
Truck,
Scissors,
ReceiptText,
ArrowRightLeft,
CreditCard as CheckoutIcon
} from "lucide-react";
import {
Card,
CardContent,
} from "@/src/components/ui/card";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent
} from "@/src/components/ui/tabs";
import { cn } from "@/src/lib/utils";
import { OrderItemsSection } from "./tabs/OrderItemsSection";
import { InfoTab } from "./tabs/InfoTab";
import { EntregaTab } from "./tabs/EntregaTab";
import { CortesTab } from "./tabs/CortesTab";
import { TransferenciaTab } from "./tabs/TransferenciaTab";
import { TV8Tab } from "./tabs/TV8Tab";
import { TabKey } from "./order-info-utils";
import { CheckoutTab } from "./tabs/CheckoutTab";
import { InfoCard } from "./InfoCard";
interface OrderInfoCardProps {
order: Order;
delivery: OrderDelivery;
loja: Store;
orderItems?: OrderItem[];
cutitens?: cutitens[];
transfers?: transfer[];
status?: status[];
tv8Deliveries?: OrderDeliveryDto[];
}
// Função para obter a cor do status
const getStatusColor = (status: string | undefined): string => {
if (!status) return "bg-white-400";
return STATUS_COLORS[status.toLowerCase()] || "bg-white-400";
};
// Status padrão para quando não vier status da API
const STATUS_DEFAULT = "PENDENTE";
/**
* Normaliza o status para o formato padrão
* Garante consistência na apresentação entre diferentes APIs
*/
function normalizeStatus(status: string | null | undefined): string {
if (!status) return STATUS_DEFAULT;
// Converte para maiúsculo para padronização visual
return status.toUpperCase();
}
/**
* Mapeia o tipo de entrega para um status apropriado
* Usado quando status não está disponível, mas podemos inferir pelo tipo
*/
function getStatusFromDeliveryType(deliveryType: string | null | undefined): string | null {
if (!deliveryType) return null;
const type = deliveryType.toUpperCase();
// Se for RP (Retirada na Praça), usamos isso como status
if (type === 'RP') return 'RP';
return null;
}
function getDeliveryTypeLabel(code: string | null | undefined): string {
if (!code) return "-";
return DELIVERY_TYPE_MAP[code] || code;
}
/**
* Componente principal para exibição de informações detalhadas de um pedido
* Possui múltiplas abas e seções
*/
export function OrderInfoCard({
order,
delivery,
loja,
orderItems = [],
cutitens = [],
transfers = [],
status = [],
tv8Deliveries: initialTv8Deliveries = []
}: OrderInfoCardProps) {
const [tv8Deliveries, setTv8Deliveries] = useState<OrderDeliveryDto[]>(initialTv8Deliveries);
const [isLoadingTv8, setIsLoadingTv8] = useState(false);
const [tv8Error, setTv8Error] = useState("");
const rawComments = [
delivery.commentOrder1,
delivery.commentOrder2,
delivery.commentDelivery1,
delivery.commentDelivery2,
delivery.commentDelivery3,
delivery.commentDelivery4,
];
const comments = rawComments.filter((c) => c?.trim());
const hasComments = comments.length > 0;
// Display flags and data preparation
const isUsingDemoData = false; // We no longer use demo data
const displayTV8Data = tv8Deliveries;
// Dados de teste (fallback) para TV8 em caso de falha na API
const testTV8Data: OrderDeliveryDto[] = [
{
storeId: 6,
createDate: new Date(),
orderId: Number(order.orderId) || 1,
orderIdSale: null,
deliveryDate: new Date(),
customerId: 12345,
customer: order.customerName || "Cliente Teste",
deliveryType: "Entrega (EF)",
quantityItens: 5,
status: "Pendente",
weight: 10.5,
shipmentId: 7001,
driverId: 101,
driverName: "FRANCISCO ANDRÉ O DE SOUZA",
carPlate: "AAA-0000",
carIdentification: "Carro 001",
observation: "Observação de teste",
deliveryConfirmationDate: null
}
];
const loadTestData = () => {
setTv8Deliveries(testTV8Data);
setIsLoadingTv8(false);
setTv8Error("");
};
useEffect(() => {
async function fetchTv8Deliveries() {
if (!order.orderId) return;
setIsLoadingTv8(true);
setTv8Error("");
try {
// Formatar orderID corretamente
const formattedOrderId = order.orderId?.toString().trim();
// Tentar dois endpoints diferentes
let response;
try {
// Adiciona verificação de params opcionais para datas
response = await ordersApi.getOrderDeliveries(formattedOrderId);
} catch (err) {
// Pequeno delay antes de tentar novamente
await new Promise(resolve => setTimeout(resolve, 500));
// Tentar URL alternativa diretamente
try {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://10.1.117.70:8888";
const url = `${baseUrl}/api/v1/tv8/${formattedOrderId}`;
response = await fetch(url)
.then(res => {
if (!res.ok) {
throw new Error(`Falha na requisição alternativa: ${res.status}`);
}
return res.json();
});
} catch (fetchErr: any) {
throw new Error(`Falha nas duas tentativas: ${fetchErr.message}`);
}
}
// Verificar se a resposta tem estrutura {success, data}
if (response && typeof response === 'object' && 'success' in response && 'data' in response) {
if (Array.isArray(response.data) && response.data.length > 0) {
// Garantir que o status está definido em cada item
const dataWithStatus = response.data.map((item: any) => {
// Verificar se o status existe e é válido, caso contrário inferir do tipo de entrega
const statusValue = item.status || getStatusFromDeliveryType(item.deliveryType) || STATUS_DEFAULT;
return {
...item,
status: normalizeStatus(statusValue)
};
});
setTv8Deliveries(dataWithStatus);
} else {
setTv8Deliveries([]);
}
} else if (Array.isArray(response) && response.length > 0) {
// Garantir que o status está definido em cada item
const dataWithStatus = response.map((item: any) => {
// Verificar se o status existe e é válido, caso contrário inferir do tipo de entrega
const statusValue = item.status || getStatusFromDeliveryType(item.deliveryType) || STATUS_DEFAULT;
return {
...item,
status: normalizeStatus(statusValue)
};
});
setTv8Deliveries(dataWithStatus);
} else {
setTv8Deliveries([]);
}
} catch (error) {
setTv8Error("Falha ao carregar entregas TV8");
setTv8Deliveries([]);
} finally {
setIsLoadingTv8(false);
}
}
fetchTv8Deliveries();
}, [order.orderId]);
// Processo dos comentários do pedido
const isValidComment = (comment: unknown): comment is string => {
return Boolean(
comment &&
typeof comment === 'string' &&
comment.trim() !== '' &&
comment.trim().toLowerCase() !== 'null'
);
};
const orderComments = [
delivery.commentOrder1,
delivery.commentOrder2
].filter(isValidComment);
return (
<div className="flex flex-col space-y-4">
<Card>
<Tabs defaultValue={TabKey.Info} className="w-full">
<div className="border-b w-full">
<TabsList className="flex w-full bg-transparent rounded-none p-0 h-auto justify-between">
<TabsTrigger
value={TabKey.Info}
className="flex flex-1 flex-col items-center justify-center gap-1 p-4 m-0 rounded-none border-0 data-[state=active]:border-b-2 data-[state=active]:border-b-blue-500 bg-transparent data-[state=active]:bg-transparent"
>
<Info className="h-5 w-5" />
<span className="text-xs">Informações</span>
</TabsTrigger>
<TabsTrigger
value={TabKey.Itens}
className="flex flex-1 flex-col items-center justify-center gap-1 p-4 m-0 rounded-none border-0 data-[state=active]:border-b-2 data-[state=active]:border-b-blue-500 bg-transparent data-[state=active]:bg-transparent"
>
<ReceiptText className="h-5 w-5" />
<span className="text-xs">Itens</span>
</TabsTrigger>
<TabsTrigger
value={TabKey.Entrega}
className="flex flex-1 flex-col items-center justify-center gap-1 p-4 m-0 rounded-none border-0 data-[state=active]:border-b-2 data-[state=active]:border-b-blue-500 bg-transparent data-[state=active]:bg-transparent"
>
<Truck className="h-5 w-5" />
<span className="text-xs">Entrega</span>
</TabsTrigger>
<TabsTrigger
value={TabKey.Cortes}
className="flex flex-1 flex-col items-center justify-center gap-1 p-4 m-0 rounded-none border-0 data-[state=active]:border-b-2 data-[state=active]:border-b-blue-500 bg-transparent data-[state=active]:bg-transparent"
>
<Scissors className="h-5 w-5" />
<span className="text-xs">Cortes</span>
</TabsTrigger>
<TabsTrigger
value={TabKey.Transferencia}
className="flex flex-1 flex-col items-center justify-center gap-1 p-4 m-0 rounded-none border-0 data-[state=active]:border-b-2 data-[state=active]:border-b-blue-500 bg-transparent data-[state=active]:bg-transparent"
>
<ArrowRightLeft className="h-5 w-5" />
<span className="text-xs">Transferência</span>
</TabsTrigger>
<TabsTrigger
value={TabKey.TV8}
className="flex flex-1 flex-col items-center justify-center gap-1 p-4 m-0 rounded-none border-0 data-[state=active]:border-b-2 data-[state=active]:border-b-blue-500 bg-transparent data-[state=active]:bg-transparent"
>
<Truck className="h-5 w-5 text-orange-500" />
<span className="text-xs">TV8</span>
</TabsTrigger>
<TabsTrigger
value={TabKey.Checkout}
className="flex flex-1 flex-col items-center justify-center gap-1 p-4 m-0 rounded-none border-0 data-[state=active]:border-b-2 data-[state=active]:border-b-blue-500 bg-transparent data-[state=active]:bg-transparent"
>
<CheckoutIcon className="h-5 w-5" />
<span className="text-xs">Acerto de caixa</span>
</TabsTrigger>
</TabsList>
</div>
<CardContent className="pt-4 pb-2 px-2 sm:px-6">
<TabsContent value={TabKey.Info} className="mt-0">
<InfoTab order={order} orderComments={orderComments} />
</TabsContent>
<TabsContent value={TabKey.Itens} className="mt-0">
<OrderItemsSection orderItems={orderItems} />
</TabsContent>
<TabsContent value={TabKey.Entrega} className="mt-0">
<EntregaTab order={order} delivery={delivery} />
</TabsContent>
<TabsContent value={TabKey.Cortes} className="mt-0">
<CortesTab cutitens={cutitens} />
</TabsContent>
<TabsContent value={TabKey.Transferencia} className="mt-0">
<TransferenciaTab transfers={transfers} />
</TabsContent>
<TabsContent value={TabKey.TV8} className="mt-0">
<TV8Tab order={order} initialDeliveries={tv8Deliveries} />
</TabsContent>
<TabsContent value={TabKey.Checkout} className="mt-0">
<CheckoutTab order={order} />
</TabsContent>
</CardContent>
</Tabs>
</Card>
</div>
);
}

View File

@@ -0,0 +1,188 @@
import React, { memo } from "react";
import { PackageOpen, Scissors, Info, ArrowRightLeft, History, Truck, CreditCard } from "lucide-react";
import { Tabs, Card, Spin, Alert } from "antd";
import type { TabsProps } from "antd";
import { OrderItemsTable } from "./OrderItemsTable";
import {
OrderItem,
cutitens,
transfer,
status,
leadtime,
OrderDeliveryDto,
Store,
Order,
OrderDelivery,
} from "@/src/components/types";
import { CortesTab } from "./tabs/CortesTab";
import { EntregaTab } from "./tabs/EntregaTab";
import { InfoTab } from "./tabs/InfoTab";
import { TransferenciaTab } from "./tabs/TransferenciaTab";
import { StatusTab } from "./tabs/StatusTab";
import { TV8Tab } from "./tabs/TV8Tab";
import { CheckoutTab } from "./tabs/CheckoutTab";
// Define um alias para o tipo de cada aba, garantindo que não seja `undefined`
type TabItem = NonNullable<TabsProps["items"]>[number];
interface OrderItemsCardProps {
orderItems: OrderItem[];
loadingItems: boolean;
itemsError: string | null;
cutitens?: cutitens[];
order?: Order;
delivery?: OrderDelivery;
transfers?: transfer[];
status?: status[];
leadtime?: leadtime[];
orderId?: string;
tv8Deliveries?: OrderDeliveryDto[];
stores?: Store[];
}
const OrderItemsCardComponent = ({
orderItems,
loadingItems,
itemsError,
cutitens = [],
order,
delivery,
transfers = [],
status = [],
leadtime = [],
orderId,
tv8Deliveries = [],
stores = [],
}: OrderItemsCardProps) => {
// abas fixas (sempre exibidas)
const baseTabs: TabItem[] = [
{
key: "itens",
label: (
<span>
<PackageOpen className="inline-block mr-1 w-4 h-4 text-blue-500" />
Itens
</span>
),
children: loadingItems ? (
<div className="py-4 text-center">
<Spin size="small" />
</div>
) : itemsError ? (
<Alert type="error" message={itemsError} />
) : (
<OrderItemsTable
orderItems={orderItems}
loadingItems={loadingItems}
itemsError={itemsError}
/>
),
},
{
key: "info",
label: (
<span>
<Info className="inline-block mr-1 w-4 h-4 text-purple-500" />
Informações
</span>
),
children: order ? (
<InfoTab order={order} orderComments={[order.commentDelivery1]} />
) : (
<Alert type="warning" message="Dados do pedido não disponíveis." />
),
},
{
key: "timeline",
label: (
<span>
<History className="inline-block mr-1 w-4 h-4 text-yellow-500" />
Timeline
</span>
),
children: <StatusTab orderId={orderId} />,
},
{
key: "cortes",
label: (
<span>
<Scissors className="inline-block mr-1 w-4 h-4 text-red-500" />
Cortes
</span>
),
children: <CortesTab cutitens={cutitens} />,
},
{
key: "entrega",
label: (
<span>
<Truck className="inline-block mr-1 w-4 h-4 text-green-600" />
Entrega
</span>
),
children:
order && delivery ? (
<EntregaTab order={order} delivery={delivery} />
) : (
<Alert type="warning" message="Dados de entrega não disponíveis." />
),
},
{
key: "transferencia",
label: (
<span>
<ArrowRightLeft className="inline-block mr-1 w-4 h-4 text-orange-500" />
Transferência
</span>
),
children: <TransferenciaTab transfers={transfers} />,
},
];
// aba TV8, só se for TV7 - Faturamento
const tv8Tab: TabItem = {
key: "tv8",
label: (
<span>
<Truck className="inline-block mr-1 w-4 h-4 text-orange-500" />
TV8
</span>
),
children: order ? (
<TV8Tab order={order} initialDeliveries={tv8Deliveries} stores={stores} />
) : (
<Alert type="warning" message="Dados para TV8 não disponíveis." />
),
};
const checkoutTab: TabItem = {
key: "checkout",
label: (
<span>
<CreditCard className="inline-block mr-1 w-4 h-4 text-purple-600" />
Acerto de caixa
</span>
),
children: order ? (
<CheckoutTab order={order} />
) : (
<Alert type="warning" message="Dados do pedido não disponíveis." />
),
};
const tabItems: TabItem[] = [
...baseTabs,
...(order?.orderType === "TV7 - Faturamento" ? [tv8Tab] : []),
checkoutTab,
];
return (
<Card size="small" variant="outlined" style={{ marginTop: 0 }} styles={{ body: { padding: 12 } }}>
<Tabs defaultActiveKey="itens" items={tabItems} tabBarStyle={{ marginBottom: 8 }} />
</Card>
);
};
export const OrderItemsCard = memo(OrderItemsCardComponent);

View File

@@ -0,0 +1,466 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/src/components/ui/table";
import { Button } from "@/src/components/ui/button";
import { Eye, GripVertical } from "lucide-react";
import { OrderItem } from "@/src/components/types";
import { cellClass } from "@/src/constants/status-options";
import { formatCurrency } from "@/src/utils/format";
import { useState, useEffect, } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from "@/src/components/ui/dropdown-menu";
interface Item {
quantity: number | null | undefined;
}
const displayQuantity = (item?: Item) => {
if (!item?.quantity && item?.quantity !== 0) {
return "-";
}
return item.quantity >= 0 ? item.quantity.toString() : "0";
};
const ALL_COLUMNS = [
"Código",
"Descrição",
"Embalagem",
"Cor",
"F. Estoque",
"Tipo Entrega",
"Quantidade",
"Preço Unitário",
"Preço Total",
"Departamento",
"Marca",
];
interface OrderItemsTableProps {
orderItems: OrderItem[];
loadingItems: boolean;
itemsError: string | null;
onViewDetails?: (item: OrderItem) => void;
}
export function OrderItemsTable({
orderItems,
loadingItems,
itemsError,
onViewDetails,
}: OrderItemsTableProps) {
// Carregar as colunas visíveis do localStorage na inicialização
const initVisibleColumns = () => {
try {
const saved = localStorage.getItem('visibleOrderItemColumns');
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed;
}
}
} catch (error) {
console.error('Erro ao carregar configurações de colunas:', error);
}
return ALL_COLUMNS;
};
// Inicializar a ordem das colunas
const initColumnOrder = () => {
try {
const saved = localStorage.getItem('orderItemColumnsOrder');
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed;
}
}
} catch (error) {
console.error('Erro ao carregar ordem das colunas:', error);
}
return ALL_COLUMNS;
};
// Estado para controlar quais colunas estão visíveis
const [visibleColumns, setVisibleColumns] = useState<string[]>(initVisibleColumns);
// Estado para controlar a ordem das colunas
const [columnOrder, setColumnOrder] = useState<string[]>(initColumnOrder);
// Estado para rastrear coluna sendo arrastada
const [draggedColumn, setDraggedColumn] = useState<string | null>(null);
// Salvar as colunas visíveis no localStorage sempre que elas mudarem
useEffect(() => {
try {
localStorage.setItem('visibleOrderItemColumns', JSON.stringify(visibleColumns));
} catch (error) {
console.error('Erro ao salvar configurações de colunas:', error);
}
}, [visibleColumns]);
// Salvar a ordem das colunas no localStorage
useEffect(() => {
try {
localStorage.setItem('orderItemColumnsOrder', JSON.stringify(columnOrder));
} catch (error) {
console.error('Erro ao salvar ordem das colunas:', error);
}
}, [columnOrder]);
// Função para alternar a visibilidade de uma coluna
const toggleColumn = (column: string) => {
setVisibleColumns((current) => {
if (current.includes(column)) {
return current.filter((c) => c !== column);
} else {
return [...current, column];
}
});
};
// Função para selecionar todas as colunas
const selectAllColumns = () => {
setVisibleColumns(ALL_COLUMNS);
};
// Função para desmarcar todas as colunas
const unselectAllColumns = () => {
// Mantém pelo menos uma coluna para garantir que a tabela não fique vazia
setVisibleColumns(["Código"]);
};
// Função para verificar se uma coluna está visível
const isColumnVisible = (column: string) => visibleColumns.includes(column);
// Funções para drag & drop
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, column: string) => {
setDraggedColumn(column);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', column);
// Adicionar um estilo visual para o elemento sendo arrastado
if (e.currentTarget) {
setTimeout(() => {
if (e.currentTarget) {
e.currentTarget.classList.add('opacity-50');
}
}, 0);
}
};
const handleDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
setDraggedColumn(null);
e.currentTarget.classList.remove('opacity-50');
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>, targetColumn: string) => {
e.preventDefault();
if (!draggedColumn || draggedColumn === targetColumn) return;
// Reordenar as colunas
const newColumnOrder = [...columnOrder];
const draggedIndex = newColumnOrder.indexOf(draggedColumn);
const targetIndex = newColumnOrder.indexOf(targetColumn);
if (draggedIndex !== -1 && targetIndex !== -1) {
newColumnOrder.splice(draggedIndex, 1);
newColumnOrder.splice(targetIndex, 0, draggedColumn);
setColumnOrder(newColumnOrder);
}
};
const getColumnClass = (title: string) => {
const textRight = ["Preço Unitário", "Preço Total", "Quantidade"];
const cellWidth: { [key: string]: string } = {
"Código": "w-20",
"Descrição": "w-56",
"Embalagem": "w-24",
"Cor": "w-20",
"F. Estoque": "w-24",
"Tipo Entrega": "w-28",
"Quantidade": "w-24",
"Preço Unitário": "w-24",
"Preço Total": "w-24",
"Departamento": "w-48",
"Marca": "w-24",
};
let classes = "";
// Não aplicar whitespace-nowrap para Departamento
if (title !== "Departamento") {
classes += "whitespace-nowrap ";
}
// Alinhar à direita
if (textRight.includes(title)) {
classes += "text-right ";
}
// Aplicar larguras
if (cellWidth[title]) {
classes += cellWidth[title] + " ";
}
return classes;
};
// Função para ordenar as colunas visíveis na ordem definida pelo usuário
const getOrderedVisibleColumns = () => {
// Primeiro, filtrar as colunas que são visíveis
const visibleColumnSet = new Set(visibleColumns);
// Depois, ordenar as colunas de acordo com a ordem definida pelo usuário
return columnOrder.filter(column => visibleColumnSet.has(column));
};
// Resetar a ordem das colunas
const resetColumnOrder = () => {
setColumnOrder(ALL_COLUMNS);
};
const handleEvent = (e: React.SyntheticEvent) => {
const element = e.currentTarget;
if (element instanceof HTMLElement) {
const timeoutId = setTimeout(() => {
element.classList.add('opacity-50');
}, 0);
// Limpeza em caso de desmontagem
return () => clearTimeout(timeoutId);
}
};
if (loadingItems) {
return <div className="py-4 text-center">Carregando itens...</div>;
}
if (itemsError) {
return <div className="py-2 text-red-500">{itemsError}</div>;
}
if (orderItems.length === 0) {
return <div className="py-2">Nenhum item encontrado para este pedido.</div>;
}
const orderedVisibleColumns = getOrderedVisibleColumns();
return (
<div className="rounded-md border-0">
<div className="flex justify-end mb-2 gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 p-2 bg-white shadow-lg border border-gray-200">
<DropdownMenuLabel className="text-sm font-bold pb-2 mb-2 border-b border-gray-200">Visibilidade das Colunas</DropdownMenuLabel>
<div className="flex justify-between mb-2 border-b pb-2">
<Button
variant="ghost"
size="sm"
onClick={selectAllColumns}
className="text-xs h-7 hover:bg-gray-100"
>
Marcar Todas
</Button>
<Button
variant="ghost"
size="sm"
onClick={unselectAllColumns}
className="text-xs h-7 hover:bg-gray-100"
>
Desmarcar Todas
</Button>
</div>
<div className="max-h-[300px] overflow-y-auto">
{ALL_COLUMNS.map((column) => (
<DropdownMenuCheckboxItem
key={column}
checked={isColumnVisible(column)}
onCheckedChange={() => toggleColumn(column)}
className="flex items-center space-x-2 cursor-pointer py-1.5 hover:bg-gray-100 px-2 rounded"
>
<div className="flex items-center gap-2 text-sm">
{isColumnVisible(column) ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-blue-600"
>
<rect width="16" height="16" x="4" y="4" rx="2" />
<path d="m9 12 2 2 4-4" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-gray-400"
>
<rect width="16" height="16" x="4" y="4" rx="2" />
</svg>
)}
{column}
</div>
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="border border-gray-300">
<div className="overflow-x-auto">
<div className="max-h-[600px] overflow-y-auto">
<Table className="border-collapse border-gray-200">
<TableHeader className="sticky top-0 z-10 bg-white border-b border-gray-200">
<TableRow className="hover:bg-transparent">
{orderedVisibleColumns.map((title, index, array) => (
<TableHead
key={index}
className={`text-xs font-medium text-gray-600 p-2 ${getColumnClass(title)} ${
index < array.length - 1 ? "border-r border-gray-200" : ""
} relative`}
draggable={true}
onDragStart={(e) => handleDragStart(e, title)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, title)}
>
<div className="flex items-center">
<GripVertical className="h-3 w-3 mr-1 cursor-move text-gray-400" />
{title}
</div>
</TableHead>
))}
{onViewDetails && (
<TableHead className="text-xs font-medium text-gray-600 p-2 w-16">
Ações
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{orderItems.map((item, index) => (
<TableRow
key={`${item.productId}-${index}`}
className="odd:bg-white even:bg-gray-50 hover:bg-gray-100"
>
{orderedVisibleColumns.map((columnName, colIndex) => {
switch (columnName) {
case "Código":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{item.productId ?? "-"}
</TableCell>
);
case "Descrição":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{item.description ?? "-"}
</TableCell>
);
case "Embalagem":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{item.pacth ?? "-"}
</TableCell>
);
case "Cor":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{item.color ?? "-"}
</TableCell>
);
case "F. Estoque":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{item.stockId ?? "-"}
</TableCell>
);
case "Tipo Entrega":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{item.deliveryType ?? "-"}
</TableCell>
);
case "Quantidade":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-right text-xs`}>
{displayQuantity(item)}
</TableCell>
);
case "Preço Unitário":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-right text-xs`}>
{formatCurrency(item.salePrice)}
</TableCell>
);
case "Preço Total":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-right text-xs`}>
{item.total != null ? formatCurrency(item.total) : "-"}
</TableCell>
);
case "Departamento":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
<div className="break-words hyphens-auto w-full">
{item.department ?? "-"}
</div>
</TableCell>
);
case "Marca":
return (
<TableCell key={colIndex} className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{item.brand ?? "-"}
</TableCell>
);
default:
return null;
}
})}
{onViewDetails && (
<TableCell className="p-2">
<Button
variant="ghost"
size="icon"
onClick={() => onViewDetails(item)}
title="Ver detalhes do item"
className="h-7 w-7"
>
<Eye className="h-3.5 w-3.5" />
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,326 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import {
FilePlus,
LoaderCircle,
Boxes,
PackageCheck,
AlarmClockOff,
Truck,
Clock,
AlertCircle,
CheckCircle,
type LucideIcon,
} from "lucide-react";
import { cn } from "../../lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { Badge } from "../../components/ui/badge";
import { ordersApi } from "../../lib/api";
/**
* Represents a timeline event in an order's lifecycle
*/
interface Leadtime {
/** Description of the stage/step */
etapas: string;
/** Detailed description of the event */
descricao: string;
/** Date and time of the event */
date: string;
/** Employee ID who handled this step */
codigoFuncionario: string;
/** Employee name who handled this step */
nomeFuncionario: string;
/** Order number associated with this event */
numeroPedido: string;
/** Icon to display for this event */
icon?: LucideIcon;
/** Status label for this event */
status?: string;
/** Color theme for this event */
color?: string;
}
/**
* Props for the Timeline component
*/
interface TimelineProps {
/** Order ID to fetch timeline data for */
orderId?: string;
/** Optional CSS class name */
className?: string;
}
/**
* Configuration for different order status types
*/
const statusConfig: Record<
string,
{ icon: LucideIcon; color: string; status: string }
> = {
pedido: { icon: FilePlus, color: "text-blue-500", status: "" },
processamento: { icon: LoaderCircle, color: "text-amber-500", status: "" },
separacao: { icon: Boxes, color: "text-purple-500", status: "" },
transporte: { icon: Truck, color: "text-orange-500", status: "" },
entregue: { icon: PackageCheck, color: "text-green-500", status: "" },
atrasado: { icon: AlarmClockOff, color: "text-red-500", status: "" },
aguardando: { icon: Clock, color: "text-gray-500", status: "" },
recebido: { icon: FilePlus, color: "text-blue-500", status: "" },
confirmado: { icon: CheckCircle, color: "text-blue-500", status: "" },
preparo: { icon: LoaderCircle, color: "text-amber-500", status: "" },
enviado: { icon: Truck, color: "text-orange-500", status: "" },
finalizado: { icon: PackageCheck, color: "text-green-500", status: "" },
};
/**
* Keywords used to match description text to status types
*/
const statusKeywords = {
pedido: ['venda realizada', 'pedido', 'receb'],
processamento: ['montagem carga', 'preparo', 'process', 'faturado'],
separacao: ['inicio separação', 'início separação', 'separac', 'separaç', 'separado'],
transporte: ['transporte', 'enviado'],
entregue: ['entrega realizada', 'entreg', 'finaliz'],
atrasado: ['atraso'],
};
/**
* Determines the status configuration based on the event description
* @param descricao - The description text to analyze
* @returns The matching status configuration object
*/
function getStatusConfig(descricao: string): { icon: LucideIcon; color: string; status: string } {
const desc = descricao?.toLowerCase().trim() || "";
for (const [status, keywords] of Object.entries(statusKeywords)) {
if (keywords.some(keyword => desc.includes(keyword))) {
return statusConfig[status as keyof typeof statusConfig] || statusConfig.aguardando;
}
}
return statusConfig.aguardando;
}
/**
* Formats a date string to localized date and time format
* @param dateString - The date string to format
* @returns Formatted date string in Brazilian format (DD/MM/YYYY HH:MM)
*/
function formatDate(dateString: string): string {
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return dateString;
return new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
} catch {
return dateString;
}
}
/**
* Formats a date string to show only the time portion
* @param dateString - The date string to format
* @returns Formatted time string in Brazilian format (HH:MM)
*/
function formatTimeOnly(dateString: string): string {
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("pt-BR", {
hour: "2-digit",
minute: "2-digit",
}).format(date);
} catch {
return "";
}
}
/**
* Maps API data to the Leadtime interface with proper status configuration
* @param apiData - Raw API data
* @param defaultOrderId - Fallback order ID if not present in API data
* @returns Array of processed Leadtime objects
*/
function mapApiDataToLeadtimes(apiData: any[], defaultOrderId: string = ""): Leadtime[] {
return apiData.map((item) => {
const { icon, color, status } = getStatusConfig(item.descricaoEtapa || "");
return {
etapas: item.descricaoEtapa ?? "",
descricao: item.descricaoEtapa ?? "Etapa não especificada",
date: item.data ?? "",
codigoFuncionario: item.codigoFuncionario?.toString() ?? "",
nomeFuncionario: item.nomeFuncionario ?? "",
numeroPedido: item.numeroPedido?.toString() ?? defaultOrderId,
icon,
status,
color,
};
});
}
/**
* Timeline component that displays the history of events for an order
*/
export default function Timeline({ orderId = "", className }: TimelineProps) {
const [leadtimes, setLeadtimes] = useState<Leadtime[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [apiData, setApiData] = useState<any[]>([]);
// Fetch data when orderId changes
useEffect(() => {
const fetchLeadtimeData = async () => {
if (!orderId) {
setLoading(false);
setError("Nenhum pedido selecionado.");
setApiData([]);
return;
}
try {
setLoading(true);
setError(null);
const data = await ordersApi.getLeadtime(orderId);
if (!data) throw new Error("Dados da API indisponíveis");
setApiData(Array.isArray(data) ? data : []);
} catch (err) {
setError(`Erro ao buscar dados: ${err instanceof Error ? err.message : "Desconhecido"}`);
setApiData([]);
} finally {
setLoading(false);
}
};
fetchLeadtimeData();
}, [orderId]);
// Process API data with memoization to avoid unnecessary recalculations
const processedLeadtimes = useMemo(() => {
return apiData.length > 0 ? mapApiDataToLeadtimes(apiData, orderId) : [];
}, [apiData, orderId]);
// Update leadtimes state when processed data changes
useEffect(() => {
setLeadtimes(processedLeadtimes);
}, [processedLeadtimes]);
if (loading) {
return (
<Card className={cn("w-full shadow-md border-0", className)}>
<CardContent className="p-2">
<div className="flex justify-center items-center h-32">
<div data-testid="loading-skeleton" className="animate-pulse flex flex-col space-y-2 w-full">
{[1, 2, 3].map((i) => (
<div key={i} className="flex space-x-2">
<div className="rounded-full bg-gray-200 dark:bg-gray-700 h-6 w-6" />
<div className="flex-1 space-y-1">
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full w-3/4" />
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full w-5/6" />
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className={cn("w-full shadow-md border-0", className)}>
<CardContent className="p-2 flex flex-col items-center justify-center text-center">
<AlertCircle className="h-8 w-8 text-red-500 mb-2 animate-pulse" />
<h3 className="text-sm font-medium">Ocorreu um problema</h3>
<p className="text-xs text-muted-foreground mt-1">
Não foi possível carregar a linha do tempo do pedido.
</p>
<p className="text-xs text-red-600 mt-2">{error}</p>
</CardContent>
</Card>
);
}
if (leadtimes.length === 0) {
return (
<Card className={cn("w-full shadow-md border-0", className)}>
<CardContent className="p-2 flex flex-col items-center justify-center text-center h-32">
<Boxes className="h-8 w-8 text-gray-400 mb-2" />
<h3 className="text-sm font-medium">Nenhuma movimentação encontrada</h3>
<p className="text-xs text-muted-foreground mt-1">
Ainda não etapas registradas para o pedido {orderId || "selecionado"}.
</p>
</CardContent>
</Card>
);
}
return (
<Card className={cn("w-full shadow-md border-0 overflow-hidden", className)}>
<CardHeader className="bg-gradient-to-r from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 border-b p-2">
<CardTitle className="text-sm font-bold flex items-center gap-1">
<span className="inline-block w-1 h-4 bg-slate-700 dark:bg-slate-400 rounded-full"></span>
Movimentação do Pedido: {leadtimes[0]?.numeroPedido || orderId}
</CardTitle>
</CardHeader>
<CardContent className="p-2">
<div className="space-y-3">
{leadtimes.map((item, index) => {
const IconComponent = item.icon || Clock;
const dateFormatted = formatDate(item.date);
const [datePart, timePart] = dateFormatted.split(' ');
return (
<div key={index} className="relative pl-5 transition-all duration-300 hover:translate-x-1">
{index < leadtimes.length - 1 && (
<div className="absolute left-2 top-4 bottom-0 w-0.5 bg-gradient-to-b from-current to-gray-200 dark:to-gray-700" style={{ opacity: 0.5 }} />
)}
<div className={cn("absolute left-0 top-0 flex h-4 w-4 items-center justify-center rounded-full border-2 border-current shadow-sm transition-transform duration-300 hover:scale-105", item.color)}>
<IconComponent className="h-2 w-2" />
</div>
<div className="flex flex-col space-y-1 bg-white dark:bg-slate-900 rounded-lg p-2 shadow-sm hover:shadow-md transition-shadow duration-300">
<div className="flex items-start justify-between gap-1 flex-wrap">
<h3 className="text-xs font-semibold">{item.descricao}</h3>
<Badge variant="outline" className={cn(item.color, "font-medium text-[10px] px-1.5 py-0.5 whitespace-nowrap")}>
{item.status}
</Badge>
</div>
<div className="flex flex-col space-y-1 text-[10px] text-muted-foreground">
<div className="flex items-center">
<Clock className="h-2.5 w-2.5 mr-1 text-gray-400" />
<span className="whitespace-nowrap text-gray-500">{datePart}, {timePart}</span>
</div>
{item.nomeFuncionario && (
<div className="flex flex-col">
<div className="text-gray-500 mb-0.5 text-[10px]">Responsável:</div>
<div className="flex flex-col">
<span className="font-medium text-[10px] text-gray-700 dark:text-gray-300">{item.nomeFuncionario.toUpperCase()}</span>
{item.codigoFuncionario && (
<span className="text-amber-600 text-[9px]">
({item.codigoFuncionario})
</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,268 @@
import { Table } from "@/src/components/ui/table";
import { Button } from "@/src/components/ui/button";
import { Order, OrderItem, Store, cutitens } from "@/src/components/types";
import { useMemo, useCallback } from "react";
import { ColumnConfig } from "./ColumnConfig/ColumnConfig";
import { useOrderTableColumns } from "../../hooks/useOrderTableColumns";
import { useKeyboardNavigation } from "../../hooks/useKeyboardNavigation";
import { useStoreNameResolver } from "@/src/hooks/useStoreNameResolver";
import { OrdersTableHeader } from "./OrdersTableHeader";
import { OrdersTableBody } from "./OrdersTableBody";
import { SelectedOrderItems } from "./SelectedOrderItems";
import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { useOrderData } from "../../../src/hooks/useOrderData";
import { useSort } from "@/src/hooks/useSort";
export type ColumnKey =
| "Data"
| "Data previsão de entrega"
| "Agendamento"
| "Filial"
| "Pedido"
| "Tipo Pedido"
| "TV 7"
| "Cliente"
| "Vendedor"
| "Situação"
| "Rota"
| "Valor"
| "Peso(kg)"
| "Carregamento"
| "Cobrança"
| "Trasportadora"
| "Praça"
| "Nota fiscal"
| "Dt Faturamento"
const ALL_COLUMNS: ColumnKey[] = [
"Data", "Data previsão de entrega", "Agendamento", "Pedido", "Tipo Pedido", "Cliente", "Vendedor", "Filial", "Situação", "Rota", "Valor", "Peso(kg)", "Carregamento", "Cobrança", "Trasportadora", "Praça", "Nota fiscal", "Dt Faturamento", "TV 7"
];
export interface OrdersTableProps {
orders: Order[];
currentOrders: Order[];
selectedOrderId: string | null;
orderItems: OrderItem[];
cutitens?: cutitens[];
loadingItems: boolean;
itemsError: string | null;
handleRowClick: (orderId: string) => void;
currentPage: number;
totalPages: number;
indexOfFirstOrder: number;
indexOfLastOrder: number;
goToPreviousPage: () => void;
goToNextPage: () => void;
goToPage?: (page: number) => void;
loadMoreOrders: () => void;
visibleOrdersCount: number;
stores: Store[];
transfers: any[];
}
export function OrdersTable({
orders,
currentOrders,
selectedOrderId,
orderItems,
cutitens = [],
loadingItems,
itemsError,
handleRowClick,
currentPage,
totalPages,
indexOfFirstOrder,
indexOfLastOrder,
goToPreviousPage,
goToNextPage,
goToPage,
loadMoreOrders,
visibleOrdersCount,
stores,
transfers,
}: OrdersTableProps) {
const { getStoreName } = useStoreNameResolver(stores);
// Find the selected order based on selectedOrderId
const selectedOrder = useMemo(() =>
selectedOrderId ? orders.find(order => order.orderId === selectedOrderId) : undefined
, [selectedOrderId, orders]);
// Use custom hook for order-related data fetching
const {
orderStatus,
orderLeadtime,
selectedDelivery,
tv8Deliveries
} = useOrderData(selectedOrderId, selectedOrder);
// Define column accessors for sorting
const columnAccessors = useMemo(() => ({
"Data": (order: Order) => new Date(order.createDate || ""),
"Pedido": (order: Order) => parseInt(order.orderId || "0", 10),
"Valor": (order: Order) => parseFloat(String(order.totalValue || "0")),
"Peso(kg)": (order: Order) => parseFloat(String(order.totalWeigth || "0")),
"Dt Faturamento": (order: Order) => new Date(order.invoiceDate || ""),
// Add other column accessors as needed
}), []);
// Use our custom sort hook
const { sortedItems, sortConfig, handleSort } = useSort<Order>(
currentOrders,
columnAccessors
);
const {
visibleColumns,
columnOrder,
getColumnClass,
getOrderedVisibleColumns,
resetColumnOrder,
toggleColumn,
selectAllColumns,
unselectAllColumns,
isColumnVisible,
handleDragStart,
handleDragEnd,
handleDragOver,
handleDrop,
} = useOrderTableColumns(ALL_COLUMNS);
const { tableRef } = useKeyboardNavigation({
currentOrders,
selectedOrderId,
handleRowClick,
});
const orderedVisibleColumns = useMemo(() => {
return getOrderedVisibleColumns();
}, [visibleColumns, columnOrder, getOrderedVisibleColumns]);
const columnConfigProps = useMemo(() => ({
allColumns: ALL_COLUMNS,
visibleColumns,
isColumnVisible,
toggleColumn,
selectAllColumns,
unselectAllColumns,
}), [visibleColumns, isColumnVisible, toggleColumn, selectAllColumns, unselectAllColumns]);
const renderHeaderControls = useCallback(() => (
<div className="flex justify-end mb-2 gap-2">
<Button
variant="ghost"
size="sm"
onClick={resetColumnOrder}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
</Button>
<div className="h-4 w-px bg-border mx-1" /> {/* Separator */}
<ColumnConfig {...columnConfigProps} />
</div>
), [resetColumnOrder, columnConfigProps]);
const renderPagination = useCallback(() => (
<div className="flex justify-between items-center px-4 py-1 border-t border-gray-200">
<div className="text-sm text-muted-foreground">
{orders.length > 0
? `Mostrando ${indexOfFirstOrder + 1}-${indexOfLastOrder} de ${orders.length} pedidos`
: "Nenhum pedido encontrado"
}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={goToPreviousPage}
disabled={currentPage <= 1}
className="h-8 w-8 p-0"
>
<span className="sr-only">Página anterior</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground">
Página {currentPage} de {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={currentPage >= totalPages}
className="h-8 w-8 p-0"
>
<span className="sr-only">Próxima página</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
), [orders.length, indexOfFirstOrder, indexOfLastOrder, currentPage, totalPages, goToPreviousPage, goToNextPage]);
const renderOrdersTable = useCallback(() => (
<div className="border border-gray-300">
<div className="overflow-x-auto">
<div className={`${orders.length <= 6 ? "max-h-fit" : "h-[400px]"} overflow-y-auto`}>
<Table className="border-collapse border-gray-200">
<OrdersTableHeader
orderedVisibleColumns={orderedVisibleColumns}
getColumnClass={getColumnClass}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragOver={handleDragOver}
handleDrop={handleDrop}
sortConfig={sortConfig}
onSort={handleSort}
/>
<OrdersTableBody
orders={orders}
currentOrders={sortedItems}
selectedOrderId={selectedOrderId}
visibleColumns={orderedVisibleColumns}
getStoreName={getStoreName}
handleRowClick={handleRowClick}
/>
</Table>
</div>
</div>
{renderPagination()}
</div>
), [
orders,
orderedVisibleColumns,
getColumnClass,
handleDragStart,
handleDragEnd,
handleDragOver,
handleDrop,
sortConfig,
handleSort,
sortedItems,
selectedOrderId,
getStoreName,
handleRowClick,
renderPagination
]);
return (
<div className="rounded-md border-0" ref={tableRef} tabIndex={0}>
{renderHeaderControls()}
{renderOrdersTable()}
<SelectedOrderItems
selectedOrderId={selectedOrderId}
orderItems={orderItems}
loadingItems={loadingItems}
itemsError={itemsError}
cutitens={cutitens}
order={selectedOrder}
delivery={selectedDelivery}
transfers={transfers}
status={orderStatus}
leadtime={orderLeadtime}
tv8Deliveries={tv8Deliveries}
/>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import React, { memo, useMemo } from "react";
import {
TableBody,
TableCell,
TableRow,
} from "@/src/components/ui/table";
import { OrderRowExpandable } from "./tabela-pedidos/components/OrderRowExpandable";
import { Order } from "@/src/components/types";
interface OrdersTableBodyProps {
orders: Order[];
currentOrders: Order[];
selectedOrderId: string | null;
visibleColumns: string[];
getStoreName: (storeId?: string) => string;
handleRowClick: (orderId: string) => void;
}
// Componente de linha vazia memoizado
const EmptyRow = memo(({ colSpan }: { colSpan: number }) => (
<TableRow>
<TableCell colSpan={colSpan} className="h-24 text-center text-xs">
Nenhum pedido encontrado com os filtros informados.
</TableCell>
</TableRow>
));
// Componente de linha de pedido memoizado
const OrderRow = memo(({
order,
isSelected,
visibleColumns,
onSelect,
getStoreName
}: {
order: Order;
isSelected: boolean;
visibleColumns: string[];
onSelect: (orderId: string) => void;
getStoreName: (storeId?: string) => string;
}) => {
// Create a new order object with the store name only (without ID prefix)
const orderWithStore = {
...order,
store: getStoreName(order.storeId),
storeId: order.storeId, // Preserve original storeId
};
return (
<OrderRowExpandable
key={order.orderId}
order={orderWithStore}
isSelected={isSelected}
onSelect={onSelect}
orderItems={[]}
loadingItems={false}
itemsError={null}
visibleColumns={visibleColumns}
/>
);
});
// Componente principal memoizado
function OrdersTableBodyComponent({
orders,
currentOrders,
selectedOrderId,
visibleColumns,
getStoreName,
handleRowClick,
}: OrdersTableBodyProps) {
// Renderização condicional baseada na presença de orders, memoizada
const content = useMemo(() => {
if (orders.length === 0) {
return <EmptyRow colSpan={visibleColumns.length} />;
}
return currentOrders.map((order) => (
<OrderRow
key={order.orderId}
order={order}
isSelected={selectedOrderId === order.orderId}
visibleColumns={visibleColumns}
onSelect={handleRowClick}
getStoreName={getStoreName}
/>
));
}, [orders.length, currentOrders, selectedOrderId, visibleColumns, getStoreName, handleRowClick]);
return <TableBody>{content}</TableBody>;
}
export const OrdersTableBody = memo(OrdersTableBodyComponent);

View File

@@ -0,0 +1,139 @@
import React, { memo, useMemo } from "react";
import {
TableHead,
TableHeader,
TableRow,
} from "@/src/components/ui/table";
import { GripVertical, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { SortConfig } from "@/src/hooks/useSort";
interface OrdersTableHeaderProps {
orderedVisibleColumns: string[];
getColumnClass: (title: string) => string;
handleDragStart: (e: React.DragEvent<HTMLDivElement>, column: string) => void;
handleDragEnd: (e: React.DragEvent<HTMLDivElement>) => void;
handleDragOver: (e: React.DragEvent<HTMLDivElement>) => void;
handleDrop: (e: React.DragEvent<HTMLDivElement>, column: string) => void;
sortConfig?: SortConfig<any> | null;
onSort?: (column: string) => void;
}
// Componente de cabeçalho de coluna memoizado
const ColumnHeader = memo(({
title,
index,
totalColumns,
getColumnClass,
handleDragStart,
handleDragEnd,
handleDragOver,
handleDrop,
sortConfig,
onSort,
}: {
title: string;
index: number;
totalColumns: number;
getColumnClass: (title: string) => string;
handleDragStart: (e: React.DragEvent<HTMLDivElement>, column: string) => void;
handleDragEnd: (e: React.DragEvent<HTMLDivElement>) => void;
handleDragOver: (e: React.DragEvent<HTMLDivElement>) => void;
handleDrop: (e: React.DragEvent<HTMLDivElement>, column: string) => void;
sortConfig?: SortConfig<any> | null;
onSort?: (column: string) => void;
}) => {
// Determinar se esta coluna é ordenável
const isSortable = ["Data", "Valor", "Peso(kg)", "Pedido", "Dt Faturamento"].includes(title);
// Verificar se esta coluna está sendo ordenada
const isSorted = sortConfig && sortConfig.key === title;
// Renderizar o ícone de ordenação apropriado
const renderSortIcon = () => {
if (!isSortable) return null;
if (!isSorted) {
return <ArrowUpDown className="h-3 w-3 ml-1 text-gray-400" />;
}
return sortConfig?.direction === 'asc'
? <ArrowUp className="h-3 w-3 ml-1 text-gray-600" />
: <ArrowDown className="h-3 w-3 ml-1 text-gray-600" />;
};
// Handler para cliques na coluna para ordenação
const handleSortClick = () => {
if (isSortable && onSort) {
onSort(title);
}
};
return (
<TableHead
key={index}
className={`text-xs font-medium text-gray-600 p-2 ${getColumnClass(title)} border-r border-gray-200 relative ${isSortable ? "cursor-pointer select-none" : ""}`}
draggable={true}
onDragStart={(e) => handleDragStart(e, title)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, title)}
onClick={handleSortClick}
>
<div className="flex items-center">
<GripVertical className="h-3 w-3 mr-1 cursor-move text-gray-400" />
<span className="flex-1">{title}</span>
{renderSortIcon()}
</div>
</TableHead>
);
});
// Componente principal memoizado
function OrdersTableHeaderComponent({
orderedVisibleColumns,
getColumnClass,
handleDragStart,
handleDragEnd,
handleDragOver,
handleDrop,
sortConfig,
onSort,
}: OrdersTableHeaderProps) {
// Memoizar os cabeçalhos para evitar recálculos desnecessários
const columnHeaders = useMemo(() => {
return orderedVisibleColumns.map((title, index) => (
<ColumnHeader
key={title + index}
title={title}
index={index}
totalColumns={orderedVisibleColumns.length}
getColumnClass={getColumnClass}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragOver={handleDragOver}
handleDrop={handleDrop}
sortConfig={sortConfig}
onSort={onSort}
/>
));
}, [
orderedVisibleColumns,
getColumnClass,
handleDragStart,
handleDragEnd,
handleDragOver,
handleDrop,
sortConfig,
onSort
]);
return (
<TableHeader className="sticky top-0 z-10 bg-white border-b border-gray-200">
<TableRow className="hover:bg-transparent">
{columnHeaders}
</TableRow>
</TableHeader>
);
}
export const OrdersTableHeader = memo(OrdersTableHeaderComponent);

View File

@@ -0,0 +1,55 @@
import React, { memo } from "react";
import { OrderItem, cutitens, Order, OrderDelivery, transfer, status, leadtime, OrderDeliveryDto, Store } from "@/src/components/types";
import { OrderItemsCard } from "./OrderItemsCard";
interface SelectedOrderItemsProps {
selectedOrderId: string | null;
orderItems: OrderItem[];
loadingItems: boolean;
itemsError: string | null;
cutitens?: cutitens[];
order?: Order;
delivery?: OrderDelivery;
transfers?: transfer[];
status?: status[];
leadtime?: leadtime[];
tv8Deliveries?: OrderDeliveryDto[];
stores?: Store[];
}
// Usando memo para evitar re-renderizações desnecessárias
const SelectedOrderItemsComponent = ({
selectedOrderId,
orderItems,
loadingItems,
itemsError,
cutitens = [],
order,
delivery,
transfers = [],
status = [],
leadtime = [],
tv8Deliveries = [],
stores = [],
}: SelectedOrderItemsProps) => {
if (!selectedOrderId) return null;
return (
<OrderItemsCard
orderItems={orderItems}
loadingItems={loadingItems}
itemsError={itemsError}
cutitens={cutitens}
order={order}
delivery={delivery}
transfers={transfers}
status={status}
leadtime={leadtime}
orderId={selectedOrderId || undefined}
tv8Deliveries={tv8Deliveries}
stores={stores}
/>
);
};
export const SelectedOrderItems = memo(SelectedOrderItemsComponent);

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect, useRef } from 'react';
import { Label } from "@/src/components/ui/label";
import { Input } from "@/src/components/ui/input";
import { dataConsultApi } from "@/src/lib/api";
import { Spinner } from "@/src/components/ui/spinner";
import { Search } from "lucide-react";
import { cn } from "@/src/lib/utils";
interface Seller {
id: number;
name: string;
code?: string;
}
interface SellerComboboxProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
onSelect?: (seller: Seller) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
}
export function SellerCombobox({
id,
label,
value,
onChange,
onSelect,
placeholder = "Buscar vendedor",
className,
disabled = false
}: SellerComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [sellers, setSellers] = useState<Seller[]>([]);
const [filteredSellers, setFilteredSellers] = useState<Seller[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isFetched, setIsFetched] = useState(false);
const comboboxRef = useRef<HTMLDivElement>(null);
// Buscar vendedores
useEffect(() => {
const fetchSellers = async () => {
if (isFetched) return;
try {
setIsLoading(true);
const response = await dataConsultApi.getSellers();
if (Array.isArray(response)) {
const mappedSellers = response.map((seller: any) => {
// Extrair o código numérico do vendedor
let code = "";
if (seller.name && seller.name.includes("-")) {
const parts = seller.name.split("-");
if (parts.length > 0 && parts[0].trim()) {
const match = parts[0].trim().match(/\d+/);
if (match) {
code = match[0];
}
}
}
return {
id: seller.id,
name: seller.name || "",
code: code
};
});
setSellers(mappedSellers);
setIsFetched(true);
}
} catch (error) {
console.error("Erro ao buscar vendedores:", error);
} finally {
setIsLoading(false);
}
};
fetchSellers();
}, [isFetched]);
// Filtrar vendedores com base no valor digitado
useEffect(() => {
if (!value) {
setFilteredSellers(sellers);
return;
}
const lowercasedValue = value.toLowerCase();
const filtered = sellers.filter(seller =>
seller.name.toLowerCase().includes(lowercasedValue)
);
setFilteredSellers(filtered);
}, [value, sellers]);
// Fechar quando clicar fora do combobox
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (comboboxRef.current && !comboboxRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onChange(newValue);
setIsOpen(true);
};
const handleSellerSelect = (seller: Seller) => {
onChange(seller.name);
if (onSelect) {
onSelect(seller);
}
setIsOpen(false);
};
return (
<div className={cn("relative w-full", className)} ref={comboboxRef}>
<div className="flex flex-col space-y-1">
{label && (
<Label htmlFor={id} className="text-xs font-medium">
{label}
</Label>
)}
<div className="relative">
<Input
id={id}
value={value}
onChange={handleInputChange}
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
className="pr-8"
disabled={disabled}
autoComplete="off"
/>
<div className="absolute right-2 top-1/2 transform -translate-y-1/2">
{isLoading ? (
<Spinner size="lg" />
) : (
<Search className="h-4 w-4 text-gray-400" />
)}
</div>
</div>
</div>
{isOpen && filteredSellers.length > 0 && (
<ul className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{filteredSellers.map((seller) => (
<li
key={seller.id}
onClick={() => handleSellerSelect(seller)}
className="relative cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-slate-100"
>
<div className="flex items-center">
<span className="font-medium">{seller.name}</span>
</div>
{seller.code && (
<span className="text-xs text-gray-500 ml-3">
RCA: {seller.code}
</span>
)}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,270 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, User, Loader2, Search, X } from "lucide-react";
import { cn } from "@/src/lib/utils";
import { Button } from "@/src/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/src/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { dataConsultApi } from "@/src/lib/api";
import { Label } from "@/src/components/ui/label";
import { Badge } from "@/src/components/ui/badge";
interface Seller {
id: number;
name: string;
code?: string;
}
interface SellerSearchInputProps {
id: string;
label: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
onSelect: (seller: Seller | null) => void;
selectedSeller?: Seller | null;
disabled?: boolean;
"aria-label"?: string;
}
// Função para normalizar texto para comparações (remover acentos, case insensitive)
const normalizeText = (text: string): string => {
return text
? text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
: "";
};
export function SellerSearchInput({
id,
label,
placeholder,
value,
onChange,
onSelect,
selectedSeller,
disabled = false,
"aria-label": ariaLabel,
}: SellerSearchInputProps) {
const [open, setOpen] = React.useState(false);
const [sellers, setSellers] = React.useState<Seller[]>([]);
const [loading, setLoading] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState("");
const [searchError, setSearchError] = React.useState<string | null>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
// Carregar vendedores baseado na busca
const searchSellers = React.useCallback(async (query: string) => {
if (!query || query.length < 2) {
setSellers([]);
return;
}
try {
setLoading(true);
setSearchError(null);
// Usar o novo método específico para busca por nome
const sellersData = await dataConsultApi.searchSellers(query);
if (sellersData.length > 0) {
// Processar vendedores
const mappedSellers = sellersData.map((seller: any) => {
// Se o vendedor já é uma string, criar um objeto
if (typeof seller === 'string') {
return {
id: 0,
name: seller,
code: ''
};
}
// Extrair o código numérico do vendedor
let code = "";
const sellerName = seller.name || '';
if (sellerName && sellerName.includes("-")) {
const parts = sellerName.split("-");
if (parts.length > 0 && parts[0].trim()) {
const match = parts[0].trim().match(/\d+/);
if (match) {
code = match[0];
}
}
}
return {
id: seller.id || 0,
name: sellerName,
code: code
};
});
setSellers(mappedSellers.slice(0, 50)); // Limitar a 50 resultados para desempenho
} else {
setSearchError(`Nenhum vendedor encontrado para "${query}"`);
setSellers([]);
}
} catch (error) {
setSearchError("Erro ao buscar vendedores");
setSellers([]);
} finally {
setLoading(false);
}
}, []);
// Debounce para a busca
React.useEffect(() => {
const timer = setTimeout(() => {
if (open && searchQuery.length >= 2) {
searchSellers(searchQuery);
}
}, 300);
return () => clearTimeout(timer);
}, [searchQuery, searchSellers, open]);
// Selecionar vendedor
const handleSelectSeller = (seller: Seller) => {
onSelect(seller);
setOpen(false);
setSearchQuery("");
};
// Limpar seleção
const handleClearSelection = (e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
}
onSelect(null);
onChange("");
setSearchQuery("");
setSellers([]);
};
// Quando o componente monta ou o valor muda
React.useEffect(() => {
// Se temos um value mas não temos selectedSeller, executar busca inicial
if (value && !selectedSeller && !loading && !open) {
searchSellers(value);
}
}, [value, selectedSeller, searchSellers, loading, open]);
return (
<div className="flex flex-col space-y-1">
<div className="flex justify-between items-center">
<Label
htmlFor={id}
className="text-xs font-medium"
>
{label}
{selectedSeller && (
<Badge variant="outline" className="ml-2 text-xs py-0">
{selectedSeller.code ? `RCA: ${selectedSeller.code}` : `ID: ${selectedSeller.id}`}
</Badge>
)}
</Label>
</div>
{searchError && (
<p className="text-xs text-red-500 mt-0.5">{searchError}</p>
)}
<div className="relative">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={ariaLabel || "Selecione um vendedor"}
disabled={disabled}
className={cn(
"w-full justify-between h-8 transition-all",
selectedSeller ? "pr-8" : "text-muted-foreground",
disabled && "opacity-50 cursor-not-allowed"
)}
>
{selectedSeller ? selectedSeller.name : placeholder}
{selectedSeller ? null : (
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
)}
</Button>
</PopoverTrigger>
{selectedSeller && (
<div
className="absolute right-0 top-0 h-full flex items-center pr-2"
onClick={(e) => {
e.stopPropagation();
handleClearSelection();
}}
>
<X className="h-4 w-4 cursor-pointer" />
<span className="sr-only">Limpar seleção</span>
</div>
)}
<PopoverContent className="min-w-[300px] p-0">
<Command>
<CommandInput
ref={searchInputRef}
placeholder="Buscar vendedor..."
value={searchQuery}
onValueChange={setSearchQuery}
className="h-9"
/>
{loading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
)}
{!loading && (
<>
<CommandEmpty>
{searchQuery.length >= 2
? "Nenhum vendedor encontrado com esse nome"
: "Digite pelo menos 2 caracteres para buscar"}
</CommandEmpty>
{sellers.length > 0 && (
<CommandGroup heading="Vendedores">
<CommandList>
{sellers.map((seller) => (
<CommandItem
key={seller.id}
value={seller.name}
onSelect={() => handleSelectSeller(seller)}
>
<User className="mr-2 h-4 w-4" />
<span>{seller.name}</span>
{seller.code && (
<span className="ml-2 text-xs text-gray-500">
RCA: {seller.code}
</span>
)}
</CommandItem>
))}
</CommandList>
</CommandGroup>
)}
</>
)}
</Command>
</PopoverContent>
</Popover>
</div>
</div>
);
}

View File

@@ -0,0 +1,313 @@
import { useState, useEffect } from "react";
import { Order, OrderDeliveryDto } from "../types";
import { ordersApi } from "../../lib/api";
import { formatDateToDisplay } from "../../utils/format-helpers";
import { getDeliveryTypeLabel, getStatusColor, normalizeStatus, getStatusFromDeliveryType, STATUS_DEFAULT } from "./order-info-utils";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from "@/src/components/ui/accordion";
import { Badge } from "@/src/components/ui/badge";
import { Truck, Loader2 } from "lucide-react";
interface TV8DeliveriesSectionProps {
order: Order;
initialDeliveries?: OrderDeliveryDto[];
}
/**
* Função pura para buscar entregas do TV8 com retry e normalização de dados
* @param orderId ID do pedido a ser consultado
* @returns Array de entregas TV8 normalizadas ou um objeto de erro
*/
export async function fetchAndNormalizeTV8Deliveries(orderId: string | number | undefined): Promise<{
success: boolean;
data: OrderDeliveryDto[];
error?: string;
}> {
if (!orderId) {
return { success: false, data: [], error: "ID do pedido não informado" };
}
try {
// Formatar orderID corretamente
const formattedOrderId = orderId.toString().trim();
// Tentar dois endpoints diferentes
let response;
try {
// Primeiro endpoint
response = await ordersApi.getOrderDeliveries(formattedOrderId);
} catch (err) {
// Pequeno delay antes de tentar novamente
await new Promise(resolve => setTimeout(resolve, 500));
// Tentar URL alternativa diretamente
try {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8888";
const url = `${baseUrl}/api/v1/orders/${formattedOrderId}`;
response = await fetch(url)
.then(res => {
if (!res.ok) {
throw new Error(`Falha na requisição alternativa: ${res.status}`);
}
return res.json();
});
} catch (fetchErr: any) {
console.error("Erro na requisição alternativa:", fetchErr);
throw new Error(`Falha nas duas tentativas: ${fetchErr.message}`);
}
}
// Função para normalizar um item de entrega
const normalizeDeliveryItem = (item: any): OrderDeliveryDto => {
const statusValue = item.status || getStatusFromDeliveryType(item.deliveryType) || STATUS_DEFAULT;
return {
...item,
status: normalizeStatus(statusValue)
};
};
// Verificar formato da resposta e normalizar dados
if (response && typeof response === 'object' && 'success' in response && 'data' in response) {
if (Array.isArray(response.data) && response.data.length > 0) {
// Garantir que o status está definido em cada item
const normalizedData = response.data.map(normalizeDeliveryItem);
return { success: true, data: normalizedData };
}
} else if (Array.isArray(response) && response.length > 0) {
// Garantir que o status está definido em cada item
const normalizedData = response.map(normalizeDeliveryItem);
return { success: true, data: normalizedData };
}
// Se chegou aqui, não há dados
return { success: true, data: [] };
} catch (error: any) {
console.error("Error fetching TV8 deliveries:", error);
return {
success: false,
data: [],
error: error.message || "Falha ao carregar entregas TV8"
};
}
}
/**
* Componente para exibir a seção de entregas TV8
* Faz requisição à API de TV8 e exibe os dados
*/
export function TV8DeliveriesSection({ order, initialDeliveries = [] }: TV8DeliveriesSectionProps) {
const [tv8Deliveries, setTv8Deliveries] = useState<OrderDeliveryDto[]>(initialDeliveries);
const [isLoadingTv8, setIsLoadingTv8] = useState(false);
const [tv8Error, setTv8Error] = useState("");
// Dados de teste para TV8 em caso de falha na API
const testTV8Data: OrderDeliveryDto[] = [
{
storeId: 6,
createDate: new Date(),
orderId: Number(order.orderId) || 1,
orderIdSale: null,
deliveryDate: new Date(),
customerId: 12345,
customer: order.customerName || "Cliente Teste",
deliveryType: "Entrega (EF)",
quantityItens: 5,
status: "Pendente",
weight: 10.5,
shipmentId: 7001,
driverId: 101,
driverName: "FRANCISCO ANDRÉ O DE SOUZA",
carPlate: "AAA-0000",
carIdentification: "Carro 001",
observation: "Observação de teste",
deliveryConfirmationDate: null
}
];
const loadTestData = () => {
setTv8Deliveries(testTV8Data);
setIsLoadingTv8(false);
setTv8Error("");
};
// Flag para controle de exibição
const displayTV8Data = tv8Deliveries;
useEffect(() => {
async function loadTV8Deliveries() {
if (!order.orderId) return;
setIsLoadingTv8(true);
setTv8Error("");
const result = await fetchAndNormalizeTV8Deliveries(order.orderId);
if (result.success) {
setTv8Deliveries(result.data);
} else {
setTv8Error(result.error || "Falha ao carregar dados");
setTv8Deliveries([]);
}
setIsLoadingTv8(false);
}
loadTV8Deliveries();
}, [order.orderId]);
return (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="tv8" className="border-0">
<AccordionTrigger className="py-3 px-4 hover:no-underline">
<div className="flex items-center">
<Truck className="h-4 w-4 text-primary mr-2" />
<h3 className="text-sm font-medium">TV8 - Entregas</h3>
{isLoadingTv8 ? (
<Badge variant="outline" className="ml-2 flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Carregando</span>
</Badge>
) : tv8Error ? (
<Badge variant="destructive" className="ml-2">
Erro ao carregar
</Badge>
) : (
<Badge variant="outline" className="ml-2">
{tv8Deliveries.length} {tv8Deliveries.length === 1 ? 'registro' : 'registros'}
</Badge>
)}
</div>
</AccordionTrigger>
<AccordionContent className="px-2 sm:px-4 pb-4">
<div className="overflow-x-auto -mx-2 sm:-mx-6">
<div className="inline-block min-w-full align-middle">
<div className="overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead>
<tr className="border-b bg-muted/50">
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Data</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Filial</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Pedido</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Cliente</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Status</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Tipo Entrega</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Data Entrega</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Qtd Itens</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Motorista</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Veículo</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Peso</th>
<th scope="col" className="p-2 whitespace-nowrap text-xs font-medium text-muted-foreground uppercase tracking-wider">Observação</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{displayTV8Data.length > 0 ? (
displayTV8Data.map((item, index) => (
<tr key={`tv8-${index}`} className="hover:bg-muted/50 transition-colors odd:bg-white even:bg-gray-50">
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium">{formatDateToDisplay(item.createDate)}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs">{item.storeId}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs">{item.orderId}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs truncate max-w-64" title={item.customer}>{item.customer}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs">
{item.status}
</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs">{getDeliveryTypeLabel(item.deliveryType)}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs">{item.deliveryDate ? formatDateToDisplay(item.deliveryDate) : (item.deliveryConfirmationDate ? formatDateToDisplay(item.deliveryConfirmationDate) : "-")}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs text-center">{item.quantityItens}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs truncate max-w-64" title={item.driverName || "-"}>{item.driverName || "-"}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs">{item.carIdentification || "-"}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs text-right">{item.weight} kg</td>
<td className="p-2 whitespace-nowrap text-xs" title={item.observation || "-"}>{item.observation || "-"}</td>
</tr>
))
) : (
<tr>
<td colSpan={12} className="py-6 text-sm text-center text-muted-foreground italic">
<div className="flex flex-col items-center gap-2">
<Truck className="h-6 w-6 text-muted-foreground" />
Nenhum registro de TV8 disponível
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Mobile view - Simplified list */}
<div className="block md:hidden mt-4">
{displayTV8Data.length > 0 ? (
<div className="space-y-3 px-1">
{displayTV8Data.map((item, index) => (
<div key={`tv8-mobile-${index}`} className="border rounded-md p-3 bg-white shadow-sm">
<div className="flex flex-wrap justify-between items-start gap-2 mb-3">
<div className="flex-1 min-w-0">
<Badge
className={`${getStatusColor(normalizeStatus(item.status))} hover:${getStatusColor(normalizeStatus(item.status))} border-0 mb-2 font-semibold text-white px-2 py-0.5 inline-flex`}
variant="outline"
>
{normalizeStatus(item.status)}
</Badge>
<div className="text-xs font-medium truncate">{formatDateToDisplay(item.createDate)}</div>
</div>
<div className="text-xs font-medium text-right">
<div className="mb-1">Pedido: {item.orderId}</div>
<div>{item.quantityItens} {item.quantityItens === 1 ? 'item' : 'itens'}</div>
</div>
</div>
<div className="grid grid-cols-1 gap-2 text-xs">
<div className="flex items-center justify-between border-b border-gray-100 pb-1.5">
<span className="text-muted-foreground">Cliente:</span>
<span className="font-medium truncate max-w-[180px]">{item.customer}</span>
</div>
<div className="flex items-center justify-between border-b border-gray-100 pb-1.5">
<span className="text-muted-foreground">Motorista:</span>
<span className="font-medium truncate max-w-[180px]">{item.driverName || "Não informado"}</span>
</div>
<div className="flex items-center justify-between border-b border-gray-100 pb-1.5">
<span className="text-muted-foreground">Tipo:</span>
<span className="font-medium">{getDeliveryTypeLabel(item.deliveryType)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Peso:</span>
<span className="font-medium">{item.weight} kg</span>
</div>
{item.observation && (
<div className="mt-1 pt-1 border-t border-gray-100">
<div className="text-muted-foreground mb-1">Observação:</div>
<div className="bg-gray-50 p-1.5 rounded text-xs">{item.observation}</div>
</div>
)}
</div>
</div>
))}
</div>
) : (
<div className="py-6 text-sm text-center text-muted-foreground italic">
<div className="flex flex-col items-center gap-2">
<Truck className="h-6 w-6 text-muted-foreground" />
Nenhum registro de TV8 disponível
</div>
</div>
)}
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@@ -0,0 +1,133 @@
import { STATUS_COLORS, DELIVERY_TYPE_MAP } from "../../constants/status-options";
import {
Clock,
FileText,
Package,
Truck,
CheckCircle,
AlertTriangle,
XCircle,
Clipboard,
SplitSquareVertical,
CheckSquare,
Search,
CreditCard,
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
// Status padrão para quando não vier status da API
export const STATUS_DEFAULT = "PENDENTE";
/**
* Função para obter a cor do status
*/
export const getStatusColor = (status: string | undefined): string => {
if (!status) return "bg-white-400";
return STATUS_COLORS[status.toLowerCase()] || "bg-white-400";
};
export const STATUS_ICONS: Record<string, LucideIcon> = {
"Digitação pedido": Clipboard,
"Montagem de carga": Package,
"emissão do mapa": FileText,
"início de separação": SplitSquareVertical,
"fim de separação": CheckSquare,
"início conferência": Search,
"fim conferência": CheckCircle,
faturamento: CreditCard,
"entrega realizada": Truck,
pendente: AlertTriangle,
cancelado: XCircle,
default: Clock,
}
/**
* Normaliza o status para o formato padrão
* Garante consistência na apresentação entre diferentes APIs
*/
export function getStatusIcon(status: string): LucideIcon {
if (!status) return Clock
const normalizedStatus = normalizeStatus(status)
return STATUS_ICONS[normalizedStatus] || STATUS_ICONS.default
}
/**
* Normaliza o status para um formato padrão
* @param status - Status a ser normalizado
* @returns Status normalizado em maiúsculas ou "NÃO DEFINIDO" para valores inválidos
*/
export function normalizeStatus(status: string | undefined | null): string {
if (!status || status.trim() === "") {
return "NÃO DEFINIDO";
}
return status.trim().toUpperCase();
}
/**
* Mapeia o tipo de entrega para um status apropriado
* Usado quando status não está disponível, mas podemos inferir pelo tipo
*/
export function getStatusFromDeliveryType(deliveryType: string | null | undefined): string | null {
if (!deliveryType) return null;
const type = deliveryType.toUpperCase();
// Se for RP (Retira posterior), usamos isso como status
if (type === 'RP') return 'RP';
return null;
}
/**
* Retorna um label legível para o tipo de entrega com base no código
*/
export function getDeliveryTypeLabel(code: string | null | undefined): string {
if (!code) return "-";
return DELIVERY_TYPE_MAP[code] || code;
}
/**
* Formata o texto da movimentação de status
* Adiciona descrições personalizadas para diferentes status
*/
export function formatStatusMovement(status: string | null | undefined): string {
if (!status) return STATUS_DEFAULT;
const normalizedStatus = normalizeStatus(status);
// Mapeamento de status para descrições mais amigáveis
const movementDescriptions: Record<string, string> = {
'PENDENTE': 'Pedido pendente',
'LIBERADO': 'Pedido liberado para separação',
'BLOQUEADO': 'Bloqueio de pedido',
'MONTADO': 'Pedido montado/separado',
'FATURADO': 'Pedido faturado',
'CANCELADO': 'Pedido cancelado',
'ENTREGUE': 'Pedido entregue',
'EM SEPARAÇÃO': 'Separação iniciada',
'EM TRANSPORTE': 'Pedido em transporte',
'AGUARDANDO SEPARAÇÃO': 'Aguardando separação',
'PENDENTE ESTOQUE': 'Pendente de estoque',
'BLOQUEIO FINANCEIRO': 'Bloqueio financeiro',
'ENTREGA': 'ENTREGA REALIZADA',
'SEPARADO': 'Pedido separado'
};
return movementDescriptions[normalizedStatus] || normalizedStatus;
}
/**
* Enum para as chaves de abas
*/
export enum TabKey {
Info = "info",
Itens = "itens",
Entrega = "entrega",
Cortes = "Cortes",
Transferencia = "transferencia",
Status = "status",
TV8 = "tv8",
Checkout = "checkout"
}

View File

@@ -0,0 +1,94 @@
/**
* @file OrderRowExpandable.tsx
* @description Componente de linha expandível para exibição de pedidos na tabela, com renderização dinâmica de colunas e link para detalhes.
*/
import React from "react";
import { useSearchParams, usePathname } from "next/navigation";
import { TableCell, TableRow } from "@/src/components/ui/table";
import { createSearchParams, buildUrl } from "@/src/utils/url-helpers";
import { Order, OrderItem } from "@/src/components/types";
import { orderCellRenderers } from "./cell-renderers";
import { columnClasses } from "../domain/column-classes";
import { OrderColumn } from "../domain/order-columns";
/**
* Props para o componente OrderRowExpandable.
*
* @property {Order} order - Objeto do pedido que será exibido na linha.
* @property {boolean} isSelected - Define se a linha está atualmente selecionada.
* @property {(orderId: string) => void} onSelect - Função chamada ao clicar na linha, passando o ID do pedido.
* @property {OrderItem[]} orderItems - Itens do pedido, usados em expansão (não utilizados diretamente aqui).
* @property {boolean} loadingItems - Indica se os itens do pedido estão sendo carregados (não utilizados diretamente aqui).
* @property {string | null} itemsError - Erro de carregamento de itens, se houver (não utilizado diretamente aqui).
* @property {string[]} [visibleColumns] - Lista de colunas visíveis na tabela.
*/
interface Props {
order: Order;
isSelected: boolean;
onSelect: (orderId: string) => void;
orderItems: OrderItem[];
loadingItems: boolean;
itemsError: string | null;
visibleColumns?: string[];
}
/**
* Componente responsável por renderizar uma linha da tabela de pedidos com colunas dinâmicas.
*
* @param {Props} props - Propriedades do componente.
* @returns {JSX.Element} Linha da tabela renderizada com base nas colunas visíveis.
*/
export function OrderRowExpandable({
order,
isSelected,
onSelect,
visibleColumns = [],
}: Props) {
const searchParams = useSearchParams();
const pathname = usePathname();
// Parâmetros para retornar à tela atual após visualizar detalhes
const detailParams = createSearchParams(searchParams, [], {
from: "lista",
returnPath: pathname,
});
// URL de detalhes do pedido
const detailUrl = buildUrl(`/orders/${order.orderId}`, detailParams);
/**
* Subcomponente responsável por renderizar uma célula da tabela de acordo com a coluna especificada.
*
* @param {{ column: string }} props - Nome da coluna a ser renderizada.
* @returns {JSX.Element | null} Célula renderizada ou `null` se a coluna não for reconhecida.
*/
const OrderCell = ({ column }: { column: string }) => {
const orderColumn = column as OrderColumn;
const Renderer = orderCellRenderers[orderColumn];
if (!Renderer) return null;
return (
<TableCell className={columnClasses[orderColumn]}>
<Renderer order={order} detailUrl={detailUrl} />
</TableCell>
);
};
return (
<TableRow
className={`hover:bg-gray-100 text-xs ${isSelected ? "bg-blue-50" : "odd:bg-white even:bg-gray-50"}`}
onClick={(e) => {
// Permite seleção de texto, apenas aciona o clique se não estiver selecionando texto
if (window.getSelection()?.toString()) {
return;
}
onSelect(order.orderId);
}}
>
{visibleColumns.map(column => (
<OrderCell key={column} column={column} />
))}
</TableRow>
);
}

View File

@@ -0,0 +1,92 @@
/**
* @file cell-renderers.tsx
* @description Renderizadores personalizados para cada coluna de pedidos na tabela de pedidos.
*/
import { OrderColumn } from "../domain/order-columns";
import { formatDateSafe } from "@/src/utils/date-helpers";
import { formatCurrency } from "@/src/utils/format-helpers";
import { getOrderStatusClass } from "../domain/order-status";
import { Button } from "@/src/components/ui/button";
import { Order } from "../../../../components/types";
import { Eye } from "lucide-react";
import Link from "next/link";
/**
* Props utilizadas pelos renderizadores de célula da tabela de pedidos.
*
* @property {Order} order - Objeto de pedido com os dados a serem renderizados.
* @property {string} detailUrl - URL para a visualização detalhada do pedido.
* @property {any[]} stores - Lista de lojas associadas ao pedido.
*/
interface CellRenderProps {
order: Order;
detailUrl?: string;
stores?: any[];
}
/**
* Mapeia cada coluna da tabela de pedidos para um componente React responsável por renderizar seu conteúdo.
*
* @type {Record<OrderColumn, React.FC<CellRenderProps>>}
*/
export const orderCellRenderers: Record<OrderColumn, React.FC<CellRenderProps>> = {
[OrderColumn.Date]: ({ order }) => <>{formatDateSafe(order.createDate)}</>,
[OrderColumn.OrderId]: ({ order, detailUrl }) => (
<div className="flex items-center justify-between w-full">
<span className="text-sm">{order.orderId}</span>
<Link href={`/orders/${order.orderId}`} passHref>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground"
onClick={(e) => e.stopPropagation()}
title="Ver detalhes do pedido"
aria-label="Ver detalhes do pedido"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
</div>
),
[OrderColumn.Branch]: ({ order }) => {
// If we have a store value, display it
if (order.store) {
// Just show the store name
return <>{order.store}</>;
}
// Fallback if we only have storeId
return <>{order.storeId ? order.storeId : "Desconhecida"}</>;
},
[OrderColumn.OrderType]: ({ order }) => <>{order.orderType}</>,
[OrderColumn.TV7]: ({ order }) => <>{order.orderSaleId}</>,
[OrderColumn.Customer]: ({ order }) => <>{order.customerName}</>,
[OrderColumn.Seller]: ({ order }) => <>{order.sellerName}</>,
[OrderColumn.Status]: ({ order }) => (
<div className={getOrderStatusClass(order.status)}>
{order.status}
</div>
),
[OrderColumn.Route]: ({ order }) => (
<div className={getOrderStatusClass(order.masterDeliveryLocal)}>
{order.masterDeliveryLocal}
</div>
),
[OrderColumn.Value]: ({ order }) => <>{formatCurrency(order.amount || 0)}</>,
[OrderColumn.Weight]: ({ order }) => <>{order.totalWeigth}</>,
[OrderColumn.Shipment]: ({ order }) => <>{order.shipmentId}</>,
[OrderColumn.Billing]: ({ order }) => <>{order.billingName}</>,
[OrderColumn.Carrier]: ({ order }) => <>{order.carrier}</>,
[OrderColumn.Place]: ({ order }) => <>{order.deliveryLocal}</>,
[OrderColumn.Invoice]: ({ order }) => <>{order.invoiceNumber}</>,
[OrderColumn.InvoiceDate]: ({ order }) => <>{formatDateSafe(order.invoiceDate)}</>,
[OrderColumn.DeliveryDate]: ({ order }) => <>{order.deliveryDate ? formatDateSafe(order.deliveryDate) : "-"}</>,
[OrderColumn.SchedulerDelivery]: ({ order }) => <>{order.schedulerDelivery || "-"}</>,
};

View File

@@ -0,0 +1,24 @@
// src/components/orders/column-classes.ts
import { OrderColumn } from "./order-columns";
export const columnClasses: Record<OrderColumn, string> = {
[OrderColumn.Date]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.SchedulerDelivery]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.OrderId]: "font-medium p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.Branch]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.OrderType]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.TV7]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.Customer]: "p-2 whitespace-nowrap max-w-xs truncate border-r border-gray-200 text-xs",
[OrderColumn.Seller]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.Status]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.Route]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.Value]: "p-2 whitespace-nowrap text-right border-r border-gray-200 text-xs",
[OrderColumn.Weight]: "p-2 whitespace-nowrap text-right border-r border-gray-200 text-xs",
[OrderColumn.Shipment]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.Billing]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.Carrier]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.Place]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.Invoice]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.InvoiceDate]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
[OrderColumn.DeliveryDate]: "p-2 whitespace-nowrap border-r border-gray-200 text-xs",
};

View File

@@ -0,0 +1,24 @@
// src/components/orders/order-columns.ts
export enum OrderColumn {
Date = "Data",
DeliveryDate = "Data previsão de entrega",
SchedulerDelivery = "Agendamento",
OrderId = "Pedido",
Branch = "Filial",
OrderType = "Tipo Pedido",
TV7 = "TV 7",
Customer = "Cliente",
Seller = "Vendedor",
Status = "Situação",
Route = "Rota",
Value = "Valor",
Weight = "Peso(kg)",
Shipment = "Carregamento",
Billing = "Cobrança",
Carrier = "Trasportadora",
Place = "Praça",
Invoice = "Nota fiscal",
InvoiceDate = "Dt Faturamento"
}

View File

@@ -0,0 +1,36 @@
/**
* Retorna a classe CSS apropriada para o status do pedido
* @param status Status do pedido
* @returns Classe CSS para estilização do status
*/
export function getOrderStatusClass(status: string | undefined | null): string {
if (!status) return "text-gray-500";
const normalizedStatus = status.trim().toUpperCase();
switch (normalizedStatus) {
case "PENDENTE":
case "AGUARDANDO":
case "EM ANÁLISE":
return "text-yellow-600";
case "LIBERADO":
case "EM SEPARAÇÃO":
case "SEPARADO":
case "MONTADO":
return "text-blue-600";
case "FATURADO":
case "ENTREGUE":
case "FINALIZADO":
return "text-green-600";
case "CANCELADO":
case "BLOQUEADO":
case "BLOQUEIO FINANCEIRO":
return "text-red-600";
default:
return "text-gray-600";
}
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useState, useEffect } from "react";
import { ordersApi } from "@/src/lib/api";
import { Order, CheckoutData } from "@/src/components/types";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/src/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/src/components/ui/table";
import { Loader2, AlertTriangle } from "lucide-react";
import { formatDateToDisplay } from "@/src/utils/format-helpers";
interface CheckoutTabProps {
order: Order;
}
export function CheckoutTab({ order }: CheckoutTabProps) {
const [checkoutData, setCheckoutData] = useState<CheckoutData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!order.orderId) return;
const fetchCheckoutData = async () => {
setIsLoading(true);
setError(null);
try {
const response = await ordersApi.findOrders({
orderId: order.orderId.toString(),
includeCheckout: true,
});
if (response.data && response.data.length > 0 && response.data[0].checkout) {
setCheckoutData(response.data[0].checkout);
} else {
setCheckoutData(null);
}
} catch (err) {
setError("Falha ao buscar informações de checkout.");
console.error(err);
setCheckoutData(null);
} finally {
setIsLoading(false);
}
};
fetchCheckoutData();
}, [order.orderId]);
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="ml-2">Carregando informações de checkout...</span>
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Erro</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
if (!checkoutData) {
return (
<div className="text-center text-muted-foreground p-4">
Caixa não acertado
</div>
);
}
return (
<div className="w-full">
{/* Tabela no estilo da interface */}
<div className="border rounded-lg overflow-hidden bg-white">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-xs font-medium text-gray-600 px-4 py-3 border-r">
Caixa
</TableHead>
<TableHead className="text-xs font-medium text-gray-600 px-4 py-3 border-r">
Funcionário do Caixa
</TableHead>
<TableHead className="text-xs font-medium text-gray-600 px-4 py-3 border-r">
Data de Fechamento
</TableHead>
<TableHead className="text-xs font-medium text-gray-600 px-4 py-3">
Acerto Caixa
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-gray-50">
<TableCell className="px-4 py-3 text-sm border-r font-medium">
{checkoutData.CAIXA ?? "—"}
</TableCell>
<TableCell className="px-4 py-3 text-sm border-r">
{checkoutData.NOME_FUNCIONARIO_CAIXA ?? "—"}
</TableCell>
<TableCell className="px-4 py-3 text-sm border-r">
{checkoutData.DATA_FECHAMENTO ? formatDateToDisplay(checkoutData.DATA_FECHAMENTO) : "—"}
</TableCell>
<TableCell className="px-4 py-3 text-sm">
{checkoutData.NOME_ACERTO_MOTORISTA ?? "—"}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,342 @@
import { Scissors, GripVertical } from "lucide-react";
import { cutitens } from "../../types";
import { useState, useEffect } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/src/components/ui/table";
import { Button } from "@/src/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from "@/src/components/ui/dropdown-menu";
const ALL_COLUMNS = [
"Código",
"Descrição",
"Embalagem",
"F. Estoque",
"Qtde Vendida",
"Qtde Corte",
];
interface CortesTabProps {
cutitens: cutitens[];
}
export function CortesTab({ cutitens = [] }: CortesTabProps) {
// Carregar as colunas visíveis do localStorage na inicialização
const initVisibleColumns = () => {
try {
const saved = localStorage.getItem('visibleCutItemColumns');
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed;
}
}
} catch (error) {
console.error('Erro ao carregar configurações de colunas:', error);
}
return ALL_COLUMNS;
};
// Inicializar a ordem das colunas
const initColumnOrder = () => {
try {
const saved = localStorage.getItem('cutItemColumnsOrder');
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed;
}
}
} catch (error) {
console.error('Erro ao carregar ordem das colunas:', error);
}
return ALL_COLUMNS;
};
// Estado para controlar quais colunas estão visíveis
const [visibleColumns, setVisibleColumns] = useState<string[]>(initVisibleColumns);
// Estado para controlar a ordem das colunas
const [columnOrder, setColumnOrder] = useState<string[]>(initColumnOrder);
// Estado para rastrear coluna sendo arrastada
const [draggedColumn, setDraggedColumn] = useState<string | null>(null);
// Salvar as colunas visíveis no localStorage sempre que elas mudarem
useEffect(() => {
try {
localStorage.setItem('visibleCutItemColumns', JSON.stringify(visibleColumns));
} catch (error) {
console.error('Erro ao salvar configurações de colunas:', error);
}
}, [visibleColumns]);
// Salvar a ordem das colunas no localStorage
useEffect(() => {
try {
localStorage.setItem('cutItemColumnsOrder', JSON.stringify(columnOrder));
} catch (error) {
console.error('Erro ao salvar ordem das colunas:', error);
}
}, [columnOrder]);
// Função para alternar a visibilidade de uma coluna
const toggleColumn = (column: string) => {
setVisibleColumns((current) => {
if (current.includes(column)) {
return current.filter((c) => c !== column);
} else {
return [...current, column];
}
});
};
// Função para selecionar todas as colunas
const selectAllColumns = () => {
setVisibleColumns(ALL_COLUMNS);
};
// Função para desmarcar todas as colunas
const unselectAllColumns = () => {
// Mantém pelo menos uma coluna para garantir que a tabela não fique vazia
setVisibleColumns(["Código"]);
};
// Função para verificar se uma coluna está visível
const isColumnVisible = (column: string) => visibleColumns.includes(column);
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, column: string) => {
setDraggedColumn(column);
// captura o elemento _de verdade_ num let
const dragEl = e.currentTarget;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', column);
// agora não depende mais do React.Event
setTimeout(() => {
dragEl.classList.add('opacity-50');
}, 0);
};
const handleDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
// e.currentTarget aqui é síncrono, mas você pode usar dragEl se quiser simetria
e.currentTarget.classList.remove('opacity-50');
setDraggedColumn(null);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>, targetColumn: string) => {
e.preventDefault();
if (!draggedColumn || draggedColumn === targetColumn) return;
// Reordenar as colunas
const newColumnOrder = [...columnOrder];
const draggedIndex = newColumnOrder.indexOf(draggedColumn);
const targetIndex = newColumnOrder.indexOf(targetColumn);
if (draggedIndex !== -1 && targetIndex !== -1) {
newColumnOrder.splice(draggedIndex, 1);
newColumnOrder.splice(targetIndex, 0, draggedColumn);
setColumnOrder(newColumnOrder);
}
};
const getColumnClass = (title: string) => {
const textRight = ["Qtde Vendida", "Qtde Corte"];
const cellWidth: { [key: string]: string } = {
"Código": "w-20",
"Descrição": "w-60",
"Embalagem": "w-24",
"F. Estoque": "w-24",
"Qtde Vendida": "w-28",
"Qtde Corte": "w-28",
};
let classes = "whitespace-nowrap ";
// Alinhar à direita
if (textRight.includes(title)) {
classes += "text-right ";
}
// Aplicar larguras
if (cellWidth[title]) {
classes += cellWidth[title] + " ";
}
return classes;
};
// Função para ordenar as colunas visíveis na ordem definida pelo usuário
const getOrderedVisibleColumns = () => {
// Primeiro, filtrar as colunas que são visíveis
const visibleColumnSet = new Set(visibleColumns);
// Depois, ordenar as colunas de acordo com a ordem definida pelo usuário
return columnOrder.filter(column => visibleColumnSet.has(column));
};
// Resetar a ordem das colunas
const resetColumnOrder = () => {
setColumnOrder(ALL_COLUMNS);
};
const orderedVisibleColumns = getOrderedVisibleColumns();
return (
<div className="rounded-md border-0 mt-0">
<div className="flex justify-end mb-3 gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-60 p-2.5 bg-white shadow-lg border border-gray-200">
<DropdownMenuLabel className="text-sm font-bold pb-2.5 mb-2.5 border-b border-gray-200">Visibilidade das Colunas</DropdownMenuLabel>
<div className="flex justify-between mb-2.5 border-b pb-2.5">
<Button
variant="ghost"
size="sm"
onClick={selectAllColumns}
className="text-xs h-8 hover:bg-gray-100 px-3"
>
Marcar Todas
</Button>
<Button
variant="ghost"
size="sm"
onClick={unselectAllColumns}
className="text-xs h-8 hover:bg-gray-100 px-3"
>
Desmarcar Todas
</Button>
</div>
<div className="max-h-[300px] overflow-y-auto py-1">
{ALL_COLUMNS.map((column) => (
<DropdownMenuCheckboxItem
key={column}
checked={isColumnVisible(column)}
onCheckedChange={() => toggleColumn(column)}
className="flex items-center space-x-2 cursor-pointer py-2 hover:bg-gray-100 px-3 rounded"
>
<div className="flex items-center gap-2.5 text-sm">
{column}
</div>
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
{cutitens.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-8">
<Scissors className="h-8 w-8 text-gray-400" />
<span className="text-gray-400 italic">Nenhum item de corte disponível</span>
</div>
) : (
<div className="border border-gray-300 rounded-sm">
<div className="overflow-x-auto">
<div className="max-h-[400px] overflow-y-auto">
<Table className="border-collapse border-gray-200">
<TableHeader className="sticky top-0 z-10 bg-white border-b border-gray-200">
<TableRow className="hover:bg-transparent">
{orderedVisibleColumns.map((title, index, array) => (
<TableHead
key={index}
className={`text-xs font-medium text-gray-600 p-3 ${getColumnClass(title)} ${
index < array.length - 1 ? "border-r border-gray-200" : ""
} relative`}
draggable={true}
onDragStart={(e) => handleDragStart(e, title)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, title)}
>
<div className="flex items-center">
<GripVertical className="h-3.5 w-3.5 mr-1.5 cursor-move text-gray-400" />
{title}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{cutitens.map((item, index) => (
<TableRow
key={`corte-${index}`}
className="odd:bg-white even:bg-gray-50 hover:bg-gray-100"
>
{orderedVisibleColumns.map((columnName, colIndex) => {
switch (columnName) {
case "Código":
return (
<TableCell key={colIndex} className="border-r border-gray-200 p-2.5 text-xs font-medium">
{item.productId ?? "-"}
</TableCell>
);
case "Descrição":
return (
<TableCell key={colIndex} className="border-r border-gray-200 p-2.5 text-xs" title={item.description}>
{item.description ?? "-"}
</TableCell>
);
case "Embalagem":
return (
<TableCell key={colIndex} className="border-r border-gray-200 p-2.5 text-xs">
{item.pacth ?? "-"}
</TableCell>
);
case "F. Estoque":
return (
<TableCell key={colIndex} className="border-r border-gray-200 p-2.5 text-xs">
{item.stockId ?? "-"}
</TableCell>
);
case "Qtde Vendida":
return (
<TableCell key={colIndex} className="border-r border-gray-200 p-2.5 text-right text-xs">
{item.saleQuantity ?? "-"}
</TableCell>
);
case "Qtde Corte":
return (
<TableCell key={colIndex} className="p-2.5 text-right text-xs">
{item.cutQuantity && item.cutQuantity > 0 ? (
<span className="px-2.5 py-1.5 text-xs font-medium rounded-full bg-red-100 text-red-800">
{item.cutQuantity}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</TableCell>
);
default:
return null;
}
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { Order, OrderDelivery } from "../../types";
import { InfoCard } from "../InfoCard";
import {
Card,
CardHeader,
CardContent
} from "@/src/components/ui/card";
import {
Info,
Truck,
User,
MapPin
} from "lucide-react";
interface EntregaTabProps {
order: Order;
delivery: OrderDelivery;
}
/**
* Componente para a aba de Entrega
* Exibe dados relacionados à entrega e observações
*/
export function EntregaTab({ order, delivery }: EntregaTabProps) {
// Filtrar comentários válidos - apenas comentários de entrega
const rawComments = [
delivery.commentDelivery1,
delivery.commentDelivery2,
delivery.commentDelivery3,
delivery.commentDelivery4,
];
const comments = rawComments.filter((c) => c?.trim());
const hasComments = comments.length > 0;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-4">
<Card className="bg-muted/50">
<CardHeader className="p-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Observações de Entrega</h4>
</div>
</CardHeader>
<CardContent className="p-1 pt-0">
{hasComments ? (
<div className="space-y-2">
{comments
.filter((c) => {
const trimmed = c.trim().toLowerCase();
return trimmed !== "null" && trimmed !== "";
})
.map((c, i) => {
const trimmed = c.trim();
return (
<div key={i} className="text-sm bg-white p-3 rounded-md border">
{trimmed}
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground italic">
Nenhuma observação de entrega disponível
</p>
)}
</CardContent>
</Card>
</div>
<div className="space-y-4">
<Card className="bg-muted/50">
<CardHeader className="p-3">
<div className="flex items-center gap-2">
<Truck className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Informações de Entrega</h4>
</div>
</CardHeader>
<CardContent className="p-3 pt-0 space-y-3">
<div className="grid grid-cols-2 gap-2">
<InfoCard
title="Motorista"
value={order.driver || "Não informado"}
icon={<User className="h-4 w-4 text-muted-foreground" />}
/>
<InfoCard
title="Veículo"
value={delivery.car || "Não informado"}
icon={<Truck className="h-4 w-4 text-muted-foreground" />}
/>
</div>
</CardContent>
</Card>
<Card className="bg-muted/50">
<CardHeader className="p-3">
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Endereço de Entrega</h4>
</div>
</CardHeader>
<CardContent className="p-3 pt-0">
<div className="space-y-1">
<p className="text-sm font-medium">
{delivery.street}, {delivery.addressNumber}
{delivery.addressComplement &&
` ${delivery.addressComplement}`}
</p>
<p className="text-sm font-medium">
BAIRRO: {delivery.bairro} <br />
CIDADE: {delivery.city}-{delivery.state} <br />
CEP: {delivery.cep}
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import { Order } from "../../types";
import { formatCurrency, formatDateToDisplay } from "../../../utils/format-helpers";
import { InfoCard } from "../InfoCard";
import { Badge } from "@/src/components/ui/badge";
import { getDeliveryTypeLabel, getStatusColor } from "../order-info-utils";
import { QRCodeRastreamento } from "../../../components/qr-code-rastreamento";
import { QrCode } from 'lucide-react';
import {
User,
StoreIcon,
Calendar,
CreditCard,
Truck,
MapPin,
MessageSquare,
Clock,
} from "lucide-react";
import { Card, CardHeader, CardContent } from "@/src/components/ui/card";
interface InfoTabProps {
order: Order;
orderComments?: (string | null | undefined)[];
}
/**
* Componente para a aba de Informações do pedido
* Exibe dados gerais como cliente, filial, datas, status, pagamento, etc.
*/
export function InfoTab({ order, orderComments = [] }: InfoTabProps) {
// Filtrar comentários válidos
const validComments = orderComments
.filter(c => typeof c === "string" && c?.trim() && c.trim().toLowerCase() !== "null");
const hasComments = validComments.length > 0;
const faturamentoTime =
order.invoiceHour && order.invoiceMinute
? `${String(order.invoiceHour).padStart(2, "0")}:${String(
order.invoiceMinute
).padStart(2, "0")}`
: "-";
return (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<InfoCard
title="Cliente"
value={order.customerName}
icon={<User className="h-4 w-4 text-muted-foreground" />}
/>
<InfoCard
title="Filial"
value={order.storeId || "Não informada"}
icon={<StoreIcon className="h-4 w-4 text-muted-foreground" />}
/>
<InfoCard
title="Data de Criação"
value={formatDateToDisplay(order.createDate)}
subtext={order.updateDate ? `Atualizado: ${formatDateToDisplay(order.updateDate)}` : undefined}
icon={<Calendar className="h-4 w-4 text-muted-foreground" />}
/>
<InfoCard
title="Data Faturamento"
value={order.invoiceDate ? formatDateToDisplay(order.invoiceDate) : "-"}
icon={<Calendar className="h-4 w-4 text-muted-foreground" />}
/>
<InfoCard
title="Hora Faturamento"
value={faturamentoTime}
icon={<Clock className="h-4 w-4 text-muted-foreground" />}
/>
<InfoCard
title="Status"
value={order.status || "-"}
customBadge={
<Badge className={`${getStatusColor(order.status)} hover:${getStatusColor(order.status)}`}>
{order.status}
</Badge>
}
/>
<InfoCard
title="Pagamento"
value={order.paymentName || "-"}
icon={<CreditCard className="h-4 w-4 text-muted-foreground" />}
/>
<InfoCard
title="Cobrança"
value={order.billingName || "-"}
/>
<InfoCard
title="Valor Total"
value={formatCurrency(order.amount ?? 0)}
className="text-emerald-600 font-medium"
/>
{/* Link Rastreamento abaixo de Valor Total */}
{order.orderType == 'TV7 - Faturamento' ? (
<InfoCard
title="Link de Rastreamento"
value= {<QRCodeRastreamento orderId={order.orderId} /> }
/>) : null}
<InfoCard
title="Tipo de Entrega"
value={getDeliveryTypeLabel(order.deliveryType)}
icon={<Truck className="h-4 w-4 text-muted-foreground" />}
/>
<InfoCard
title="Praça da Entrega"
value={order.deliveryLocal || "-"}
icon={<MapPin className="h-4 w-4 text-muted-foreground" />}
/>
</div>
{/* Seção de comentários do pedido */}
<Card className="bg-muted/50">
<CardHeader className="p-3">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Observações do Pedido</h4>
</div>
</CardHeader>
<CardContent className="p-3 pt-0">
{hasComments ? (
<div className="space-y-2">
{validComments.map((c, i) => (
<div key={i} className="text-sm bg-white p-3 rounded-md border">
{c?.trim()}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground italic py-2">
Nenhuma observação do pedido disponível
</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { OrderItem } from "../../types";
import { formatCurrency } from "../../../utils/format-helpers";
interface OrderItemsSectionProps {
orderItems: OrderItem[];
}
/**
* Componente para a seção de Itens do Pedido
* Exibe lista de itens do pedido em uma tabela
*/
export function OrderItemsSection({ orderItems = [] }: OrderItemsSectionProps) {
return (
<div className="w-full">
{orderItems.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
Nenhum item encontrado para este pedido.
</p>
) : (
<div className="overflow-x-auto -mx-2 sm:-mx-6">
<div className="inline-block min-w-full align-middle">
<div className="overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead>
<tr className="border-b bg-muted/50">
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Código</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Descrição</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider">Qtd</th>
<th scope="col" className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium text-muted-foreground uppercase tracking-wider text-right">Preço</th>
<th scope="col" className="p-2 whitespace-nowrap text-xs font-medium text-muted-foreground uppercase tracking-wider text-right">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{orderItems.map((item, index) => (
<tr key={`item-${index}`} className="hover:bg-muted/50 transition-colors odd:bg-white even:bg-gray-50">
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs font-medium">{item.productId ?? "-"}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs">{item.description ?? "-"}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs text-center">{item.quantity ?? "-"}</td>
<td className="p-2 whitespace-nowrap border-r border-gray-200 text-xs text-right">{formatCurrency(item.salePrice ?? 0)}</td>
<td className="p-2 whitespace-nowrap text-xs text-right">{formatCurrency(item.total ?? 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import OrderTimeline from "../OrderTimeline";
interface StatusTabProps {
orderId?: string;
}
/**
* Componente para a aba de Status
* Utiliza o componente OrderTimeline para exibir a timeline de movimentação do pedido
*/
export function StatusTab({ orderId }: StatusTabProps) {
return (
<div className="w-full px-0.5 py-1">
<OrderTimeline orderId={orderId} className="max-w-full overflow-x-hidden" />
</div>
);
}

View File

@@ -0,0 +1,359 @@
import { useState, useEffect } from "react";
import { Order, OrderDeliveryDto, Store } from "../../types";
import { ordersApi } from "../../../lib/api";
import { formatDateToDisplay } from "../../../utils/format-helpers";
import { getDeliveryTypeLabel, getStatusColor, normalizeStatus, getStatusFromDeliveryType, STATUS_DEFAULT } from "../order-info-utils";
import { Badge } from "@/src/components/ui/badge";
import { Truck, Loader2, GripVertical, Eye } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/src/components/ui/table";
import { Button } from "@/src/components/ui/button";
import Link from "next/link";
import { useStoreNameResolver } from "@/src/hooks/useStoreNameResolver";
interface TV8TabProps {
order: Order;
initialDeliveries?: OrderDeliveryDto[];
stores?: Store[];
}
/**
* Função pura para buscar entregas do TV8 com retry e normalização de dados
* @param orderId ID do pedido a ser consultado
* @returns Array de entregas TV8 normalizadas ou um objeto de erro
*/
export async function fetchAndNormalizeTV8Deliveries(orderId: string | number | undefined): Promise<{
success: boolean;
data: OrderDeliveryDto[];
error?: string;
}> {
if (!orderId) {
return { success: false, data: [], error: "ID do pedido não informado" };
}
try {
// Formatar orderID corretamente
const formattedOrderId = orderId.toString().trim();
// Tentar dois endpoints diferentes
let response;
try {
// Primeiro endpoint
response = await ordersApi.getOrderDeliveries(formattedOrderId);
} catch (err) {
// Pequeno delay antes de tentar novamente
await new Promise(resolve => setTimeout(resolve, 500));
// Tentar URL alternativa diretamente
try {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8888";
const url = `${baseUrl}/api/v1/orders/${formattedOrderId}`;
response = await fetch(url)
.then(res => {
if (!res.ok) {
throw new Error(`Falha na requisição alternativa: ${res.status}`);
}
return res.json();
});
} catch (fetchErr: any) {
console.error("Erro na requisição alternativa:", fetchErr);
throw new Error(`Falha nas duas tentativas: ${fetchErr.message}`);
}
}
// Verificar formato da resposta e normalizar dados
if (response && typeof response === 'object' && 'success' in response && 'data' in response) {
if (Array.isArray(response.data) && response.data.length > 0) {
// Apenas retorne os dados crus
return { success: true, data: response.data };
}
} else if (Array.isArray(response) && response.length > 0) {
// Apenas retorne os dados crus
return { success: true, data: response };
}
// Se chegou aqui, não há dados
return { success: true, data: [] };
} catch (error: any) {
console.error("Error fetching TV8 deliveries:", error);
return {
success: false,
data: [],
error: error.message || "Falha ao carregar entregas TV8"
};
}
}
/**
* Componente para exibir a aba de entregas TV8
* Faz requisição à API de TV8 e exibe os dados em formato de tabela
*/
export function TV8Tab({ order, initialDeliveries = [], stores = [] }: TV8TabProps) {
const [tv8Deliveries, setTv8Deliveries] = useState<OrderDeliveryDto[]>(initialDeliveries);
const [isLoadingTv8, setIsLoadingTv8] = useState(false);
const [tv8Error, setTv8Error] = useState("");
const { getStoreName } = useStoreNameResolver(stores);
const rawOrderStatus = order.status ?? STATUS_DEFAULT;
const orderStatusKey = normalizeStatus(rawOrderStatus);
const orderStatusColor = getStatusColor(orderStatusKey);
// Flag para controle de exibição
const displayTV8Data = tv8Deliveries;
useEffect(() => {
async function loadTV8Deliveries() {
if (!order.orderId) return;
setIsLoadingTv8(true);
setTv8Error("");
const result = await fetchAndNormalizeTV8Deliveries(order.orderId);
if (result.success) {
setTv8Deliveries(result.data);
} else {
setTv8Error(result.error || "Falha ao carregar dados");
setTv8Deliveries([]);
}
setIsLoadingTv8(false);
}
loadTV8Deliveries();
}, [order.orderId, order.schedulerDelivery]);
const getColumnClass = (title: string) => {
const textRight = ["Peso"];
const cellWidth: { [key: string]: string } = {
"Data": "w-28",
"Filial": "w-16",
"Pedido": "w-20",
"Cliente": "w-56",
"Status": "w-24",
"Tipo Entrega": "w-28",
"Data Entrega": "w-28",
"Qtd Itens": "w-20",
"Motorista": "w-48",
"Veículo": "w-24",
"Peso": "w-20",
"Observação": "w-48",
};
let classes = "";
if (title !== "Observação" && title !== "Cliente" && title !== "Motorista") {
classes += "whitespace-nowrap ";
}
if (textRight.includes(title)) {
classes += "text-right ";
}
if (cellWidth[title]) {
classes += cellWidth[title] + " ";
}
// Adiciona borda divisória para todas as colunas, exceto a última
if (title !== "Observação") {
classes += "border-r border-gray-200 ";
}
return classes;
};
if (isLoadingTv8) {
return <div className="py-4 text-center">
<Loader2 className="h-6 w-6 animate-spin text-primary mx-auto mb-2" />
<span className="text-sm">Carregando dados TV8...</span>
</div>;
}
if (tv8Error) {
return <div className="py-2 text-red-500 text-center">
<p>Erro ao carregar dados TV8</p>
<p className="text-sm">{tv8Error}</p>
</div>;
}
if (displayTV8Data.length === 0) {
return <div className="py-6 text-sm text-center text-muted-foreground italic">
<div className="flex flex-col items-center gap-2">
<Truck className="h-6 w-6 text-muted-foreground" />
Nenhum registro de TV8 disponível
</div>
</div>;
}
const columns = [
"Data", "Data previsão de entrega", "Filial", "Pedido", "Cliente", "Status", "Tipo Entrega",
"Data Entrega", "Qtd Itens", "Motorista", "Veículo", "Peso", "Observação"
];
return (
<div className="rounded-md border-0">
<div className="border border-gray-300">
<div className="overflow-x-auto">
<div className="max-h-[600px] overflow-y-auto">
<Table className="border-collapse border-gray-200">
<TableHeader className="sticky top-0 z-10 bg-white border-b border-gray-200">
<TableRow className="hover:bg-transparent">
{columns.map((title, index, array) => (
<TableHead
key={index}
className={`text-xs font-medium text-gray-600 p-2 ${getColumnClass(title)} ${
index < array.length - 1 ? "border-r border-gray-200" : ""
} relative`}
>
<div className="flex items-center">
<GripVertical className="h-3 w-3 mr-1 cursor-move text-gray-400" />
{title}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{displayTV8Data.map((item, index) => (
<TableRow
key={`tv8-${index}`}
className="odd:bg-white even:bg-gray-50 hover:bg-gray-100"
>
<TableCell className={`border-r border-gray-200 p-2 text-xs ${getColumnClass("Data")}`}>
{formatDateToDisplay(item.createDate)}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs ${getColumnClass("Data previsão de entrega")}`}>
{item.deliveryDate ? formatDateToDisplay(item.deliveryDate) : "-"}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs ${getColumnClass("Filial")}`}>
{getStoreName(item.storeId?.toString()) || item.storeId}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs ${getColumnClass("Pedido")}`}>
<div className="flex items-center justify-between w-full">
<span>{item.orderId}</span>
<Link href={`/orders/${item.orderId}`} passHref>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground"
onClick={(e) => e.stopPropagation()}
title="Ver detalhes do pedido"
aria-label="Ver detalhes do pedido"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
</div>
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs truncate ${getColumnClass("Cliente")}`} title={item.customer}>
{item.customer}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs ${getColumnClass("Status")}`}>
{item.status }
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs ${getColumnClass("Tipo Entrega")}`}>
{getDeliveryTypeLabel(item.deliveryType)}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs ${getColumnClass("Data Entrega")}`}>
{item.deliveryDate ? formatDateToDisplay(item.deliveryDate) : (item.deliveryConfirmationDate ? formatDateToDisplay(item.deliveryConfirmationDate) : "-")}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs text-center ${getColumnClass("Qtd Itens")}`}>
{item.quantityItens}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs truncate ${getColumnClass("Motorista")}`} title={item.driverName || "-"}>
{item.driverName || "-"}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs ${getColumnClass("Veículo")}`}>
{item.carIdentification || "-"}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs text-right ${getColumnClass("Peso")}`}>
{item.weight} kg
</TableCell>
<TableCell className={`p-2 text-xs ${getColumnClass("Observação")}`} title={item.observation || "-"}>
{item.observation || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
{/* Mobile view - Simplified list */}
<div className="block md:hidden mt-4">
{displayTV8Data.map((item, index) => (
<div key={`tv8-mobile-${index}`} className="border rounded-md p-3 bg-white shadow-sm mb-3">
<div className="flex flex-wrap justify-between items-start gap-2 mb-3">
<div className="flex-1 min-w-0">
<Badge
className={`${getStatusColor(normalizeStatus(item.status))} hover:${getStatusColor(normalizeStatus(item.status))} border-0 mb-2 font-semibold text-white px-2 py-0.5 inline-flex`}
variant="outline"
>
{normalizeStatus(item.status)}
</Badge>
<div className="text-xs font-medium truncate">{formatDateToDisplay(item.createDate)}</div>
</div>
<div className="text-xs font-medium text-right">
<div className="mb-1 flex items-center justify-end gap-1">
Pedido: {item.orderId}
<Link href={`/orders/${item.orderId}`} passHref>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground p-0"
onClick={(e) => e.stopPropagation()}
title="Ver detalhes do pedido"
aria-label="Ver detalhes do pedido"
>
<Eye className="h-3.5 w-3.5" />
</Button>
</Link>
</div>
<div>{item.quantityItens} {item.quantityItens === 1 ? 'item' : 'itens'}</div>
</div>
</div>
<div className="grid grid-cols-1 gap-2 text-xs">
<div className="flex items-center justify-between border-b border-gray-100 pb-1.5">
<span className="text-muted-foreground">Cliente:</span>
<span className="font-medium truncate max-w-[180px]">{item.customer}</span>
</div>
<div className="flex items-center justify-between border-b border-gray-100 pb-1.5">
<span className="text-muted-foreground">Motorista:</span>
<span className="font-medium truncate max-w-[180px]">{item.driverName || "Não informado"}</span>
</div>
<div className="flex items-center justify-between border-b border-gray-100 pb-1.5">
<span className="text-muted-foreground">Tipo:</span>
<span className="font-medium">{getDeliveryTypeLabel(item.deliveryType)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Peso:</span>
<span className="font-medium">{item.weight} kg</span>
</div>
{item.observation && (
<div className="mt-1 pt-1 border-t border-gray-100">
<div className="text-muted-foreground mb-1">Observação:</div>
<div className="bg-gray-50 p-1.5 rounded text-xs">{item.observation}</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { transfer } from "../../types";
import { formatDateToDisplay } from "../../../utils/format-helpers";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/src/components/ui/table";
interface TransferenciaTabProps {
transfers: transfer[];
}
/**
* Componente para a aba de Transferência
* Exibe informações sobre transferências do pedido entre carregamentos
*/
export function TransferenciaTab({ transfers = [] }: TransferenciaTabProps) {
const cellClass = "whitespace-nowrap";
return (
<div className="border border-gray-300">
<div className="overflow-x-auto">
<div className="max-h-[600px] overflow-y-auto">
<Table className="border-collapse border-gray-200">
<TableHeader className="sticky top-0 z-10 bg-white border-b border-gray-200">
<TableRow className="hover:bg-transparent">
<TableHead className="text-xs font-medium text-gray-600 p-2 border-r border-gray-200">Data</TableHead>
<TableHead className="text-xs font-medium text-gray-600 p-2 border-r border-gray-200">NF</TableHead>
<TableHead className="text-xs font-medium text-gray-600 p-2 border-r border-gray-200">Trans.</TableHead>
<TableHead className="text-xs font-medium text-gray-600 p-2 border-r border-gray-200">Carr.Ant</TableHead>
<TableHead className="text-xs font-medium text-gray-600 p-2 border-r border-gray-200">Carr.Atual</TableHead>
<TableHead className="text-xs font-medium text-gray-600 p-2 border-r border-gray-200">Motivo</TableHead>
<TableHead className="text-xs font-medium text-gray-600 p-2 border-r border-gray-200">Obs.</TableHead>
<TableHead className="text-xs font-medium text-gray-600 p-2 border-r border-gray-200">Resp.</TableHead>
<TableHead className="text-xs font-medium text-gray-600 p-2">Rotina</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transfers && transfers.length > 0 ? (
transfers.map((item, index) => {
// Get ship IDs from either standard or alternative field names
const oldShipId = item.oldShipmentId ?? item.oldshipmentid ?? 0;
const newShipId = item.newShipmentId ?? item.newshipmentid ?? 0;
// Format for display
const oldShipmentIdText = oldShipId !== 0 ? String(oldShipId) : "-";
const newShipmentIdText = newShipId !== 0 ? String(newShipId) : "-";
return (
<TableRow
key={`transfer-${index}`}
className="odd:bg-white even:bg-gray-50 hover:bg-gray-100"
>
<TableCell className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{formatDateToDisplay(item.transferDate)}
</TableCell>
<TableCell className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{item.invoiceId}
</TableCell>
<TableCell className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{item.transactionId}
</TableCell>
<TableCell className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{oldShipmentIdText}
</TableCell>
<TableCell className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{newShipmentIdText}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs`}>
{item.cause || "-"}
</TableCell>
<TableCell className={`border-r border-gray-200 p-2 text-xs max-w-[120px] truncate`}>
{item.transferText || "-"}
</TableCell>
<TableCell className={`${cellClass} border-r border-gray-200 p-2 text-xs`}>
{item.userName}
</TableCell>
<TableCell className={`${cellClass} p-2 text-xs`}>
{item.program}
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={9} className="py-6 text-sm text-center text-gray-500 italic">
Nenhuma transferência disponível
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client"
import { useState, useMemo } from "react"
import md5 from "crypto-js/md5"
import { Button } from "../components/ui/button"
import { QrCode, Check, X } from "lucide-react"
import { useToast } from "../hooks/use-toast"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../components/ui/dialog"
import { Input } from "../components/ui/input"
interface QRCodeRastreamentoProps {
orderId?: string | number
}
export function QRCodeRastreamento({ orderId }: QRCodeRastreamentoProps) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const { toast } = useToast()
const url = useMemo(() => {
if (!orderId) return ""
const hash = md5(String(orderId) + "@Juru2025$").toString().toUpperCase()
return `https://rastreamento.jurunense.com/${hash}`
}, [orderId])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
toast({
title: "Link copiado!",
description: "O link foi copiado para a área de transferência.",
})
setTimeout(() => setCopied(false), 2000)
} catch {
toast({
title: "Erro ao copiar",
description: "Não foi possível copiar o link.",
variant: "destructive",
})
}
}
const handleOpen = () => {
if (!orderId) {
toast({
title: "Erro",
description: "Número do pedido não disponível.",
variant: "destructive",
})
return
}
setOpen(true)
}
return (
<>
<Button
variant="ghost"
size="icon"
className="rounded-full transition-all duration-200"
onClick={handleOpen}
title="Abrir link de rastreamento"
>
<QrCode className="h-10 w-10" />
<span className="sr-only">Abrir link de rastreamento</span>
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Link de Rastreamento</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Input readOnly value={url} />
</div>
</DialogContent>
</Dialog>
</>
)
}

178
src/components/sidebar.tsx Normal file
View File

@@ -0,0 +1,178 @@
"use client";
import { cn } from "@/src/lib/utils";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import Image from "next/image";
import logoJuru from "./logo_juru.png";
import {
Home,
ShoppingCart,
ChevronDown,
ChevronRight,
User,
Settings,
LogOut,
ChevronsLeftRight,
} from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
const routes = [
{
label: "Home",
icon: Home,
href: "/",
color: "text-sky-400",
},
{
label: "Pedidos",
icon: ShoppingCart,
href: "/orders",
color: "text-pink-700",
submenu: [{ label: "Buscar Pedidos", href: "/orders/find" }],
},
];
export default function Sidebar() {
const pathname = usePathname();
const [openDropdowns, setOpenDropdowns] = useState<Record<string, boolean>>(
{}
);
const [isCollapsed, setIsCollapsed] = useState(false);
const toggleDropdown = (href: string) => {
setOpenDropdowns((prev) => ({
...prev,
[href]: !prev[href],
}));
};
const handleLogout = () => {
console.log('Sidebar - iniciando logout');
};
return (
<AnimatePresence>
<motion.div
className={cn(
"flex h-full flex-col border-r bg-blue-950 text-gray-100 transition-all",
isCollapsed ? "w-16" : "w-60"
)}
>
<div className="flex h-24 items-center justify-between border-b border-blue-800 px-6 py-6">
{!isCollapsed && (
<Link href="/" className="flex items-center">
<Image
src={logoJuru}
alt="Logo Juru"
width={160}
height={60}
className="object-contain"
/>
</Link>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="text-blue-300 hover:text-white"
>
<ChevronsLeftRight className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-auto py-1">
<nav className="grid items-start px-2 text-sm">
{routes.map((route) => {
const isActive = pathname === route.href && route.href !== "/";
const isExpanded = openDropdowns[route.href];
return (
<div key={route.href} className="mb-1">
<motion.button
whileTap={{ scale: 0.97 }}
whileHover={{ scale: 1.01 }}
transition={{ type: "spring", stiffness: 300 }}
onClick={() => route.submenu && toggleDropdown(route.href)}
className={cn(
"group flex w-full items-center justify-between rounded-md px-3 py-3 text-left text-sm font-medium transition-all hover:bg-white-800 hover:text-white",
isActive
? "bg-white-800 text-white shadow-md"
: "text-white-100"
)}
>
<span className="flex items-center">
<route.icon className={cn("mr-3 h-5 w-5", route.color)} />
{!isCollapsed && route.label}
</span>
{!isCollapsed &&
route.submenu &&
(isExpanded ? (
<ChevronDown className="h-4 w-4 text-blue-300" />
) : (
<ChevronRight className="h-4 w-4 text-blue-300" />
))}
</motion.button>
{!isCollapsed && route.submenu && (
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="mt-1 space-y-1 rounded-md py-1 overflow-hidden"
>
{route.submenu.map((submenu) => (
<Link
key={submenu.href}
href={submenu.href}
className={cn(
"ml-8 block rounded-md py-2 pl-4 pr-3 text-sm font-medium transition-colors hover:bg-white-800 hover:text-white",
pathname === submenu.href
? "bg-white/10 backdrop-blur"
: "text-white-200"
)}
>
{submenu.label}
</Link>
))}
</motion.div>
)}
</AnimatePresence>
)}
</div>
);
})}
</nav>
</div>
<div className="border-t border-blue-800 p-4">
<div className="flex items-center space-x-3 px-2 py-3">
<User className="h-5 w-5 text-blue-300" />
{!isCollapsed && (
<div className="flex flex-col">
</div>
)}
</div>
{!isCollapsed && (
<div className="mt-3 grid grid-cols-2 gap-2">
<button className="flex items-center justify-center rounded-md bg-blue-800 p-2 text-xs hover:bg-blue-700">
<Settings className="mr-1 h-4 w-4" />
<span>Configurações</span>
</button>
<button
onClick={handleLogout}
className="flex items-center justify-center rounded-md bg-blue-800 p-2 text-xs hover:bg-blue-700"
>
<LogOut className="mr-1 h-4 w-4" />
<span>Sair</span>
</button>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

352
src/components/types.ts Normal file
View File

@@ -0,0 +1,352 @@
import { LucideProps } from "lucide-react"
import { JSX } from "react"
export type LucideIcon = (props: LucideProps) => JSX.Element
// Importações e exportações de constantes de data
export * from '@/src/utils/date-helpers';
// Tipo para eventos de timeline
export interface TimelineEvent {
id: string | number;
title?: string;
description: string;
date: string | Date;
status?: string;
icon?: LucideIcon;
color?: string;
user?: string;
}
export interface cutitens {
productId: number;
description: string;
pacth: string;
stockId: number;
saleQuantity: number;
cutQuantity: number;
separedQuantity: number;
}
export interface transfer {
orderId: number;
transferDate: Date;
invoiceId: number;
transactionId: number;
oldShipmentId: number;
newShipmentId: number;
transferText: string;
cause: string;
userName: string;
program: string;
oldshipmentid?: number;
newshipmentid?: number;
}
export interface leadtime{
descricaoEtapa: string;
orderId: number;
descricao: string;
data: string;
codigoFuncionario: string;
nomeFuncionario: string;
numeroPedido: string;
icon?: LucideIcon;
status?: string;
color?: string;
}
export interface status {
orderId: number;
status: string;
statusDate: Date;
userName: string;
comments: string;
}
export interface CustomerDto {
id: string;
name: string;
document: string;
}
export interface Order {
orderId: string;
clientId: string;
clientDocument: string;
customer?: CustomerDto;
shippimentId?: string;
clientName: string;
storeId: string;
sellerId: string;
stockId: string;
storeName: string;
store?: string;
createDate: string | Date;
updateDate?: string | Date;
status?: string;
totalValue: number;
invoiceHour?: string;
invoiceMinute?: string;
paymentMethod?: string;
paymentStatus?: string;
paymentPlan?: string;
paymentName?: string;
driver?: string;
carDescription?: string;
deliveryLocal?: string;
deliveryType?: string;
commentDelivery1?: string;
billingId?: string;
masterDeliveryLocal?: string;
orderType?: string;
orderSaleId?: string | number;
customerName?: string;
customerId?: string;
sellerName?: string;
amount?: number;
totalWeigth?: number | string;
shipmentId?: string | number;
billingName?: string;
invoiceNumber?: string;
invoiceDate?: string | Date;
shipmentDate?: string | Date;
deliveryDate?: string | Date;
schedulerDelivery?: string;
deliveryBranch?: string;
deliveryAddress?: string;
customerContact?: string;
observations?: string;
carIdentification?: string;
carrier?: string;
checkout?: OrderCheckout;
}
export interface OrderItem {
productId: number;
description: string;
pacth: string;
color: string | null;
stockId: number;
quantity: number;
salePrice: number;
deliveryType: string;
total: number;
weight: number | null;
department: string;
brand: string;
saleQuantity: number;
cutQuantity: number;
separedQuantity: number;
}
// Exportando o tipo ClientData
export interface ClientData {
name: string;
email: string;
phone: string;
address: string;
city: string;
state: string;
zipCode: string;
cpfCnpj: string;
createdAt: string;
updatedAt: string;
status: 'active' | 'inactive';
type: 'individual' | 'company';
creditLimit: number;
lastPurchaseDate: string | null;
totalPurchases: number;
}
export type OrderStatus =
| 'Aguardando pagamento'
| 'Pagamento aprovado'
| 'Em separação'
| 'Em transporte'
| 'Entregue'
| 'Cancelado';
export type PaymentStatus =
| 'Pendente'
| 'Aprovado'
| 'Rejeitado'
| 'Estornado'
| 'Em análise';
export type Customer = {
id: string;
name: string;
document: string;
};
export interface LoginDto {
username: string;
password: string;
}
export interface LoginResponseDto {
id: number;
sellerId: number;
name: string;
username: string;
storeId: string;
email: string;
token: string;
}
export interface Cliente {
id?: string;
name?: string;
document?: string;
}
/**
* Interface para parâmetros de pesquisa de pedidos
* Define todos os campos possíveis usados no formulário de busca
*/
export interface OrderSearchParams {
/** ID do pedido */
orderId?: string;
/** Código da filial de venda */
codfilial?: string;
/** ID do cliente (mesma funcionalidade que customerId) */
clientId?: string;
/** ID do cliente */
customerId?: string;
/** ID do carregamento */
shippimentId?: string;
/** ID alternativo (compatibilidade) */
id?: string;
/** Código da filial de estoque */
stockId?: string;
/** Documento do cliente (CPF/CNPJ) */
Document?: string;
/** Nome do cliente para busca */
name?: string;
/** Tipo de venda (pode ser string única ou array) */
type?: string | string[];
/** Cliente selecionado pelo componente de busca */
selectedCustomer?: Cliente | null;
/** Data inicial para filtro */
createDateIni?: string;
/** Data final para filtro */
createDateEnd?: string;
/** Status do pedido */
status?: string | string[];
/** Local de entrega */
deliveryLocal?: string;
/** ID do vendedor */
sellerId?: string | number;
/** Documento do cliente (CPF/CNPJ) - duplicado para compatibilidade */
document?: string;
/** Número da nota fiscal */
invoiceNumber?: string;
/** Tipo de entrega */
deliveryType?: string | string[];
/** ID da nota fiscal (mapeado de invoiceNumber) */
invoiceId?: string;
/** Número da nota fiscal (NFe) */
nfe?: string;
}
export interface CustomerSearchParams {
id?: string;
Document?: string;
name?: string;
}
export interface dataConsult {
id?: string;
store?: string;
storeId?: string;
code?: string;
name?: string;
document?: string;
}
export interface Store {
id?: string;
store?: string;
storeId?: string;
code?: string;
name?: string;
description?: string;
label?: string;
}
export interface OrderDelivery {
placeId: number;
placeName: string;
street: string;
addressNumber: string;
bairro: string;
city: string;
state: string;
addressComplement: string;
cep: string;
commentOrder1: string;
commentOrder2: string;
commentDelivery1: string;
commentDelivery2: string;
commentDelivery3: string;
commentDelivery4: string;
shippimentId: number;
shippimentDate: Date;
shippimentComment: string;
place: string;
driver: string;
car: string;
closeDate: Date;
separatorName: string;
confName: string;
releaseDate: Date;
}
export interface CustomerSearchParams {
id?: string;
Document?: string;
name?: string;
}
export interface OrderDeliveryDto {
storeId: number;
storeDescription?: string;
createDate: Date;
orderId: number;
orderIdSale: number | null;
customerId: number;
customer: string;
deliveryType: string | null;
quantityItens: number;
status: string;
weight: number;
shipmentId: number;
driverId: number | null;
driverName: string | null;
carPlate: string | null;
carIdentification: string | null;
observation: string | null;
deliveryConfirmationDate: Date | null;
schedulerDelivery?: string;
deliveryDate: Date | null;
}
export interface OrderCheckout {
CAIXA: number;
NOME_FUNCIONARIO_CAIXA: string;
DATA_FECHAMENTO: string;
NOME_ACERTO_MOTORISTA: string;
}
export interface CheckoutData {
CAIXA: number;
NOME_FUNCIONARIO_CAIXA: string;
DATA_FECHAMENTO: string;
NOME_ACERTO_MOTORISTA: string;
}

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { AlertCircle } from 'lucide-react';
interface ApiErrorAlertProps {
message: string;
variant: 'unauthorized' | 'forbidden' | 'error';
}
export function ApiErrorAlert({ message, variant }: ApiErrorAlertProps) {
const getVariantStyles = () => {
switch (variant) {
case 'unauthorized':
return 'bg-yellow-50 text-yellow-800 border-yellow-200';
case 'forbidden':
return 'bg-red-50 text-red-800 border-red-200';
default:
return 'bg-gray-50 text-gray-800 border-gray-200';
}
};
return (
<div className={`flex items-center gap-3 p-4 rounded-lg border ${getVariantStyles()}`}>
<AlertCircle className="w-5 h-5" />
<p className="text-sm font-medium">{message}</p>
</div>
);
}

View File

@@ -0,0 +1,23 @@
interface StatusBadgeProps {
status?: string;
}
export function StatusBadge({ status }: StatusBadgeProps) {
const statusClasses: Record<string, string> = {
Liberado: "bg-green-100 text-green-800",
Pendente: "bg-yellow-100 text-yellow-800",
Bloqueado: "bg-red-100 text-red-800",
Montado: "bg-blue-100 text-blue-800",
Cancelado: "bg-red-100 text-red-800",
Faturado: "bg-violet-100 text-violet-800",
};
const style = status && statusClasses[status] ? statusClasses[status] : "bg-gray-100 text-gray-700";
return (
<span className={`px-3 py-1 text-xs font-semibold rounded-full ${style}`}>
{status}
</span>
);
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/src/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/src/lib/utils"
import { buttonVariants } from "@/src/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/src/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,7 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/src/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/src/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/src/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,58 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/src/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
blue: "bg-blue-600 text-white hover:bg-blue-700",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/src/lib/utils"
import { buttonVariants } from "@/src/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,262 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/src/lib/utils"
import { Button } from "@/src/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

365
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/src/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/src/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/src/lib/utils"
import { Dialog, DialogContent } from "@/src/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

Some files were not shown because too many files have changed in this diff Show More