first
8
.babelrc.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">{
|
||||
"associatedIndex": 8
|
||||
}</component>
|
||||
<component name="ProjectId" id="2w8VwZI8V1ZiF86I9ohOaePeoEH" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"git-widget-placeholder": "feature/react-data-grid-orders",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"js.debugger.nextJs.config.created.client": "true",
|
||||
"js.debugger.nextJs.config.created.server": "true",
|
||||
"junie.onboarding.icon.badge.shown": "true",
|
||||
"last_opened_file_path": "C:/Users/Joelson/Desktop/conceito-335",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"npm.Next.js: server-side.executor": "Run",
|
||||
"ts.external.directory.path": "C:\\Users\\Joelson\\Desktop\\conceito-335\\node_modules\\typescript\\lib",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}</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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
131
package.json
Normal 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
8
postcss.config.mjs
Normal 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
|
After Width: | Height: | Size: 25 KiB |
1
public/file.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 |
66
src/app/api/v1/auth/login/route.ts
Normal 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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
112
src/app/orders/SellerSearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/orders/[id]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/app/orders/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
3
src/app/orders/find/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
432
src/app/orders/find/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/app/orders/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
16
src/app/orders/not-found.tsx
Normal 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
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function OrdersPage() {
|
||||
|
||||
redirect("/orders/find");
|
||||
}
|
||||
15
src/app/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
|
||||
|
||||
|
||||
|
||||
export default function Home() {
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Home</h1>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
208
src/components/forms/OrderSearchForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
src/components/logo_juru.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
75
src/components/orders/ColumnConfig/ColumnConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/components/orders/ColumnConfig/ColumnConfigTrigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/orders/ColumnConfig/ColumnList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/components/orders/ColumnConfig/ColumnToggleActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/orders/ColumnConfig/ColumnVisibilityItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
694
src/components/orders/CustomerSearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/components/orders/DateRangeFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/orders/FilterInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/components/orders/FilterSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/orders/InfoCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/components/orders/MultiFilterSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
src/components/orders/OrderDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/orders/OrderDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
342
src/components/orders/OrderInfoCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
src/components/orders/OrderItemsCard.tsx
Normal 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);
|
||||
466
src/components/orders/OrderItemsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
326
src/components/orders/OrderTimeline.tsx
Normal 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 há 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>
|
||||
);
|
||||
}
|
||||
268
src/components/orders/OrdersTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/orders/OrdersTableBody.tsx
Normal 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);
|
||||
139
src/components/orders/OrdersTableHeader.tsx
Normal 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);
|
||||
55
src/components/orders/SelectedOrderItems.tsx
Normal 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);
|
||||
180
src/components/orders/SellerCombobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
270
src/components/orders/SellerSearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
313
src/components/orders/TV8DeliveriesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/components/orders/order-info-utils.ts
Normal 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"
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 || "-"}</>,
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
24
src/components/orders/tabela-pedidos/domain/order-columns.ts
Normal 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"
|
||||
}
|
||||
|
||||
36
src/components/orders/tabela-pedidos/domain/order-status.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
128
src/components/orders/tabs/CheckoutTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
342
src/components/orders/tabs/CortesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/components/orders/tabs/EntregaTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
src/components/orders/tabs/InfoTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/orders/tabs/OrderItemsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/orders/tabs/StatusTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
359
src/components/orders/tabs/TV8Tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/orders/tabs/TransferenciaTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/qr-code-rastreamento.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
11
src/components/theme-provider.tsx
Normal 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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
27
src/components/ui/ApiErrorAlert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/ui/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
58
src/components/ui/accordion.tsx
Normal 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 }
|
||||
141
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
59
src/components/ui/alert.tsx
Normal 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 }
|
||||
7
src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
50
src/components/ui/avatar.tsx
Normal 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 }
|
||||
36
src/components/ui/badge.tsx
Normal 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 }
|
||||
115
src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
}
|
||||
58
src/components/ui/button.tsx
Normal 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 }
|
||||
66
src/components/ui/calendar.tsx
Normal 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 }
|
||||
79
src/components/ui/card.tsx
Normal 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 }
|
||||
262
src/components/ui/carousel.tsx
Normal 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
@@ -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,
|
||||
}
|
||||
30
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
11
src/components/ui/collapsible.tsx
Normal 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 }
|
||||
153
src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||