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,
|
||||||
|
}
|
||||||