mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 14:25:23 +02:00
Compare commits
220
Commits
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: find-skills
|
||||
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
||||
---
|
||||
|
||||
# Find Skills
|
||||
|
||||
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when the user:
|
||||
|
||||
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||
- Says "find a skill for X" or "is there a skill for X"
|
||||
- Asks "can you do X" where X is a specialized capability
|
||||
- Expresses interest in extending agent capabilities
|
||||
- Wants to search for tools, templates, or workflows
|
||||
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||
|
||||
## What is the Skills CLI?
|
||||
|
||||
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||
|
||||
**Key commands:**
|
||||
|
||||
- `npx skills find [query]` - Search for skills interactively or by keyword
|
||||
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
||||
- `npx skills check` - Check for skill updates
|
||||
- `npx skills update` - Update all installed skills
|
||||
|
||||
**Browse skills at:** https://skills.sh/
|
||||
|
||||
## How to Help Users Find Skills
|
||||
|
||||
### Step 1: Understand What They Need
|
||||
|
||||
When a user asks for help with something, identify:
|
||||
|
||||
1. The domain (e.g., React, testing, design, deployment)
|
||||
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||
3. Whether this is a common enough task that a skill likely exists
|
||||
|
||||
### Step 2: Check the Leaderboard First
|
||||
|
||||
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
|
||||
|
||||
For example, top skills for web development include:
|
||||
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
|
||||
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
|
||||
|
||||
### Step 3: Search for Skills
|
||||
|
||||
If the leaderboard doesn't cover the user's need, run the find command:
|
||||
|
||||
```bash
|
||||
npx skills find [query]
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||
|
||||
### Step 4: Verify Quality Before Recommending
|
||||
|
||||
**Do not recommend a skill based solely on search results.** Always verify:
|
||||
|
||||
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
|
||||
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
|
||||
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
|
||||
|
||||
### Step 5: Present Options to the User
|
||||
|
||||
When you find relevant skills, present them to the user with:
|
||||
|
||||
1. The skill name and what it does
|
||||
2. The install count and source
|
||||
3. The install command they can run
|
||||
4. A link to learn more at skills.sh
|
||||
|
||||
Example response:
|
||||
|
||||
```
|
||||
I found a skill that might help! The "react-best-practices" skill provides
|
||||
React and Next.js performance optimization guidelines from Vercel Engineering.
|
||||
(185K installs)
|
||||
|
||||
To install it:
|
||||
npx skills add vercel-labs/agent-skills@react-best-practices
|
||||
|
||||
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
|
||||
```
|
||||
|
||||
### Step 6: Offer to Install
|
||||
|
||||
If the user wants to proceed, you can install the skill for them:
|
||||
|
||||
```bash
|
||||
npx skills add <owner/repo@skill> -g -y
|
||||
```
|
||||
|
||||
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
||||
|
||||
## Common Skill Categories
|
||||
|
||||
When searching, consider these common categories:
|
||||
|
||||
| Category | Example Queries |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Web Development | react, nextjs, typescript, css, tailwind |
|
||||
| Testing | testing, jest, playwright, e2e |
|
||||
| DevOps | deploy, docker, kubernetes, ci-cd |
|
||||
| Documentation | docs, readme, changelog, api-docs |
|
||||
| Code Quality | review, lint, refactor, best-practices |
|
||||
| Design | ui, ux, design-system, accessibility |
|
||||
| Productivity | workflow, automation, git |
|
||||
|
||||
## Tips for Effective Searches
|
||||
|
||||
1. **Use specific keywords**: "react testing" is better than just "testing"
|
||||
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
||||
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
||||
|
||||
## When No Skills Are Found
|
||||
|
||||
If no relevant skills exist:
|
||||
|
||||
1. Acknowledge that no existing skill was found
|
||||
2. Offer to help with the task directly using your general capabilities
|
||||
3. Suggest the user could create their own skill with `npx skills init`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
I searched for skills related to "xyz" but didn't find any matches.
|
||||
I can still help you with this task directly! Would you like me to proceed?
|
||||
|
||||
If this is something you do often, you could create your own skill:
|
||||
npx skills init my-xyz-skill
|
||||
```
|
||||
@@ -0,0 +1,289 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" pre-bash'",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" pre-edit'",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" post-edit'",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" post-bash'",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" route'",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-restore'",
|
||||
"timeout": 15000
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/auto-memory-hook.mjs\" import'",
|
||||
"timeout": 8000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-end'",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/auto-memory-hook.mjs\" sync'",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": "manual",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" compact-manual'"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-end'",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "auto",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" compact-auto'"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-end'",
|
||||
"timeout": 6000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" status'",
|
||||
"timeout": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" post-task'",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" notify'",
|
||||
"timeout": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/statusline.cjs\"'"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx @claude-flow*)",
|
||||
"Bash(npx claude-flow*)",
|
||||
"Bash(node .claude/*)",
|
||||
"mcp__claude-flow__:*"
|
||||
],
|
||||
"deny": [
|
||||
"Read(./.env)",
|
||||
"Read(./.env.*)"
|
||||
]
|
||||
},
|
||||
"attribution": {
|
||||
"commit": "Co-Authored-By: claude-flow <ruv@ruv.net>",
|
||||
"pr": "🤖 Generated with [claude-flow](https://github.com/ruvnet/claude-flow)"
|
||||
},
|
||||
"env": {
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
|
||||
"CLAUDE_FLOW_V3_ENABLED": "true",
|
||||
"CLAUDE_FLOW_HOOKS_ENABLED": "true"
|
||||
},
|
||||
"claudeFlow": {
|
||||
"version": "3.0.0",
|
||||
"enabled": true,
|
||||
"platform": {
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"shell": "zsh"
|
||||
},
|
||||
"modelPreferences": {
|
||||
"default": "claude-opus-4-6",
|
||||
"routing": "claude-haiku-4-5-20251001"
|
||||
},
|
||||
"agentTeams": {
|
||||
"enabled": true,
|
||||
"teammateMode": "auto",
|
||||
"taskListEnabled": true,
|
||||
"mailboxEnabled": true,
|
||||
"coordination": {
|
||||
"autoAssignOnIdle": true,
|
||||
"trainPatternsOnComplete": true,
|
||||
"notifyLeadOnComplete": true,
|
||||
"sharedMemoryNamespace": "agent-teams"
|
||||
},
|
||||
"hooks": {
|
||||
"teammateIdle": {
|
||||
"enabled": true,
|
||||
"autoAssign": true,
|
||||
"checkTaskList": true
|
||||
},
|
||||
"taskCompleted": {
|
||||
"enabled": true,
|
||||
"trainPatterns": true,
|
||||
"notifyLead": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"swarm": {
|
||||
"topology": "hierarchical-mesh",
|
||||
"maxAgents": 15
|
||||
},
|
||||
"memory": {
|
||||
"backend": "hybrid",
|
||||
"enableHNSW": true,
|
||||
"learningBridge": {
|
||||
"enabled": true
|
||||
},
|
||||
"memoryGraph": {
|
||||
"enabled": true
|
||||
},
|
||||
"agentScopes": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"neural": {
|
||||
"enabled": true
|
||||
},
|
||||
"daemon": {
|
||||
"autoStart": false,
|
||||
"workers": [
|
||||
"map",
|
||||
"audit",
|
||||
"optimize"
|
||||
],
|
||||
"schedules": {
|
||||
"audit": {
|
||||
"interval": "4h",
|
||||
"priority": "critical"
|
||||
},
|
||||
"optimize": {
|
||||
"interval": "2h",
|
||||
"priority": "high"
|
||||
}
|
||||
}
|
||||
},
|
||||
"learning": {
|
||||
"enabled": true,
|
||||
"autoTrain": true,
|
||||
"patterns": [
|
||||
"coordination",
|
||||
"optimization",
|
||||
"prediction"
|
||||
],
|
||||
"retention": {
|
||||
"shortTerm": "24h",
|
||||
"longTerm": "30d"
|
||||
}
|
||||
},
|
||||
"adr": {
|
||||
"autoGenerate": true,
|
||||
"directory": "/docs/adr",
|
||||
"template": "madr"
|
||||
},
|
||||
"ddd": {
|
||||
"trackDomains": true,
|
||||
"validateBoundedContexts": true,
|
||||
"directory": "/docs/ddd"
|
||||
},
|
||||
"security": {
|
||||
"autoScan": true,
|
||||
"scanOnEdit": true,
|
||||
"cveCheck": true,
|
||||
"threatModel": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/find-skills
|
||||
@@ -3,6 +3,22 @@
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# Business Central OAuth client credentials
|
||||
BC_TENANT_ID="MY_BC_TENANT_ID"
|
||||
BC_CLIENT_ID="MY_BC_CLIENT_ID"
|
||||
BC_CLIENT_SECRET="MY_BC_CLIENT_SECRET"
|
||||
BC_COMPANY_ID="MY_BC_COMPANY_ID"
|
||||
BC_WRITE_METHOD="PATCH"
|
||||
BC_WRITE_URL_TEMPLATE="{{itemsUrl}}('{{itemNo}}')"
|
||||
BC_WRITE_BODY_TEMPLATE='{"cpnpNo":"{{cpnpNo}}"}'
|
||||
BC_ITEMS_WRITE_METHOD="PATCH"
|
||||
BC_ITEMS_WRITE_URL_TEMPLATE="{{itemsUrl}}('{{itemNo}}')"
|
||||
BC_ITEMS_WRITE_BODY_TEMPLATE=''
|
||||
BC_UOM_WRITE_METHOD="PATCH"
|
||||
BC_UOM_CODE="OUTER"
|
||||
BC_UOM_WRITE_URL_TEMPLATE="{{itemUnitsOfMeasureUrl}}(itemNo='{{itemNo}}',code='{{itemUnitsCode}}')"
|
||||
BC_UOM_WRITE_BODY_TEMPLATE=''
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
|
||||
+13
@@ -8,3 +8,16 @@ coverage/
|
||||
!.env.example
|
||||
.vercel
|
||||
.env*.local
|
||||
|
||||
# RuFlo / Claude-Flow
|
||||
.claude-flow/
|
||||
.claude/agents/
|
||||
.claude/helpers/
|
||||
.claude/commands/
|
||||
.claude/skills/
|
||||
.agents/
|
||||
.swarm/
|
||||
agentdb.rvf*
|
||||
ruvector.db
|
||||
claude-flow.config.json
|
||||
scratch/
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-flow": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@claude-flow/cli@latest",
|
||||
"mcp",
|
||||
"start"
|
||||
],
|
||||
"env": {
|
||||
"npm_config_update_notifier": "false",
|
||||
"CLAUDE_FLOW_MODE": "v3",
|
||||
"CLAUDE_FLOW_HOOKS_ENABLED": "true",
|
||||
"CLAUDE_FLOW_TOPOLOGY": "hierarchical-mesh",
|
||||
"CLAUDE_FLOW_MAX_AGENTS": "15",
|
||||
"CLAUDE_FLOW_MEMORY_BACKEND": "hybrid"
|
||||
},
|
||||
"autoStart": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/find-skills
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
agentdb.rvf
|
||||
agentdb.rvf.lock
|
||||
ruvector.db
|
||||
scratch
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -1,5 +1,16 @@
|
||||
# AGENTS.md - Developer Guidelines for Craze-Data-check
|
||||
|
||||
## Ruflo Standard Operating Procedure (SOP)
|
||||
**MANDATORY: Follow these steps for every user instruction.**
|
||||
|
||||
1. **Retrieve (Semantic Memory)**: Before starting, run `ruflo memory search -q "<task description>"` to find relevant historical patterns or previous solutions.
|
||||
2. **Verify (Security)**: Use `ruflo security defend -i "<user instruction>"` to ensure the instruction is safe and follows security protocols.
|
||||
3. **Execute (Agent Swarm)**: For complex tasks (refactors, multi-file changes), consider spawning specialized agents with `ruflo agent spawn -t <type>`.
|
||||
4. **Version Control**: Use `agentic-jujutsu` for managing concurrent changes or if a lock-free approach is needed.
|
||||
5. **Record (Learning)**: After completion, record the outcome with `ruflo memory store --key "<task_id>" --value "<outcome_details>"` to improve future performance.
|
||||
|
||||
---
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
|
||||
### Development
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# Claude Code Configuration - RuFlo V3
|
||||
|
||||
## Behavioral Rules (Always Enforced)
|
||||
|
||||
- Do what has been asked; nothing more, nothing less
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal
|
||||
- ALWAYS prefer editing an existing file to creating a new one
|
||||
- NEVER proactively create documentation files (*.md) or README files unless explicitly requested
|
||||
- NEVER save working files, text/mds, or tests to the root folder
|
||||
- Never continuously check status after spawning a swarm — wait for results
|
||||
- ALWAYS read a file before editing it
|
||||
- NEVER commit secrets, credentials, or .env files
|
||||
|
||||
## File Organization
|
||||
|
||||
- NEVER save to root folder — use the directories below
|
||||
- Use `/src` for source code files
|
||||
- Use `/tests` for test files
|
||||
- Use `/docs` for documentation and markdown files
|
||||
- Use `/config` for configuration files
|
||||
- Use `/scripts` for utility scripts
|
||||
- Use `/examples` for example code
|
||||
|
||||
## Project Architecture
|
||||
|
||||
- Follow Domain-Driven Design with bounded contexts
|
||||
- Keep files under 500 lines
|
||||
- Use typed interfaces for all public APIs
|
||||
- Prefer TDD London School (mock-first) for new code
|
||||
- Use event sourcing for state changes
|
||||
- Ensure input validation at system boundaries
|
||||
|
||||
### Project Config
|
||||
|
||||
- **Topology**: hierarchical-mesh
|
||||
- **Max Agents**: 15
|
||||
- **Memory**: hybrid
|
||||
- **HNSW**: Enabled
|
||||
- **Neural**: Enabled
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Test
|
||||
npm test
|
||||
|
||||
# Lint
|
||||
npm run lint
|
||||
```
|
||||
|
||||
- ALWAYS run tests after making code changes
|
||||
- ALWAYS verify build succeeds before committing
|
||||
|
||||
## Security Rules
|
||||
|
||||
- NEVER hardcode API keys, secrets, or credentials in source files
|
||||
- NEVER commit .env files or any file containing secrets
|
||||
- Always validate user input at system boundaries
|
||||
- Always sanitize file paths to prevent directory traversal
|
||||
- Run `npx @claude-flow/cli@latest security scan` after security-related changes
|
||||
|
||||
## Concurrency: 1 MESSAGE = ALL RELATED OPERATIONS
|
||||
|
||||
- All operations MUST be concurrent/parallel in a single message
|
||||
- Use Claude Code's Agent tool for spawning agents, not just MCP
|
||||
- ALWAYS spawn ALL agents in ONE message with full instructions via Agent tool
|
||||
- ALWAYS batch ALL file reads/writes/edits in ONE message
|
||||
- ALWAYS batch ALL Bash commands in ONE message
|
||||
|
||||
## Swarm Orchestration
|
||||
|
||||
- MUST initialize the swarm using CLI tools when starting complex tasks
|
||||
- MUST spawn concurrent agents using Claude Code's Agent tool
|
||||
- Never use CLI tools alone for execution — Agent tool agents do the actual work
|
||||
- MUST call CLI tools AND Agent tool in ONE message for complex work
|
||||
|
||||
### 3-Tier Model Routing (ADR-026)
|
||||
|
||||
| Tier | Handler | Latency | Cost | Use Cases |
|
||||
|------|---------|---------|------|-----------|
|
||||
| **1** | Agent Booster (WASM) | <1ms | $0 | Simple transforms (var→const, add types) — Skip LLM |
|
||||
| **2** | Haiku | ~500ms | $0.0002 | Simple tasks, low complexity (<30%) |
|
||||
| **3** | Sonnet/Opus | 2-5s | $0.003-0.015 | Complex reasoning, architecture, security (>30%) |
|
||||
|
||||
- For Tier 1 simple transforms, use Edit tool directly — no LLM agent needed
|
||||
|
||||
## Swarm Configuration & Anti-Drift
|
||||
|
||||
- ALWAYS use hierarchical topology for coding swarms
|
||||
- Keep maxAgents at 6-8 for tight coordination
|
||||
- Use specialized strategy for clear role boundaries
|
||||
- Use `raft` consensus for hive-mind (leader maintains authoritative state)
|
||||
- Run frequent checkpoints via `post-task` hooks
|
||||
- Keep shared memory namespace for all agents
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 --strategy specialized
|
||||
```
|
||||
|
||||
## Swarm Execution Rules
|
||||
|
||||
- ALWAYS use `run_in_background: true` for all Agent tool calls
|
||||
- ALWAYS put ALL Agent calls in ONE message for parallel execution
|
||||
- After spawning, STOP — do NOT add more tool calls or check status
|
||||
- Never poll agent status repeatedly — trust agents to return
|
||||
- When agent results arrive, review ALL results before proceeding
|
||||
|
||||
## V3 CLI Commands
|
||||
|
||||
### Core Commands
|
||||
|
||||
| Command | Subcommands | Description |
|
||||
|---------|-------------|-------------|
|
||||
| `init` | 4 | Project initialization |
|
||||
| `agent` | 8 | Agent lifecycle management |
|
||||
| `swarm` | 6 | Multi-agent swarm coordination |
|
||||
| `memory` | 11 | AgentDB memory with HNSW search |
|
||||
| `task` | 6 | Task creation and lifecycle |
|
||||
| `session` | 7 | Session state management |
|
||||
| `hooks` | 17 | Self-learning hooks + 12 workers |
|
||||
| `hive-mind` | 6 | Byzantine fault-tolerant consensus |
|
||||
|
||||
### Quick CLI Examples
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli@latest init --wizard
|
||||
npx @claude-flow/cli@latest agent spawn -t coder --name my-coder
|
||||
npx @claude-flow/cli@latest swarm init --v3-mode
|
||||
npx @claude-flow/cli@latest memory search --query "authentication patterns"
|
||||
npx @claude-flow/cli@latest doctor --fix
|
||||
```
|
||||
|
||||
## Available Agents (16 Roles + Custom)
|
||||
|
||||
### Core Development
|
||||
`coder`, `reviewer`, `tester`, `planner`, `researcher`
|
||||
|
||||
### Specialized
|
||||
`security-architect`, `security-auditor`, `memory-specialist`, `performance-engineer`
|
||||
|
||||
### Coordination
|
||||
`hierarchical-coordinator`, `mesh-coordinator`, `adaptive-coordinator`
|
||||
|
||||
### GitHub & Repository
|
||||
`pr-manager`, `code-review-swarm`, `issue-tracker`, `release-manager`
|
||||
|
||||
Any string can be used as a custom agent type — these are the typed roles with specialized behavior.
|
||||
|
||||
## Memory & Vector Search
|
||||
|
||||
### MCP Tools (use via ToolSearch to discover)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `memory_store` | Store value with ONNX 384-dim vector embedding |
|
||||
| `memory_search` | Semantic vector search by query |
|
||||
| `memory_retrieve` | Get entry by key |
|
||||
| `memory_list` | List entries in namespace |
|
||||
| `memory_delete` | Delete entry |
|
||||
| `memory_import_claude` | Import Claude Code memories into AgentDB (allProjects=true for all) |
|
||||
| `memory_search_unified` | Search across ALL namespaces (Claude + AgentDB + patterns) |
|
||||
| `memory_bridge_status` | Show bridge health, vectors, SONA, intelligence |
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Store with vector embedding
|
||||
npx @claude-flow/cli@latest memory store --key "pattern-auth" --value "JWT with refresh" --namespace patterns
|
||||
|
||||
# Semantic search
|
||||
npx @claude-flow/cli@latest memory search --query "authentication patterns"
|
||||
|
||||
# Import all Claude Code memories into AgentDB
|
||||
node .claude/helpers/auto-memory-hook.mjs import-all
|
||||
```
|
||||
|
||||
### Claude Code ↔ AgentDB Bridge
|
||||
|
||||
Claude Code auto-memory files (`~/.claude/projects/*/memory/*.md`) are automatically imported into AgentDB with ONNX vector embeddings on session start. Use `memory_search_unified` to search across both stores.
|
||||
|
||||
## Key MCP Tools (314 available — use ToolSearch to discover)
|
||||
|
||||
### Most Used Tools
|
||||
|
||||
| Category | Tools | What They Do |
|
||||
|----------|-------|-------------|
|
||||
| **Memory** | `memory_store`, `memory_search`, `memory_search_unified` | Store/search with ONNX vector embeddings |
|
||||
| **Claude Bridge** | `memory_import_claude`, `memory_bridge_status` | Import Claude memories into AgentDB |
|
||||
| **Swarm** | `swarm_init`, `swarm_status`, `swarm_health` | Multi-agent coordination |
|
||||
| **Agents** | `agent_spawn`, `agent_list`, `agent_status` | Agent lifecycle |
|
||||
| **Hive-Mind** | `hive-mind_init`, `hive-mind_spawn`, `hive-mind_consensus` | Byzantine/Raft consensus |
|
||||
| **Hooks** | `hooks_route`, `hooks_session-start`, `hooks_post-task` | Task routing + learning |
|
||||
| **Workers** | `hooks_worker-list`, `hooks_worker-dispatch` | 12 background workers |
|
||||
| **Security** | `aidefence_scan`, `aidefence_is_safe` | Prompt injection detection |
|
||||
| **Intelligence** | `hooks_intelligence`, `neural_status` | Pattern learning + SONA |
|
||||
|
||||
### Swarm Capabilities
|
||||
|
||||
- **Topologies**: hierarchical (anti-drift), mesh, ring, star, adaptive
|
||||
- **Consensus**: Raft (leader-based), Byzantine (PBFT), Gossip (eventual)
|
||||
- **Hive-Mind**: Queen-led coordination with spawn, broadcast, consensus voting, shared memory
|
||||
- **12 Background Workers**: audit, optimize, testgaps, map, deepdive, document, refactor, benchmark, ultralearn, consolidate, predict, preload
|
||||
|
||||
### Memory Capabilities
|
||||
|
||||
- **ONNX Embeddings**: all-MiniLM-L6-v2, 384 dimensions — real neural vectors
|
||||
- **DiskANN**: SSD-friendly vector search (8,000x faster insert than HNSW, perfect recall at 1K)
|
||||
- **sql.js**: Cross-platform SQLite (WASM, no native compilation)
|
||||
- **Claude Code Bridge**: Auto-imports MEMORY.md files into AgentDB on session start
|
||||
- **Unified Search**: `memory_search_unified` searches Claude memories + AgentDB + patterns
|
||||
- **SONA Learning**: Trajectory recording → pattern extraction → file persistence
|
||||
|
||||
### How to Discover Tools
|
||||
|
||||
Use ToolSearch to find specific tools:
|
||||
```
|
||||
ToolSearch("memory search") → memory_store, memory_search, memory_search_unified
|
||||
ToolSearch("swarm") → swarm_init, swarm_status, swarm_health, swarm_shutdown
|
||||
ToolSearch("hive consensus") → hive-mind_consensus, hive-mind_status
|
||||
ToolSearch("+aidefence") → aidefence_scan, aidefence_is_safe, aidefence_has_pii
|
||||
```
|
||||
|
||||
## Quick Setup
|
||||
|
||||
```bash
|
||||
claude mcp add claude-flow -- npx -y @claude-flow/cli@latest
|
||||
npx @claude-flow/cli@latest daemon start
|
||||
npx @claude-flow/cli@latest doctor --fix
|
||||
```
|
||||
|
||||
## Claude Code vs MCP Tools
|
||||
|
||||
- **Claude Code Agent tool** handles execution: agents, file ops, code generation, git
|
||||
- **MCP tools** (via ToolSearch) handle coordination: swarm, memory, hooks, routing, hive-mind
|
||||
- **CLI commands** (via Bash) are the same tools with terminal output
|
||||
- Use `ToolSearch("keyword")` to discover available MCP tools
|
||||
|
||||
## Support
|
||||
|
||||
- Documentation: https://github.com/ruvnet/ruflo
|
||||
- Issues: https://github.com/ruvnet/ruflo/issues
|
||||
@@ -16,5 +16,12 @@ View your app in AI Studio: https://ai.studio/apps/bac9908e-4996-4e4c-8ec7-3add5
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
3. To use the Business Central download button, set:
|
||||
`BC_TENANT_ID`, `BC_CLIENT_ID`, `BC_CLIENT_SECRET`, `BC_COMPANY_ID`
|
||||
4. If your Business Central writable endpoint is not the default `PATCH`, also set:
|
||||
`BC_WRITE_METHOD`, `BC_WRITE_URL_TEMPLATE`, `BC_WRITE_BODY_TEMPLATE`
|
||||
5. For the safer BC sync preview/apply flow, you can also set:
|
||||
`BC_ITEMS_WRITE_METHOD`, `BC_ITEMS_WRITE_URL_TEMPLATE`, `BC_ITEMS_WRITE_BODY_TEMPLATE`,
|
||||
`BC_UOM_CODE`, `BC_UOM_WRITE_METHOD`, `BC_UOM_WRITE_URL_TEMPLATE`, `BC_UOM_WRITE_BODY_TEMPLATE`
|
||||
6. Run the app:
|
||||
`npm run dev`
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const LOCALHOST_ORIGIN_PREFIXES = ['http://localhost:', 'http://127.0.0.1:'];
|
||||
|
||||
export function isAllowedOrigin(origin) {
|
||||
if (!origin) return false;
|
||||
|
||||
if (LOCALHOST_ORIGIN_PREFIXES.some(prefix => origin.startsWith(prefix))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
if (url.hostname === 'craze-data-check.vercel.app') return true;
|
||||
if (url.hostname.endsWith('.vercel.app')) return true;
|
||||
if (process.env.APP_URL) {
|
||||
const appUrl = new URL(process.env.APP_URL);
|
||||
if (url.hostname === appUrl.hostname) return true;
|
||||
}
|
||||
if (process.env.VERCEL_URL) {
|
||||
const vercelHost = process.env.VERCEL_URL.replace(/^https?:\/\//, '');
|
||||
if (url.hostname === vercelHost) return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function applyCors(req, res, methods) {
|
||||
const origin = req.headers.origin;
|
||||
if (isAllowedOrigin(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', methods);
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, apikey');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||
|
||||
const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
|
||||
const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET;
|
||||
const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN;
|
||||
const BACKUP_SECRET = process.env.BACKUP_SECRET;
|
||||
const BACKUP_CRON_ENABLED = process.env.BACKUP_CRON_ENABLED === 'true';
|
||||
|
||||
const DROPBOX_BACKUP_FOLDER = '/CrazeBackups';
|
||||
const MAX_BACKUP_AGE_DAYS = 7;
|
||||
|
||||
async function getDropboxToken() {
|
||||
const res = await fetch('https://api.dropboxapi.com/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: DROPBOX_REFRESH_TOKEN,
|
||||
client_id: DROPBOX_APP_KEY,
|
||||
client_secret: DROPBOX_APP_SECRET,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.access_token) throw new Error('Dropbox token failed: ' + JSON.stringify(data));
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
async function fetchSupabaseTable(table) {
|
||||
const PAGE_SIZE = 1000;
|
||||
const rows = [];
|
||||
let offset = 0;
|
||||
for (let page = 0; page < 50; page++) {
|
||||
const res = await fetch(
|
||||
`${SUPABASE_URL}/rest/v1/${table}?select=*&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||
{ headers: { apikey: SUPABASE_SERVICE_KEY, Authorization: `Bearer ${SUPABASE_SERVICE_KEY}` } }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`Supabase ${table} fetch failed (${res.status}): ${txt}`);
|
||||
}
|
||||
const batch = await res.json();
|
||||
rows.push(...batch);
|
||||
if (batch.length < PAGE_SIZE) break;
|
||||
offset += PAGE_SIZE;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function uploadToDropbox(token, path, content) {
|
||||
const res = await fetch('https://content.dropboxapi.com/2/files/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Dropbox-API-Arg': JSON.stringify({ path, mode: 'overwrite', autorename: false }),
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`Dropbox upload failed (${res.status}): ${txt}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function deleteOldBackups(token) {
|
||||
const listRes = await fetch('https://api.dropboxapi.com/2/files/list_folder', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: DROPBOX_BACKUP_FOLDER, limit: 200 }),
|
||||
});
|
||||
if (!listRes.ok) return; // folder may not exist yet — skip
|
||||
const list = await listRes.json();
|
||||
const cutoff = Date.now() - MAX_BACKUP_AGE_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const entry of list.entries || []) {
|
||||
if (entry['.tag'] !== 'file') continue;
|
||||
const modified = new Date(entry.server_modified).getTime();
|
||||
if (modified < cutoff) {
|
||||
await fetch('https://api.dropboxapi.com/2/files/delete_v2', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: entry.path_lower }),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Backup cron disabled by default to prevent noisy scheduled failures.
|
||||
if (!BACKUP_CRON_ENABLED) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: 'backup cron disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const authHeader = req.headers['authorization'];
|
||||
const isCron = !req.headers.origin;
|
||||
const hasSecret = BACKUP_SECRET && authHeader === `Bearer ${BACKUP_SECRET}`;
|
||||
|
||||
if (!isCron && !hasSecret) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [syncedRows, history] = await Promise.all([
|
||||
fetchSupabaseTable('products'),
|
||||
fetchSupabaseTable('products_history'),
|
||||
]);
|
||||
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const filename = `backup_${timestamp}.json`;
|
||||
const path = `${DROPBOX_BACKUP_FOLDER}/${filename}`;
|
||||
|
||||
const payload = JSON.stringify({
|
||||
created_at: now.toISOString(),
|
||||
synced_rows_count: syncedRows.length,
|
||||
history_count: history.length,
|
||||
synced_rows: syncedRows,
|
||||
history,
|
||||
});
|
||||
|
||||
const token = await getDropboxToken();
|
||||
await Promise.all([
|
||||
uploadToDropbox(token, path, payload),
|
||||
deleteOldBackups(token),
|
||||
]);
|
||||
|
||||
console.log(`[backup] OK — ${filename} | rows: ${syncedRows.length} | history: ${history.length}`);
|
||||
return res.json({
|
||||
success: true,
|
||||
filename,
|
||||
synced_rows_count: syncedRows.length,
|
||||
history_count: history.length,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[backup] error:', err.message);
|
||||
return res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { applyCors, isAllowedOrigin } from './_cors.js';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getBcConfig, getBCToken, fetchAllItems, buildWorkbook } from '../bc-runtime.js';
|
||||
|
||||
function setCors(req, res) {
|
||||
applyCors(req, res, 'GET, OPTIONS');
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
setCors(req, res);
|
||||
|
||||
if (req.method === 'OPTIONS') return res.status(204).end();
|
||||
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !isAllowedOrigin(origin)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = getBcConfig();
|
||||
const token = await getBCToken(config);
|
||||
const items = await fetchAllItems(config, token);
|
||||
|
||||
if (req.query?.format === 'json') {
|
||||
return res.json({ success: true, count: items.length, items });
|
||||
}
|
||||
|
||||
const workbook = buildWorkbook(items);
|
||||
const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' });
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="BusinessCentral_Items_${dateStr}.xlsx"`);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(200).send(buffer);
|
||||
} catch (err) {
|
||||
console.error('[bc-export] error:', err?.message || err);
|
||||
return res.status(500).json({ success: false, error: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { applyCors, isAllowedOrigin } from './_cors.js';
|
||||
import { getBcConfig, getBCToken, findItem, patchItemCpnpNo } from '../bc-runtime.js';
|
||||
|
||||
function setCors(req, res) {
|
||||
applyCors(req, res, 'POST, OPTIONS');
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
setCors(req, res);
|
||||
|
||||
if (req.method === 'OPTIONS') return res.status(204).end();
|
||||
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !isAllowedOrigin(origin)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { articleNo, cpnpNo } = req.body || {};
|
||||
if (!articleNo || cpnpNo === undefined) {
|
||||
return res.status(400).json({ error: 'Missing articleNo or cpnpNo' });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = getBcConfig();
|
||||
const token = await getBCToken(config);
|
||||
const item = await findItem(config, token, articleNo);
|
||||
await patchItemCpnpNo(config, token, item, String(cpnpNo));
|
||||
return res.json({ success: true, articleNo, cpnpNo });
|
||||
} catch (err) {
|
||||
console.error('[bc-proxy] error:', err.message);
|
||||
return res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { applyCors, isAllowedOrigin } from './_cors.js';
|
||||
import { getBcConfig, getBCToken } from '../bc-runtime.js';
|
||||
import { applyBusinessCentralCpnp, applyBusinessCentralSync } from '../bc-sync-runtime.js';
|
||||
|
||||
function setCors(req, res) {
|
||||
applyCors(req, res, 'POST, OPTIONS');
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
setCors(req, res);
|
||||
|
||||
if (req.method === 'OPTIONS') return res.status(204).end();
|
||||
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !isAllowedOrigin(origin)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { headers, row, articleNo, cpnpNo, previewToken } = req.body || {};
|
||||
|
||||
try {
|
||||
const config = getBcConfig();
|
||||
const token = await getBCToken(config);
|
||||
|
||||
if (Array.isArray(headers) && Array.isArray(row)) {
|
||||
const result = await applyBusinessCentralSync(config, token, headers, row, previewToken);
|
||||
return res.json({ success: true, ...result });
|
||||
}
|
||||
|
||||
if (articleNo && cpnpNo !== undefined) {
|
||||
const result = await applyBusinessCentralCpnp(config, token, articleNo, cpnpNo, previewToken);
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'Missing headers/row or articleNo/cpnpNo' });
|
||||
} catch (err) {
|
||||
console.error('[bc-sync-apply] error:', err.message);
|
||||
return res.status(err.statusCode || 500).json({ success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { applyCors, isAllowedOrigin } from './_cors.js';
|
||||
import { getBcConfig, getBCToken } from '../bc-runtime.js';
|
||||
import { previewBusinessCentralCpnp, previewBusinessCentralSync } from '../bc-sync-runtime.js';
|
||||
|
||||
function setCors(req, res) {
|
||||
applyCors(req, res, 'POST, OPTIONS');
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
setCors(req, res);
|
||||
|
||||
if (req.method === 'OPTIONS') return res.status(204).end();
|
||||
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !isAllowedOrigin(origin)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { headers, row, articleNo, cpnpNo } = req.body || {};
|
||||
|
||||
try {
|
||||
const config = getBcConfig();
|
||||
const token = await getBCToken(config);
|
||||
|
||||
if (Array.isArray(headers) && Array.isArray(row)) {
|
||||
const preview = await previewBusinessCentralSync(config, token, headers, row);
|
||||
return res.json({ success: true, ...preview });
|
||||
}
|
||||
|
||||
if (articleNo && cpnpNo !== undefined) {
|
||||
const preview = await previewBusinessCentralCpnp(config, token, articleNo, cpnpNo);
|
||||
return res.json({ success: true, ...preview });
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'Missing headers/row or articleNo/cpnpNo' });
|
||||
} catch (err) {
|
||||
console.error('[bc-sync-preview] error:', err.message);
|
||||
return res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export default async function handler(req, res) {
|
||||
return res.json({
|
||||
env: {
|
||||
SUPABASE_URL: !!process.env.SUPABASE_URL || !!process.env.VITE_SUPABASE_URL,
|
||||
SUPABASE_SERVICE_KEY: !!process.env.SUPABASE_SERVICE_KEY || !!process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
SUPABASE_ANON_KEY: !!process.env.VITE_SUPABASE_ANON_KEY || !!process.env.SUPABASE_ANON_KEY
|
||||
},
|
||||
url: process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || 'not set'
|
||||
});
|
||||
}
|
||||
+100
-35
@@ -1,16 +1,47 @@
|
||||
const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
|
||||
const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET;
|
||||
const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN;
|
||||
import { applyCors, isAllowedOrigin } from './_cors.js';
|
||||
|
||||
const DEFAULT_DROPBOX_SHARED_URL = 'https://www.dropbox.com/scl/fi/usa8me7ywgylrij2bt6hj/Data-Matrix.xlsx?rlkey=tsec8csrhye54u1fdvk15ped1&st=qbxxs4cn&dl=0';
|
||||
|
||||
function getDropboxConfig() {
|
||||
return {
|
||||
appKey: process.env.DROPBOX_APP_KEY,
|
||||
appSecret: process.env.DROPBOX_APP_SECRET,
|
||||
refreshToken: process.env.DROPBOX_REFRESH_TOKEN,
|
||||
sharedUrl: process.env.DROPBOX_SHARED_URL || DEFAULT_DROPBOX_SHARED_URL,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDropboxSharedUrl(sharedUrl) {
|
||||
const url = new URL(sharedUrl);
|
||||
url.searchParams.delete('dl');
|
||||
url.searchParams.delete('raw');
|
||||
url.searchParams.delete('st');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function buildDropboxDownloadUrl(sharedUrl) {
|
||||
const url = new URL(normalizeDropboxSharedUrl(sharedUrl));
|
||||
url.searchParams.set('raw', '1');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function setCors(req, res) {
|
||||
applyCors(req, res, 'GET, OPTIONS');
|
||||
}
|
||||
|
||||
async function getAccessToken() {
|
||||
const { appKey, appSecret, refreshToken } = getDropboxConfig();
|
||||
if (!appKey || !appSecret || !refreshToken) {
|
||||
throw new Error('Dropbox auth env vars are not configured.');
|
||||
}
|
||||
const response = await fetch('https://api.dropboxapi.com/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: DROPBOX_REFRESH_TOKEN,
|
||||
client_id: DROPBOX_APP_KEY,
|
||||
client_secret: DROPBOX_APP_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
client_id: appKey,
|
||||
client_secret: appSecret,
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
@@ -21,47 +52,81 @@ async function getAccessToken() {
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
let accessToken;
|
||||
try {
|
||||
accessToken = await getAccessToken();
|
||||
} catch (err) {
|
||||
console.error('Token refresh error:', err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
setCors(req, res);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !isAllowedOrigin(origin)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.query.info === '1') {
|
||||
try {
|
||||
const fileInfo = await fetch('https://api.dropboxapi.com/2/files/get_metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
|
||||
});
|
||||
const data = await fileInfo.json();
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
return res.json({ rev: data.rev, size: data.size, server_modified: data.server_modified });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
return res.json({ rev: 'new-url-v1', size: 0, server_modified: new Date().toISOString() });
|
||||
}
|
||||
|
||||
const { sharedUrl } = getDropboxConfig();
|
||||
const sharingUrlForApi = normalizeDropboxSharedUrl(sharedUrl);
|
||||
const downloadUrl = buildDropboxDownloadUrl(sharedUrl);
|
||||
|
||||
// Try authenticated Dropbox API first — bypasses CDN cache so we always get the latest version.
|
||||
// Falls back to the shared URL if credentials are not configured.
|
||||
let upstream = null;
|
||||
let usedAuth = false;
|
||||
|
||||
try {
|
||||
const upstream = await fetch('https://content.dropboxapi.com/2/files/download', {
|
||||
const accessToken = await getAccessToken();
|
||||
const apiRes = await fetch('https://content.dropboxapi.com/2/sharing/get_shared_link_file', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Dropbox-API-Arg': JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
|
||||
}
|
||||
'Dropbox-API-Arg': JSON.stringify({ url: sharingUrlForApi }),
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
});
|
||||
if (apiRes.ok) {
|
||||
upstream = apiRes;
|
||||
usedAuth = true;
|
||||
console.log('Dropbox: downloaded via authenticated API (no CDN cache)');
|
||||
} else {
|
||||
const errText = await apiRes.text();
|
||||
console.warn('Dropbox API download failed, falling back to sharing link:', apiRes.status, errText.substring(0, 200));
|
||||
}
|
||||
} catch (authErr) {
|
||||
console.warn('Dropbox auth unavailable, falling back to sharing link:', authErr?.message || String(authErr));
|
||||
}
|
||||
|
||||
try {
|
||||
if (!upstream) {
|
||||
upstream = await fetch(downloadUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type');
|
||||
if (!usedAuth && contentType && contentType.includes('text/html')) {
|
||||
console.error('Dropbox returned HTML instead of file');
|
||||
return res.status(500).send('Error: El enlace de Dropbox ha devuelto una página HTML en lugar del archivo. Es probable que el enlace haya caducado o necesite ser renovado.');
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text();
|
||||
console.error('Dropbox API error:', upstream.status, errText);
|
||||
return res.status(upstream.status).send('Dropbox error: ' + errText);
|
||||
console.error('Dropbox URL error:', upstream.status, errText);
|
||||
return res.status(upstream.status).send(
|
||||
'Dropbox error: ' +
|
||||
errText +
|
||||
'\n\nSet DROPBOX_SHARED_URL in Vercel if the sharing link changed.'
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
@@ -70,6 +135,6 @@ export default async function handler(req, res) {
|
||||
res.send(Buffer.from(buffer));
|
||||
} catch (err) {
|
||||
console.error('Dropbox proxy error:', err);
|
||||
res.status(500).send('Proxy error: ' + err.message);
|
||||
res.status(500).send('Proxy error: ' + (err?.message || String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
+69
-21
@@ -1,58 +1,106 @@
|
||||
import { applyCors, isAllowedOrigin } from './_cors.js';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
|
||||
|
||||
function setCors(req, res) {
|
||||
applyCors(req, res, 'POST, OPTIONS');
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
setCors(req, res);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !isAllowedOrigin(origin)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { rows, fileMeta } = req.body;
|
||||
|
||||
const { rows } = req.body;
|
||||
|
||||
if (!rows || !Array.isArray(rows)) {
|
||||
return res.status(400).json({ error: 'Missing rows data' });
|
||||
}
|
||||
|
||||
const articleNoIdx = 0;
|
||||
|
||||
// Fetch current product rows so we can preserve internal-only columns
|
||||
// that do not exist in the live Dropbox workbook.
|
||||
const { data: existingProducts } = await supabase
|
||||
.from('products')
|
||||
.select('product_id, data, status');
|
||||
const existingProductMap = new Map((existingProducts || []).map(p => [p.product_id, p]));
|
||||
const productsToUpsert = [];
|
||||
|
||||
|
||||
for (const row of rows) {
|
||||
const productId = String(row[articleNoIdx]);
|
||||
if (productId && productId.trim() !== '') {
|
||||
if (!productId || productId.trim() === '') continue;
|
||||
|
||||
const existing = existingProductMap.get(productId);
|
||||
const dbRowData = existing?.data;
|
||||
|
||||
if (dbRowData && Array.isArray(dbRowData)) {
|
||||
const mergedData = [...row];
|
||||
while (mergedData.length < dbRowData.length) {
|
||||
mergedData.push(null);
|
||||
}
|
||||
|
||||
for (let i = 100; i < dbRowData.length; i++) {
|
||||
const internalValue = dbRowData[i];
|
||||
if (internalValue !== undefined && internalValue !== null && internalValue !== '') {
|
||||
mergedData[i] = internalValue;
|
||||
}
|
||||
}
|
||||
|
||||
productsToUpsert.push({
|
||||
product_id: productId,
|
||||
data: mergedData,
|
||||
status: existing?.status || 'excel',
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
// Normal excel sync
|
||||
productsToUpsert.push({
|
||||
product_id: productId,
|
||||
data: row,
|
||||
status: 'synced',
|
||||
status: 'excel',
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Upserting', productsToUpsert.length, 'products to Supabase...');
|
||||
console.log('Upserting/updating', productsToUpsert.length, 'products in Supabase...');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('products')
|
||||
.upsert(productsToUpsert, {
|
||||
onConflict: 'product_id',
|
||||
ignoreDuplicates: true
|
||||
});
|
||||
// Chunk upsert updates to avoid payload sizes or API limits
|
||||
const CHUNK_SIZE = 100;
|
||||
for (let i = 0; i < productsToUpsert.length; i += CHUNK_SIZE) {
|
||||
const chunk = productsToUpsert.slice(i, i + CHUNK_SIZE);
|
||||
const { error } = await supabase
|
||||
.from('products')
|
||||
.upsert(chunk, { onConflict: 'product_id' });
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase upsert error:', error);
|
||||
return res.status(500).json({ error: error.message, detail: 'Failed to upsert products' });
|
||||
if (error) {
|
||||
console.error('Supabase upsert error in chunk:', error);
|
||||
return res.status(500).json({ error: error.message, detail: 'Failed to upsert products chunk' });
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
syncedCount: productsToUpsert.length
|
||||
return res.json({
|
||||
success: true,
|
||||
syncedCount: productsToUpsert.length
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Handler error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
|
||||
|
||||
export default async function handler(req, res) {
|
||||
try {
|
||||
console.log('Starting migration v3...');
|
||||
|
||||
// 1. Fetch products
|
||||
const { data: products, error: pErr } = await supabase
|
||||
.from('products')
|
||||
.select('product_id, data')
|
||||
.in('status', ['edited', 'synced', 'pending']);
|
||||
|
||||
if (pErr) return res.status(500).json({ error: 'Fetch products failed', details: pErr });
|
||||
|
||||
let pCount = 0;
|
||||
if (products && products.length > 0) {
|
||||
for (const p of products) {
|
||||
const oldData = p.data;
|
||||
if (!oldData || oldData.length < 13) continue;
|
||||
|
||||
const valAt12 = oldData[12];
|
||||
const isOld = typeof valAt12 === 'number' || (typeof valAt12 === 'string' && /^[0-9.]+$/.test(valAt12));
|
||||
|
||||
if (isOld && oldData.length < 110) {
|
||||
const newData = [...oldData];
|
||||
newData.splice(12, 0, '');
|
||||
const { error: updateErr } = await supabase.from('products').update({ data: newData }).eq('product_id', p.product_id);
|
||||
if (!updateErr) pCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. History
|
||||
const { data: history, error: hErr } = await supabase.from('products_history').select('*');
|
||||
if (hErr) return res.status(500).json({ error: 'Fetch history failed', details: hErr });
|
||||
|
||||
let hCount = 0;
|
||||
if (history && history.length > 0) {
|
||||
for (const h of history) {
|
||||
let changed = false;
|
||||
let nOld = h.old_data;
|
||||
let nNew = h.new_data;
|
||||
|
||||
if (nOld && nOld.length >= 13 && (typeof nOld[12] === 'number' || (typeof nOld[12] === 'string' && /^[0-9.]+$/.test(nOld[12])))) {
|
||||
nOld = [...nOld];
|
||||
nOld.splice(12, 0, '');
|
||||
changed = true;
|
||||
}
|
||||
if (nNew && nNew.length >= 13 && (typeof nNew[12] === 'number' || (typeof nNew[12] === 'string' && /^[0-9.]+$/.test(nNew[12])))) {
|
||||
nNew = [...nNew];
|
||||
nNew.splice(12, 0, '');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
const { error: histErr } = await supabase.from('products_history').update({ old_data: nOld, new_data: nNew }).eq('id', h.id);
|
||||
if (!histErr) hCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
productsMigrated: pCount,
|
||||
historyMigrated: hCount,
|
||||
message: 'Migration v3 finished.'
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { applyCors, isAllowedOrigin } from './_cors.js';
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
||||
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||
|
||||
const MASTER_USERS = new Set([
|
||||
'christian.vidal@craze-group.com',
|
||||
'jingying.shi@craze-group.com',
|
||||
]);
|
||||
|
||||
function getValidatedFromMetadata(user) {
|
||||
return user?.app_metadata?.validated === true || user?.user_metadata?.validated === true;
|
||||
}
|
||||
|
||||
async function fetchAdminUser(userId) {
|
||||
const res = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${userId}`, {
|
||||
headers: {
|
||||
'apikey': SUPABASE_SERVICE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Failed to fetch auth user: ${errText}`);
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
return payload?.user || payload;
|
||||
}
|
||||
|
||||
async function updateAuthValidationMetadata(userId, validated) {
|
||||
const currentUser = await fetchAdminUser(userId);
|
||||
const res = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'apikey': SUPABASE_SERVICE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
app_metadata: {
|
||||
...(currentUser?.app_metadata || {}),
|
||||
validated,
|
||||
},
|
||||
user_metadata: currentUser?.user_metadata || {},
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Failed to update auth metadata: ${errText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApprovalsMap() {
|
||||
const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?select=*`, {
|
||||
headers: {
|
||||
'apikey': SUPABASE_SERVICE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!approvalsRes.ok) {
|
||||
const errText = await approvalsRes.text();
|
||||
console.error('Failed to fetch approvals:', errText);
|
||||
return {
|
||||
approvalMap: null,
|
||||
warning: 'Validation table unavailable; using Auth metadata fallback.'
|
||||
};
|
||||
}
|
||||
|
||||
const approvals = await approvalsRes.json();
|
||||
return {
|
||||
approvalMap: new Map(approvals.map(a => [a.id, a.validated])),
|
||||
warning: null,
|
||||
};
|
||||
}
|
||||
|
||||
function setCors(req, res) {
|
||||
applyCors(req, res, 'POST, OPTIONS');
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
setCors(req, res);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !isAllowedOrigin(origin)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
// 1. Authenticate caller
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized: Missing token' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
||||
headers: {
|
||||
'apikey': SUPABASE_ANON_KEY,
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!userRes.ok) {
|
||||
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
|
||||
}
|
||||
|
||||
const user = await userRes.json();
|
||||
const callerEmail = user.email;
|
||||
const callerId = user.id;
|
||||
|
||||
if (!callerEmail) {
|
||||
return res.status(401).json({ error: 'Unauthorized: Invalid user payload' });
|
||||
}
|
||||
|
||||
const isMaster = MASTER_USERS.has(callerEmail.toLowerCase());
|
||||
const { action } = req.body;
|
||||
|
||||
if (!action) {
|
||||
return res.status(400).json({ error: 'Missing action' });
|
||||
}
|
||||
|
||||
// --- Action: Check Validation Status ---
|
||||
if (action === 'check-status') {
|
||||
if (isMaster) {
|
||||
return res.json({ validated: true });
|
||||
}
|
||||
|
||||
const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${callerId}&select=validated`, {
|
||||
headers: {
|
||||
'apikey': SUPABASE_SERVICE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!approvalsRes.ok) {
|
||||
const errText = await approvalsRes.text();
|
||||
console.error('Failed to query user approvals:', errText);
|
||||
return res.json({ validated: getValidatedFromMetadata(user) });
|
||||
}
|
||||
|
||||
const approvals = await approvalsRes.json();
|
||||
const isApproved = approvals.length > 0
|
||||
? approvals[0].validated === true
|
||||
: getValidatedFromMetadata(user);
|
||||
return res.json({ validated: isApproved });
|
||||
}
|
||||
|
||||
// --- Admin-only Actions ---
|
||||
if (!isMaster) {
|
||||
return res.status(403).json({ error: 'Forbidden: Admin access required' });
|
||||
}
|
||||
|
||||
if (action === 'list') {
|
||||
// Fetch all users from GoTrue Admin API
|
||||
const usersRes = await fetch(`${SUPABASE_URL}/auth/v1/admin/users`, {
|
||||
headers: {
|
||||
'apikey': SUPABASE_SERVICE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!usersRes.ok) {
|
||||
const errText = await usersRes.text();
|
||||
console.error('Failed to fetch auth users:', errText);
|
||||
return res.status(500).json({ error: 'Failed to fetch users from authentication' });
|
||||
}
|
||||
|
||||
const usersData = await usersRes.json();
|
||||
const authUsers = usersData.users || [];
|
||||
|
||||
const { approvalMap, warning } = await fetchApprovalsMap();
|
||||
|
||||
const mergedUsers = authUsers.map(u => {
|
||||
const email = u.email;
|
||||
const id = u.id;
|
||||
const createdAt = u.created_at;
|
||||
|
||||
let status = 'Pending';
|
||||
if (MASTER_USERS.has(email?.toLowerCase())) {
|
||||
status = 'Master';
|
||||
} else if (approvalMap?.has(id)) {
|
||||
status = approvalMap.get(id) ? 'Validated' : 'Pending';
|
||||
} else if (getValidatedFromMetadata(u)) {
|
||||
status = 'Validated';
|
||||
}
|
||||
|
||||
return { id, email, created_at: createdAt, status };
|
||||
});
|
||||
|
||||
return res.json({ users: mergedUsers, warning });
|
||||
}
|
||||
|
||||
if (action === 'validate') {
|
||||
const { targetUserId, email, validated } = req.body;
|
||||
if (!targetUserId || !email) {
|
||||
return res.status(400).json({ error: 'Missing targetUserId or email' });
|
||||
}
|
||||
|
||||
let tableWarning = null;
|
||||
|
||||
try {
|
||||
const upsertRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': SUPABASE_SERVICE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Prefer': 'resolution=merge-duplicates,return=representation'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: targetUserId,
|
||||
email,
|
||||
validated,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
if (!upsertRes.ok) {
|
||||
const errText = await upsertRes.text();
|
||||
console.error('Failed to upsert approval:', errText);
|
||||
tableWarning = 'Validation table unavailable; Auth metadata was updated instead.';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Approval table update threw:', err);
|
||||
tableWarning = 'Validation table unavailable; Auth metadata was updated instead.';
|
||||
}
|
||||
|
||||
await updateAuthValidationMetadata(targetUserId, validated);
|
||||
return res.json({ success: true, warning: tableWarning });
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
const { targetUserId } = req.body;
|
||||
if (!targetUserId) {
|
||||
return res.status(400).json({ error: 'Missing targetUserId' });
|
||||
}
|
||||
|
||||
// Prevent master user self-deletion via API
|
||||
const targetUserRes = await fetchAdminUser(targetUserId).catch(() => null);
|
||||
|
||||
if (targetUserRes && MASTER_USERS.has(targetUserRes.email?.toLowerCase())) {
|
||||
return res.status(400).json({ error: 'Cannot delete a master user account' });
|
||||
}
|
||||
|
||||
// 1. Delete user from auth
|
||||
const deleteAuthRes = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${targetUserId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'apikey': SUPABASE_SERVICE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!deleteAuthRes.ok) {
|
||||
const errText = await deleteAuthRes.text();
|
||||
console.error('Failed to delete auth user:', errText);
|
||||
return res.status(500).json({ error: 'Failed to delete user from authentication' });
|
||||
}
|
||||
|
||||
// 2. Delete user approval record from public.user_approvals if exists
|
||||
await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${targetUserId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'apikey': SUPABASE_SERVICE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'Invalid action' });
|
||||
} catch (err) {
|
||||
console.error('Error in users-admin function:', err);
|
||||
return res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export function getBcConfig(env = process.env) {
|
||||
const legacyWriteMethod = String(env.BC_WRITE_METHOD || 'PATCH').toUpperCase();
|
||||
const legacyWriteUrlTemplate = env.BC_WRITE_URL_TEMPLATE || `{{itemsUrl}}('{{itemNo}}')`;
|
||||
const legacyWriteBodyTemplate = env.BC_WRITE_BODY_TEMPLATE || JSON.stringify({ cpnpNo: '{{cpnpNo}}' });
|
||||
|
||||
return {
|
||||
tenantId: env.BC_TENANT_ID || 'fab724f7-6b6d-4e3b-86e3-8c1e05e36b2a',
|
||||
clientId: env.BC_CLIENT_ID || '6f832138-cb48-43e7-8601-efca120b45dc',
|
||||
clientSecret: env.BC_CLIENT_SECRET,
|
||||
companyId: env.BC_COMPANY_ID || '2acec35c-7d06-ed11-82f8-0022485ceea3',
|
||||
writeMethod: legacyWriteMethod,
|
||||
writeUrlTemplate: legacyWriteUrlTemplate,
|
||||
writeBodyTemplate: legacyWriteBodyTemplate,
|
||||
itemsWriteMethod: String(env.BC_ITEMS_WRITE_METHOD || legacyWriteMethod).toUpperCase(),
|
||||
itemsWriteUrlTemplate: env.BC_ITEMS_WRITE_URL_TEMPLATE || legacyWriteUrlTemplate,
|
||||
itemsWriteBodyTemplate: env.BC_ITEMS_WRITE_BODY_TEMPLATE || null,
|
||||
itemUnitsCode: env.BC_UOM_CODE || 'OUTER',
|
||||
itemUnitsWriteMethod: env.BC_UOM_WRITE_METHOD ? String(env.BC_UOM_WRITE_METHOD).toUpperCase() : 'PATCH',
|
||||
itemUnitsWriteUrlTemplate: env.BC_UOM_WRITE_URL_TEMPLATE || `{{itemUnitsOfMeasureUrl}}(itemNo='{{itemNo}}',code='{{itemUnitsCode}}')`,
|
||||
itemUnitsWriteBodyTemplate: env.BC_UOM_WRITE_BODY_TEMPLATE || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTokenUrl(config) {
|
||||
return `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
|
||||
}
|
||||
|
||||
export function getItemsUrl(config) {
|
||||
return `https://api.businesscentral.dynamics.com/v2.0/${config.tenantId}/production/api/craze/integrations/v1.0/companies(${config.companyId})/items`;
|
||||
}
|
||||
|
||||
export function getItemUnitsOfMeasureUrl(config) {
|
||||
return `https://api.businesscentral.dynamics.com/v2.0/${config.tenantId}/production/api/craze/integrations/v1.0/companies(${config.companyId})/itemUnitsOfMeasure2`;
|
||||
}
|
||||
|
||||
let tokenCache = { token: null, expiresAt: 0 };
|
||||
|
||||
export async function getBCToken(config) {
|
||||
const now = Date.now();
|
||||
if (tokenCache.token && now < tokenCache.expiresAt) {
|
||||
return tokenCache.token;
|
||||
}
|
||||
|
||||
if (!config.clientSecret) {
|
||||
throw new Error('BC_CLIENT_SECRET env var not set');
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
scope: 'https://api.businesscentral.dynamics.com/.default',
|
||||
});
|
||||
|
||||
const res = await fetch(getTokenUrl(config), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.access_token) {
|
||||
throw new Error('BC token error: ' + JSON.stringify(data));
|
||||
}
|
||||
|
||||
tokenCache = { token: data.access_token, expiresAt: now + 55 * 60 * 1000 };
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
export async function fetchAllItems(config, token) {
|
||||
const items = [];
|
||||
let url = `${getItemsUrl(config)}?$top=1000`;
|
||||
|
||||
while (url) {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`BC GET items failed (${res.status}): ${txt}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (Array.isArray(json.value)) {
|
||||
items.push(...json.value);
|
||||
}
|
||||
|
||||
url = json['@odata.nextLink'] || null;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function findItem(config, token, articleNo) {
|
||||
const filter = encodeURIComponent(`no eq '${articleNo}'`);
|
||||
const url = `${getItemsUrl(config)}?$filter=${filter}&$select=systemId,no,cpnpNo`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`BC GET items failed (${res.status}): ${txt}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
const items = json.value || [];
|
||||
console.log('[bc-proxy] GET items result:', JSON.stringify(items));
|
||||
if (items.length === 0) throw new Error(`Item not found in BC: ${articleNo}`);
|
||||
return items[0];
|
||||
}
|
||||
|
||||
export async function patchItemCpnpNo(config, token, item, cpnpNo) {
|
||||
const etag = item['@odata.etag'] || '*';
|
||||
const itemsUrl = getItemsUrl(config);
|
||||
const url = config.writeUrlTemplate
|
||||
.replaceAll('{{itemsUrl}}', itemsUrl)
|
||||
.replaceAll('{{tenantId}}', config.tenantId)
|
||||
.replaceAll('{{companyId}}', config.companyId)
|
||||
.replaceAll('{{itemNo}}', encodeURIComponent(String(item.no)))
|
||||
.replaceAll('{{systemId}}', encodeURIComponent(String(item.systemId || '')))
|
||||
.replaceAll('{{cpnpNo}}', String(cpnpNo));
|
||||
const bodyText = config.writeBodyTemplate
|
||||
.replaceAll('{{itemsUrl}}', itemsUrl)
|
||||
.replaceAll('{{tenantId}}', config.tenantId)
|
||||
.replaceAll('{{companyId}}', config.companyId)
|
||||
.replaceAll('{{itemNo}}', String(item.no))
|
||||
.replaceAll('{{systemId}}', String(item.systemId || ''))
|
||||
.replaceAll('{{cpnpNo}}', String(cpnpNo));
|
||||
console.log('[bc-proxy] WRITE url:', url);
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if ((config.writeMethod || 'PATCH') === 'PATCH') {
|
||||
headers['If-Match'] = etag;
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: config.writeMethod || 'PATCH',
|
||||
headers,
|
||||
body: bodyText,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`BC write failed (${res.status}): ${txt}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normalizeValue(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildWorkbook(items) {
|
||||
const headers = [];
|
||||
const seen = new Set();
|
||||
|
||||
items.forEach(item => {
|
||||
Object.keys(item || {}).forEach(key => {
|
||||
if (key.startsWith('@odata.')) return;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
headers.push(key);
|
||||
});
|
||||
});
|
||||
|
||||
const rows = items.map(item => {
|
||||
const row = {};
|
||||
headers.forEach(key => {
|
||||
row[key] = normalizeValue(item?.[key]);
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(rows, { header: headers });
|
||||
ws['!autofilter'] = {
|
||||
ref: XLSX.utils.encode_range({
|
||||
s: { c: 0, r: 0 },
|
||||
e: { c: Math.max(headers.length - 1, 0), r: Math.max(rows.length, 0) },
|
||||
}),
|
||||
};
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Items');
|
||||
return wb;
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
import crypto from 'crypto';
|
||||
import { getBcConfig, getBCToken, getItemsUrl, getItemUnitsOfMeasureUrl } from './bc-runtime.js';
|
||||
|
||||
function findHeaderIndex(headers, patterns) {
|
||||
const normalized = headers.map(h => String(h || '').toLowerCase());
|
||||
return normalized.findIndex(header =>
|
||||
patterns.every(pattern => header.includes(pattern.toLowerCase()))
|
||||
);
|
||||
}
|
||||
|
||||
function findCategorizationCodeIndex(headers) {
|
||||
const normalized = headers.map(h => String(h || '').toLowerCase().trim());
|
||||
const categorizationIdx = normalized.findIndex(header => header.replace(/[\s_-]+/g, '') === 'categorizationcode');
|
||||
if (categorizationIdx >= 0) return categorizationIdx;
|
||||
|
||||
const typeIdx = normalized.findIndex(header => header === 'type');
|
||||
if (typeIdx >= 0) return typeIdx;
|
||||
|
||||
return normalized.findIndex(header => header.includes('product') && header.includes('type'));
|
||||
}
|
||||
|
||||
function formatDateForBc(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
|
||||
if (typeof value === 'number' && value >= 25569 && value <= 60000) {
|
||||
const excelEpoch = new Date(1899, 11, 30);
|
||||
const date = new Date(excelEpoch.getTime() + value * 86400000);
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
const str = String(value).trim();
|
||||
if (!str) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
||||
|
||||
const parsed = new Date(str);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
function getValue(row, index) {
|
||||
if (index === null || index < 0) return null;
|
||||
const value = row[index];
|
||||
return value === undefined || value === '' ? null : value;
|
||||
}
|
||||
|
||||
function toBcDecimal(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
|
||||
const normalized = String(value).trim().replace(/\s+/g, '').replace(',', '.');
|
||||
if (!normalized) return null;
|
||||
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function makeFieldPreview(sourceLabel, sourceIndex, targetField, value) {
|
||||
return { sourceLabel, sourceIndex, targetField, value };
|
||||
}
|
||||
|
||||
export function buildBusinessCentralMappingPreview(headers, row) {
|
||||
const articleNoIdx = findHeaderIndex(headers, ['article', 'no']);
|
||||
const articleDetailsEnIdx = findHeaderIndex(headers, ['article', 'details', 'english']);
|
||||
const articleDetailsDeIdx = findHeaderIndex(headers, ['article', 'details', 'german']);
|
||||
const launchDateIdx = findHeaderIndex(headers, ['launch']);
|
||||
const readyToOrderDateIdx = findHeaderIndex(headers, ['ready']);
|
||||
const moqIdx = findHeaderIndex(headers, ['moq']);
|
||||
const shortDeIdx = findHeaderIndex(headers, ['short', 'description', 'german']);
|
||||
const shortEnIdx = findHeaderIndex(headers, ['short', 'description', 'english']);
|
||||
const cpnpIdx = findHeaderIndex(headers, ['cpnp']);
|
||||
const categorizationCodeIdx = findCategorizationCodeIndex(headers);
|
||||
|
||||
const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']);
|
||||
const units40HqIdx = (() => {
|
||||
const hqIdx = findHeaderIndex(headers, ['40', 'hq']);
|
||||
if (hqIdx >= 0) return hqIdx;
|
||||
return findHeaderIndex(headers, ['40', 'hc']);
|
||||
})();
|
||||
const innerWIdx = findHeaderIndex(headers, ['inner', 'w']);
|
||||
const innerLIdx = findHeaderIndex(headers, ['inner', 'l']);
|
||||
const innerHIdx = findHeaderIndex(headers, ['inner', 'h']);
|
||||
const outerWIdx = findHeaderIndex(headers, ['outer', 'w']);
|
||||
const outerLIdx = findHeaderIndex(headers, ['outer', 'l']);
|
||||
const outerHIdx = findHeaderIndex(headers, ['outer', 'h']);
|
||||
|
||||
const articleNo = String(getValue(row, articleNoIdx) ?? '');
|
||||
|
||||
const categorizationCode = getValue(row, categorizationCodeIdx);
|
||||
const hasCategorizationCode = String(categorizationCode ?? '').trim() !== '';
|
||||
|
||||
const itemsPayload = {
|
||||
no: getValue(row, articleNoIdx),
|
||||
articleDetailsEnglish: getValue(row, articleDetailsEnIdx),
|
||||
articleDetailsGerman: getValue(row, articleDetailsDeIdx),
|
||||
launchDate: formatDateForBc(getValue(row, launchDateIdx)),
|
||||
readyToOrderDate: formatDateForBc(getValue(row, readyToOrderDateIdx)),
|
||||
minimumOrderQuantity: toBcDecimal(getValue(row, moqIdx)),
|
||||
shortDescriptionInGerman: getValue(row, shortDeIdx),
|
||||
shortDescriptionInEnglish: getValue(row, shortEnIdx),
|
||||
cpnpNo: getValue(row, cpnpIdx),
|
||||
};
|
||||
|
||||
if (hasCategorizationCode) {
|
||||
itemsPayload.categorizationCode = String(categorizationCode).trim();
|
||||
}
|
||||
|
||||
const itemUnitsOfMeasurePayload = {
|
||||
itemNo: getValue(row, articleNoIdx),
|
||||
code: 'OUTER',
|
||||
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, unitsOuterIdx)),
|
||||
width: toBcDecimal(getValue(row, outerWIdx)),
|
||||
length: toBcDecimal(getValue(row, outerLIdx)),
|
||||
height: toBcDecimal(getValue(row, outerHIdx)),
|
||||
};
|
||||
|
||||
const itemUnits40HCPayload = {
|
||||
itemNo: getValue(row, articleNoIdx),
|
||||
code: '40HC',
|
||||
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, units40HqIdx)),
|
||||
};
|
||||
|
||||
return {
|
||||
articleNo,
|
||||
itemsPayload,
|
||||
itemUnitsOfMeasurePayload,
|
||||
itemUnits40HCPayload,
|
||||
itemsFields: [
|
||||
makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no),
|
||||
makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish),
|
||||
makeFieldPreview('Article Details - German', articleDetailsDeIdx, 'articleDetailsGerman', itemsPayload.articleDetailsGerman),
|
||||
makeFieldPreview('Launch Date', launchDateIdx, 'launchDate', itemsPayload.launchDate),
|
||||
makeFieldPreview('Ready to Order Date', readyToOrderDateIdx, 'readyToOrderDate', itemsPayload.readyToOrderDate),
|
||||
makeFieldPreview('MOQ', moqIdx, 'minimumOrderQuantity', itemsPayload.minimumOrderQuantity),
|
||||
makeFieldPreview('Short Description - German', shortDeIdx, 'shortDescriptionInGerman', itemsPayload.shortDescriptionInGerman),
|
||||
makeFieldPreview('Short Description - English', shortEnIdx, 'shortDescriptionInEnglish', itemsPayload.shortDescriptionInEnglish),
|
||||
makeFieldPreview('CPNP', cpnpIdx, 'cpnpNo', itemsPayload.cpnpNo),
|
||||
...(hasCategorizationCode
|
||||
? [makeFieldPreview('CategorizationCode', categorizationCodeIdx, 'categorizationCode', itemsPayload.categorizationCode)]
|
||||
: []),
|
||||
],
|
||||
itemUnitsFields: [
|
||||
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnitsOfMeasurePayload.itemNo),
|
||||
makeFieldPreview('Units Outer', unitsOuterIdx, 'qtyPerUnitOfMeasure', itemUnitsOfMeasurePayload.qtyPerUnitOfMeasure),
|
||||
makeFieldPreview('Outer W (cm)', outerWIdx, 'width', itemUnitsOfMeasurePayload.width),
|
||||
makeFieldPreview('Outer L (cm)', outerLIdx, 'length', itemUnitsOfMeasurePayload.length),
|
||||
makeFieldPreview('Outer H (cm)', outerHIdx, 'height', itemUnitsOfMeasurePayload.height),
|
||||
],
|
||||
itemUnits40HCFields: [
|
||||
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnits40HCPayload.itemNo),
|
||||
makeFieldPreview('Units 40FT HQ', units40HqIdx, 'qtyPerUnitOfMeasure', itemUnits40HCPayload.qtyPerUnitOfMeasure),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeForComparison(field, value) {
|
||||
const strField = String(field || '').toLowerCase();
|
||||
|
||||
if (DECIMAL_FIELDS.has(field) && (value === null || value === undefined || value === '')) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
if (strField.includes('date')) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '0001-01-01';
|
||||
}
|
||||
|
||||
const normalizedDate = formatDateForBc(value);
|
||||
return normalizedDate === '0001-01-01' ? '0001-01-01' : normalizedDate;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(/\r\n/g, '\n').trimEnd();
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function buildFieldChanges(fields, currentRecord, desiredPayload) {
|
||||
return fields
|
||||
.filter(field => field.targetField !== 'itemNo' && field.targetField !== 'no')
|
||||
.map(field => {
|
||||
const before = currentRecord ? currentRecord[field.targetField] : undefined;
|
||||
const after = desiredPayload[field.targetField];
|
||||
const normalizedBefore = normalizeForComparison(field.targetField, before);
|
||||
const normalizedAfter = normalizeForComparison(field.targetField, after);
|
||||
|
||||
return {
|
||||
sourceLabel: field.sourceLabel,
|
||||
sourceIndex: field.sourceIndex,
|
||||
targetField: field.targetField,
|
||||
before,
|
||||
after,
|
||||
changed: normalizedBefore !== normalizedAfter,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function describeUnappliedChanges(sectionLabel, changes) {
|
||||
return changes
|
||||
.filter(change => change.changed)
|
||||
.map(change => `${change.sourceLabel} (${change.targetField}): expected ${JSON.stringify(change.after)} but BC has ${JSON.stringify(change.before)}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function buildChangedPayload(changes) {
|
||||
const payload = {};
|
||||
|
||||
changes
|
||||
.filter(change => change.changed)
|
||||
.forEach(change => {
|
||||
const field = change.targetField;
|
||||
const value = change.after;
|
||||
|
||||
if (DECIMAL_FIELDS.has(field)) {
|
||||
payload[field] = toBcDecimal(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (EMPTY_STRING_FIELDS.has(field)) {
|
||||
payload[field] = value === null || value === undefined ? '' : String(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(field || '').toLowerCase().includes('date')) {
|
||||
payload[field] = formatDateForBc(value);
|
||||
return;
|
||||
}
|
||||
|
||||
payload[field] = value;
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildPreviewHash(payload) {
|
||||
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
}
|
||||
|
||||
function getItemsSelect() {
|
||||
return [
|
||||
'systemId',
|
||||
'no',
|
||||
'articleDetailsEnglish',
|
||||
'articleDetailsGerman',
|
||||
'launchDate',
|
||||
'readyToOrderDate',
|
||||
'minimumOrderQuantity',
|
||||
'shortDescriptionInGerman',
|
||||
'shortDescriptionInEnglish',
|
||||
'cpnpNo',
|
||||
'categorizationCode',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
function getItemUnitsSelect() {
|
||||
return [
|
||||
'itemNo',
|
||||
'code',
|
||||
'qtyPerUnitOfMeasure',
|
||||
'qtyRoundingPrecision',
|
||||
'length',
|
||||
'width',
|
||||
'height',
|
||||
'cubage',
|
||||
'weight',
|
||||
'layerPerPalet2CRZ',
|
||||
'outerPerLayer2CRZ',
|
||||
'barCodeCRZ',
|
||||
'layerPerPaletCRZ',
|
||||
'outerPerLayerCRZ',
|
||||
'netWeightCRZ',
|
||||
'innerTypeBCT',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
async function fetchJsonOrThrow(url, token, label) {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`${label} failed (${res.status}): ${txt}`);
|
||||
}
|
||||
|
||||
return {
|
||||
json: await res.json(),
|
||||
etag: res.headers.get('etag') || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchBusinessCentralSnapshot(config, token, articleNo) {
|
||||
const itemsFilter = encodeURIComponent(`no eq '${articleNo}'`);
|
||||
const itemsUrl = `${getItemsUrl(config)}?$filter=${itemsFilter}&$select=${getItemsSelect()}`;
|
||||
const { json: itemsJson, etag: itemsEtag } = await fetchJsonOrThrow(itemsUrl, token, 'BC GET items');
|
||||
const item = Array.isArray(itemsJson.value) && itemsJson.value.length > 0 ? itemsJson.value[0] : null;
|
||||
|
||||
const outerCode = config.itemUnitsCode || 'OUTER';
|
||||
const uomUrl = getItemUnitsOfMeasureUrl(config);
|
||||
|
||||
const outerFilter = encodeURIComponent(`itemNo eq '${articleNo}' and code eq '${outerCode}'`);
|
||||
const outerUrl = `${uomUrl}?$filter=${outerFilter}&$select=${getItemUnitsSelect()}`;
|
||||
const { json: outerJson, etag: outerEtag } = await fetchJsonOrThrow(outerUrl, token, 'BC GET itemUnitsOfMeasure OUTER');
|
||||
const itemUnitsOfMeasure = Array.isArray(outerJson.value) && outerJson.value.length > 0 ? outerJson.value[0] : null;
|
||||
|
||||
const hcFilter = encodeURIComponent(`itemNo eq '${articleNo}' and code eq '40HC'`);
|
||||
const hcUrl = `${uomUrl}?$filter=${hcFilter}&$select=${getItemUnitsSelect()}`;
|
||||
const { json: hcJson, etag: hcEtag } = await fetchJsonOrThrow(hcUrl, token, 'BC GET itemUnitsOfMeasure 40HC');
|
||||
const itemUnits40HC = Array.isArray(hcJson.value) && hcJson.value.length > 0 ? hcJson.value[0] : null;
|
||||
|
||||
return {
|
||||
item,
|
||||
itemUnitsOfMeasure,
|
||||
itemUnits40HC,
|
||||
itemEtag: item?.['@odata.etag'] || itemsEtag || '*',
|
||||
itemUnitsEtag: itemUnitsOfMeasure?.['@odata.etag'] || outerEtag || '*',
|
||||
itemUnits40HCEtag: itemUnits40HC?.['@odata.etag'] || hcEtag || '*',
|
||||
};
|
||||
}
|
||||
|
||||
function makePreviewSection({
|
||||
type,
|
||||
desiredPayload,
|
||||
currentRecord,
|
||||
fieldPreviews,
|
||||
writeMethod,
|
||||
writeUrlTemplate,
|
||||
writeBodyTemplate,
|
||||
supported = true,
|
||||
supportReason = null,
|
||||
}) {
|
||||
const changes = buildFieldChanges(fieldPreviews, currentRecord, desiredPayload);
|
||||
return {
|
||||
type,
|
||||
desired: desiredPayload,
|
||||
current: currentRecord || null,
|
||||
changes,
|
||||
changedFields: changes.filter(change => change.changed).map(change => change.targetField),
|
||||
writeConfigured: Boolean(writeUrlTemplate),
|
||||
writeMethod: writeMethod || null,
|
||||
writeUrlTemplate: writeUrlTemplate || null,
|
||||
writeBodyTemplate: writeBodyTemplate || null,
|
||||
canApply: Boolean(writeUrlTemplate) && supported,
|
||||
supported,
|
||||
supportReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBusinessCentralSyncPreview(config, mapping, snapshot) {
|
||||
const itemsSection = makePreviewSection({
|
||||
type: 'items',
|
||||
desiredPayload: mapping.itemsPayload,
|
||||
currentRecord: snapshot.item,
|
||||
fieldPreviews: mapping.itemsFields,
|
||||
writeMethod: config.itemsWriteMethod || config.writeMethod,
|
||||
writeUrlTemplate: config.itemsWriteUrlTemplate || config.writeUrlTemplate,
|
||||
writeBodyTemplate: config.itemsWriteBodyTemplate || null,
|
||||
});
|
||||
|
||||
const itemUnitsSection = makePreviewSection({
|
||||
type: 'itemUnitsOfMeasure',
|
||||
desiredPayload: mapping.itemUnitsOfMeasurePayload,
|
||||
currentRecord: snapshot.itemUnitsOfMeasure,
|
||||
fieldPreviews: mapping.itemUnitsFields,
|
||||
writeMethod: config.itemUnitsWriteMethod || null,
|
||||
writeUrlTemplate: config.itemUnitsWriteUrlTemplate || null,
|
||||
writeBodyTemplate: config.itemUnitsWriteBodyTemplate || null,
|
||||
});
|
||||
|
||||
const itemUnits40HCSection = makePreviewSection({
|
||||
type: 'itemUnits40HC',
|
||||
desiredPayload: mapping.itemUnits40HCPayload,
|
||||
currentRecord: snapshot.itemUnits40HC,
|
||||
fieldPreviews: mapping.itemUnits40HCFields,
|
||||
writeMethod: config.itemUnits40HCWriteMethod || config.itemUnitsWriteMethod || null,
|
||||
writeUrlTemplate: config.itemUnits40HCWriteUrlTemplate || config.itemUnitsWriteUrlTemplate || null,
|
||||
writeBodyTemplate: config.itemUnits40HCWriteBodyTemplate || config.itemUnitsWriteBodyTemplate || null,
|
||||
});
|
||||
|
||||
const previewPayload = {
|
||||
articleNo: mapping.articleNo,
|
||||
items: itemsSection,
|
||||
itemUnitsOfMeasure: itemUnitsSection,
|
||||
itemUnits40HC: itemUnits40HCSection,
|
||||
};
|
||||
|
||||
return {
|
||||
...previewPayload,
|
||||
hasChanges:
|
||||
itemsSection.changes.some(change => change.changed) ||
|
||||
itemUnitsSection.changes.some(change => change.changed) ||
|
||||
itemUnits40HCSection.changes.some(change => change.changed),
|
||||
previewToken: buildPreviewHash({
|
||||
articleNo: mapping.articleNo,
|
||||
items: itemsSection.changes,
|
||||
itemUnitsOfMeasure: itemUnitsSection.changes,
|
||||
itemUnits40HC: itemUnits40HCSection.changes,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function previewBusinessCentralSync(config, token, headers, row) {
|
||||
const mapping = buildBusinessCentralMappingPreview(headers, row);
|
||||
const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
|
||||
return buildBusinessCentralSyncPreview(config, mapping, snapshot);
|
||||
}
|
||||
|
||||
async function renderAndPatchRecord({ token, url, method, body, etag }) {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (String(method || 'PATCH').toUpperCase() === 'PATCH') {
|
||||
headers['If-Match'] = etag || '*';
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: method || 'PATCH',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`BC write failed (${res.status}): ${txt}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTemplate(template, context) {
|
||||
return String(template).replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, key) => {
|
||||
const value = context[key];
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
const DECIMAL_FIELDS = new Set([
|
||||
'minimumOrderQuantity',
|
||||
'qtyPerUnitOfMeasure',
|
||||
'length',
|
||||
'width',
|
||||
'height',
|
||||
'cubage',
|
||||
'weight',
|
||||
'layerPerPalet2CRZ',
|
||||
'outerPerLayer2CRZ',
|
||||
'netWeightCRZ',
|
||||
]);
|
||||
|
||||
const EMPTY_STRING_FIELDS = new Set([
|
||||
'articleDetailsEnglish',
|
||||
'articleDetailsGerman',
|
||||
'shortDescriptionInGerman',
|
||||
'shortDescriptionInEnglish',
|
||||
'cpnpNo',
|
||||
]);
|
||||
|
||||
function normalizeDecimalPayload(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeDecimalPayload);
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const next = {};
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (EMPTY_STRING_FIELDS.has(key) && (nested === null || nested === undefined)) {
|
||||
next[key] = '';
|
||||
continue;
|
||||
}
|
||||
if (DECIMAL_FIELDS.has(key)) {
|
||||
next[key] = toBcDecimal(nested);
|
||||
continue;
|
||||
}
|
||||
next[key] = normalizeDecimalPayload(nested);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function renderJsonBody(template, context, fallbackPayload) {
|
||||
const rendered = template ? renderTemplate(template, context) : JSON.stringify(fallbackPayload);
|
||||
if (typeof rendered !== 'string') {
|
||||
return JSON.stringify(normalizeDecimalPayload(rendered));
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rendered);
|
||||
return JSON.stringify(normalizeDecimalPayload(parsed));
|
||||
} catch {
|
||||
return rendered;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyItemsSection(config, token, snapshot, preview, context) {
|
||||
const changedFields = preview.items.changes.filter(change => change.changed);
|
||||
if (changedFields.length === 0) {
|
||||
return { applied: false, reason: 'No item changes' };
|
||||
}
|
||||
|
||||
if (!config.itemsWriteUrlTemplate) {
|
||||
return { applied: false, reason: 'Items write template not configured' };
|
||||
}
|
||||
|
||||
const url = renderTemplate(config.itemsWriteUrlTemplate, context);
|
||||
const payload = renderJsonBody(preview.items.writeBodyTemplate, context, buildChangedPayload(preview.items.changes));
|
||||
|
||||
await renderAndPatchRecord({
|
||||
token,
|
||||
url,
|
||||
method: preview.items.writeMethod || 'PATCH',
|
||||
body: payload,
|
||||
etag: snapshot.itemEtag || snapshot.item?.['@odata.etag'] || '*',
|
||||
});
|
||||
|
||||
return { applied: true, url };
|
||||
}
|
||||
|
||||
async function applyItemUnitsSection(config, token, snapshot, preview, context) {
|
||||
const changedFields = preview.itemUnitsOfMeasure.changes.filter(change => change.changed);
|
||||
if (changedFields.length === 0) {
|
||||
return { applied: false, reason: 'No itemUnitsOfMeasure changes' };
|
||||
}
|
||||
|
||||
if (!preview.itemUnitsOfMeasure.supported) {
|
||||
return { applied: false, reason: preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only' };
|
||||
}
|
||||
|
||||
if (!config.itemUnitsWriteUrlTemplate) {
|
||||
return { applied: false, reason: 'itemUnitsOfMeasure write template not configured' };
|
||||
}
|
||||
|
||||
const url = renderTemplate(config.itemUnitsWriteUrlTemplate, context);
|
||||
const payload = renderJsonBody(preview.itemUnitsOfMeasure.writeBodyTemplate, context, buildChangedPayload(preview.itemUnitsOfMeasure.changes));
|
||||
|
||||
await renderAndPatchRecord({
|
||||
token,
|
||||
url,
|
||||
method: preview.itemUnitsOfMeasure.writeMethod || 'PATCH',
|
||||
body: payload,
|
||||
etag: snapshot.itemUnitsEtag || snapshot.itemUnitsOfMeasure?.['@odata.etag'] || '*',
|
||||
});
|
||||
|
||||
return { applied: true, url };
|
||||
}
|
||||
|
||||
async function applyItemUnits40HCSection(config, token, snapshot, preview, context) {
|
||||
const changedFields = preview.itemUnits40HC.changes.filter(change => change.changed);
|
||||
if (changedFields.length === 0) {
|
||||
return { applied: false, reason: 'No itemUnits40HC changes' };
|
||||
}
|
||||
|
||||
if (!config.itemUnits40HCWriteUrlTemplate && !config.itemUnitsWriteUrlTemplate) {
|
||||
return { applied: false, reason: 'itemUnits40HC write template not configured' };
|
||||
}
|
||||
|
||||
const urlTemplate = config.itemUnits40HCWriteUrlTemplate || config.itemUnitsWriteUrlTemplate;
|
||||
const writeMethod = preview.itemUnits40HC.writeMethod || 'PATCH';
|
||||
const hcContext = {
|
||||
...context,
|
||||
itemUnitsCode: '40HC',
|
||||
itemUnits40HCCode: '40HC',
|
||||
itemUnitsPayload: preview.itemUnits40HC.desired,
|
||||
};
|
||||
const url = renderTemplate(urlTemplate, hcContext);
|
||||
const payload = renderJsonBody(preview.itemUnits40HC.writeBodyTemplate, hcContext, buildChangedPayload(preview.itemUnits40HC.changes));
|
||||
|
||||
await renderAndPatchRecord({
|
||||
token,
|
||||
url,
|
||||
method: writeMethod,
|
||||
body: payload,
|
||||
etag: snapshot.itemUnits40HCEtag || snapshot.itemUnits40HC?.['@odata.etag'] || '*',
|
||||
});
|
||||
|
||||
return { applied: true, url };
|
||||
}
|
||||
|
||||
export async function applyBusinessCentralSync(config, token, headers, row, previewToken) {
|
||||
const mapping = buildBusinessCentralMappingPreview(headers, row);
|
||||
const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
|
||||
const preview = buildBusinessCentralSyncPreview(config, mapping, snapshot);
|
||||
|
||||
const hasItemUnitsChanges = preview.itemUnitsOfMeasure.changes.some(change => change.changed);
|
||||
if (hasItemUnitsChanges && !snapshot.itemUnitsOfMeasure) {
|
||||
const error = new Error(`BC itemUnitsOfMeasure row missing for ${mapping.articleNo}. This BC API cannot update these fields until the row exists or BC exposes an upsert action.`);
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const hasItemUnits40HCChanges = preview.itemUnits40HC.changes.some(change => change.changed);
|
||||
if (hasItemUnits40HCChanges && !snapshot.itemUnits40HC) {
|
||||
const error = new Error(`BC itemUnits40HC row missing for ${mapping.articleNo}. This BC API cannot update these fields until the row exists or BC exposes an upsert action.`);
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (previewToken && previewToken !== preview.previewToken) {
|
||||
const error = new Error('Preview token mismatch. BC data changed or preview is stale.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const context = {
|
||||
itemsUrl: getItemsUrl(config),
|
||||
itemUnitsOfMeasureUrl: getItemUnitsOfMeasureUrl(config),
|
||||
articleNo: mapping.articleNo,
|
||||
itemNo: mapping.articleNo,
|
||||
itemUnitsCode: config.itemUnitsCode || 'OUTER',
|
||||
itemUnits40HCCode: '40HC',
|
||||
systemId: snapshot.item?.systemId || '',
|
||||
cpnpNo: mapping.itemsPayload.cpnpNo,
|
||||
itemsPayload: preview.items.desired,
|
||||
itemUnitsPayload: preview.itemUnitsOfMeasure.desired,
|
||||
itemUnits40HCPayload: preview.itemUnits40HC.desired,
|
||||
...preview.items.desired,
|
||||
...preview.itemUnitsOfMeasure.desired,
|
||||
...preview.itemUnits40HC.desired,
|
||||
};
|
||||
|
||||
const results = {
|
||||
items: await applyItemsSection(config, token, snapshot, preview, context),
|
||||
itemUnitsOfMeasure: await applyItemUnitsSection(config, token, snapshot, preview, context),
|
||||
itemUnits40HC: await applyItemUnits40HCSection(config, token, snapshot, preview, context),
|
||||
};
|
||||
|
||||
if (results.items.applied || results.itemUnitsOfMeasure.applied || results.itemUnits40HC.applied) {
|
||||
const verificationSnapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
|
||||
|
||||
if (results.items.applied) {
|
||||
const itemMismatches = preview.items.changes
|
||||
.filter(change => change.changed)
|
||||
.map(change => ({
|
||||
...change,
|
||||
before: verificationSnapshot.item ? verificationSnapshot.item[change.targetField] : undefined,
|
||||
}))
|
||||
.filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after));
|
||||
if (itemMismatches.length > 0) {
|
||||
const error = new Error(`BC verification failed for items: ${describeUnappliedChanges('items', itemMismatches)}`);
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (results.itemUnitsOfMeasure.applied) {
|
||||
const uomMismatches = preview.itemUnitsOfMeasure.changes
|
||||
.filter(change => change.changed)
|
||||
.map(change => ({
|
||||
...change,
|
||||
before: verificationSnapshot.itemUnitsOfMeasure ? verificationSnapshot.itemUnitsOfMeasure[change.targetField] : undefined,
|
||||
}))
|
||||
.filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after));
|
||||
if (uomMismatches.length > 0) {
|
||||
const error = new Error(`BC verification failed for itemUnitsOfMeasure: ${describeUnappliedChanges('itemUnitsOfMeasure', uomMismatches)}`);
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (results.itemUnits40HC.applied) {
|
||||
const hcMismatches = preview.itemUnits40HC.changes
|
||||
.filter(change => change.changed)
|
||||
.map(change => ({
|
||||
...change,
|
||||
before: verificationSnapshot.itemUnits40HC ? verificationSnapshot.itemUnits40HC[change.targetField] : undefined,
|
||||
}))
|
||||
.filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after));
|
||||
if (hcMismatches.length > 0) {
|
||||
const error = new Error(`BC verification failed for itemUnits40HC: ${describeUnappliedChanges('itemUnits40HC', hcMismatches)}`);
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
articleNo: mapping.articleNo,
|
||||
previewToken: preview.previewToken,
|
||||
results,
|
||||
preview,
|
||||
warning: (preview.itemUnitsOfMeasure.changes.some(change => change.changed) && !preview.itemUnitsOfMeasure.supported)
|
||||
? (preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only')
|
||||
: (preview.itemUnits40HC.changes.some(change => change.changed) && !preview.itemUnits40HC.supported)
|
||||
? (preview.itemUnits40HC.supportReason || 'itemUnits40HC sync is not supported by this BC API yet; preview only')
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function previewBusinessCentralCpnp(config, token, articleNo, cpnpNo) {
|
||||
const { json } = await fetchJsonOrThrow(
|
||||
`${getItemsUrl(config)}?$filter=${encodeURIComponent(`no eq '${articleNo}'`)}&$select=${getItemsSelect()}`,
|
||||
token,
|
||||
'BC GET items'
|
||||
);
|
||||
const item = Array.isArray(json.value) && json.value.length > 0 ? json.value[0] : null;
|
||||
|
||||
if (!item) {
|
||||
throw new Error(`Item not found in BC: ${articleNo}`);
|
||||
}
|
||||
|
||||
const desired = { cpnpNo: String(cpnpNo) };
|
||||
const current = { cpnpNo: item.cpnpNo };
|
||||
const changes = buildFieldChanges(
|
||||
[{ sourceLabel: 'CPNP', sourceIndex: null, targetField: 'cpnpNo' }],
|
||||
current,
|
||||
desired
|
||||
);
|
||||
|
||||
return {
|
||||
articleNo,
|
||||
items: {
|
||||
type: 'items',
|
||||
desired,
|
||||
current,
|
||||
changes,
|
||||
changedFields: changes.filter(change => change.changed).map(change => change.targetField),
|
||||
writeConfigured: Boolean(config.writeUrlTemplate),
|
||||
writeMethod: config.writeMethod || 'PATCH',
|
||||
writeUrlTemplate: config.writeUrlTemplate || null,
|
||||
writeBodyTemplate: config.writeBodyTemplate || null,
|
||||
canApply: Boolean(config.writeUrlTemplate),
|
||||
},
|
||||
itemUnitsOfMeasure: {
|
||||
type: 'itemUnitsOfMeasure',
|
||||
desired: {},
|
||||
current: null,
|
||||
changes: [],
|
||||
changedFields: [],
|
||||
writeConfigured: Boolean(config.itemUnitsWriteUrlTemplate),
|
||||
writeMethod: config.itemUnitsWriteMethod || null,
|
||||
writeUrlTemplate: config.itemUnitsWriteUrlTemplate || null,
|
||||
writeBodyTemplate: config.itemUnitsWriteBodyTemplate || null,
|
||||
canApply: false,
|
||||
},
|
||||
hasChanges: changes.some(change => change.changed),
|
||||
previewToken: buildPreviewHash({
|
||||
articleNo,
|
||||
cpnpNo: String(cpnpNo),
|
||||
current: item.cpnpNo,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyBusinessCentralCpnp(config, token, articleNo, cpnpNo, previewToken) {
|
||||
const preview = await previewBusinessCentralCpnp(config, token, articleNo, cpnpNo);
|
||||
if (previewToken && previewToken !== preview.previewToken) {
|
||||
const error = new Error('Preview token mismatch. BC data changed or preview is stale.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const item = preview.items.current;
|
||||
const url = renderTemplate(config.writeUrlTemplate || `{{itemsUrl}}('{{itemNo}}')`, {
|
||||
itemsUrl: getItemsUrl(config),
|
||||
articleNo,
|
||||
itemNo: articleNo,
|
||||
cpnpNo: String(cpnpNo),
|
||||
systemId: item?.systemId || '',
|
||||
itemsPayload: { cpnpNo: String(cpnpNo) },
|
||||
});
|
||||
const body = config.writeBodyTemplate
|
||||
? renderTemplate(config.writeBodyTemplate, {
|
||||
itemsUrl: getItemsUrl(config),
|
||||
articleNo,
|
||||
itemNo: articleNo,
|
||||
cpnpNo: String(cpnpNo),
|
||||
systemId: item?.systemId || '',
|
||||
itemsPayload: { cpnpNo: String(cpnpNo) },
|
||||
})
|
||||
: JSON.stringify({ cpnpNo: String(cpnpNo) });
|
||||
|
||||
await renderAndPatchRecord({
|
||||
token,
|
||||
url,
|
||||
method: config.writeMethod || 'PATCH',
|
||||
body,
|
||||
etag: item?.['@odata.etag'] || '*',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
articleNo,
|
||||
cpnpNo: String(cpnpNo),
|
||||
previewToken: preview.previewToken,
|
||||
};
|
||||
}
|
||||
|
||||
export { getBcConfig, getBCToken, getItemsUrl, getItemUnitsOfMeasureUrl };
|
||||
@@ -0,0 +1,3 @@
|
||||
Credit balance is too low
|
||||
SessionEnd hook [node "${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs"] failed: /bin/sh: node: command not found
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,171 @@
|
||||
# Maximize Tab View — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add fullscreen maximize mode to each tab view, hiding Sidebar and TopBar for maximum data visibility.
|
||||
|
||||
**Architecture:** Each view component manages its own `isFullscreen` state locally. No global state. Toggle between normal and fullscreen modes with conditional rendering.
|
||||
|
||||
**Tech Stack:** React useState, lucide-react icons, Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
### Task 1: ProductDescriptions.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ProductDescriptions.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports and state**
|
||||
|
||||
Add `Maximize2` to lucide-react imports.
|
||||
Add `useState` if not present.
|
||||
|
||||
```tsx
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add maximize button**
|
||||
|
||||
Find the component's header section (where stats/controls are rendered).
|
||||
Add maximize button with `Maximize2` icon next to existing controls.
|
||||
|
||||
```tsx
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 rounded transition-colors"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wrap content in conditional render**
|
||||
|
||||
After existing return statement, wrap in:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<>
|
||||
{isFullscreen ? (
|
||||
<div className="fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto">
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
|
||||
title="Exit fullscreen"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Existing JSX content - remove any overflow constraints */}
|
||||
<div className="[content goes here]" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="...">
|
||||
{/* Existing JSX content - keep current overflow settings */}
|
||||
<div className="[content goes here]" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: MatrixView.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/MatrixView.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports and state**
|
||||
|
||||
Add `Maximize2`, `X` to lucide-react imports.
|
||||
Add `useState` if not present.
|
||||
|
||||
- [ ] **Step 2: Add maximize button**
|
||||
|
||||
Add button in the header area (search bar section).
|
||||
|
||||
- [ ] **Step 3: Add conditional render**
|
||||
|
||||
Same pattern as Task 1.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: DimensionsView.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/DimensionsView.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports and state**
|
||||
|
||||
- [ ] **Step 2: Add maximize button**
|
||||
|
||||
- [ ] **Step 3: Add conditional render**
|
||||
|
||||
---
|
||||
|
||||
### Task 4: PricingView.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/PricingView.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports and state**
|
||||
|
||||
- [ ] **Step 2: Add maximize button**
|
||||
|
||||
- [ ] **Step 3: Add conditional render**
|
||||
|
||||
---
|
||||
|
||||
### Task 5: ArticleDetails.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ArticleDetails.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports and state**
|
||||
|
||||
- [ ] **Step 2: Add maximize button**
|
||||
|
||||
- [ ] **Step 3: Add conditional render**
|
||||
|
||||
---
|
||||
|
||||
### Task 6: PendingValidationView.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/PendingValidationView.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports and state**
|
||||
|
||||
- [ ] **Step 2: Add maximize button**
|
||||
|
||||
- [ ] **Step 3: Add conditional render**
|
||||
|
||||
---
|
||||
|
||||
### Task 7: MissingDataView.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/MissingDataView.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports and state**
|
||||
|
||||
- [ ] **Step 2: Add maximize button**
|
||||
|
||||
- [ ] **Step 3: Add conditional render**
|
||||
|
||||
---
|
||||
|
||||
### Task 8: HistoryView.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/HistoryView.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports and state**
|
||||
|
||||
- [ ] **Step 2: Add maximize button**
|
||||
|
||||
- [ ] **Step 3: Add conditional render**
|
||||
|
||||
---
|
||||
|
||||
**Verification:** Run `npm run lint` to ensure no TypeScript errors.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Maximize Tab View — Spec
|
||||
|
||||
## Concept & Vision
|
||||
|
||||
Each tab view (Matrix, Product Descriptions, Dimensions, etc.) can be maximized to fullscreen mode, hiding all navigation chrome (Sidebar, TopBar) to maximize data visibility. A floating close button allows returning to normal view. The effect is immersive and focused — like pressing F11 in a browser.
|
||||
|
||||
## Design
|
||||
|
||||
### Normal Mode
|
||||
- Sidebar (240px) visible on left
|
||||
- TopBar visible on top
|
||||
- Main content fills remaining space
|
||||
|
||||
### Maximized Mode
|
||||
- Sidebar: `display: none`
|
||||
- TopBar: `display: none`
|
||||
- Fullscreen overlay covers entire viewport
|
||||
- Background: `#041021` (matches main content bg)
|
||||
- Content area: max-height `100vh`, overflow scroll
|
||||
- Floating close button: top-right corner, fixed position
|
||||
|
||||
### Close Button
|
||||
- Position: fixed, top-right
|
||||
- Size: 40x40px
|
||||
- Background: `rgba(30, 41, 59, 0.9)` with blur
|
||||
- Border: 1px `slate-600`
|
||||
- Icon: `X` from lucide-react, white, 20px
|
||||
- Hover: bg `slate-700`, scale 1.05
|
||||
- Z-index: 50
|
||||
- Tooltip: "Exit fullscreen"
|
||||
|
||||
## Layout & Structure
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
Each view component receives `isFullscreen?: boolean` prop and renders:
|
||||
|
||||
```
|
||||
{maximizeButton && (
|
||||
<button onClick={toggleFullscreen} className="..." title="Fullscreen">
|
||||
<Maximize2 />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isFullscreen ? (
|
||||
<div className="fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto">
|
||||
<button onClick={toggleFullscreen} className="fixed top-4 right-4 ...">
|
||||
<X />
|
||||
</button>
|
||||
{/* Tab content rendered at full height */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="...">
|
||||
{/* Tab content normal */}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### State Management
|
||||
- Each tab manages its own `isFullscreen` state locally
|
||||
- No global state needed — each tab remembers its own fullscreen mode
|
||||
- State resets to normal when switching tabs (intentional)
|
||||
|
||||
## Features & Interactions
|
||||
|
||||
### Toggle Button
|
||||
- Location: Each tab view has a maximize button (icon) in its header area
|
||||
- Icon: `Maximize2` from lucide-react
|
||||
- Click: Enter fullscreen mode
|
||||
- Hover: Slight scale increase, cursor pointer
|
||||
|
||||
### Exit Fullscreen
|
||||
- Click floating X button
|
||||
- Instantly returns to normal layout
|
||||
- No animation needed
|
||||
|
||||
## Component Inventory
|
||||
|
||||
### MaximizeButton (in each tab header)
|
||||
- States: default, hover
|
||||
- Color: `slate-400` default, `slate-200` hover
|
||||
|
||||
### FullscreenCloseButton (floating)
|
||||
- States: default, hover, active
|
||||
- Default: semi-transparent dark bg, subtle border
|
||||
- Hover: lighter bg, slight scale
|
||||
- Z-index ensures it's above all content
|
||||
|
||||
### FullscreenOverlay
|
||||
- Fixed positioning
|
||||
- Matches app background color
|
||||
- Contains scrollable content
|
||||
|
||||
## Technical Approach
|
||||
|
||||
- Add `isFullscreen` state to each view component
|
||||
- Add toggle function
|
||||
- Conditional rendering based on state
|
||||
- Icons from `lucide-react`: `Maximize2`, `X`
|
||||
- All styling via Tailwind classes
|
||||
- No additional dependencies
|
||||
|
||||
## Affected Components
|
||||
|
||||
1. `ProductDescriptions.tsx`
|
||||
2. `MatrixView.tsx`
|
||||
3. `DimensionsView.tsx`
|
||||
4. `PricingView.tsx`
|
||||
5. `ArticleDetails.tsx`
|
||||
6. `PendingValidationView.tsx`
|
||||
7. `MissingDataView.tsx`
|
||||
8. `HistoryView.tsx`
|
||||
Generated
-7
@@ -12,7 +12,6 @@
|
||||
"@supabase/supabase-js": "^2.103.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"buzz": "^2.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"dropbox": "^10.34.0",
|
||||
@@ -1931,12 +1930,6 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buzz": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buzz/-/buzz-2.0.0.tgz",
|
||||
"integrity": "sha512-eYeTETPJp7hWUX7j3o8iJNR8VLaaWRuOYYPWTSQqA5pIxZ2g3ZJUdyjfNZnGvr85IDhfr4ONQQflGp8+MC034A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"@supabase/supabase-js": "^2.103.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"buzz": "^2.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"dropbox": "^10.34.0",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const buf = fs.readFileSync('data (3).xlsx');
|
||||
const wb = XLSX.read(buf, {type: 'buffer'});
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const data = XLSX.utils.sheet_to_json(ws, {header: 1});
|
||||
|
||||
const itemRow = data.find(r => String(r[0]) === '75432');
|
||||
if (itemRow) {
|
||||
console.log('Row for 75432:');
|
||||
console.log(`Index 0 (Article No): ${itemRow[0]}`);
|
||||
console.log(`Index 31 (Units Inner): ${itemRow[31]}`);
|
||||
console.log(`Index 32 (Units Outer): ${itemRow[32]}`);
|
||||
console.log(`Index 33 (Units Pallet): ${itemRow[33]}`);
|
||||
} else {
|
||||
console.log('Item 75432 not found');
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const buf = fs.readFileSync('data (3).xlsx');
|
||||
const wb = XLSX.read(buf, {type: 'buffer'});
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const data = XLSX.utils.sheet_to_json(ws, {header: 1});
|
||||
const headers = data[0];
|
||||
|
||||
console.log('Headers:');
|
||||
headers.forEach((h, i) => {
|
||||
console.log(`${i}: ${h}`);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"find-skills": {
|
||||
"source": "vercel-labs/skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "9e1c8b3103f92fa8092568a44fe64858de7c5c9dc65ce4bea8f168080e889cfd"
|
||||
}
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills/find-skills
|
||||
+834
-169
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,24 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X } from 'lucide-react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X, Maximize2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
import { SyncStatusPill } from './SyncStatusPill';
|
||||
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
|
||||
|
||||
interface ArticleDetailsProps {
|
||||
data: ExcelRow[];
|
||||
onEdit: (index: number) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
dashboardDrilldown?: DashboardDrilldownRequest | null;
|
||||
onDashboardDrilldownApplied?: () => void;
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
||||
|
||||
export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProps) {
|
||||
export function ArticleDetails({ data, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: ArticleDetailsProps) {
|
||||
const COLUMNS = useColumns();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState('');
|
||||
@@ -22,6 +28,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||
[COLUMNS.ARTICLE_NO]: 100,
|
||||
[COLUMNS.ARTICLE_NAME]: 200,
|
||||
@@ -31,6 +38,18 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
[COLUMNS.DETAILS_EN]: 150,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'article_details') return;
|
||||
|
||||
const focus = dashboardDrilldown.focus as TabType;
|
||||
setActiveTab(focus);
|
||||
setSearch('');
|
||||
setLineFilter('');
|
||||
setColumnFilters({});
|
||||
setPage(1);
|
||||
onDashboardDrilldownApplied?.();
|
||||
}, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied]);
|
||||
|
||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
@@ -159,9 +178,19 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{tabs.map(tab => (
|
||||
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex flex-col h-full"}>
|
||||
{isFullscreen && (
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
|
||||
title="Exit fullscreen"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => { setActiveTab(tab.id); setPage(1); }}
|
||||
@@ -175,6 +204,14 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded-md transition-colors"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
|
||||
@@ -244,6 +281,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
id={`details-filter-trigger-${col}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||
@@ -265,6 +303,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
|
||||
{openFilterCol === col && (
|
||||
<ColumnFilterPopover
|
||||
triggerId={`details-filter-trigger-${col}`}
|
||||
uniqueValues={getUniqueValues(col)}
|
||||
selectedValues={columnFilters[col] || []}
|
||||
onToggle={(val) => toggleColumnFilter(col, val)}
|
||||
@@ -293,11 +332,15 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
key={index}
|
||||
className={cn(
|
||||
"hover:bg-slate-700/20 transition-colors",
|
||||
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
|
||||
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
||||
""
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{row[COLUMNS.ARTICLE_NO]}</span>
|
||||
{saveStatus && <SyncStatusPill status={saveStatus} className="self-start" />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>
|
||||
{row[COLUMNS.ARTICLE_NAME]}
|
||||
</td>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Search, Check, X } from 'lucide-react';
|
||||
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Search, Check, X, Filter } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
type FilterType = 'equals' | 'notEquals' | 'contains' | 'startsWith' | 'endsWith' | 'greaterThan' | 'lessThan' | 'between';
|
||||
|
||||
interface FilterCondition {
|
||||
type: FilterType;
|
||||
value: string;
|
||||
value2?: string;
|
||||
}
|
||||
|
||||
interface ColumnFilterPopoverProps {
|
||||
uniqueValues: string[];
|
||||
selectedValues: string[];
|
||||
@@ -11,6 +20,8 @@ interface ColumnFilterPopoverProps {
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
zIndex?: number;
|
||||
triggerId?: string;
|
||||
}
|
||||
|
||||
export function ColumnFilterPopover({
|
||||
@@ -21,102 +32,407 @@ export function ColumnFilterPopover({
|
||||
onClear,
|
||||
onClose,
|
||||
title,
|
||||
className
|
||||
className,
|
||||
zIndex = 50,
|
||||
triggerId
|
||||
}: ColumnFilterPopoverProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'values' | 'condition'>('values');
|
||||
const [condition, setCondition] = useState<FilterCondition>({ type: 'equals', value: '' });
|
||||
const [conditionResult, setConditionResult] = useState<string[]>([]);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerId) return;
|
||||
const trigger = document.getElementById(triggerId);
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: Math.min(rect.left + window.scrollX, window.innerWidth + window.scrollX - 300)
|
||||
});
|
||||
}, [triggerId]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'filter-portal-' + Math.random().toString(36).substring(2, 9);
|
||||
container.style.position = 'absolute';
|
||||
container.style.zIndex = '9999';
|
||||
container.style.top = '0';
|
||||
container.style.left = '0';
|
||||
container.style.width = '100%';
|
||||
container.style.pointerEvents = 'none';
|
||||
document.body.appendChild(container);
|
||||
setPortalContainer(container);
|
||||
|
||||
updatePosition();
|
||||
|
||||
// Update on scroll and resize
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
if (document.body.contains(container)) {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
};
|
||||
}, [updatePosition]);
|
||||
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const dragStartRef = React.useRef<number | null>(null);
|
||||
const safeUniqueValues = uniqueValues || [];
|
||||
const safeSelectedValues = selectedValues || [];
|
||||
const hoveredIndexRef = React.useRef<number | null>(null);
|
||||
const selectedValuesRef = React.useRef<string[]>(safeSelectedValues);
|
||||
const onSelectAllRef = React.useRef(onSelectAll);
|
||||
const filteredValuesRef = React.useRef<string[]>([]);
|
||||
const didDragRef = React.useRef(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState<number | null>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const filteredValues = useMemo(() => {
|
||||
return uniqueValues.filter(v =>
|
||||
return safeUniqueValues.filter(v =>
|
||||
String(v || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}, [uniqueValues, search]);
|
||||
}, [safeUniqueValues, search]);
|
||||
|
||||
const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0;
|
||||
filteredValuesRef.current = filteredValues;
|
||||
selectedValuesRef.current = safeSelectedValues;
|
||||
onSelectAllRef.current = onSelectAll;
|
||||
|
||||
const isAllSelected = safeSelectedValues.length === safeUniqueValues.length && safeUniqueValues.length > 0;
|
||||
|
||||
const applyCondition = () => {
|
||||
const results: string[] = [];
|
||||
const valNum = parseFloat(condition.value);
|
||||
const val2Num = condition.value2 ? parseFloat(condition.value2) : 0;
|
||||
|
||||
filteredValues.forEach(v => {
|
||||
const strVal = String(v || '');
|
||||
const numVal = parseFloat(v);
|
||||
|
||||
let matches = false;
|
||||
|
||||
switch (condition.type) {
|
||||
case 'equals':
|
||||
matches = strVal.toLowerCase() === condition.value.toLowerCase();
|
||||
break;
|
||||
case 'notEquals':
|
||||
matches = strVal.toLowerCase() !== condition.value.toLowerCase();
|
||||
break;
|
||||
case 'contains':
|
||||
matches = strVal.toLowerCase().includes(condition.value.toLowerCase());
|
||||
break;
|
||||
case 'startsWith':
|
||||
matches = strVal.toLowerCase().startsWith(condition.value.toLowerCase());
|
||||
break;
|
||||
case 'endsWith':
|
||||
matches = strVal.toLowerCase().endsWith(condition.value.toLowerCase());
|
||||
break;
|
||||
case 'greaterThan':
|
||||
matches = !isNaN(numVal) && !isNaN(valNum) && numVal > valNum;
|
||||
break;
|
||||
case 'lessThan':
|
||||
matches = !isNaN(numVal) && !isNaN(valNum) && numVal < valNum;
|
||||
break;
|
||||
case 'between':
|
||||
matches = !isNaN(numVal) && !isNaN(val2Num) && numVal >= valNum && numVal <= val2Num;
|
||||
break;
|
||||
}
|
||||
|
||||
if (matches) results.push(v);
|
||||
});
|
||||
|
||||
setConditionResult(results);
|
||||
onSelectAll(results);
|
||||
setActiveTab('values');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleMouseDown = (index: number) => {
|
||||
isDraggingRef.current = true;
|
||||
dragStartRef.current = index;
|
||||
hoveredIndexRef.current = index;
|
||||
didDragRef.current = false;
|
||||
setIsDragging(true);
|
||||
setDragStart(index);
|
||||
setHoveredIndex(index);
|
||||
};
|
||||
|
||||
const handleMouseEnter = (index: number) => {
|
||||
if (isDraggingRef.current && dragStartRef.current !== null) {
|
||||
hoveredIndexRef.current = index;
|
||||
setHoveredIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleGlobalMouseUp = () => {
|
||||
if (isDraggingRef.current) {
|
||||
const start = dragStartRef.current;
|
||||
const end = hoveredIndexRef.current;
|
||||
|
||||
if (start !== null && end !== null && start !== end) {
|
||||
const s = Math.min(start, end);
|
||||
const e = Math.max(start, end);
|
||||
const itemsToSelect = filteredValuesRef.current.slice(s, e + 1);
|
||||
const newSelected = new Set([...selectedValuesRef.current]);
|
||||
itemsToSelect.forEach(v => newSelected.add(v));
|
||||
onSelectAllRef.current(Array.from(newSelected));
|
||||
didDragRef.current = true;
|
||||
setTimeout(() => { didDragRef.current = false; }, 100);
|
||||
}
|
||||
|
||||
isDraggingRef.current = false;
|
||||
dragStartRef.current = null;
|
||||
hoveredIndexRef.current = null;
|
||||
setIsDragging(false);
|
||||
setDragStart(null);
|
||||
setHoveredIndex(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 mt-1 w-64 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter values..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 pr-7 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
|
||||
{filteredValues.map(val => (
|
||||
<div
|
||||
key={val}
|
||||
role="checkbox"
|
||||
aria-checked={selectedValues.includes(val)}
|
||||
tabIndex={0}
|
||||
onClick={() => onToggle(val)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); } }}
|
||||
className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group"
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
|
||||
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
|
||||
)}>
|
||||
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
|
||||
</div>
|
||||
))}
|
||||
{filteredValues.length === 0 && (
|
||||
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-700 mt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isAllSelected) {
|
||||
onSelectAll([]);
|
||||
} else {
|
||||
onSelectAll(uniqueValues);
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors uppercase tracking-tight"
|
||||
>
|
||||
{isAllSelected ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
<span className="text-slate-600 font-bold">•</span>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight"
|
||||
>
|
||||
Clear Current
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black rounded transition-colors shadow-lg active:scale-95 uppercase"
|
||||
<div className="fixed inset-0 z-0 pointer-events-none">
|
||||
{portalContainer && createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-72 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100 pointer-events-auto",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
zIndex: 9999,
|
||||
top: position.top,
|
||||
left: position.left
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 text-slate-500 hover:text-white p-1 rounded transition-colors z-10"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
|
||||
|
||||
<div className="flex gap-1 border-b border-slate-700 pb-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('values')}
|
||||
className={cn(
|
||||
"flex-1 px-2 py-1 text-[10px] font-bold rounded transition-colors",
|
||||
activeTab === 'values' ? "bg-blue-600 text-white" : "text-slate-400 hover:text-white"
|
||||
)}
|
||||
>
|
||||
Values ({safeSelectedValues.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('condition')}
|
||||
className={cn(
|
||||
"flex-1 px-2 py-1 text-[10px] font-bold rounded transition-colors flex items-center justify-center gap-1",
|
||||
activeTab === 'condition' ? "bg-blue-600 text-white" : "text-slate-400 hover:text-white"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3 h-3" />
|
||||
Condition
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'values' ? (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter values..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (search) {
|
||||
onSelectAll(filteredValues);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 pr-7 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar"
|
||||
>
|
||||
{filteredValues.map((val, idx) => {
|
||||
const isDragSelected = isDragging && dragStart !== null && hoveredIndex !== null &&
|
||||
((idx >= dragStart && idx <= hoveredIndex) || (idx <= dragStart && idx >= hoveredIndex));
|
||||
return (
|
||||
<div
|
||||
key={val}
|
||||
role="checkbox"
|
||||
aria-checked={safeSelectedValues.includes(val)}
|
||||
tabIndex={0}
|
||||
onMouseDown={(e) => { e.preventDefault(); handleMouseDown(idx); }}
|
||||
onMouseEnter={() => handleMouseEnter(idx)}
|
||||
onClick={(e) => {
|
||||
if (didDragRef.current) return;
|
||||
if (e.shiftKey && lastClickedIndex !== null) {
|
||||
const start = Math.min(lastClickedIndex, idx);
|
||||
const end = Math.max(lastClickedIndex, idx);
|
||||
const itemsToSelect = filteredValues.slice(start, end + 1);
|
||||
const newSelected = new Set([...safeSelectedValues, ...itemsToSelect]);
|
||||
onSelectAll(Array.from(newSelected));
|
||||
} else {
|
||||
onToggle(val);
|
||||
}
|
||||
setLastClickedIndex(idx);
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); setLastClickedIndex(idx); } }}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-1.5 rounded cursor-pointer group transition-colors select-none",
|
||||
isDragSelected ? "bg-blue-600/40" : "hover:bg-slate-700/50"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
|
||||
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
|
||||
)}>
|
||||
{safeSelectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filteredValues.length === 0 && (
|
||||
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-700 mt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isAllSelected) {
|
||||
onSelectAll([]);
|
||||
} else {
|
||||
onSelectAll(safeUniqueValues);
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors uppercase tracking-tight"
|
||||
>
|
||||
{isAllSelected ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
<span className="text-slate-600 font-bold">•</span>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black rounded transition-colors shadow-lg active:scale-95 uppercase"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 uppercase">Condition Type</label>
|
||||
<select
|
||||
value={condition.type}
|
||||
onChange={e => setCondition({ ...condition, type: e.target.value as FilterType })}
|
||||
className="w-full mt-1 bg-slate-900 border border-slate-700 rounded p-1.5 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="equals">Equals (=)</option>
|
||||
<option value="notEquals">Not Equals (≠)</option>
|
||||
<option value="contains">Contains</option>
|
||||
<option value="startsWith">Starts With</option>
|
||||
<option value="endsWith">Ends With</option>
|
||||
<option value="greaterThan">Greater Than (>)</option>
|
||||
<option value="lessThan">Less Than (<)</option>
|
||||
<option value="between">Between</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 uppercase">Value</label>
|
||||
<input
|
||||
type="text"
|
||||
value={condition.value}
|
||||
onChange={e => setCondition({ ...condition, value: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
applyCondition();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter value..."
|
||||
className="w-full mt-1 bg-slate-900 border border-slate-700 rounded p-1.5 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{condition.type === 'between' && (
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 uppercase">And</label>
|
||||
<input
|
||||
type="text"
|
||||
value={condition.value2 || ''}
|
||||
onChange={e => setCondition({ ...condition, value2: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
applyCondition();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter second value..."
|
||||
className="w-full mt-1 bg-slate-900 border border-slate-700 rounded p-1.5 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={applyCondition}
|
||||
className="w-full py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-bold rounded transition-colors"
|
||||
>
|
||||
Apply Condition
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{conditionResult.length > 0 && (
|
||||
<div className="text-[10px] text-green-400 text-center pt-2 border-t border-slate-700">
|
||||
Found {conditionResult.length} matching value{conditionResult.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
portalContainer
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowDownRight, ArrowUpRight, LayoutDashboard, RefreshCw } from 'lucide-react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
import {
|
||||
ArticleDetailsDashboardSnapshot,
|
||||
CosmeticDashboardSnapshot,
|
||||
DashboardDrilldownRequest,
|
||||
DescriptionsDashboardSnapshot,
|
||||
PendingRowInfo,
|
||||
SnapshotStore,
|
||||
computeArticleDetailsDashboardSnapshot,
|
||||
computeCosmeticDashboardSnapshot,
|
||||
computeDescriptionsDashboardSnapshot,
|
||||
computePricingDashboardSnapshot,
|
||||
ensureDailyDashboardSnapshot,
|
||||
getDaysAgoKey,
|
||||
loadDashboardSnapshots,
|
||||
} from '../lib/controlDashboard';
|
||||
|
||||
interface ControlDashboardViewProps {
|
||||
headers: string[];
|
||||
data: ExcelRow[];
|
||||
pendingRows: Record<string, PendingRowInfo>;
|
||||
rowStatuses: Record<string, string>;
|
||||
activeModule?: string;
|
||||
onOpenTab?: (tabId: string) => void;
|
||||
onDrillDown?: (request: DashboardDrilldownRequest) => void;
|
||||
}
|
||||
|
||||
type MetricKey = keyof Pick<DescriptionsDashboardSnapshot, 'ok' | 'longDeMissing' | 'longEnMissing' | 'shortDeMissing' | 'shortEnMissing'>;
|
||||
type ArticleMetricKey = keyof Pick<ArticleDetailsDashboardSnapshot, 'ok' | 'detailsDeMissing' | 'detailsEnMissing'>;
|
||||
type CosmeticMetricKey = keyof Pick<CosmeticDashboardSnapshot, 'ok' | 'cpnpMissing'>;
|
||||
type PricingMetricKey = 'ok' | 'itemToLogisticMissing' | 'uvpMissing' | 'srpIntMissing' | 'srpUkMissing' | 'unitsOuterMissing' | 'outerWMissing' | 'outerLMissing' | 'outerHMissing' | 'units40fMissing' | 'moqMissing' | 'weightIssues';
|
||||
type SnapshotKey = MetricKey | ArticleMetricKey | CosmeticMetricKey | PricingMetricKey;
|
||||
|
||||
const METRICS: Array<{
|
||||
key: SnapshotKey;
|
||||
label: string;
|
||||
toneClass: string;
|
||||
positiveIsGood: boolean;
|
||||
drilldownFocus: string;
|
||||
}> = [
|
||||
{ key: 'ok', label: 'All OK', toneClass: 'border-emerald-400/30 bg-emerald-400/15 text-emerald-100', positiveIsGood: true, drilldownFocus: 'complete' },
|
||||
{ key: 'longDeMissing', label: 'Missing Long DE', toneClass: 'border-sky-400/30 bg-sky-400/15 text-sky-100', positiveIsGood: false, drilldownFocus: 'missingLongDE' },
|
||||
{ key: 'longEnMissing', label: 'Missing Long EN', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: false, drilldownFocus: 'missingLongEN' },
|
||||
{ key: 'shortDeMissing', label: 'Missing Short DE', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: false, drilldownFocus: 'missingShortDE' },
|
||||
{ key: 'shortEnMissing', label: 'Missing Short EN', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'missingShortEN' },
|
||||
];
|
||||
|
||||
const ARTICLE_METRICS: Array<{
|
||||
key: ArticleMetricKey;
|
||||
label: string;
|
||||
toneClass: string;
|
||||
positiveIsGood: boolean;
|
||||
drilldownFocus: string;
|
||||
}> = [
|
||||
{ key: 'ok', label: 'All OK', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: true, drilldownFocus: 'all' },
|
||||
{ key: 'detailsDeMissing', label: 'Missing Details DE', toneClass: 'border-cyan-400/30 bg-cyan-400/15 text-cyan-100', positiveIsGood: false, drilldownFocus: 'missingDetailsDE' },
|
||||
{ key: 'detailsEnMissing', label: 'Missing Details EN', toneClass: 'border-amber-400/30 bg-amber-400/15 text-amber-100', positiveIsGood: false, drilldownFocus: 'missingDetailsEN' },
|
||||
];
|
||||
|
||||
const PRICING_METRICS: Array<{
|
||||
key: PricingMetricKey;
|
||||
label: string;
|
||||
toneClass: string;
|
||||
positiveIsGood: boolean;
|
||||
drilldownFocus: string;
|
||||
}> = [
|
||||
{ key: 'ok', label: 'All OK', toneClass: 'border-amber-400/30 bg-amber-400/15 text-amber-100', positiveIsGood: true, drilldownFocus: 'all' },
|
||||
{ key: 'itemToLogisticMissing', label: 'Missing Item to Logistic', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: false, drilldownFocus: 'itemToLogisticMissing' },
|
||||
{ key: 'uvpMissing', label: 'Missing UVP (€)', toneClass: 'border-emerald-400/30 bg-emerald-400/15 text-emerald-100', positiveIsGood: false, drilldownFocus: 'uvpMissing' },
|
||||
{ key: 'srpIntMissing', label: 'Missing SRP INT', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: false, drilldownFocus: 'srpIntMissing' },
|
||||
{ key: 'srpUkMissing', label: 'Missing SRP UK (£)', toneClass: 'border-cyan-400/30 bg-cyan-400/15 text-cyan-100', positiveIsGood: false, drilldownFocus: 'srpUkMissing' },
|
||||
{ key: 'unitsOuterMissing', label: 'Missing Units/Outer', toneClass: 'border-sky-400/30 bg-sky-400/15 text-sky-100', positiveIsGood: false, drilldownFocus: 'unitsOuterMissing' },
|
||||
{ key: 'outerWMissing', label: 'Missing Outer W', toneClass: 'border-violet-400/30 bg-violet-400/15 text-violet-100', positiveIsGood: false, drilldownFocus: 'outerWMissing' },
|
||||
{ key: 'outerLMissing', label: 'Missing Outer L', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'outerLMissing' },
|
||||
{ key: 'outerHMissing', label: 'Missing Outer H', toneClass: 'border-pink-400/30 bg-pink-400/15 text-pink-100', positiveIsGood: false, drilldownFocus: 'outerHMissing' },
|
||||
{ key: 'units40fMissing', label: 'Missing Units 40F', toneClass: 'border-teal-400/30 bg-teal-400/15 text-teal-100', positiveIsGood: false, drilldownFocus: 'units40fMissing' },
|
||||
{ key: 'moqMissing', label: 'Missing MOQ', toneClass: 'border-orange-400/30 bg-orange-400/15 text-orange-100', positiveIsGood: false, drilldownFocus: 'moqMissing' },
|
||||
{ key: 'weightIssues', label: 'Weight Issues', toneClass: 'border-red-400/30 bg-red-400/15 text-red-100', positiveIsGood: false, drilldownFocus: 'weightIssues' },
|
||||
];
|
||||
|
||||
const COSMETIC_METRICS: Array<{
|
||||
key: CosmeticMetricKey;
|
||||
label: string;
|
||||
toneClass: string;
|
||||
positiveIsGood: boolean;
|
||||
drilldownFocus: string;
|
||||
}> = [
|
||||
{ key: 'ok', label: 'CPNP present', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: true, drilldownFocus: 'present' },
|
||||
{ key: 'cpnpMissing', label: 'CPNP missing', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'missing' },
|
||||
];
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat('en-GB').format(value);
|
||||
}
|
||||
|
||||
function formatDelta(current: number, historical: number, positiveIsGood: boolean): { text: string; className: string } {
|
||||
const delta = current - historical;
|
||||
|
||||
if (delta === 0) {
|
||||
return { text: '0 change', className: 'text-slate-400' };
|
||||
}
|
||||
|
||||
if (positiveIsGood) {
|
||||
return delta > 0
|
||||
? { text: `↑${delta} improved`, className: 'text-emerald-400' }
|
||||
: { text: `↓${Math.abs(delta)} worse`, className: 'text-rose-400' };
|
||||
}
|
||||
|
||||
return delta < 0
|
||||
? { text: `↓${Math.abs(delta)} resolved`, className: 'text-emerald-400' }
|
||||
: { text: `↑${delta} new`, className: 'text-rose-400' };
|
||||
}
|
||||
|
||||
function MetricTile({
|
||||
label,
|
||||
current,
|
||||
historical,
|
||||
toneClass,
|
||||
positiveIsGood,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
current: number;
|
||||
historical?: number;
|
||||
toneClass: string;
|
||||
positiveIsGood: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const delta = historical === undefined ? null : formatDelta(current, historical, positiveIsGood);
|
||||
const tileClassName = cn(
|
||||
'rounded-2xl border p-3 shadow-inner shadow-black/20 transition-all duration-200 bg-slate-950/70 text-left',
|
||||
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/70',
|
||||
toneClass
|
||||
);
|
||||
|
||||
return (
|
||||
onClick ? (
|
||||
<button type="button" onClick={onClick} className={tileClassName}>
|
||||
<p className="text-[10px] uppercase tracking-[0.16em] leading-none text-slate-400">{label}</p>
|
||||
<div className="mt-2.5 flex items-end justify-between gap-2.5">
|
||||
<div className="text-2xl font-semibold text-white tabular-nums leading-none">
|
||||
{formatNumber(current)}
|
||||
</div>
|
||||
{historical === undefined ? (
|
||||
<div className="text-right text-[10px] text-slate-500 leading-tight">
|
||||
No historical data available
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">7 days ago</div>
|
||||
<div className="text-xs font-medium text-slate-300 tabular-nums">
|
||||
{formatNumber(historical)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{delta && (
|
||||
<div className={cn('mt-1.5 text-[11px] font-semibold', delta.className)}>
|
||||
{delta.text}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className={tileClassName}>
|
||||
<p className="text-[10px] uppercase tracking-[0.16em] leading-none text-slate-400">{label}</p>
|
||||
<div className="mt-2.5 flex items-end justify-between gap-2.5">
|
||||
<div className="text-2xl font-semibold text-white tabular-nums leading-none">
|
||||
{formatNumber(current)}
|
||||
</div>
|
||||
{historical === undefined ? (
|
||||
<div className="text-right text-[10px] text-slate-500 leading-tight">
|
||||
No historical data available
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">7 days ago</div>
|
||||
<div className="text-xs font-medium text-slate-300 tabular-nums">
|
||||
{formatNumber(historical)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{delta && (
|
||||
<div className={cn('mt-1.5 text-[11px] font-semibold', delta.className)}>
|
||||
{delta.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type DashboardSnapshotLike = {
|
||||
total: number;
|
||||
ok: number;
|
||||
[key: string]: number;
|
||||
};
|
||||
|
||||
function DashboardCard({
|
||||
title,
|
||||
subtitle,
|
||||
accentClass,
|
||||
accentBarClass,
|
||||
badgeClass,
|
||||
badgeLabel,
|
||||
titleClass,
|
||||
current,
|
||||
historical,
|
||||
metrics,
|
||||
metricGridClassName,
|
||||
tabId,
|
||||
onDrillDown,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
accentClass: string;
|
||||
accentBarClass: string;
|
||||
badgeClass: string;
|
||||
badgeLabel: string;
|
||||
titleClass: string;
|
||||
current: DashboardSnapshotLike;
|
||||
historical?: DashboardSnapshotLike;
|
||||
metricGridClassName?: string;
|
||||
metrics: Array<{
|
||||
key: SnapshotKey;
|
||||
label: string;
|
||||
toneClass: string;
|
||||
positiveIsGood: boolean;
|
||||
drilldownFocus: string;
|
||||
}>;
|
||||
tabId: DashboardDrilldownRequest['tabId'];
|
||||
onDrillDown?: (request: DashboardDrilldownRequest) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn(
|
||||
'rounded-3xl border bg-gradient-to-br from-slate-900 via-slate-950 to-black shadow-2xl backdrop-blur-sm overflow-hidden',
|
||||
accentClass
|
||||
)}>
|
||||
<div className={cn('h-1 w-full opacity-100 shadow-[0_0_18px_rgba(255,255,255,0.18)]', accentBarClass)} />
|
||||
|
||||
<div className="flex items-start justify-between gap-4 border-b border-slate-700/80 bg-black/20 px-4 py-3.5">
|
||||
<div>
|
||||
<div className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.22em]', badgeClass)}>
|
||||
{badgeLabel}
|
||||
</div>
|
||||
<h2 className={cn('mt-2 text-base font-semibold', titleClass)}>{title}</h2>
|
||||
<p className="mt-1 text-[11px] text-slate-300">{subtitle}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span className="rounded-full border border-slate-600/70 bg-slate-800/80 px-2.5 py-1 text-[10px] font-medium text-slate-200">
|
||||
Total {formatNumber(current.total)}
|
||||
</span>
|
||||
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] font-semibold text-emerald-200">
|
||||
All OK {formatNumber(current.ok)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn('grid gap-2.5 px-4 py-4 sm:grid-cols-2 lg:grid-cols-3', metricGridClassName)}>
|
||||
{metrics.map(metric => (
|
||||
<React.Fragment key={metric.key}>
|
||||
<MetricTile
|
||||
label={metric.label}
|
||||
current={current[metric.key]}
|
||||
historical={historical?.[metric.key]}
|
||||
toneClass={metric.toneClass}
|
||||
positiveIsGood={metric.positiveIsGood}
|
||||
onClick={onDrillDown ? () => onDrillDown({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
tabId: tabId as DashboardDrilldownRequest['tabId'],
|
||||
focus: metric.drilldownFocus,
|
||||
}) : undefined}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-700/70 bg-black/20 px-4 py-3 text-[11px] text-slate-300">
|
||||
{historical ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ArrowUpRight className="h-3 w-3 text-emerald-400" />
|
||||
Improvements are shown in green
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ArrowDownRight className="h-3 w-3 text-rose-400" />
|
||||
Regressions are shown in red
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>No historical data available</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ControlDashboardView({
|
||||
headers,
|
||||
data,
|
||||
pendingRows,
|
||||
rowStatuses,
|
||||
onDrillDown,
|
||||
}: ControlDashboardViewProps) {
|
||||
const [dashboardSnapshots, setDashboardSnapshots] = useState<SnapshotStore>({});
|
||||
const [snapshotsLoading, setSnapshotsLoading] = useState(true);
|
||||
|
||||
const currentSnapshot = useMemo(() => {
|
||||
return computeDescriptionsDashboardSnapshot(headers, {
|
||||
data,
|
||||
pendingRows,
|
||||
rowStatuses,
|
||||
historyEntries: [],
|
||||
});
|
||||
}, [headers, data, pendingRows, rowStatuses]);
|
||||
|
||||
const currentArticleSnapshot = useMemo(() => {
|
||||
return computeArticleDetailsDashboardSnapshot(headers, {
|
||||
data,
|
||||
pendingRows,
|
||||
rowStatuses,
|
||||
historyEntries: [],
|
||||
});
|
||||
}, [headers, data, pendingRows, rowStatuses]);
|
||||
|
||||
const currentPricingSnapshot = useMemo(() => {
|
||||
return computePricingDashboardSnapshot(headers, {
|
||||
data,
|
||||
pendingRows,
|
||||
rowStatuses,
|
||||
historyEntries: [],
|
||||
});
|
||||
}, [headers, data, pendingRows, rowStatuses]);
|
||||
|
||||
const currentCosmeticSnapshot = useMemo(() => {
|
||||
return computeCosmeticDashboardSnapshot(headers, {
|
||||
data,
|
||||
pendingRows,
|
||||
rowStatuses,
|
||||
historyEntries: [],
|
||||
});
|
||||
}, [headers, data, pendingRows, rowStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadSnapshots = async () => {
|
||||
setSnapshotsLoading(true);
|
||||
try {
|
||||
const store = await loadDashboardSnapshots();
|
||||
if (!cancelled) {
|
||||
setDashboardSnapshots(store);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setSnapshotsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSnapshots();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshotsLoading) return;
|
||||
void ensureDailyDashboardSnapshot(new Date(), {
|
||||
descriptions: currentSnapshot,
|
||||
articleDetails: currentArticleSnapshot,
|
||||
pricing: currentPricingSnapshot,
|
||||
cosmeticItems: currentCosmeticSnapshot,
|
||||
});
|
||||
}, [snapshotsLoading, currentSnapshot, currentArticleSnapshot, currentPricingSnapshot, currentCosmeticSnapshot]);
|
||||
|
||||
const historicalSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.descriptions;
|
||||
const historicalArticleSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.articleDetails;
|
||||
const historicalPricingSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.pricing;
|
||||
const historicalCosmeticSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.cosmeticItems;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="xl:col-span-2 flex items-center justify-between gap-4 rounded-3xl border border-slate-700/60 bg-slate-900/80 px-4 py-3.5 shadow-xl">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<LayoutDashboard className="h-4 w-4 text-blue-400" />
|
||||
<h1 className="text-lg font-semibold text-white">Control Dashboard</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Compact overview of Product Descriptions, Article Details and Pricing & Units.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-600/70 bg-slate-800/80 px-3 py-2 text-[11px] font-medium text-slate-200 hover:border-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DashboardCard
|
||||
title="Product Descriptions"
|
||||
subtitle="Long DE, Long EN, Short DE and Short EN."
|
||||
accentClass="border-emerald-400/40 bg-emerald-500/8 shadow-[0_0_0_1px_rgba(52,211,153,0.12)]"
|
||||
accentBarClass="bg-gradient-to-r from-emerald-300 via-lime-300 to-cyan-300"
|
||||
badgeClass="bg-emerald-500/20 text-emerald-100 ring-1 ring-emerald-300/30"
|
||||
badgeLabel="Descriptions"
|
||||
titleClass="text-emerald-100"
|
||||
current={currentSnapshot}
|
||||
historical={historicalSnapshot}
|
||||
metrics={METRICS}
|
||||
metricGridClassName="sm:grid-cols-2 lg:grid-cols-3"
|
||||
tabId="descriptions"
|
||||
onDrillDown={onDrillDown}
|
||||
/>
|
||||
|
||||
<DashboardCard
|
||||
title="Article Details"
|
||||
subtitle="DETAILS DE and DETAILS EN."
|
||||
accentClass="border-indigo-400/40 bg-indigo-500/12 shadow-[0_0_0_1px_rgba(129,140,248,0.12)]"
|
||||
accentBarClass="bg-gradient-to-r from-indigo-300 via-violet-300 to-cyan-300"
|
||||
badgeClass="bg-indigo-500/20 text-indigo-100 ring-1 ring-indigo-300/30"
|
||||
badgeLabel="Details"
|
||||
titleClass="text-indigo-100"
|
||||
current={currentArticleSnapshot}
|
||||
historical={historicalArticleSnapshot}
|
||||
metrics={ARTICLE_METRICS}
|
||||
metricGridClassName="sm:grid-cols-2 lg:grid-cols-3"
|
||||
tabId="article_details"
|
||||
onDrillDown={onDrillDown}
|
||||
/>
|
||||
|
||||
<DashboardCard
|
||||
title="Pricing & Units"
|
||||
subtitle="Item to Logistic, pricing, Units 40F and Weight Issues."
|
||||
accentClass="border-amber-400/40 bg-amber-500/12 shadow-[0_0_0_1px_rgba(251,191,36,0.12)]"
|
||||
accentBarClass="bg-gradient-to-r from-amber-300 via-orange-300 to-red-300"
|
||||
badgeClass="bg-amber-500/20 text-amber-100 ring-1 ring-amber-300/30"
|
||||
badgeLabel="Pricing"
|
||||
titleClass="text-amber-100"
|
||||
current={currentPricingSnapshot}
|
||||
historical={historicalPricingSnapshot}
|
||||
metrics={PRICING_METRICS}
|
||||
metricGridClassName="sm:grid-cols-2 xl:grid-cols-4"
|
||||
tabId="pricing"
|
||||
onDrillDown={onDrillDown}
|
||||
/>
|
||||
|
||||
<DashboardCard
|
||||
title="Cosmetic Items"
|
||||
subtitle="CPNP present vs missing, with 7-day evolution."
|
||||
accentClass="border-fuchsia-400/40 bg-fuchsia-500/12 shadow-[0_0_0_1px_rgba(232,121,249,0.12)]"
|
||||
accentBarClass="bg-gradient-to-r from-fuchsia-300 via-pink-300 to-rose-300"
|
||||
badgeClass="bg-fuchsia-500/20 text-fuchsia-100 ring-1 ring-fuchsia-300/30"
|
||||
badgeLabel="Cosmetic"
|
||||
titleClass="text-fuchsia-100"
|
||||
current={currentCosmeticSnapshot}
|
||||
historical={historicalCosmeticSnapshot}
|
||||
metrics={COSMETIC_METRICS}
|
||||
metricGridClassName="grid-cols-1 sm:grid-cols-2"
|
||||
tabId="cosmetic_items"
|
||||
onDrillDown={onDrillDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { Search, ChevronDown, ChevronUp, X, Save, Check, Loader2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
import { usePersistentState } from '../contexts/FilterContext';
|
||||
import { saveRowToSupabase } from '../lib/supabase';
|
||||
import { SyncStatusPill } from './SyncStatusPill';
|
||||
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
|
||||
|
||||
const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS'];
|
||||
|
||||
interface CosmeticItemsViewProps {
|
||||
data: ExcelRow[];
|
||||
headers: string[];
|
||||
onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void;
|
||||
onCaptureState: (message: string) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
onQueueBcSync: (articleNo: string, rowIndex: number, originalData: ExcelRow, newData: ExcelRow, articleName: string) => void;
|
||||
dashboardDrilldown?: DashboardDrilldownRequest | null;
|
||||
onDashboardDrilldownApplied?: () => void;
|
||||
}
|
||||
|
||||
export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses, onQueueBcSync, dashboardDrilldown, onDashboardDrilldownApplied }: CosmeticItemsViewProps) {
|
||||
const COLUMNS = useColumns();
|
||||
const [search, setSearch] = usePersistentState('cosmeticItems-search', '');
|
||||
const [sortCol, setSortCol] = usePersistentState<number | null>('cosmeticItems-sortCol', null);
|
||||
const [sortDesc, setSortDesc] = usePersistentState('cosmeticItems-sortDesc', false);
|
||||
const [cpnpFilter, setCpnpFilter] = usePersistentState<'all' | 'present' | 'missing'>('cosmeticItems-cpnpFilter', 'all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('cosmeticItems-columnFilters', {});
|
||||
const [openFilter, setOpenFilter] = useState<number | null>(null);
|
||||
const [editingCpnp, setEditingCpnp] = useState<{ rowIndex: number; value: string } | null>(null);
|
||||
const [savingCpnp, setSavingCpnp] = useState<number | null>(null);
|
||||
const cpnpInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCpnp !== null) cpnpInputRef.current?.focus();
|
||||
}, [editingCpnp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'cosmetic_items') return;
|
||||
|
||||
const focus = dashboardDrilldown.focus === 'missing' ? 'missing' : 'present';
|
||||
setCpnpFilter(focus);
|
||||
setSearch('');
|
||||
setColumnFilters({});
|
||||
setPage(1);
|
||||
onDashboardDrilldownApplied?.();
|
||||
}, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied, setSearch, setColumnFilters, setCpnpFilter]);
|
||||
|
||||
const pageSize = 100;
|
||||
|
||||
const columns = [
|
||||
{ col: COLUMNS.ARTICLE_NO, label: 'SKU', width: 110 },
|
||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Name', width: 240 },
|
||||
{ col: COLUMNS.LINE, label: 'Line', width: 120 },
|
||||
{ col: COLUMNS.ITEM_AVAILABLE, label: 'Item Available', width: 120 },
|
||||
{ col: COLUMNS.CPNP_NO, label: 'CPNP No.', width: 160 },
|
||||
];
|
||||
|
||||
const columnUniqueValues = useMemo(() => {
|
||||
const result: Record<number, Set<string>> = {};
|
||||
columns.forEach(c => { result[c.col] = new Set(); });
|
||||
|
||||
data.forEach(row => {
|
||||
const lineVal = String(row[COLUMNS.LINE] ?? '').trim().toUpperCase();
|
||||
if (!COSMETIC_LINES.some(l => lineVal === l)) return;
|
||||
|
||||
columns.forEach(({ col }) => {
|
||||
result[col].add(String(row[col] ?? '').replace(/\s+/g, ' ').trim());
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [data, COLUMNS]);
|
||||
|
||||
const getUniqueValues = (col: number): string[] =>
|
||||
Array.from(columnUniqueValues[col] as Set<string> ?? []).sort((a, b) => {
|
||||
const na = parseFloat(a), nb = parseFloat(b);
|
||||
if (!isNaN(na) && !isNaN(nb)) return na - nb;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data.map((row, index) => ({ row, index }));
|
||||
|
||||
// Filter: only cosmetic lines
|
||||
result = result.filter(({ row }) => {
|
||||
const lineVal = String(row[COLUMNS.LINE] ?? '').trim().toUpperCase();
|
||||
return COSMETIC_LINES.some(l => lineVal === l);
|
||||
});
|
||||
|
||||
if (cpnpFilter !== 'all') {
|
||||
result = result.filter(({ row }) => {
|
||||
const cpnp = String(row[COLUMNS.CPNP_NO] ?? '').trim();
|
||||
return cpnpFilter === 'present' ? cpnp !== '' : cpnp === '';
|
||||
});
|
||||
}
|
||||
|
||||
// Global search
|
||||
if (search) {
|
||||
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
result = result.filter(({ row }) => {
|
||||
const articleNo = String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
|
||||
const articleName = String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
|
||||
return terms.every(t => articleNo.includes(t) || articleName.includes(t));
|
||||
});
|
||||
}
|
||||
|
||||
// Column filters
|
||||
(Object.entries(columnFilters) as [string, string[]][]).forEach(([colIdx, filterValues]) => {
|
||||
if (!filterValues || filterValues.length === 0) return;
|
||||
const colNum = parseInt(colIdx);
|
||||
const knownCount = columnUniqueValues[colNum]?.size ?? 0;
|
||||
// All known values selected → no filtering needed
|
||||
if (filterValues.length >= knownCount) return;
|
||||
const normalize = (v: any) => String(v ?? '').replace(/\s+/g, ' ').trim();
|
||||
const filterSet = new Set(filterValues.map(normalize));
|
||||
result = result.filter(({ row }) => filterSet.has(normalize(row[colNum])));
|
||||
});
|
||||
|
||||
// Sort
|
||||
if (sortCol !== null) {
|
||||
result.sort((a, b) => {
|
||||
const valA = a.row[sortCol];
|
||||
const valB = b.row[sortCol];
|
||||
if (typeof valA === 'number' && typeof valB === 'number') {
|
||||
return sortDesc ? valB - valA : valA - valB;
|
||||
}
|
||||
const sA = String(valA ?? '');
|
||||
const sB = String(valB ?? '');
|
||||
return sortDesc ? sB.localeCompare(sA) : sA.localeCompare(sB);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, search, sortCol, sortDesc, columnFilters, COLUMNS, columnUniqueValues, cpnpFilter]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, page]);
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
|
||||
const handleSort = (col: number) => {
|
||||
if (sortCol === col) setSortDesc(d => !d);
|
||||
else { setSortCol(col); setSortDesc(false); }
|
||||
};
|
||||
|
||||
const startEditCpnp = (rowIndex: number, currentValue: any) => {
|
||||
setEditingCpnp({ rowIndex, value: String(currentValue ?? '') });
|
||||
};
|
||||
|
||||
const saveCpnp = async (rowIndex: number) => {
|
||||
if (!editingCpnp || editingCpnp.rowIndex !== rowIndex) return;
|
||||
const row = data[rowIndex];
|
||||
const articleNo = String(row[COLUMNS.ARTICLE_NO]);
|
||||
const cpnpValue = editingCpnp.value.trim();
|
||||
const newRow = [...row];
|
||||
newRow[COLUMNS.CPNP_NO] = cpnpValue;
|
||||
setSavingCpnp(rowIndex);
|
||||
setEditingCpnp(null);
|
||||
onCaptureState(`Updated CPNP No. for ${articleNo}`);
|
||||
onSaveRow(rowIndex, newRow);
|
||||
|
||||
const supabaseResult = await saveRowToSupabase(articleNo, newRow, 'edited');
|
||||
if (supabaseResult.success) {
|
||||
onQueueBcSync(articleNo, rowIndex, row, newRow, String(row[COLUMNS.ARTICLE_NAME] || articleNo));
|
||||
}
|
||||
|
||||
setSavingCpnp(null);
|
||||
|
||||
const errors: string[] = [];
|
||||
if (!supabaseResult.success) errors.push(`Supabase: ${supabaseResult.error}`);
|
||||
if (errors.length > 0) {
|
||||
alert(`Error saving CPNP No. for ${articleNo}:\n${errors.join('\n')}`);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEditCpnp = () => setEditingCpnp(null);
|
||||
|
||||
const lineBadgeColor = (line: string) => {
|
||||
const l = String(line).toUpperCase();
|
||||
if (l === 'INKEE') return 'bg-pink-500/10 text-pink-400 border-pink-500/20';
|
||||
if (l === 'BATH FUN') return 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20';
|
||||
if (l === 'TOP FASHION') return 'bg-purple-500/10 text-purple-400 border-purple-500/20';
|
||||
if (l === 'SENSES') return 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20';
|
||||
return 'bg-slate-700/50 text-slate-400 border-slate-600/50';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search SKU or Name..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-full pl-9 pr-10 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setPage(1); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||
<span className="rounded-full border border-fuchsia-500/20 bg-fuchsia-500/10 px-3 py-1 font-medium text-fuchsia-200">
|
||||
Filtered {filteredData.length}
|
||||
</span>
|
||||
<span className="text-slate-600">·</span>
|
||||
<span>Lines: {COSMETIC_LINES.join(', ')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{[
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'present', label: 'With CPNP' },
|
||||
{ id: 'missing', label: 'Missing CPNP' },
|
||||
].map(option => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => { setCpnpFilter(option.id as 'all' | 'present' | 'missing'); setPage(1); }}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-2 text-xs font-medium transition-colors",
|
||||
cpnpFilter === option.id
|
||||
? "border-indigo-500 bg-indigo-500/15 text-indigo-200"
|
||||
: "border-slate-700 bg-slate-900/50 text-slate-400 hover:border-slate-600 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
||||
<tr>
|
||||
{columns.map(({ col, label, width }) => {
|
||||
const selectedFilters = columnFilters[col] || [];
|
||||
const filterCount = selectedFilters.length;
|
||||
const isCpnp = col === COLUMNS.CPNP_NO;
|
||||
return (
|
||||
<th
|
||||
key={col}
|
||||
style={{ width, minWidth: width }}
|
||||
className="px-2 py-2 font-medium border-r border-slate-700/30 relative"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer select-none hover:text-white"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSort(col)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') handleSort(col); }}
|
||||
>
|
||||
{label}
|
||||
{sortCol === col && (
|
||||
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||
)}
|
||||
{isCpnp && (
|
||||
<span className="ml-1 text-[9px] text-indigo-400 font-normal">(editable)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 relative">
|
||||
<button
|
||||
id={`cosmetic-filter-trigger-${col}`}
|
||||
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === col ? null : col); }}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-1.5 py-0.5 bg-slate-800 border rounded text-[10px] transition-colors",
|
||||
filterCount > 0 ? "border-indigo-500 text-white" : "border-slate-600 text-slate-400 hover:border-slate-500"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{filterCount > 0 ? `${filterCount} selected` : 'Filter...'}</span>
|
||||
<ChevronDown className={cn("w-3 h-3 transition-transform", openFilter === col && "rotate-180")} />
|
||||
</button>
|
||||
{openFilter === col && (
|
||||
<ColumnFilterPopover
|
||||
triggerId={`cosmetic-filter-trigger-${col}`}
|
||||
uniqueValues={getUniqueValues(col)}
|
||||
selectedValues={selectedFilters}
|
||||
onToggle={(val) => setColumnFilters(prev => {
|
||||
const current = prev[col] || [];
|
||||
if (current.includes(val)) {
|
||||
return { ...prev, [col]: current.filter(v => v !== val) };
|
||||
}
|
||||
return { ...prev, [col]: [...current, val] };
|
||||
})}
|
||||
onSelectAll={(vals) => setColumnFilters(prev => ({ ...prev, [col]: vals }))}
|
||||
onClear={() => { setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; }); }}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/30">
|
||||
{paginatedData.map(({ row, index }) => {
|
||||
const articleNo = String(row[COLUMNS.ARTICLE_NO] ?? '');
|
||||
const status = rowStatuses[articleNo];
|
||||
const isEditing = editingCpnp?.rowIndex === index;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={articleNo || index}
|
||||
className={cn(
|
||||
"hover:bg-slate-700/20 transition-colors",
|
||||
false
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: 110 }}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{articleNo}</span>
|
||||
{status && <SyncStatusPill status={status} className="self-start" />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: 240 }} title={row[COLUMNS.ARTICLE_NAME]}>
|
||||
{row[COLUMNS.ARTICLE_NAME]}
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{ width: 120 }}>
|
||||
<span className={cn(
|
||||
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
|
||||
lineBadgeColor(String(row[COLUMNS.LINE] ?? ''))
|
||||
)}>
|
||||
{row[COLUMNS.LINE]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-300 text-right" style={{ width: 120 }}>
|
||||
{row[COLUMNS.ITEM_AVAILABLE]}
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{ width: 160 }}>
|
||||
{savingCpnp === index ? (
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
) : isEditing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={cpnpInputRef}
|
||||
type="text"
|
||||
value={editingCpnp.value}
|
||||
onChange={e => setEditingCpnp(prev => prev ? { ...prev, value: e.target.value } : prev)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveCpnp(index);
|
||||
if (e.key === 'Escape') cancelEditCpnp();
|
||||
}}
|
||||
className="flex-1 min-w-0 bg-slate-900 border border-indigo-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="Enter CPNP No."
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveCpnp(index)}
|
||||
className="p-1 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-400/10 rounded transition-colors"
|
||||
title="Save"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditCpnp}
|
||||
className="p-1 text-slate-500 hover:text-slate-300 hover:bg-slate-700 rounded transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (() => {
|
||||
const cpnpVal = String(row[COLUMNS.CPNP_NO] ?? '').trim();
|
||||
return cpnpVal ? (
|
||||
<button
|
||||
onClick={() => startEditCpnp(index, row[COLUMNS.CPNP_NO])}
|
||||
className="w-full text-left px-2 py-1 rounded border border-slate-600 text-emerald-400 hover:border-indigo-500 hover:text-indigo-400 transition-colors text-[10px] font-mono"
|
||||
title="Click to edit CPNP No."
|
||||
>
|
||||
{cpnpVal}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startEditCpnp(index, row[COLUMNS.CPNP_NO])}
|
||||
className="w-full text-left px-2 py-1 rounded border border-dashed border-slate-600 text-slate-500 hover:border-indigo-500 hover:text-indigo-400 transition-colors text-[10px]"
|
||||
title="Click to enter CPNP No."
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Save className="w-3 h-3 opacity-50" />
|
||||
Click to fill...
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{paginatedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-500">
|
||||
{cpnpFilter === 'all'
|
||||
? 'No cosmetic items found.'
|
||||
: cpnpFilter === 'present'
|
||||
? 'No cosmetic items with CPNP No. found.'
|
||||
: 'No cosmetic items missing CPNP No. found.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-xs text-slate-500">
|
||||
<div>
|
||||
Showing {paginatedData.length} of {filteredData.length} filtered items
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-slate-300">Page {page} of {totalPages || 1}</span>
|
||||
<button
|
||||
disabled={page === totalPages || totalPages === 0}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { ExcelRow } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
@@ -9,6 +10,7 @@ interface DataCompletenessProps {
|
||||
}
|
||||
|
||||
export function DataCompleteness({ data, headers }: DataCompletenessProps) {
|
||||
const COLUMNS = useColumns();
|
||||
const [sortCol, setSortCol] = useState<number | 'score'>('score');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface DateFilterPopoverProps {
|
||||
selectedRange: { start: string; end: string };
|
||||
onRangeChange: (range: { start: string; end: string }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DateFilterPopover({ selectedRange, onRangeChange, onClose }: DateFilterPopoverProps) {
|
||||
const [startDate, setStartDate] = useState(selectedRange.start);
|
||||
const [endDate, setEndDate] = useState(selectedRange.end);
|
||||
|
||||
const handleApply = () => {
|
||||
onRangeChange({ start: startDate, end: endDate });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
onRangeChange({ start: '', end: '' });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const hasValue = startDate || endDate;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-full left-0 mt-1 w-72 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-4 flex flex-col gap-4 animate-in fade-in zoom-in-95 duration-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Filter by Date Range
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wider">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={e => setStartDate(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wider">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={e => setEndDate(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-700">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-[10px] font-medium text-slate-300 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black rounded transition-colors shadow-lg"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X, Undo2 } from 'lucide-react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X, Undo2, Maximize2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
import { usePersistentState } from '../contexts/FilterContext';
|
||||
import { SyncStatusPill } from './SyncStatusPill';
|
||||
|
||||
interface DimensionsViewProps {
|
||||
data: ExcelRow[];
|
||||
@@ -35,6 +38,7 @@ interface NearDuplicateCluster {
|
||||
}
|
||||
|
||||
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState, rowStatuses, onRevertRow }: DimensionsViewProps) {
|
||||
const COLUMNS = useColumns();
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
|
||||
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
|
||||
@@ -51,10 +55,23 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
targetGroupKey: string;
|
||||
selectedIndices: number[];
|
||||
} | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState<string[]>([]);
|
||||
const [classFilter, setClassFilter] = useState<string[]>([]);
|
||||
const [search, setSearch] = usePersistentState('dimensions-search', '');
|
||||
const [lineFilter, setLineFilter] = usePersistentState<string[]>('dimensions-lineFilter', []);
|
||||
const [classFilter, setClassFilter] = usePersistentState<string[]>('dimensions-classFilter', []);
|
||||
const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState<number | null>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const [activeClusterKey, setActiveClusterKey] = useState<string | null>(null);
|
||||
const [openDropdown, setOpenDropdown] = useState<{ rowIndex: number; field: 'outer' | 'units' | 'moq' } | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const dragStartRef = React.useRef<number | null>(null);
|
||||
const hoveredIndexRef = React.useRef<number | null>(null);
|
||||
const activeClusterKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
|
||||
@@ -133,10 +150,15 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
if (search || lineFilter.length > 0 || classFilter.length > 0) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(g => {
|
||||
const matchesSearch = !search || g.rows.some(({ row }) =>
|
||||
String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
const matchesSearch = !search || (() => {
|
||||
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (terms.length === 0) return true;
|
||||
return g.rows.some(({ row }) => {
|
||||
const articleNo = String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
|
||||
const articleName = String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
|
||||
return terms.every(term => articleNo.includes(term) || articleName.includes(term));
|
||||
});
|
||||
})();
|
||||
const matchesLine = lineFilter.length === 0 || g.rows.some(({ row }) => lineFilter.includes(String(row[COLUMNS.LINE] || '')));
|
||||
const matchesClass = classFilter.length === 0 || g.rows.some(({ row }) => classFilter.includes(String(row[COLUMNS.CLASSIFICATION] || '')));
|
||||
|
||||
@@ -205,6 +227,55 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
return clusters;
|
||||
}, [groups]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleGlobalMouseUp = () => {
|
||||
if (isDraggingRef.current && activeClusterKeyRef.current) {
|
||||
const clusterKey = activeClusterKeyRef.current;
|
||||
const start = dragStartRef.current;
|
||||
const end = hoveredIndexRef.current;
|
||||
|
||||
if (start !== null && end !== null) {
|
||||
const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey);
|
||||
if (cluster) {
|
||||
const allClusterRows = cluster.groups.flatMap(g => g.rows);
|
||||
const s = Math.min(start, end);
|
||||
const e = Math.max(start, end);
|
||||
const indicesToSelect = allClusterRows.slice(s, e + 1).map(r => r.index);
|
||||
|
||||
setClusterSelections(prev => {
|
||||
const next = new Set(prev[clusterKey] ?? []);
|
||||
indicesToSelect.forEach(idx => next.add(idx));
|
||||
return { ...prev, [clusterKey]: next };
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
isDraggingRef.current = false;
|
||||
dragStartRef.current = null;
|
||||
hoveredIndexRef.current = null;
|
||||
activeClusterKeyRef.current = null;
|
||||
setIsDragging(false);
|
||||
setDragStart(null);
|
||||
setHoveredIndex(null);
|
||||
setActiveClusterKey(null);
|
||||
};
|
||||
|
||||
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
return () => window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
}, [nearDuplicateClusters]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (openDropdown && !(e.target as Element)?.closest('.dropdown-container')) {
|
||||
setOpenDropdown(null);
|
||||
}
|
||||
};
|
||||
if (openDropdown) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [openDropdown]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
const next = new Set(expandedGroups);
|
||||
if (next.has(key)) {
|
||||
@@ -223,6 +294,47 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
setPendingAction({ group, sourceRow, fieldType: 'all' });
|
||||
};
|
||||
|
||||
const handleSingleFieldChange = async (rowIndex: number, field: 'outer' | 'units' | 'moq', value: string | number) => {
|
||||
const row = data[rowIndex];
|
||||
const updatedRow = [...row];
|
||||
|
||||
if (field === 'outer') {
|
||||
const parts = String(value).split('x').map(Number);
|
||||
if (parts.length === 3) {
|
||||
updatedRow[COLUMNS.OUTER_L] = parts[0];
|
||||
updatedRow[COLUMNS.OUTER_W] = parts[1];
|
||||
updatedRow[COLUMNS.OUTER_H] = parts[2];
|
||||
}
|
||||
} else if (field === 'units') {
|
||||
updatedRow[COLUMNS.UNITS_OUTER] = value;
|
||||
} else if (field === 'moq') {
|
||||
updatedRow[COLUMNS.MOQ] = value;
|
||||
}
|
||||
|
||||
await onSaveRow(rowIndex, updatedRow);
|
||||
onCaptureState(`Updated ${field} for article ${row[COLUMNS.ARTICLE_NO]}`);
|
||||
setOpenDropdown(null);
|
||||
};
|
||||
|
||||
const getUniqueGroupValues = (group: DimensionGroup, field: 'outer' | 'units' | 'moq'): string[] => {
|
||||
const values = new Set<string>();
|
||||
group.rows.forEach(({ row }) => {
|
||||
if (field === 'outer') {
|
||||
const outer = `${row[COLUMNS.OUTER_L]}x${row[COLUMNS.OUTER_W]}x${row[COLUMNS.OUTER_H]}`;
|
||||
if (outer !== 'undefinedxundefinedxundefined' && row[COLUMNS.OUTER_L] != null) {
|
||||
values.add(outer);
|
||||
}
|
||||
} else if (field === 'units') {
|
||||
const val = String(row[COLUMNS.UNITS_OUTER] ?? '');
|
||||
if (val) values.add(val);
|
||||
} else if (field === 'moq') {
|
||||
const val = String(row[COLUMNS.MOQ] ?? '');
|
||||
if (val) values.add(val);
|
||||
}
|
||||
});
|
||||
return Array.from(values).sort();
|
||||
};
|
||||
|
||||
const handleVerifyGroup = (e: React.MouseEvent, group: DimensionGroup) => {
|
||||
e.stopPropagation();
|
||||
group.rows.forEach(({ row, index }) => {
|
||||
@@ -310,7 +422,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "space-y-6"}>
|
||||
{isFullscreen && (
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
|
||||
title="Exit fullscreen"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-4 bg-slate-800/50 p-4 rounded-lg border border-slate-700">
|
||||
<div className="relative flex-1 min-w-[250px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
@@ -334,6 +455,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
id="dims-filter-trigger-line"
|
||||
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
|
||||
@@ -345,6 +467,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
</button>
|
||||
{openFilter === 'line' && (
|
||||
<ColumnFilterPopover
|
||||
triggerId="dims-filter-trigger-line"
|
||||
uniqueValues={uniqueLines}
|
||||
selectedValues={lineFilter}
|
||||
onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||
@@ -359,6 +482,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
id="dims-filter-trigger-class"
|
||||
onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
|
||||
@@ -370,6 +494,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
</button>
|
||||
{openFilter === 'class' && (
|
||||
<ColumnFilterPopover
|
||||
triggerId="dims-filter-trigger-class"
|
||||
uniqueValues={uniqueClasses}
|
||||
selectedValues={classFilter}
|
||||
onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||
@@ -405,6 +530,13 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
/>
|
||||
Show only inconsistent
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors ml-4"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="text-[10px] font-bold text-amber-500 bg-amber-500/10 px-2 py-1 rounded border border-amber-500/20 whitespace-nowrap">
|
||||
{groups.filter(g => g.isInconsistent).length} ISSUES
|
||||
</div>
|
||||
@@ -492,24 +624,64 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-violet-500/10">
|
||||
{allClusterRows.map(({ row, index }) => {
|
||||
{allClusterRows.map(({ row, index }, rowIdx) => {
|
||||
const isSelected = selection.has(index);
|
||||
const isInDragRange = isDragging && activeClusterKey === clusterKey && dragStart !== null && hoveredIndex !== null &&
|
||||
((rowIdx >= dragStart && rowIdx <= hoveredIndex) || (rowIdx <= dragStart && rowIdx >= hoveredIndex));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors",
|
||||
isSelected && "bg-violet-500/10"
|
||||
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors select-none",
|
||||
isSelected && "bg-violet-500/10",
|
||||
isInDragRange && "bg-violet-500/20 ring-1 ring-violet-500/30"
|
||||
)}
|
||||
onClick={() => setClusterSelections(prev => {
|
||||
const current = new Set(prev[clusterKey] ?? []);
|
||||
if (current.has(index)) current.delete(index); else current.add(index);
|
||||
return { ...prev, [clusterKey]: current };
|
||||
})}
|
||||
onMouseDown={(e) => {
|
||||
// Only handle left click
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
dragStartRef.current = rowIdx;
|
||||
hoveredIndexRef.current = rowIdx;
|
||||
activeClusterKeyRef.current = clusterKey;
|
||||
setIsDragging(true);
|
||||
setDragStart(rowIdx);
|
||||
setHoveredIndex(rowIdx);
|
||||
setActiveClusterKey(clusterKey);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (isDraggingRef.current && activeClusterKeyRef.current === clusterKey) {
|
||||
hoveredIndexRef.current = rowIdx;
|
||||
setHoveredIndex(rowIdx);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (!isDragging) {
|
||||
if (e.shiftKey && lastClickedIndex !== null) {
|
||||
const start = Math.min(lastClickedIndex, rowIdx);
|
||||
const end = Math.max(lastClickedIndex, rowIdx);
|
||||
const indicesToSelect = allClusterRows.slice(start, end + 1).map(r => r.index);
|
||||
|
||||
setClusterSelections(prev => {
|
||||
const next = new Set(prev[clusterKey] ?? []);
|
||||
indicesToSelect.forEach(idx => next.add(idx));
|
||||
return { ...prev, [clusterKey]: next };
|
||||
});
|
||||
} else {
|
||||
setClusterSelections(prev => {
|
||||
const current = new Set(prev[clusterKey] ?? []);
|
||||
if (current.has(index)) current.delete(index); else current.add(index);
|
||||
return { ...prev, [clusterKey]: current };
|
||||
});
|
||||
}
|
||||
setLastClickedIndex(rowIdx);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
checked={isSelected || isInDragRange}
|
||||
readOnly
|
||||
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
|
||||
/>
|
||||
@@ -631,6 +803,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
<tbody className="divide-y divide-slate-700/50">
|
||||
{group.rows.map(({ row, index }) => {
|
||||
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
|
||||
const syncStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
@@ -642,6 +815,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-slate-200">{row[COLUMNS.ARTICLE_NO]}</div>
|
||||
<div className="text-[10px] text-slate-500 truncate max-w-[200px]">{row[COLUMNS.ARTICLE_NAME]}</div>
|
||||
{syncStatus && <SyncStatusPill status={syncStatus} className="mt-1" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400 font-mono">
|
||||
{row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? row[COLUMNS.INNER_L] : '-'} × {row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? row[COLUMNS.INNER_W] : '-'} × {row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? row[COLUMNS.INNER_H] : '-'}
|
||||
@@ -650,48 +824,105 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
"px-4 py-3 font-mono",
|
||||
group.discrepancies.outer ? "text-amber-300" : "text-slate-400"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 group/cell">
|
||||
<div className="flex items-center gap-2 group/cell dropdown-container relative">
|
||||
<button
|
||||
onClick={() => handleSyncField(group, row, 'outer')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenDropdown(openDropdown?.rowIndex === index && openDropdown?.field === 'outer' ? null : { rowIndex: index, field: 'outer' });
|
||||
}}
|
||||
disabled={syncing?.key === group.key}
|
||||
title="Apply these Outer Dims to all in group"
|
||||
title="Change value"
|
||||
className="p-1 hover:bg-emerald-600/20 text-slate-600 hover:text-emerald-400 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{syncing?.key === group.key && syncing?.field === 'outer' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
|
||||
{syncing?.key === group.key && syncing?.field === 'outer' ? <Loader2 className="w-3 h-3 animate-spin" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
<span>{row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? row[COLUMNS.OUTER_L] : '-'} × {row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? row[COLUMNS.OUTER_W] : '-'} × {row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? row[COLUMNS.OUTER_H] : '-'}</span>
|
||||
{openDropdown?.rowIndex === index && openDropdown?.field === 'outer' && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-slate-800 border border-slate-600 rounded-md shadow-lg z-50 py-1 min-w-[150px]">
|
||||
{getUniqueGroupValues(group, 'outer').map(val => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSingleFieldChange(index, 'outer', val);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-slate-300 hover:bg-emerald-600/20 hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={cn(
|
||||
"px-4 py-3",
|
||||
group.discrepancies.units ? "text-amber-300 font-bold" : "text-slate-400"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 group/cell">
|
||||
<div className="flex items-center gap-2 group/cell dropdown-container relative">
|
||||
<button
|
||||
onClick={() => handleSyncField(group, row, 'units')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenDropdown(openDropdown?.rowIndex === index && openDropdown?.field === 'units' ? null : { rowIndex: index, field: 'units' });
|
||||
}}
|
||||
disabled={syncing?.key === group.key}
|
||||
title="Apply this Units/Outer to all in group"
|
||||
title="Change value"
|
||||
className="p-1 hover:bg-emerald-600/20 text-slate-600 hover:text-emerald-400 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{syncing?.key === group.key && syncing?.field === 'units' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
|
||||
{syncing?.key === group.key && syncing?.field === 'units' ? <Loader2 className="w-3 h-3 animate-spin" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
<span>{row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? row[COLUMNS.UNITS_OUTER] : '-'}</span>
|
||||
{openDropdown?.rowIndex === index && openDropdown?.field === 'units' && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-slate-800 border border-slate-600 rounded-md shadow-lg z-50 py-1 min-w-[100px]">
|
||||
{getUniqueGroupValues(group, 'units').map(val => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSingleFieldChange(index, 'units', val);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-slate-300 hover:bg-emerald-600/20 hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={cn(
|
||||
"px-4 py-3",
|
||||
group.discrepancies.moq ? "text-amber-300 font-bold" : "text-slate-400"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 group/cell">
|
||||
<div className="flex items-center gap-2 group/cell dropdown-container relative">
|
||||
<button
|
||||
onClick={() => handleSyncField(group, row, 'moq')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenDropdown(openDropdown?.rowIndex === index && openDropdown?.field === 'moq' ? null : { rowIndex: index, field: 'moq' });
|
||||
}}
|
||||
disabled={syncing?.key === group.key}
|
||||
title="Apply this MOQ to all in group"
|
||||
title="Change value"
|
||||
className="p-1 hover:bg-emerald-600/20 text-slate-600 hover:text-emerald-400 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{syncing?.key === group.key && syncing?.field === 'moq' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
|
||||
{syncing?.key === group.key && syncing?.field === 'moq' ? <Loader2 className="w-3 h-3 animate-spin" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
<span>{row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? row[COLUMNS.MOQ] : '-'}</span>
|
||||
{openDropdown?.rowIndex === index && openDropdown?.field === 'moq' && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-slate-800 border border-slate-600 rounded-md shadow-lg z-50 py-1 min-w-[100px]">
|
||||
{getUniqueGroupValues(group, 'moq').map(val => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSingleFieldChange(index, 'moq', val);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-slate-300 hover:bg-emerald-600/20 hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
|
||||
+114
-26
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2, Mic, MicOff } from 'lucide-react';
|
||||
import { generateGemini } from '../services/gemini';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -39,6 +40,48 @@ const DimensionInput = ({ label, value, isModified, onChange, placeholder }: Dim
|
||||
</div>
|
||||
);
|
||||
|
||||
const AutoResizeTextarea = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
className,
|
||||
placeholder
|
||||
}: {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [value, adjustHeight]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(className, "overflow-hidden resize-none")}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface FieldEditorProps {
|
||||
title: string;
|
||||
field: string;
|
||||
@@ -111,13 +154,13 @@ const FieldEditor = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
<AutoResizeTextarea
|
||||
id={`field-${field}`}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
"w-full h-32 bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
||||
"w-full min-h-[128px] bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
||||
isModified ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||
)}
|
||||
placeholder={`Enter ${title}...`}
|
||||
@@ -126,6 +169,7 @@ const FieldEditor = ({
|
||||
);
|
||||
|
||||
export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: EditPanelProps) {
|
||||
const COLUMNS = useColumns();
|
||||
const [formData, setFormData] = useState({
|
||||
longDe: row[COLUMNS.LONG_DE] || '',
|
||||
longEn: row[COLUMNS.LONG_EN] || '',
|
||||
@@ -141,6 +185,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
outerH: row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? String(row[COLUMNS.OUTER_H]) : '',
|
||||
unitsOuter: row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? String(row[COLUMNS.UNITS_OUTER]) : '',
|
||||
moq: row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? String(row[COLUMNS.MOQ]) : '',
|
||||
productType: row[COLUMNS.CATEGORIZATION_CODE] !== undefined && row[COLUMNS.CATEGORIZATION_CODE] !== null ? String(row[COLUMNS.CATEGORIZATION_CODE]) : '',
|
||||
itemToLogistic: row[COLUMNS.ITEM_TO_LOGISTIC] !== undefined && row[COLUMNS.ITEM_TO_LOGISTIC] !== null ? String(row[COLUMNS.ITEM_TO_LOGISTIC]) : '',
|
||||
});
|
||||
|
||||
const [loadingField, setLoadingField] = useState<string | null>(null);
|
||||
@@ -176,28 +222,35 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
moq: COLUMNS.MOQ,
|
||||
detailsDe: COLUMNS.DETAILS_DE,
|
||||
detailsEn: COLUMNS.DETAILS_EN,
|
||||
productType: COLUMNS.CATEGORIZATION_CODE,
|
||||
itemToLogistic: COLUMNS.ITEM_TO_LOGISTIC,
|
||||
};
|
||||
const colIndex = (colMap as Record<string, number>)[field as string];
|
||||
if (colIndex === undefined) return false;
|
||||
return formData[field] !== (row[colIndex] !== undefined && row[colIndex] !== null ? String(row[colIndex]) : '');
|
||||
};
|
||||
|
||||
const handleGenerate = async (field: keyof typeof formData) => {
|
||||
const handleGenerate = (field: keyof typeof formData) => {
|
||||
console.log('[EditPanel] handleGenerate called for field:', field);
|
||||
setError(null); // Clear any previous error
|
||||
setPendingGeminiField(field);
|
||||
};
|
||||
|
||||
const executeGenerate = async () => {
|
||||
if (!pendingGeminiField) return;
|
||||
if (!pendingGeminiField) {
|
||||
console.warn('[EditPanel] executeGenerate called but no field pending');
|
||||
return;
|
||||
}
|
||||
const field = pendingGeminiField;
|
||||
setPendingGeminiField(null);
|
||||
console.log('[EditPanel] Starting generation for field:', field);
|
||||
setLoadingField(field);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
let prompt = '';
|
||||
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${formData.detailsEn || 'N/A'}\nArticle Details (DE): ${formData.detailsDe || 'N/A'}`;
|
||||
|
||||
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. CRITICAL: Output ONLY the translated or generated content. Do not include any introductions, conclusions, or conversational text. Translate EVERYTHING, including phrases in ALL CAPS (maintain the all-caps casing for those phrases in the translation). Your response must contain only the final product description.";
|
||||
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. CRITICAL: Output ONLY the translated or generated content. Do not include any introductions, conclusions, or conversational text. Translate EVERYTHING, including phrases in ALL CAPS (maintain the all-caps casing for those phrases in the translation). Your response must be complete, with all sentences finished and logically concluded. Never end mid-sentence.";
|
||||
|
||||
if (field === 'longDe') {
|
||||
if (formData.longEn) {
|
||||
@@ -208,11 +261,13 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
3. Keep all technical specs intact.
|
||||
4. Translate every single paragraph into natural, commercial German for toy buyers.
|
||||
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
|
||||
6. ENSURE the translation is complete and does not cut off. The final sentence must be fully finished.
|
||||
|
||||
English Text to Translate:
|
||||
${formData.longEn}`;
|
||||
} else {
|
||||
prompt = `Based on the following product details, generate a long commercial description in German. It should be fluent, oriented towards B2B toy buyers, 3-5 paragraphs long. Include features, benefits, and material if available.\n\n${baseContext}`;
|
||||
prompt = `Based on the following product details, generate a long commercial description in German. It should be fluent, oriented towards B2B toy buyers, 3-5 paragraphs long. Include features, benefits, and material if available.
|
||||
IMPORTANT: Ensure the description is complete and ends with a finished sentence.\n\n${baseContext}`;
|
||||
}
|
||||
} else if (field === 'longEn') {
|
||||
if (formData.longDe) {
|
||||
@@ -223,29 +278,47 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
3. Keep all technical specs intact.
|
||||
4. Translate every single paragraph into natural, commercial English for toy buyers.
|
||||
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
|
||||
6. ENSURE the translation is complete and does not cut off. The final sentence must be fully finished.
|
||||
|
||||
German Text to Translate:
|
||||
${formData.longDe}`;
|
||||
} else {
|
||||
prompt = `Based on the following product details, generate a long commercial description in English. It should be fluent, oriented towards B2B toy buyers, 3-5 paragraphs long.\n\n${baseContext}`;
|
||||
prompt = `Based on the following product details, generate a long commercial description in English. It should be fluent, oriented towards B2B toy buyers, 3-5 paragraphs long.
|
||||
IMPORTANT: Ensure the description is complete and ends with a finished sentence.\n\n${baseContext}`;
|
||||
}
|
||||
} else if (field === 'shortDe') {
|
||||
if (formData.shortEn) {
|
||||
prompt = `Translate exactly this short English product description into professional German for the toy market. IMPORTANT: Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting:\n\n${formData.shortEn}`;
|
||||
prompt = `Translate exactly this short English product description into professional German for the toy market.
|
||||
IMPORTANT:
|
||||
1. Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting.
|
||||
2. ENSURE the translation is complete and does not cut off mid-sentence.\n\n${formData.shortEn}`;
|
||||
} else if (formData.longDe) {
|
||||
const targetChars = Math.round(formData.longDe.length * 0.3);
|
||||
prompt = `Create a concise summary of the following German product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longDe}`;
|
||||
prompt = `Create a concise summary of the following German product description.
|
||||
CRITICAL: The summary MUST be between 250 and 450 characters long.
|
||||
Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers.
|
||||
Do not use bullet points — write flowing prose.
|
||||
IMPORTANT: Ensure the summary is a complete thought and ends with a finished sentence.\n\nOriginal description:\n${formData.longDe}`;
|
||||
} else {
|
||||
prompt = `Based on the following product details, generate a short commercial description in German (2-4 sentences max).\n\n${baseContext}`;
|
||||
prompt = `Based on the following product details, generate a short commercial description in German.
|
||||
CRITICAL: Length MUST be between 250 and 450 characters.
|
||||
Ensure the text is complete and ends with a finished sentence.\n\n${baseContext}`;
|
||||
}
|
||||
} else if (field === 'shortEn') {
|
||||
if (formData.shortDe) {
|
||||
prompt = `Translate exactly this short German product description into professional English for the toy market. IMPORTANT: Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting:\n\n${formData.shortDe}`;
|
||||
prompt = `Translate exactly this short German product description into professional English for the toy market.
|
||||
IMPORTANT:
|
||||
1. Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting.
|
||||
2. ENSURE the translation is complete and does not cut off mid-sentence.\n\n${formData.shortDe}`;
|
||||
} else if (formData.longEn) {
|
||||
const targetChars = Math.round(formData.longEn.length * 0.3);
|
||||
prompt = `Create a concise summary of the following English product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longEn}`;
|
||||
prompt = `Create a concise summary of the following English product description.
|
||||
CRITICAL: The summary MUST be between 250 and 450 characters long.
|
||||
Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers.
|
||||
Do not use bullet points — write flowing prose.
|
||||
IMPORTANT: Ensure the summary is a complete thought and ends with a finished sentence.\n\nOriginal description:\n${formData.longEn}`;
|
||||
} else {
|
||||
prompt = `Based on the following product details, generate a short commercial description in English (2-4 sentences max).\n\n${baseContext}`;
|
||||
prompt = `Based on the following product details, generate a short commercial description in English.
|
||||
CRITICAL: Length MUST be between 250 and 450 characters.
|
||||
Ensure the text is complete and ends with a finished sentence.\n\n${baseContext}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,8 +330,10 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
const textarea = document.getElementById(`field-${field}`);
|
||||
if (textarea) textarea.focus();
|
||||
}, 100);
|
||||
setPendingGeminiField(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred during generation.');
|
||||
setPendingGeminiField(null); // Close modal on error so error message is visible in panel
|
||||
} finally {
|
||||
setLoadingField(null);
|
||||
}
|
||||
@@ -285,6 +360,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
newRow[COLUMNS.MOQ] = formData.moq;
|
||||
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
|
||||
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
|
||||
newRow[COLUMNS.CATEGORIZATION_CODE] = formData.productType;
|
||||
newRow[COLUMNS.ITEM_TO_LOGISTIC] = formData.itemToLogistic;
|
||||
onSave(rowIndex, newRow);
|
||||
};
|
||||
|
||||
@@ -324,11 +401,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="block text-xs text-slate-500 mb-1">Details (DE)</span>
|
||||
<textarea
|
||||
<AutoResizeTextarea
|
||||
value={formData.detailsDe}
|
||||
onChange={e => setFormData(prev => ({ ...prev, detailsDe: e.target.value }))}
|
||||
className={cn(
|
||||
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
||||
"w-full min-h-[80px] bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
||||
isModified('detailsDe') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
|
||||
)}
|
||||
placeholder="Enter details in German..."
|
||||
@@ -336,11 +413,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="block text-xs text-slate-500 mb-1">Details (EN)</span>
|
||||
<textarea
|
||||
<AutoResizeTextarea
|
||||
value={formData.detailsEn}
|
||||
onChange={e => setFormData(prev => ({ ...prev, detailsEn: e.target.value }))}
|
||||
className={cn(
|
||||
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
||||
"w-full min-h-[80px] bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
||||
isModified('detailsEn') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
|
||||
)}
|
||||
placeholder="Enter details in English..."
|
||||
@@ -371,6 +448,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
<DimensionInput label="Units per Outer" value={formData.unitsOuter} field="unitsOuter" isModified={isModified('unitsOuter')} onChange={(val) => setFormData(p => ({...p, unitsOuter: val}))} />
|
||||
<DimensionInput label="MOQ" value={formData.moq} field="moq" isModified={isModified('moq')} onChange={(val) => setFormData(p => ({...p, moq: val}))} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 border-t border-slate-700/30">
|
||||
<DimensionInput label="CategorizationCode" value={formData.productType} field="productType" isModified={isModified('productType')} onChange={(val) => setFormData(p => ({...p, productType: val}))} />
|
||||
<DimensionInput label="Item to Logistic" value={formData.itemToLogistic} field="itemToLogistic" isModified={isModified('itemToLogistic')} onChange={(val) => setFormData(p => ({...p, itemToLogistic: val}))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldEditor
|
||||
@@ -408,7 +490,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
field="shortDe"
|
||||
value={formData.shortDe}
|
||||
isModified={isModified('shortDe')}
|
||||
canTranslate={!!formData.shortEn || !!formData.longDe}
|
||||
canTranslate={!!formData.shortEn}
|
||||
isGenerated={generatedFields.has('shortDe')}
|
||||
isLoading={loadingField === 'shortDe'}
|
||||
onGenerate={() => handleGenerate('shortDe')}
|
||||
@@ -420,7 +502,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
field="shortEn"
|
||||
value={formData.shortEn}
|
||||
isModified={isModified('shortEn')}
|
||||
canTranslate={!!formData.shortDe || !!formData.longEn}
|
||||
canTranslate={!!formData.shortDe}
|
||||
isGenerated={generatedFields.has('shortEn')}
|
||||
isLoading={loadingField === 'shortEn'}
|
||||
onGenerate={() => handleGenerate('shortEn')}
|
||||
@@ -456,10 +538,16 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!pendingGeminiField}
|
||||
isLoading={loadingField !== null}
|
||||
onConfirm={executeGenerate}
|
||||
onCancel={() => setPendingGeminiField(null)}
|
||||
title="Confirm AI Action"
|
||||
message={`Are you sure you want to use Gemini to ${((pendingGeminiField === 'longEn' && formData.longDe) || (pendingGeminiField === 'shortEn' && formData.shortDe)) ? 'translate' : 'generate'} the ${pendingGeminiField} field?`}
|
||||
message={`Are you sure you want to use Gemini to ${
|
||||
((pendingGeminiField === 'shortEn' && formData.shortDe) ||
|
||||
(pendingGeminiField === 'shortDe' && formData.shortEn) ||
|
||||
(pendingGeminiField === 'longEn' && formData.longDe) ||
|
||||
(pendingGeminiField === 'longDe' && formData.longEn))
|
||||
? 'translate' : 'generate'} the ${pendingGeminiField} field?`}
|
||||
type="info"
|
||||
confirmText="Start Generation"
|
||||
/>
|
||||
|
||||
+429
-48
@@ -1,34 +1,98 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2 } from 'lucide-react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2, Maximize2, Check, Loader2, CloudUpload } from 'lucide-react';
|
||||
import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { ExcelRow } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { cn } from '../lib/utils';
|
||||
import { usePersistentState } from '../contexts/FilterContext';
|
||||
import { previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
|
||||
import { SyncStatusPill } from './SyncStatusPill';
|
||||
|
||||
interface HistoryViewProps {
|
||||
headers: string[];
|
||||
data: ExcelRow[];
|
||||
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: number) => void;
|
||||
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: string) => void;
|
||||
onEdit?: (rowIndex: number) => void;
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: HistoryViewProps) {
|
||||
type HistorySyncStatus = 'bc_pending' | 'previewed' | 'preview_only' | 'syncing' | 'synced' | 'failed';
|
||||
|
||||
interface HistorySyncRecord {
|
||||
selected: boolean;
|
||||
status: HistorySyncStatus;
|
||||
previewToken?: string;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
type HistorySyncMap = Record<string, HistorySyncRecord>;
|
||||
|
||||
function readStoredHistorySyncMap(): HistorySyncMap {
|
||||
try {
|
||||
const raw = localStorage.getItem('history-bcSync');
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
return parsed as HistorySyncMap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProps) {
|
||||
const COLUMNS = useColumns();
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [search, setSearch] = usePersistentState('history-search', '');
|
||||
const [userFilter, setUserFilter] = usePersistentState('history-userFilter', '');
|
||||
const [statusFilter, setStatusFilter] = usePersistentState<'all' | 'bc_pending' | 'previewed' | 'preview_only' | 'synced' | 'failed'>('history-statusFilter', 'all');
|
||||
const [bcHistorySync, setBcHistorySync] = useState<HistorySyncMap>(() => readStoredHistorySyncMap());
|
||||
const [bcSyncBusy, setBcSyncBusy] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
loadHistory();
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('history-bcSync', JSON.stringify(bcHistorySync));
|
||||
} catch {
|
||||
// Ignore storage quota or serialization errors.
|
||||
}
|
||||
}, [bcHistorySync]);
|
||||
|
||||
const loadHistory = async () => {
|
||||
setLoading(true);
|
||||
const data = await getHistory(sessionToken);
|
||||
setHistory(data);
|
||||
const historyData = await getHistory();
|
||||
setHistory(historyData);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (history.length === 0) return;
|
||||
const validKeys = new Set(history.map(entry => String(entry.id || `${entry.product_id}-${entry.changed_at}`)));
|
||||
setBcHistorySync(prev => {
|
||||
let changed = false;
|
||||
const next: Record<string, HistorySyncRecord> = {};
|
||||
for (const [key, value] of Object.entries(prev)) {
|
||||
if (!validKeys.has(key)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[key] = value as HistorySyncRecord;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [history]);
|
||||
|
||||
const handleRevert = (entry: HistoryEntry) => {
|
||||
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
|
||||
const currentRow = data.find(r => String(r[0]) === entry.product_id);
|
||||
@@ -70,12 +134,186 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const filteredHistory = history.filter(entry =>
|
||||
entry.article_name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
entry.product_id.toLowerCase().includes(search.toLowerCase()) ||
|
||||
entry.changed_by?.toLowerCase().includes(search.toLowerCase())
|
||||
const uniqueUsers = Array.from(new Set(history.map(e => e.changed_by))).sort();
|
||||
|
||||
const getEntryKey = (entry: HistoryEntry) => String(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||
|
||||
const getEntryStatus = (entry: HistoryEntry): HistorySyncStatus => {
|
||||
return bcHistorySync[getEntryKey(entry)]?.status || 'bc_pending';
|
||||
};
|
||||
|
||||
const getEntrySelected = (entry: HistoryEntry): boolean => {
|
||||
return bcHistorySync[getEntryKey(entry)]?.selected || false;
|
||||
};
|
||||
|
||||
// history is asc (oldest first) so index+1 = chronological #
|
||||
// display newest first by reversing for render only
|
||||
const filteredHistory = [...history].reverse().filter(entry => {
|
||||
if (userFilter && entry.changed_by !== userFilter) return false;
|
||||
const status = getEntryStatus(entry);
|
||||
if (statusFilter !== 'all' && status !== statusFilter) return false;
|
||||
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (terms.length === 0) return true;
|
||||
const searchableText = `${entry.article_name} ${entry.product_id} ${entry.changed_by}`.toLowerCase();
|
||||
return terms.every(term => searchableText.includes(term));
|
||||
});
|
||||
|
||||
const selectedEntries = useMemo(
|
||||
() => history.filter(entry => getEntrySelected(entry)),
|
||||
[history, bcHistorySync]
|
||||
);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts = { bc_pending: 0, previewed: 0, preview_only: 0, synced: 0, failed: 0 };
|
||||
history.forEach(entry => {
|
||||
const status = getEntryStatus(entry);
|
||||
if (status in counts) counts[status as keyof typeof counts] += 1;
|
||||
});
|
||||
return counts;
|
||||
}, [history, bcHistorySync]);
|
||||
|
||||
const updateEntry = (entry: HistoryEntry, patch: Partial<HistorySyncRecord>) => {
|
||||
const key = getEntryKey(entry);
|
||||
setBcHistorySync(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
selected: prev[key]?.selected ?? false,
|
||||
status: prev[key]?.status ?? 'bc_pending',
|
||||
previewToken: prev[key]?.previewToken,
|
||||
error: prev[key]?.error,
|
||||
...prev[key],
|
||||
...patch,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const selectAllVisible = (selected: boolean) => {
|
||||
setBcHistorySync(prev => {
|
||||
const next = { ...prev };
|
||||
filteredHistory.forEach(entry => {
|
||||
const key = getEntryKey(entry);
|
||||
next[key] = {
|
||||
selected,
|
||||
status: next[key]?.status ?? 'bc_pending',
|
||||
previewToken: next[key]?.previewToken,
|
||||
error: next[key]?.error,
|
||||
warning: next[key]?.warning,
|
||||
};
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const discardSelected = () => {
|
||||
if (selectedEntries.length === 0) return;
|
||||
if (!window.confirm(`Discard ${selectedEntries.length} pending sync(s)? They will be reset to "Pending BC" and will not be synced to Business Central.`)) return;
|
||||
setBcHistorySync(prev => {
|
||||
const next = { ...prev };
|
||||
selectedEntries.forEach(entry => {
|
||||
const key = getEntryKey(entry);
|
||||
next[key] = { selected: false, status: 'bc_pending' };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const previewSelected = async () => {
|
||||
if (selectedEntries.length === 0) return;
|
||||
setBcSyncBusy(true);
|
||||
try {
|
||||
for (const entry of [...selectedEntries].sort((a, b) => {
|
||||
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
|
||||
if (timeDelta !== 0) return timeDelta;
|
||||
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||
})) {
|
||||
const preview = await previewBusinessCentralSync(headers, entry.new_data);
|
||||
if (!preview.success) {
|
||||
updateEntry(entry, { status: 'failed', error: preview.error || 'Preview failed', warning: undefined, selected: true });
|
||||
continue;
|
||||
}
|
||||
updateEntry(entry, {
|
||||
status: 'previewed',
|
||||
previewToken: preview.previewToken,
|
||||
error: undefined,
|
||||
warning: undefined,
|
||||
selected: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setBcSyncBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncSelected = async () => {
|
||||
if (selectedEntries.length === 0) return;
|
||||
setBcSyncBusy(true);
|
||||
try {
|
||||
const orderedEntries = [...selectedEntries].sort((a, b) => {
|
||||
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
|
||||
if (timeDelta !== 0) return timeDelta;
|
||||
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
|
||||
for (const entry of orderedEntries) {
|
||||
updateEntry(entry, { status: 'syncing', error: undefined, selected: true });
|
||||
const key = getEntryKey(entry);
|
||||
const currentRecord = bcHistorySync[key];
|
||||
let previewToken = currentRecord?.previewToken;
|
||||
|
||||
if (!previewToken) {
|
||||
const preview = await previewBusinessCentralSync(headers, entry.new_data);
|
||||
if (!preview.success) {
|
||||
updateEntry(entry, { status: 'failed', error: preview.error || 'Preview failed', warning: undefined, selected: true });
|
||||
continue;
|
||||
}
|
||||
previewToken = preview.previewToken;
|
||||
updateEntry(entry, { status: 'previewed', previewToken, error: undefined, warning: undefined, selected: true });
|
||||
}
|
||||
|
||||
const apply = await applyBusinessCentralSync(headers, entry.new_data, previewToken);
|
||||
if (!apply.success) {
|
||||
if (isPreviewTokenMismatchError(apply.error)) {
|
||||
const refreshedPreview = await previewBusinessCentralSync(headers, entry.new_data);
|
||||
if (refreshedPreview.success) {
|
||||
updateEntry(entry, {
|
||||
status: 'previewed',
|
||||
previewToken: refreshedPreview.previewToken,
|
||||
error: undefined,
|
||||
warning: undefined,
|
||||
selected: true,
|
||||
});
|
||||
const retryApply = await applyBusinessCentralSync(headers, entry.new_data, refreshedPreview.previewToken);
|
||||
if (retryApply.success) {
|
||||
updateEntry(entry, {
|
||||
status: 'synced',
|
||||
error: undefined,
|
||||
warning: retryApply.warning,
|
||||
selected: false,
|
||||
previewToken: retryApply.previewToken || refreshedPreview.previewToken,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
updateEntry(entry, { status: 'failed', error: retryApply.error || 'BC sync failed', warning: retryApply.warning, selected: true });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
updateEntry(entry, { status: 'failed', error: apply.error || 'BC sync failed', warning: apply.warning, selected: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
updateEntry(entry, {
|
||||
status: 'synced',
|
||||
error: undefined,
|
||||
warning: apply.warning,
|
||||
selected: false,
|
||||
previewToken: apply.previewToken || previewToken,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setBcSyncBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-400">
|
||||
@@ -86,19 +324,55 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500"}>
|
||||
{isFullscreen && (
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="fixed top-4 right-4 z-50 p-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors border border-slate-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<History className="w-8 h-8 text-blue-500" />
|
||||
Change History
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-1">Review and revert any changes made to products.</p>
|
||||
<p className="text-slate-400 mt-1">
|
||||
{history.length > 0 ? (
|
||||
<><span className="text-white font-semibold">{history.length}</span> total records{filteredHistory.length !== history.length && <> — showing <span className="text-white font-semibold">{filteredHistory.length}</span></>}</>
|
||||
) : 'Review and revert any changes made to products.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
||||
<select
|
||||
value={userFilter}
|
||||
onChange={e => setUserFilter(e.target.value)}
|
||||
className={cn(
|
||||
"pl-9 pr-8 py-2 bg-slate-800 border rounded-md text-sm focus:outline-none focus:border-blue-500 transition-colors w-52 appearance-none",
|
||||
userFilter ? "border-blue-500 text-white" : "border-slate-700 text-slate-400"
|
||||
)}
|
||||
>
|
||||
<option value="">All users</option>
|
||||
{uniqueUsers.map(u => (
|
||||
<option key={u} value={u}>{u}</option>
|
||||
))}
|
||||
</select>
|
||||
{userFilter && (
|
||||
<button
|
||||
onClick={() => setUserFilter('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search history..."
|
||||
value={search}
|
||||
@@ -121,6 +395,89 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors border border-slate-700"
|
||||
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/70 border border-slate-800 rounded-xl p-4 flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span className="font-semibold text-slate-200">{selectedEntries.length}</span> selected
|
||||
<span className="text-slate-600">·</span>
|
||||
<span>{statusCounts.bc_pending} pending</span>
|
||||
<span>{statusCounts.previewed} previewed</span>
|
||||
<span>{statusCounts.preview_only} preview only</span>
|
||||
<span>{statusCounts.synced} synced</span>
|
||||
<span>{statusCounts.failed} failed</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value as any)}
|
||||
className="bg-slate-800 border border-slate-700 rounded-md px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="bc_pending">Pending BC</option>
|
||||
<option value="previewed">Previewed</option>
|
||||
<option value="preview_only">Preview only</option>
|
||||
<option value="synced">Synced BC</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => selectAllVisible(true)}
|
||||
className="px-3 py-2 rounded-md bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs border border-slate-700"
|
||||
>
|
||||
Select all visible
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectAllVisible(false)}
|
||||
className="px-3 py-2 rounded-md bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs border border-slate-700"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
<button
|
||||
onClick={discardSelected}
|
||||
disabled={selectedEntries.length === 0}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-md text-xs border transition-colors",
|
||||
selectedEntries.length === 0
|
||||
? "bg-slate-700 text-slate-500 border-slate-600 cursor-not-allowed"
|
||||
: "bg-slate-800 hover:bg-red-900/40 text-red-400 border-red-500/30 hover:border-red-500/60"
|
||||
)}
|
||||
>
|
||||
Discard selected
|
||||
</button>
|
||||
<button
|
||||
onClick={previewSelected}
|
||||
disabled={bcSyncBusy || selectedEntries.length === 0}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-xs font-semibold border transition-colors",
|
||||
bcSyncBusy || selectedEntries.length === 0
|
||||
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
|
||||
: "bg-indigo-600 hover:bg-indigo-500 text-white border-indigo-500/30"
|
||||
)}
|
||||
>
|
||||
{bcSyncBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
Preview selected
|
||||
</button>
|
||||
<button
|
||||
onClick={syncSelected}
|
||||
disabled={bcSyncBusy || selectedEntries.length === 0}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-xs font-semibold border transition-colors",
|
||||
bcSyncBusy || selectedEntries.length === 0
|
||||
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
|
||||
: "bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-500/30"
|
||||
)}
|
||||
>
|
||||
{bcSyncBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CloudUpload className="w-3.5 h-3.5" />}
|
||||
Sync selected to BC
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,22 +492,36 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-800">
|
||||
{filteredHistory.map((entry) => {
|
||||
{filteredHistory.map((entry, filteredIdx) => {
|
||||
const isExpanded = expandedId === entry.id;
|
||||
const changes = getChangedFields(entry.old_data, entry.new_data);
|
||||
|
||||
const globalNumber = history.indexOf(entry) + 1;
|
||||
const status = getEntryStatus(entry);
|
||||
const selected = getEntrySelected(entry);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={cn(
|
||||
"transition-colors",
|
||||
isExpanded ? "bg-blue-600/5" : "hover:bg-slate-800/30"
|
||||
)}>
|
||||
{/* Summary Row */}
|
||||
<div
|
||||
<div
|
||||
className="p-4 flex items-center gap-4 cursor-pointer"
|
||||
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={() => updateEntry(entry, { selected: !selected })}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
{isExpanded ? <ChevronDown className="w-5 h-5 text-slate-500" /> : <ChevronRight className="w-5 h-5 text-slate-500" />}
|
||||
|
||||
|
||||
<div className="w-8 text-right shrink-0">
|
||||
<span className="text-xs font-mono text-slate-500">#{globalNumber}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-4 gap-4 items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500 font-bold shrink-0">
|
||||
@@ -172,33 +543,41 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
|
||||
<span className="text-sm">{formatDate(entry.changed_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 text-sm">
|
||||
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
|
||||
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onEdit) {
|
||||
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
|
||||
if (idx !== -1) onEdit(idx);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRevert(entry);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Revert
|
||||
</button>
|
||||
<div className="flex flex-col items-end gap-2 text-sm">
|
||||
<div className="flex items-center justify-end gap-3 flex-wrap">
|
||||
<SyncStatusPill status={status} />
|
||||
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
|
||||
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onEdit) {
|
||||
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
|
||||
if (idx !== -1) onEdit(idx);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRevert(entry);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Revert
|
||||
</button>
|
||||
</div>
|
||||
{status === 'failed' && bcHistorySync[getEntryKey(entry)]?.error && (
|
||||
<div className="max-w-[40rem] rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||
<span className="font-semibold">BC error:</span> {bcHistorySync[getEntryKey(entry)].error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,7 +589,8 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-800/50 text-slate-400 text-left">
|
||||
<th className="px-4 py-2 font-medium">Field</th>
|
||||
<th className="px-4 py-2 font-medium w-8">#</th>
|
||||
<th className="px-4 py-2 font-medium">Column</th>
|
||||
<th className="px-4 py-2 font-medium">Original Value</th>
|
||||
<th className="px-4 py-2 font-medium">New Value</th>
|
||||
</tr>
|
||||
@@ -218,6 +598,7 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{changes.map((change, idx) => (
|
||||
<tr key={idx} className="hover:bg-slate-700/20">
|
||||
<td className="px-4 py-2 text-slate-600 text-xs font-mono">{idx + 1}</td>
|
||||
<td className="px-4 py-2 text-slate-300 font-medium whitespace-nowrap">
|
||||
{change.header}
|
||||
</td>
|
||||
|
||||
+361
-15
@@ -1,8 +1,12 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { Search, Filter, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { Search, Filter, ChevronDown, ChevronUp, X, Maximize2, Check, Loader2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
import { usePersistentState } from '../contexts/FilterContext';
|
||||
import { buildBusinessCentralMappingPreview } from '../services/businessCentralMapping';
|
||||
import { applyBusinessCentralSync, previewBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
|
||||
|
||||
interface MatrixViewProps {
|
||||
data: ExcelRow[];
|
||||
@@ -11,13 +15,21 @@ interface MatrixViewProps {
|
||||
}
|
||||
|
||||
export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
const resolvedCols = useColumns();
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [search, setSearch] = useState('');
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [search, setSearch] = usePersistentState('matrix-search', '');
|
||||
const [bcSku, setBcSku] = usePersistentState('matrix-bcSku', '');
|
||||
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('matrix-columnFilters', {});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [sortCol, setSortCol] = usePersistentState<number | null>('matrix-sortCol', null);
|
||||
const [sortDesc, setSortDesc] = usePersistentState('matrix-sortDesc', false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [bcValidationResult, setBcValidationResult] = useState<any>(null);
|
||||
const [bcValidationLoading, setBcValidationLoading] = useState(false);
|
||||
const [bcApplyLoading, setBcApplyLoading] = useState(false);
|
||||
const [bcValidationError, setBcValidationError] = useState<string | null>(null);
|
||||
const [bcValidationWarning, setBcValidationWarning] = useState<string | null>(null);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data.map((row, index) => ({ row, index }));
|
||||
@@ -66,6 +78,25 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, page, pageSize]);
|
||||
|
||||
const bcPreviewRow = useMemo(() => {
|
||||
const targetSku = String(bcSku || '').trim();
|
||||
if (!targetSku) return null;
|
||||
return data.find(row => String(row[resolvedCols.ARTICLE_NO] ?? '') === targetSku) || null;
|
||||
}, [bcSku, data, resolvedCols.ARTICLE_NO]);
|
||||
|
||||
const bcMappingPreview = useMemo(() => {
|
||||
if (!bcPreviewRow) return null;
|
||||
return buildBusinessCentralMappingPreview(headers, bcPreviewRow);
|
||||
}, [bcPreviewRow, headers]);
|
||||
|
||||
useEffect(() => {
|
||||
setBcValidationResult(null);
|
||||
setBcValidationError(null);
|
||||
setBcValidationWarning(null);
|
||||
}, [bcSku]);
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
|
||||
|
||||
const handleSort = (col: number) => {
|
||||
if (sortCol === col) {
|
||||
setSortDesc(!sortDesc);
|
||||
@@ -75,6 +106,25 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = (colIndex: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.pageX;
|
||||
const startWidth = columnWidths[colIndex] || 150;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
|
||||
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const getUniqueValues = (col: number) => {
|
||||
const values = data.map(r => String(r[col] || ''));
|
||||
return Array.from(new Set(values)).sort();
|
||||
@@ -96,11 +146,67 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePreviewBcSync = async () => {
|
||||
if (!bcPreviewRow) return;
|
||||
setBcValidationLoading(true);
|
||||
setBcValidationError(null);
|
||||
setBcValidationWarning(null);
|
||||
try {
|
||||
const result = await previewBusinessCentralSync(headers, bcPreviewRow);
|
||||
if (!result.success) {
|
||||
setBcValidationResult(null);
|
||||
setBcValidationError(result.error || 'Preview failed');
|
||||
return;
|
||||
}
|
||||
setBcValidationResult(result);
|
||||
} finally {
|
||||
setBcValidationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyBcSync = async () => {
|
||||
if (!bcPreviewRow || !bcValidationResult?.previewToken) return;
|
||||
setBcApplyLoading(true);
|
||||
setBcValidationError(null);
|
||||
setBcValidationWarning(null);
|
||||
try {
|
||||
const result = await applyBusinessCentralSync(headers, bcPreviewRow, bcValidationResult.previewToken);
|
||||
if (!result.success) {
|
||||
if (isPreviewTokenMismatchError(result.error)) {
|
||||
const refreshedPreview = await previewBusinessCentralSync(headers, bcPreviewRow);
|
||||
if (refreshedPreview.success) {
|
||||
setBcValidationResult(refreshedPreview);
|
||||
const retry = await applyBusinessCentralSync(headers, bcPreviewRow, refreshedPreview.previewToken);
|
||||
if (retry.success) {
|
||||
setBcValidationResult(prev => retry.preview ? { ...retry.preview, hasChanges: false } : prev);
|
||||
if (retry.warning) {
|
||||
setBcValidationWarning(retry.warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setBcValidationError(retry.error || 'Apply failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBcValidationError(result.error || 'Apply failed');
|
||||
if (result.warning) {
|
||||
setBcValidationWarning(result.warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setBcValidationResult(prev => result.preview ? { ...result.preview, hasChanges: false } : prev);
|
||||
if (result.warning) {
|
||||
setBcValidationWarning(result.warning);
|
||||
}
|
||||
} finally {
|
||||
setBcApplyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCellValue = (val: any, header: string = '') => {
|
||||
if (val === undefined || val === null || val === '') return '';
|
||||
|
||||
// Convert header to lowercase for checks
|
||||
const h = header.toLowerCase();
|
||||
const h = (header || '').toLowerCase();
|
||||
|
||||
// Handle date columns - Excel serial dates are numbers >= 25569 (Jan 1, 1970)
|
||||
if (h.includes('date') || h.includes('launch') || h.includes('ready')) {
|
||||
@@ -156,13 +262,40 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden">
|
||||
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden"}>
|
||||
{isFullscreen && (
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
|
||||
title="Exit fullscreen"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="p-4 border-b border-slate-700 bg-slate-800/50 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Matrix View</h2>
|
||||
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 flex-wrap justify-end">
|
||||
<div className="relative min-w-[260px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Preview BC mapping by SKU..."
|
||||
value={bcSku}
|
||||
onChange={e => setBcSku(e.target.value)}
|
||||
className="w-full pl-9 pr-10 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium"
|
||||
/>
|
||||
{bcSku && (
|
||||
<button
|
||||
onClick={() => setBcSku('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative min-w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
@@ -193,21 +326,225 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
Clear All Column Filters
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto flex-1">
|
||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||
{bcSku && (
|
||||
<div className="mx-4 mt-4 mb-2 rounded-xl border border-slate-700 bg-slate-900/70 p-4">
|
||||
{!bcMappingPreview ? (
|
||||
<div className="text-sm text-slate-400">
|
||||
No row found for SKU <span className="text-white font-semibold">{bcSku}</span>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={handlePreviewBcSync}
|
||||
disabled={bcValidationLoading}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors border",
|
||||
bcValidationLoading
|
||||
? "bg-slate-700 text-slate-400 border-slate-600 cursor-wait"
|
||||
: "bg-blue-600 hover:bg-blue-500 text-white border-blue-500/30"
|
||||
)}
|
||||
>
|
||||
{bcValidationLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
Preview BC sync
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApplyBcSync}
|
||||
disabled={bcApplyLoading || !bcValidationResult?.previewToken}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors border",
|
||||
bcApplyLoading || !bcValidationResult?.previewToken
|
||||
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
|
||||
: "bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-500/30"
|
||||
)}
|
||||
>
|
||||
{bcApplyLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Apply to BC
|
||||
</button>
|
||||
<span className="text-xs text-slate-500">
|
||||
Preview checks the current BC row before any write happens.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{bcValidationError && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{bcValidationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bcValidationWarning && !bcValidationError && (
|
||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
||||
{bcValidationWarning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bcValidationResult && (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-950/40 p-4">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Business Central validation</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
{bcValidationResult.hasChanges
|
||||
? 'Changes detected and ready for apply when the BC endpoint is writable.'
|
||||
: 'No differences detected against BC.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-xs px-2 py-1 rounded-full border",
|
||||
bcValidationResult.hasChanges
|
||||
? "border-amber-500/20 bg-amber-500/10 text-amber-300"
|
||||
: "border-emerald-500/20 bg-emerald-500/10 text-emerald-300"
|
||||
)}>
|
||||
{bcValidationResult.hasChanges ? 'Pending changes' : 'In sync'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
{(['items', 'itemUnitsOfMeasure', 'itemUnits40HC'] as const).map(sectionKey => {
|
||||
const section = bcValidationResult[sectionKey];
|
||||
const changed = section.changes.filter((change: any) => change.changed);
|
||||
|
||||
return (
|
||||
<div key={sectionKey} className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-white">
|
||||
API `{sectionKey}`
|
||||
</h4>
|
||||
<p className="text-xs text-slate-400">
|
||||
{changed.length} changed field{changed.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
{section.supported === false && (
|
||||
<span className="text-[10px] px-2 py-1 rounded-full border border-amber-500/20 bg-amber-500/10 text-amber-300">
|
||||
preview only
|
||||
</span>
|
||||
)}
|
||||
{!section.writeConfigured && (
|
||||
<span className="text-[10px] px-2 py-1 rounded-full border border-slate-600 text-slate-400">
|
||||
write not configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{changed.length === 0 ? (
|
||||
<div className="text-xs text-slate-500">No changes for this endpoint.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{section.supported === false && section.supportReason && (
|
||||
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-200">
|
||||
{section.supportReason}
|
||||
</div>
|
||||
)}
|
||||
{changed.map((change: any) => (
|
||||
<div key={change.targetField} className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div className="text-slate-400 truncate">{change.sourceLabel}</div>
|
||||
<div className="text-slate-500 truncate">{change.targetField}</div>
|
||||
<div className="text-white truncate">
|
||||
{String(change.before ?? '—')} <span className="text-slate-500">→</span> {String(change.after ?? '—')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">API `items` preview</h3>
|
||||
<p className="text-xs text-slate-400">SKU {bcMappingPreview.articleNo}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{bcMappingPreview.itemsFields.map(field => (
|
||||
<div key={field.targetField} className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div className="text-slate-400">{field.sourceLabel}</div>
|
||||
<div className="text-slate-500 truncate">{field.targetField}</div>
|
||||
<div className="text-white truncate">{String(field.value ?? '—')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<pre className="mt-4 text-[11px] leading-relaxed bg-slate-950 border border-slate-800 rounded p-3 overflow-auto text-slate-200">
|
||||
{JSON.stringify(bcMappingPreview.itemsPayload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">API `itemUnitsOfMeasure2` preview</h3>
|
||||
<p className="text-xs text-slate-400">SKU {bcMappingPreview.articleNo}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{bcMappingPreview.itemUnitsFields.map(field => (
|
||||
<div key={field.targetField} className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div className="text-slate-400">{field.sourceLabel}</div>
|
||||
<div className="text-slate-500 truncate">{field.targetField}</div>
|
||||
<div className="text-white truncate">{String(field.value ?? '—')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<pre className="mt-4 text-[11px] leading-relaxed bg-slate-950 border border-slate-800 rounded p-3 overflow-auto text-slate-200">
|
||||
{JSON.stringify(bcMappingPreview.itemUnitsOfMeasurePayload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">API `itemUnitsOfMeasure2` 40HC preview</h3>
|
||||
<p className="text-xs text-slate-400">SKU {bcMappingPreview.articleNo}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{bcMappingPreview.itemUnits40HCFields.map(field => (
|
||||
<div key={field.targetField} className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div className="text-slate-400">{field.sourceLabel}</div>
|
||||
<div className="text-slate-500 truncate">{field.targetField}</div>
|
||||
<div className="text-white truncate">{String(field.value ?? '—')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<pre className="mt-4 text-[11px] leading-relaxed bg-slate-950 border border-slate-800 rounded p-3 overflow-auto text-slate-200">
|
||||
{JSON.stringify(bcMappingPreview.itemUnits40HCPayload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-left text-sm whitespace-nowrap" style={{ tableLayout: 'fixed' }}>
|
||||
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
|
||||
<tr>
|
||||
{headers.map((header, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative"
|
||||
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative border-r border-slate-700/30"
|
||||
style={{ width: columnWidths[index] || 150, minWidth: columnWidths[index] || 150 }}
|
||||
>
|
||||
<div className="flex items-center overflow-hidden">
|
||||
<span
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-white"
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-white truncate"
|
||||
onClick={() => handleSort(index)}
|
||||
>
|
||||
{header}
|
||||
@@ -216,6 +553,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
id={`matrix-filter-trigger-${index}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === index ? null : index);
|
||||
@@ -229,8 +567,15 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resizer handle */}
|
||||
<div
|
||||
onMouseDown={(e) => handleResize(index, e)}
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
|
||||
/>
|
||||
|
||||
{openFilterCol === index && (
|
||||
<ColumnFilterPopover
|
||||
triggerId={`matrix-filter-trigger-${index}`}
|
||||
uniqueValues={getUniqueValues(index)}
|
||||
selectedValues={columnFilters[index] || []}
|
||||
onToggle={(val) => toggleColumnFilter(index, val)}
|
||||
@@ -259,7 +604,8 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
return (
|
||||
<td
|
||||
key={colIndex}
|
||||
className="px-4 py-3 text-slate-300 max-w-[200px] truncate"
|
||||
className="px-4 py-3 text-slate-300 truncate border-r border-slate-700/30"
|
||||
style={{ width: columnWidths[colIndex] || 150, minWidth: columnWidths[colIndex] || 150 }}
|
||||
title={String(row[colIndex] || '')}
|
||||
>
|
||||
{formattedValue}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { Search, ChevronDown, ChevronUp, X, Edit2, Save, Filter, XCircle } from 'lucide-react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { Search, ChevronDown, ChevronUp, X, Edit2, Save, Maximize2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
import { DateFilterPopover } from './DateFilterPopover';
|
||||
import { usePersistentState } from '../contexts/FilterContext';
|
||||
|
||||
interface MissingDataViewProps {
|
||||
data: ExcelRow[];
|
||||
@@ -10,17 +14,34 @@ interface MissingDataViewProps {
|
||||
onCaptureState: (message: string) => void;
|
||||
}
|
||||
|
||||
function formatDateForInput(val: string): string {
|
||||
if (!val) return '';
|
||||
// If it's already YYYY-MM-DD, return it
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return val;
|
||||
// If it's DD/MM/YYYY, convert to YYYY-MM-DD
|
||||
const parts = val.split('/');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
function formatDateFromInput(val: string): string {
|
||||
// Always output YYYY-MM-DD as requested
|
||||
return val || '';
|
||||
}
|
||||
|
||||
function formatDateValue(val: any): string {
|
||||
if (val === null || val === undefined || val === '') return '';
|
||||
if (typeof val === 'number') {
|
||||
if (val >= 25569 && val <= 60000) {
|
||||
const excelEpoch = new Date(1899, 11, 30);
|
||||
const date = new Date(excelEpoch.getTime() + val * 86400000);
|
||||
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
return date.toISOString().split('T')[0]; // Returns YYYY-MM-DD
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return String(val);
|
||||
return formatDateForInput(String(val));
|
||||
}
|
||||
|
||||
function isEmptyOrEpoch(val: any): boolean {
|
||||
@@ -36,7 +57,7 @@ function isEmptyOrEpoch(val: any): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
type TabType = 'missingClass' | 'missingLaunch';
|
||||
type TabType = 'missingLaunch' | 'launchInconsistent' | 'upcomingLaunch';
|
||||
|
||||
interface EditingState {
|
||||
rowIndex: number;
|
||||
@@ -46,59 +67,188 @@ interface EditingState {
|
||||
}
|
||||
|
||||
export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: MissingDataViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('missingClass');
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const COLUMNS = useColumns();
|
||||
const [activeTab, setActiveTab] = usePersistentState<TabType>('missingData-tab', 'missingLaunch');
|
||||
const [search, setSearch] = usePersistentState('missingData-search', '');
|
||||
const [sortCol, setSortCol] = usePersistentState<number | null>('missingData-sortCol', null);
|
||||
const [sortDesc, setSortDesc] = usePersistentState('missingData-sortDesc', false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [editing, setEditing] = useState<EditingState | null>(null);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
|
||||
const [showFilterMenu, setShowFilterMenu] = useState<number | null>(null);
|
||||
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('missingData-columnFilters', {});
|
||||
const [dateFilters, setDateFilters] = usePersistentState<Record<number, { start: string; end: string }>>('missingData-dateFilters', {});
|
||||
const [openFilter, setOpenFilter] = useState<number | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const pageSize = 100;
|
||||
|
||||
const launchDateCol = useMemo(() =>
|
||||
headers.findIndex(h => h.toLowerCase().includes('launch')), [headers]);
|
||||
const readyToOrderCol = useMemo(() =>
|
||||
headers.findIndex(h => h.toLowerCase().includes('ready')), [headers]);
|
||||
const launchDateCol = useMemo(() => {
|
||||
const idx = headers.findIndex(h => (h || '').toLowerCase().includes('launch'));
|
||||
if (idx >= 0) return idx;
|
||||
return headers.findIndex(h => (h || '').toLowerCase().includes('date'));
|
||||
}, [headers]);
|
||||
const readyToOrderCol = useMemo(() => {
|
||||
const idx = headers.findIndex(h => (h || '').toLowerCase().includes('ready'));
|
||||
if (idx >= 0) return idx;
|
||||
return headers.findIndex(h => (h || '').toLowerCase().includes('order'));
|
||||
}, [headers]);
|
||||
|
||||
const launchHeader = launchDateCol >= 0 ? headers[launchDateCol] : 'Launch Date';
|
||||
const readyHeader = readyToOrderCol >= 0 ? headers[readyToOrderCol] : 'Ready to Order';
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data.map((row, index) => ({ row, index }));
|
||||
const columns = [
|
||||
{ col: COLUMNS.ARTICLE_NO, label: 'SKU', width: 100 },
|
||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Name', width: 220 },
|
||||
{ col: COLUMNS.CLASSIFICATION, label: 'Classification', width: 130 },
|
||||
...(launchDateCol >= 0 ? [{ col: launchDateCol, label: launchHeader, width: 130 }] : []),
|
||||
...(readyToOrderCol >= 0 ? [{ col: readyToOrderCol, label: readyHeader, width: 130 }] : []),
|
||||
...((activeTab === 'launchInconsistent' || activeTab === 'upcomingLaunch') ? [{ col: -1, label: 'Days Until Launch', width: 120 }] : []),
|
||||
];
|
||||
|
||||
if (activeTab === 'missingClass') {
|
||||
result = result.filter(r => {
|
||||
const val = r.row[COLUMNS.CLASSIFICATION];
|
||||
return !val || String(val).trim() === '';
|
||||
const columnUniqueValues = useMemo(() => {
|
||||
const cols = columns.map(c => c.col);
|
||||
const result: Record<number, Set<string>> = {};
|
||||
cols.forEach(col => result[col] = new Set());
|
||||
|
||||
data.forEach(row => {
|
||||
cols.forEach(col => {
|
||||
let val: any = row[col];
|
||||
if (col === launchDateCol || col === readyToOrderCol) {
|
||||
val = formatDateValue(val) || String(val ?? '');
|
||||
} else {
|
||||
val = String(val ?? '');
|
||||
}
|
||||
result[col].add(val);
|
||||
});
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [data, columns, launchDateCol, readyToOrderCol]);
|
||||
|
||||
const getUniqueValues = (col: number): string[] => {
|
||||
const values = columnUniqueValues[col];
|
||||
if (!values) return [];
|
||||
return Array.from(values).sort() as string[];
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data.map((row, index) => ({ row, index, status: 'ok' as string, daysUntil: 0 }));
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (activeTab === 'missingLaunch') {
|
||||
result = result.filter(r => {
|
||||
const val = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
|
||||
return isEmptyOrEpoch(val);
|
||||
const launchVal = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
|
||||
const classVal = r.row[COLUMNS.CLASSIFICATION];
|
||||
return isEmptyOrEpoch(launchVal) || isEmptyOrEpoch(classVal);
|
||||
});
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
if (activeTab === 'launchInconsistent' || activeTab === 'upcomingLaunch') {
|
||||
const minDate = new Date('2025-12-01');
|
||||
|
||||
result = result.map(r => {
|
||||
const launchVal = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
|
||||
const readyVal = readyToOrderCol >= 0 ? r.row[readyToOrderCol] : undefined;
|
||||
const launchDate = formatDateValue(launchVal);
|
||||
const readyDate = formatDateValue(readyVal);
|
||||
let daysUntil = 0;
|
||||
let status = 'ok';
|
||||
|
||||
if (launchDate && /^\d{4}-\d{2}-\d{2}$/.test(launchDate)) {
|
||||
const launchD = new Date(launchDate);
|
||||
const diffTime = launchD.getTime() - today.getTime();
|
||||
daysUntil = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (launchD < minDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeTab === 'launchInconsistent') {
|
||||
if (readyDate && /^\d{4}-\d{2}-\d{2}$/.test(readyDate)) {
|
||||
const readyD = new Date(readyDate);
|
||||
if (readyD > launchD) {
|
||||
status = 'error_ready_after_launch';
|
||||
} else {
|
||||
status = 'ok';
|
||||
}
|
||||
} else {
|
||||
status = 'ok';
|
||||
}
|
||||
} else if (activeTab === 'upcomingLaunch') {
|
||||
if (daysUntil <= 0) {
|
||||
status = 'past';
|
||||
} else if (daysUntil <= 120) {
|
||||
status = 'critical';
|
||||
} else if (daysUntil <= 180) {
|
||||
status = 'warning';
|
||||
} else {
|
||||
status = 'ok';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status = 'missing_launch';
|
||||
}
|
||||
|
||||
return { ...r, status, daysUntil };
|
||||
});
|
||||
|
||||
result = result.filter(r => r !== null);
|
||||
|
||||
if (activeTab === 'launchInconsistent') {
|
||||
result = result.filter(r =>
|
||||
r && (r.status === 'error_ready_after_launch' || (r.status === 'ok' && r.daysUntil > 0 && isEmptyOrEpoch(r.row[readyToOrderCol])))
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'upcomingLaunch') {
|
||||
result = result.filter(r => r && (r.status === 'warning' || r.status === 'critical' || r.status === 'past'));
|
||||
}
|
||||
}
|
||||
|
||||
(Object.entries(columnFilters) as [string, string][]).forEach(([colIdx, filterValue]) => {
|
||||
if (!filterValue) return;
|
||||
// Global search (SKU + Name) - Multi-word AND support
|
||||
if (search) {
|
||||
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (terms.length > 0) {
|
||||
result = result.filter(r => {
|
||||
const articleNo = String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
|
||||
const articleName = String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
|
||||
return terms.every(term => articleNo.includes(term) || articleName.includes(term));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(Object.entries(columnFilters) as [string, string[]][]).forEach(([colIdx, filterValues]) => {
|
||||
if (!filterValues || filterValues.length === 0) return;
|
||||
const colIdxNum = parseInt(colIdx);
|
||||
const filterLower = filterValue.toLowerCase();
|
||||
if (colIdxNum === launchDateCol || colIdxNum === readyToOrderCol) return;
|
||||
result = result.filter(r => {
|
||||
const val: any = r.row[colIdxNum];
|
||||
const displayVal = colIdxNum === launchDateCol || colIdxNum === readyToOrderCol
|
||||
? formatDateValue(val) || String(val ?? '')
|
||||
: String(val ?? '');
|
||||
return displayVal.toLowerCase().includes(filterLower);
|
||||
return filterValues.includes(displayVal);
|
||||
});
|
||||
});
|
||||
|
||||
(Object.entries(dateFilters) as [string, { start: string; end: string }][]).forEach(([colIdx, range]) => {
|
||||
if (!range.start && !range.end) return;
|
||||
const colIdxNum = parseInt(colIdx);
|
||||
result = result.filter(r => {
|
||||
const val = r.row[colIdxNum];
|
||||
const dateStr = formatDateValue(val);
|
||||
if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return false;
|
||||
|
||||
const rowDate = new Date(dateStr);
|
||||
if (isNaN(rowDate.getTime())) return false;
|
||||
|
||||
if (range.start) {
|
||||
const startDate = new Date(range.start);
|
||||
if (rowDate < startDate) return false;
|
||||
}
|
||||
if (range.end) {
|
||||
const endDate = new Date(range.end);
|
||||
if (rowDate > endDate) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,8 +284,8 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
|
||||
setEditing({
|
||||
rowIndex,
|
||||
classification: String(row[COLUMNS.CLASSIFICATION] || ''),
|
||||
launchDate: launchDateCol >= 0 ? formatDateValue(row[launchDateCol]) || String(row[launchDateCol] ?? '') : '',
|
||||
readyToOrder: readyToOrderCol >= 0 ? formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol] ?? '') : '',
|
||||
launchDate: launchDateCol >= 0 ? formatDateForInput(formatDateValue(row[launchDateCol]) || String(row[launchDateCol] ?? '')) : '',
|
||||
readyToOrder: readyToOrderCol >= 0 ? formatDateForInput(formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol] ?? '')) : '',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -145,29 +295,31 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
|
||||
onCaptureState(`Updated product ${row[COLUMNS.ARTICLE_NO]}`);
|
||||
const newRow = [...row];
|
||||
newRow[COLUMNS.CLASSIFICATION] = editing.classification;
|
||||
if (launchDateCol >= 0) newRow[launchDateCol] = editing.launchDate;
|
||||
if (readyToOrderCol >= 0) newRow[readyToOrderCol] = editing.readyToOrder;
|
||||
if (launchDateCol >= 0) newRow[launchDateCol] = formatDateFromInput(editing.launchDate);
|
||||
if (readyToOrderCol >= 0) newRow[readyToOrderCol] = formatDateFromInput(editing.readyToOrder);
|
||||
onSaveRow(editing.rowIndex, newRow);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const tabs: { id: TabType; label: string }[] = [
|
||||
{ id: 'missingClass', label: 'Missing Classification' },
|
||||
{ id: 'missingLaunch', label: 'Missing Launch Date' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ col: COLUMNS.ARTICLE_NO, label: 'SKU', width: 100 },
|
||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Name', width: 220 },
|
||||
{ col: COLUMNS.CLASSIFICATION, label: 'Classification', width: 130 },
|
||||
...(launchDateCol >= 0 ? [{ col: launchDateCol, label: launchHeader, width: 130 }] : []),
|
||||
...(readyToOrderCol >= 0 ? [{ col: readyToOrderCol, label: readyHeader, width: 130 }] : []),
|
||||
{ id: 'missingLaunch', label: 'Missing Data' },
|
||||
{ id: 'launchInconsistent', label: 'Launch Date Inconsistencies' },
|
||||
{ id: 'upcomingLaunch', label: 'Upcoming Launch Date' },
|
||||
];
|
||||
|
||||
const editingRow = editing ? data[editing.rowIndex] : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex flex-col h-full"}>
|
||||
{isFullscreen && (
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
|
||||
title="Exit fullscreen"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
@@ -183,6 +335,13 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="ml-auto p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
|
||||
@@ -214,7 +373,11 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
||||
<tr>
|
||||
{columns.map(({ col, label, width }) => (
|
||||
{columns.map(({ col, label, width }) => {
|
||||
const selectedFilters = columnFilters[col] || [];
|
||||
const allValues = getUniqueValues(col);
|
||||
const filterCount = selectedFilters.length;
|
||||
return (
|
||||
<th
|
||||
key={col}
|
||||
style={{ width, minWidth: width }}
|
||||
@@ -230,38 +393,79 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={columnFilters[col] || ''}
|
||||
onChange={(e) => {
|
||||
setColumnFilters(prev => ({ ...prev, [col]: e.target.value }));
|
||||
setPage(1);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full px-1.5 py-0.5 bg-slate-800 border border-slate-600 rounded text-[10px] text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
{columnFilters[col] && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; });
|
||||
setPage(1);
|
||||
}}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white"
|
||||
>
|
||||
<XCircle className="w-3 h-3" />
|
||||
</button>
|
||||
{col === launchDateCol || col === readyToOrderCol ? (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === col ? null : col); }}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-1.5 py-0.5 bg-slate-800 border rounded text-[10px] transition-colors",
|
||||
dateFilters[col]?.start || dateFilters[col]?.end
|
||||
? "border-indigo-500 text-white"
|
||||
: "border-slate-600 text-slate-400 hover:border-slate-500"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{dateFilters[col]?.start || dateFilters[col]?.end
|
||||
? `${dateFilters[col].start ? dateFilters[col].start : '...'} - ${dateFilters[col].end ? dateFilters[col].end : '...'}`
|
||||
: 'Filter...'}
|
||||
</span>
|
||||
<ChevronDown className={cn("w-3 h-3 transition-transform", openFilter === col && "rotate-180")} />
|
||||
</button>
|
||||
{openFilter === col && (
|
||||
<DateFilterPopover
|
||||
selectedRange={dateFilters[col] || { start: '', end: '' }}
|
||||
onRangeChange={(range) => setDateFilters(prev => ({ ...prev, [col]: range }))}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
id={`missing-filter-trigger-${col}`}
|
||||
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === col ? null : col); }}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-1.5 py-0.5 bg-slate-800 border rounded text-[10px] transition-colors",
|
||||
filterCount > 0 ? "border-indigo-500 text-white" : "border-slate-600 text-slate-400 hover:border-slate-500"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{filterCount > 0 ? `${filterCount} selected` : 'Filter...'}</span>
|
||||
<ChevronDown className={cn("w-3 h-3 transition-transform", openFilter === col && "rotate-180")} />
|
||||
</button>
|
||||
{openFilter === col && (
|
||||
<ColumnFilterPopover
|
||||
triggerId={`missing-filter-trigger-${col}`}
|
||||
uniqueValues={allValues}
|
||||
selectedValues={selectedFilters}
|
||||
onToggle={(val) => setColumnFilters(prev => {
|
||||
const current = prev[col] || [];
|
||||
if (current.includes(val)) {
|
||||
return { ...prev, [col]: current.filter(v => v !== val) };
|
||||
}
|
||||
return { ...prev, [col]: [...current, val] };
|
||||
})}
|
||||
onSelectAll={(vals) => setColumnFilters(prev => ({ ...prev, [col]: vals }))}
|
||||
onClear={() => { setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; }); }}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
);})}
|
||||
<th className="px-2 py-2 font-medium text-right" style={{ width: 60 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/30">
|
||||
{paginatedData.map(({ row, index }) => (
|
||||
<tr key={index} className="hover:bg-slate-700/20 transition-colors">
|
||||
{paginatedData.map(({ row, index, status, daysUntil }) => (
|
||||
<tr key={index} className={cn(
|
||||
"hover:bg-slate-700/20 transition-colors",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
)}>
|
||||
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: 100 }}>
|
||||
{row[COLUMNS.ARTICLE_NO]}
|
||||
</td>
|
||||
@@ -300,6 +504,29 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{(activeTab === 'launchInconsistent' || activeTab === 'upcomingLaunch') && (
|
||||
<td className="px-3 py-2 font-mono" style={{ width: 120 }}>
|
||||
{status === 'error_ready_after_launch' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-400 border border-red-500/40">
|
||||
Ready > Launch
|
||||
</span>
|
||||
) : status === 'warning' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-yellow-500/20 text-yellow-400 border border-yellow-500/40">
|
||||
{daysUntil} days
|
||||
</span>
|
||||
) : status === 'critical' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-400 border border-red-500/40">
|
||||
{daysUntil} days
|
||||
</span>
|
||||
) : status === 'past' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-red-600/30 text-red-300 border border-red-500/50">
|
||||
Past
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2 text-right" style={{ width: 60 }}>
|
||||
<button
|
||||
onClick={() => openEdit(index, row)}
|
||||
@@ -366,30 +593,34 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
|
||||
<div className="flex-1 overflow-auto p-6 space-y-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Classification</label>
|
||||
<input
|
||||
type="text"
|
||||
<select
|
||||
value={editing.classification}
|
||||
onChange={e => setEditing(prev => prev ? { ...prev, classification: e.target.value } : prev)}
|
||||
placeholder="e.g. CORE, OOC..."
|
||||
className={cn(
|
||||
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
||||
editing.classification !== String(editingRow[COLUMNS.CLASSIFICATION] || '')
|
||||
? "border-blue-500 focus:ring-blue-500"
|
||||
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<option value="">Select classification...</option>
|
||||
{getUniqueValues(COLUMNS.CLASSIFICATION).map(val => (
|
||||
<option key={val} value={val}>{val}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{launchDateCol >= 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{launchHeader}</label>
|
||||
<input
|
||||
type="text"
|
||||
type="date"
|
||||
value={editing.launchDate}
|
||||
onChange={e => setEditing(prev => prev ? { ...prev, launchDate: e.target.value } : prev)}
|
||||
placeholder="DD/MM/YYYY"
|
||||
onFocus={(e) => e.target.showPicker()}
|
||||
onClick={(e) => e.currentTarget.showPicker()}
|
||||
className={cn(
|
||||
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white font-mono focus:outline-none focus:ring-1 transition-colors",
|
||||
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer [color-scheme:dark]",
|
||||
editing.launchDate !== (formatDateValue(editingRow[launchDateCol]) || String(editingRow[launchDateCol] ?? ''))
|
||||
? "border-blue-500 focus:ring-blue-500"
|
||||
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||
@@ -402,12 +633,13 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{readyHeader}</label>
|
||||
<input
|
||||
type="text"
|
||||
type="date"
|
||||
value={editing.readyToOrder}
|
||||
onChange={e => setEditing(prev => prev ? { ...prev, readyToOrder: e.target.value } : prev)}
|
||||
placeholder="DD/MM/YYYY"
|
||||
onFocus={(e) => e.target.showPicker()}
|
||||
onClick={(e) => e.currentTarget.showPicker()}
|
||||
className={cn(
|
||||
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white font-mono focus:outline-none focus:ring-1 transition-colors",
|
||||
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer [color-scheme:dark]",
|
||||
editing.readyToOrder !== (formatDateValue(editingRow[readyToOrderCol]) || String(editingRow[readyToOrderCol] ?? ''))
|
||||
? "border-blue-500 focus:ring-blue-500"
|
||||
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X } from 'lucide-react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X, Maximize2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { usePersistentState } from '../contexts/FilterContext';
|
||||
import { SyncStatusPill } from './SyncStatusPill';
|
||||
|
||||
interface PendingValidationViewProps {
|
||||
data: ExcelRow[];
|
||||
@@ -12,16 +15,21 @@ interface PendingValidationViewProps {
|
||||
}
|
||||
|
||||
export function PendingValidationView({ data, pendingRows, rowStatuses, onRevertRow, onEdit }: PendingValidationViewProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const COLUMNS = useColumns();
|
||||
const [search, setSearch] = usePersistentState('pending-search', '');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const pendingEntries = Object.entries(pendingRows);
|
||||
|
||||
const filteredEntries = search
|
||||
? pendingEntries.filter(([articleNo, { articleName }]) =>
|
||||
articleNo.toLowerCase().includes(search.toLowerCase()) ||
|
||||
articleName.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: pendingEntries;
|
||||
const filteredEntries = (() => {
|
||||
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (terms.length === 0) return pendingEntries;
|
||||
|
||||
return pendingEntries.filter(([articleNo, { articleName }]) => {
|
||||
const searchableText = `${articleNo} ${articleName}`.toLowerCase();
|
||||
return terms.every(term => searchableText.includes(term));
|
||||
});
|
||||
})();
|
||||
|
||||
const getFieldDiff = (original: ExcelRow, updated: ExcelRow, colIndex: number) => {
|
||||
const orig = original[colIndex];
|
||||
@@ -64,7 +72,16 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex-1 flex flex-col h-full overflow-hidden"}>
|
||||
{isFullscreen && (
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
|
||||
title="Exit fullscreen"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-5 h-5 text-yellow-500" />
|
||||
@@ -92,6 +109,13 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,6 +145,7 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
|
||||
{articleNo}
|
||||
</div>
|
||||
<div className="text-sm text-slate-300">{articleName}</div>
|
||||
{status && <SyncStatusPill status={status} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
||||
+2315
-204
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,14 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { useColumns } from '../contexts/ColumnsContext';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
import { usePersistentState } from '../contexts/FilterContext';
|
||||
import { SyncStatusPill } from './SyncStatusPill';
|
||||
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
|
||||
|
||||
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
|
||||
|
||||
interface ProductDescriptionsProps {
|
||||
data: ExcelRow[];
|
||||
@@ -10,23 +16,25 @@ interface ProductDescriptionsProps {
|
||||
asinColumnIndex: number | null;
|
||||
onEdit: (index: number) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
dashboardDrilldown?: DashboardDrilldownRequest | null;
|
||||
onDashboardDrilldownApplied?: () => void;
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
|
||||
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: ProductDescriptionsProps) {
|
||||
const COLUMNS = useColumns();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Description columns that should only have Present/Missing filters
|
||||
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN];
|
||||
|
||||
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses }: ProductDescriptionsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState('');
|
||||
const [licenseFilter, setLicenseFilter] = useState('');
|
||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
// Description columns that should only have Present/Missing filters
|
||||
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN, COLUMNS.DETAILS_DE, COLUMNS.DETAILS_EN];
|
||||
const [activeTab, setActiveTab] = usePersistentState<string>('descriptions-tab', 'all');
|
||||
const [search, setSearch] = usePersistentState('descriptions-search', '');
|
||||
const [lineFilter, setLineFilter] = usePersistentState('descriptions-lineFilter', '');
|
||||
const [licenseFilter, setLicenseFilter] = usePersistentState('descriptions-licenseFilter', '');
|
||||
const [sortCol, setSortCol] = usePersistentState<number | null>('descriptions-sortCol', null);
|
||||
const [sortDesc, setSortDesc] = usePersistentState('descriptions-sortDesc', false);
|
||||
const [pageSize, setPageSize] = usePersistentState('descriptions-pageSize', 100);
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('descriptions-columnFilters', {});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||
[COLUMNS.ARTICLE_NO]: 110,
|
||||
@@ -41,6 +49,19 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
[COLUMNS.SHORT_EN]: 110,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'descriptions') return;
|
||||
|
||||
const focus = dashboardDrilldown.focus as TabType;
|
||||
setActiveTab(focus);
|
||||
setSearch('');
|
||||
setLineFilter('');
|
||||
setLicenseFilter('');
|
||||
setColumnFilters({});
|
||||
setPage(1);
|
||||
onDashboardDrilldownApplied?.();
|
||||
}, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied, setActiveTab, setSearch, setLineFilter, setLicenseFilter, setColumnFilters]);
|
||||
|
||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||
const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
|
||||
|
||||
@@ -64,11 +85,14 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (terms.length > 0) {
|
||||
result = result.filter(r => {
|
||||
const articleNo = String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
|
||||
const articleName = String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
|
||||
return terms.every(term => articleNo.includes(term) || articleName.includes(term));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown filters
|
||||
@@ -76,27 +100,29 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter);
|
||||
|
||||
// Column-specific filters (Excel-like)
|
||||
console.log('[Filter] applying columnFilters:', columnFilters, 'result count before:', result.length);
|
||||
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
||||
const col = Number(colIdx);
|
||||
const vals = selectedValues as string[];
|
||||
if (vals.length > 0) {
|
||||
// For description columns, filter by present/missing
|
||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||
result = result.filter(r => {
|
||||
const hasValue = Boolean(r.row[col]);
|
||||
const shouldInclude = (vals.includes('Present') && hasValue) || (vals.includes('Missing') && !hasValue);
|
||||
return shouldInclude;
|
||||
});
|
||||
} else {
|
||||
// For other columns, use regular value matching
|
||||
const before = result.length;
|
||||
result = result.filter(r => {
|
||||
const cellVal = String(r.row[col] ?? '').trim();
|
||||
return vals.some(v => v.trim() === cellVal);
|
||||
});
|
||||
console.log('[Filter] col', col, 'vals', vals, 'before:', before, 'after:', result.length);
|
||||
}
|
||||
result = result.filter(r => {
|
||||
const cellVal = r.row[col];
|
||||
|
||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||
// For description columns, we match synthetic 'Present'/'Missing' values
|
||||
const hasValue = cellVal !== undefined && cellVal !== null && String(cellVal).trim() !== '';
|
||||
const matchPresent = vals.includes('Present') && hasValue;
|
||||
const matchMissing = vals.includes('Missing') && !hasValue;
|
||||
return matchPresent || matchMissing;
|
||||
} else {
|
||||
// For other columns, use regular value matching with improved empty value handling
|
||||
const cellStr = String(cellVal ?? '').trim();
|
||||
// If the cell is empty/null/undefined, it matches if 'Empty' or '' is selected
|
||||
return vals.some(v => {
|
||||
const filterVal = String(v ?? '').trim();
|
||||
return filterVal === cellStr;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -177,34 +203,19 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
};
|
||||
|
||||
const getRowColor = (row: ExcelRow) => {
|
||||
// EOL Rule: OOC Classification and 0 or negative stock (Item Available)
|
||||
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
|
||||
const stock = Number(row[COLUMNS.ITEM_AVAILABLE] || 0);
|
||||
|
||||
if (classification === 'OOC' && stock <= 0) {
|
||||
return 'bg-yellow-500/10 hover:bg-yellow-500/20'; // EOL Highlight
|
||||
}
|
||||
|
||||
const fields = [
|
||||
row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN],
|
||||
row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]
|
||||
];
|
||||
const filled = fields.filter(Boolean).length;
|
||||
if (filled === 4) return 'bg-green-900/10 hover:bg-green-900/20';
|
||||
if (filled === 0) return 'bg-red-900/10 hover:bg-red-900/20';
|
||||
return 'bg-yellow-900/10 hover:bg-yellow-900/20';
|
||||
return '';
|
||||
};
|
||||
|
||||
const Badge = ({ content, row }: { content: any, row: ExcelRow }) => {
|
||||
if (content !== undefined && content !== null && content !== '') return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30">✓</span>;
|
||||
|
||||
// EOL Exception: OOC and stock <= 0
|
||||
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
|
||||
const stock = Number(row[COLUMNS.ITEM_AVAILABLE] || 0);
|
||||
|
||||
if (classification === 'OOC' && stock <= 0) {
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">EOL not neccessary</span>;
|
||||
}
|
||||
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-500/20 text-red-400 border border-red-500/30">✗ Missing</span>;
|
||||
};
|
||||
@@ -220,7 +231,16 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex flex-col h-full"}>
|
||||
{isFullscreen && (
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
|
||||
title="Exit fullscreen"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
@@ -236,9 +256,16 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="ml-auto p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800 p-4 rounded-xl border border-slate-700 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6 bg-slate-800 p-4 rounded-xl border border-slate-700 shadow-sm">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
@@ -285,6 +312,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
<option value="">All Licenses</option>
|
||||
{licenses.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
|
||||
</select>
|
||||
<div className="ml-auto flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-200">
|
||||
<span className="text-[11px] uppercase tracking-[0.18em] text-emerald-300/80">In view</span>
|
||||
<span className="font-semibold tabular-nums">{filteredData.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||
@@ -317,6 +348,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
id={`desc-filter-trigger-${col}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||
@@ -338,6 +370,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
|
||||
{openFilterCol === col && (
|
||||
<ColumnFilterPopover
|
||||
triggerId={`desc-filter-trigger-${col}`}
|
||||
uniqueValues={getUniqueValues(col)}
|
||||
selectedValues={columnFilters[col] || []}
|
||||
onToggle={(val) => toggleColumnFilter(col, val)}
|
||||
@@ -367,11 +400,12 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getRowColor(row),
|
||||
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
|
||||
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
||||
""
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>
|
||||
{row[COLUMNS.ARTICLE_NO]}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-white truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
||||
{asinColumnIndex !== null && (
|
||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[asinColumnIndex] || 100 }} title={row[asinColumnIndex]}>{row[asinColumnIndex] || '—'}</td>
|
||||
@@ -428,6 +462,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
<option value={100}>100 per page</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 rounded-full border border-slate-700 bg-slate-800 px-3 py-1 text-xs text-slate-300">
|
||||
<span className="text-slate-500 uppercase tracking-[0.18em]">Filtered</span>
|
||||
<span className="font-semibold tabular-nums text-white">{filteredData.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page === 1}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Settings, Key, Save, Trash2, CheckCircle2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const stored = localStorage.getItem('GEMINI_API_KEY') || '';
|
||||
setApiKey(stored);
|
||||
setSaved(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (apiKey.trim()) {
|
||||
localStorage.setItem('GEMINI_API_KEY', apiKey.trim());
|
||||
} else {
|
||||
localStorage.removeItem('GEMINI_API_KEY');
|
||||
}
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setSaved(false);
|
||||
onClose();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
localStorage.removeItem('GEMINI_API_KEY');
|
||||
setApiKey('');
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 1500);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-300">
|
||||
<div className="bg-slate-900 border border-slate-700 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700/50 bg-slate-800/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<Settings className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white uppercase tracking-tight text-xs">AI Settings</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-slate-500 hover:text-white hover:bg-slate-700 rounded-md transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Key className="w-3 h-3" />
|
||||
Gemini API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value="AIzaSyDizFnYYnsBRBCfTuT9xhIPBXY0jFQd7HA"
|
||||
readOnly
|
||||
className="w-full bg-slate-800/50 border border-slate-700 rounded-lg px-4 py-3 text-sm text-slate-500 focus:outline-none transition-all cursor-not-allowed font-mono"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-blue-500">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-blue-400 leading-relaxed italic font-bold">
|
||||
The Gemini API Key is now fixed for all users. Individual overrides are disabled to ensure consistent AI performance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-500/5 border border-blue-500/10 rounded-lg p-4">
|
||||
<h4 className="text-[10px] font-bold text-blue-400 uppercase mb-2">Instructions</h4>
|
||||
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3">
|
||||
<li>Go to <a href="https://aistudio.google.com/" target="_blank" rel="noreferrer" className="text-blue-500 hover:underline">Google AI Studio</a></li>
|
||||
<li>Enable billing in Settings if you want to use the paid tier</li>
|
||||
<li>Generate a new API key and paste it here</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end p-4 bg-slate-800/40 border-t border-slate-700/50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-slate-700 hover:bg-slate-600 text-white text-xs font-black uppercase tracking-widest rounded-lg shadow-lg transition-all active:scale-95"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,34 @@
|
||||
import React from 'react';
|
||||
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle } from 'lucide-react';
|
||||
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle, Sparkles, LayoutDashboard, Users } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ControlTabId } from '../lib/controlDashboard';
|
||||
|
||||
interface SidebarProps {
|
||||
activeModule: string;
|
||||
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data') => void;
|
||||
activeModule: ControlTabId;
|
||||
setActiveModule: (m: ControlTabId) => void;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarProps) {
|
||||
const isMasterUser = userEmail?.toLowerCase() === 'christian.vidal@craze-group.com';
|
||||
const MASTER_USERS = new Set([
|
||||
'christian.vidal@craze-group.com',
|
||||
'jingying.shi@craze-group.com',
|
||||
]);
|
||||
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
|
||||
|
||||
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data';
|
||||
|
||||
const navItems: { id: ModuleId; label: string; icon: React.ElementType }[] = [
|
||||
const navItems: { id: ControlTabId; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'matrix', label: 'Matrix', icon: Table },
|
||||
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
||||
{ id: 'article_details', label: 'Article Details', icon: Package },
|
||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
||||
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
|
||||
{ id: 'cosmetic_items', label: 'Cosmetic Items', icon: Sparkles },
|
||||
...(isMasterUser ? [
|
||||
{ id: 'pending_validation' as ModuleId, label: 'Pending Validation', icon: Clock },
|
||||
{ id: 'history' as ModuleId, label: 'Change History', icon: History }
|
||||
{ id: 'pending_validation' as ControlTabId, label: 'Pending Validation', icon: Clock },
|
||||
{ id: 'history' as ControlTabId, label: 'Change History', icon: History },
|
||||
{ id: 'user_management' as ControlTabId, label: 'User Validation', icon: Users }
|
||||
] : [])
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
type SyncStatus = 'pending' | 'bc_pending' | 'synced' | 'failed' | 'saved' | 'edited' | 'error' | string | undefined;
|
||||
|
||||
interface SyncStatusPillProps {
|
||||
status: SyncStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SyncStatusPill({ status, className }: SyncStatusPillProps) {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
|
||||
const meta = (() => {
|
||||
if (normalized === 'bc_pending') {
|
||||
return { label: 'Pending BC', tone: 'bg-blue-500/10 text-blue-300 border-blue-500/20' };
|
||||
}
|
||||
if (normalized === 'previewed') {
|
||||
return { label: 'Previewed', tone: 'bg-indigo-500/10 text-indigo-300 border-indigo-500/20' };
|
||||
}
|
||||
if (normalized === 'preview_only' || normalized === 'preview only') {
|
||||
return { label: 'Preview only', tone: 'bg-amber-500/10 text-amber-300 border-amber-500/20' };
|
||||
}
|
||||
if (normalized === 'syncing') {
|
||||
return { label: 'Syncing BC', tone: 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20' };
|
||||
}
|
||||
if (normalized === 'pending' || normalized === 'edited') {
|
||||
return { label: 'Pending app', tone: 'bg-amber-500/10 text-amber-300 border-amber-500/20' };
|
||||
}
|
||||
if (normalized === 'synced' || normalized === 'saved') {
|
||||
return { label: 'Synced BC', tone: 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20' };
|
||||
}
|
||||
if (normalized === 'failed' || normalized === 'error') {
|
||||
return { label: 'BC failed', tone: 'bg-red-500/10 text-red-300 border-red-500/20' };
|
||||
}
|
||||
if (!status) {
|
||||
return { label: 'In app', tone: 'bg-slate-500/10 text-slate-300 border-slate-500/20' };
|
||||
}
|
||||
return { label: String(status), tone: 'bg-slate-500/10 text-slate-300 border-slate-500/20' };
|
||||
})();
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border whitespace-nowrap',
|
||||
meta.tone,
|
||||
className
|
||||
)}>
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+167
-21
@@ -1,12 +1,11 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Download, LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw } from 'lucide-react';
|
||||
import { LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw, Maximize2, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface TopBarProps {
|
||||
stats: any;
|
||||
onExport: () => void;
|
||||
hasData: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
activeModule?: string;
|
||||
onRefresh?: () => void;
|
||||
userEmail?: string;
|
||||
onSignOut?: () => void;
|
||||
canUndo: boolean;
|
||||
@@ -18,16 +17,40 @@ interface TopBarProps {
|
||||
onSaveAll: () => Promise<void>;
|
||||
onRevertRow: (articleNo: string) => void;
|
||||
isSavingAll: boolean;
|
||||
bcQueueCount: number;
|
||||
bcQueueEntries: Record<string, { articleName: string; selected: boolean; status: string; error?: string; warning?: string; categorizationCode?: string }>;
|
||||
onToggleBcQueueSelection: (articleNo: string) => void;
|
||||
onSelectAllBcQueue: (selected: boolean) => void;
|
||||
onPreviewSelectedBcSync: () => Promise<void>;
|
||||
onSyncSelectedBcSync: () => Promise<void>;
|
||||
onDiscardSelectedBcQueue: () => void;
|
||||
isSyncingBC: boolean;
|
||||
isMaximized: boolean;
|
||||
onToggleMaximize: () => void;
|
||||
}
|
||||
|
||||
export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, pendingChanges, onSaveAll, onRevertRow, isSavingAll }: TopBarProps) {
|
||||
export function TopBar({ stats, activeModule, onRefresh, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, pendingChanges, onSaveAll, onRevertRow, isSavingAll, bcQueueCount, bcQueueEntries, onToggleBcQueueSelection, onSelectAllBcQueue, onPreviewSelectedBcSync, onSyncSelectedBcSync, onDiscardSelectedBcQueue, isSyncingBC, isMaximized, onToggleMaximize }: TopBarProps) {
|
||||
const [showPending, setShowPending] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [showBcQueue, setShowBcQueue] = useState(false);
|
||||
const pendingDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const bcQueueDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const selectedBcQueueCount = Object.values(bcQueueEntries).filter(entry => entry.selected).length;
|
||||
|
||||
const formatQueueStatus = (status: string) => {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
if (normalized === 'preview_only') return 'Preview only';
|
||||
if (normalized === 'previewed') return 'Previewed';
|
||||
if (normalized === 'syncing') return 'Syncing';
|
||||
if (normalized === 'synced') return 'Synced';
|
||||
if (normalized === 'failed') return 'Failed';
|
||||
if (normalized === 'queued') return 'Queued';
|
||||
return String(status || '');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPending) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
if (pendingDropdownRef.current && !pendingDropdownRef.current.contains(e.target as Node)) {
|
||||
setShowPending(false);
|
||||
}
|
||||
};
|
||||
@@ -35,13 +58,28 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBcQueue) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (bcQueueDropdownRef.current && !bcQueueDropdownRef.current.contains(e.target as Node)) {
|
||||
setShowBcQueue(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showBcQueue]);
|
||||
|
||||
// Close dropdown when all changes are saved/reverted
|
||||
useEffect(() => {
|
||||
if (pendingCount === 0) setShowPending(false);
|
||||
}, [pendingCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bcQueueCount === 0) setShowBcQueue(false);
|
||||
}, [bcQueueCount]);
|
||||
|
||||
return (
|
||||
<header className="bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 z-10 shadow-2xl">
|
||||
<header className="relative z-[200] bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 shadow-2xl">
|
||||
<div className="flex items-center -ml-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
@@ -50,7 +88,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
{stats && activeModule === 'descriptions' && (
|
||||
<div className="flex items-center gap-4 text-xs font-medium">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-slate-400">Total</span>
|
||||
@@ -80,7 +118,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="relative" ref={pendingDropdownRef}>
|
||||
{/* Split button: Save All + dropdown toggle */}
|
||||
<div className={cn(
|
||||
"flex items-center rounded-md overflow-hidden shadow-lg transition-all",
|
||||
@@ -120,7 +158,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
||||
|
||||
{/* Dropdown: list of pending changes */}
|
||||
{showPending && pendingCount > 0 && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 overflow-hidden">
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-[9999] overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Pending changes</span>
|
||||
<span className="text-xs text-slate-500">{pendingCount} unsaved</span>
|
||||
@@ -151,21 +189,128 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasData && (
|
||||
<div className="relative" ref={bcQueueDropdownRef}>
|
||||
<div className={cn(
|
||||
"flex items-center rounded-md overflow-hidden shadow-lg transition-all",
|
||||
bcQueueCount > 0 ? "shadow-blue-900/30" : "shadow-none opacity-40"
|
||||
)}>
|
||||
<button
|
||||
onClick={onSyncSelectedBcSync}
|
||||
disabled={isSyncingBC || bcQueueCount === 0}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-bold transition-all text-white",
|
||||
bcQueueCount > 0
|
||||
? "bg-blue-600 hover:bg-blue-500 disabled:opacity-60"
|
||||
: "bg-slate-700 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isSyncingBC ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudUpload className="w-4 h-4" />}
|
||||
{isSyncingBC
|
||||
? 'Syncing...'
|
||||
: bcQueueCount > 0
|
||||
? `Sync all ${bcQueueCount} to BC`
|
||||
: 'No BC queue'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => bcQueueCount > 0 && setShowBcQueue(v => !v)}
|
||||
disabled={isSyncingBC || bcQueueCount === 0}
|
||||
className={cn(
|
||||
"flex items-center px-2 py-2 text-white border-l transition-all",
|
||||
bcQueueCount > 0
|
||||
? "bg-blue-700 hover:bg-blue-600 border-blue-500/40"
|
||||
: "bg-slate-700 cursor-not-allowed border-slate-600"
|
||||
)}
|
||||
title="View BC sync queue"
|
||||
>
|
||||
<ChevronDown className={cn("w-4 h-4 transition-transform", showBcQueue && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBcQueue && bcQueueCount > 0 && (
|
||||
<div className="absolute right-0 top-full mt-2 w-96 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-[9999] overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">BC sync queue</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSelectAllBcQueue(true)}
|
||||
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-200"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelectAllBcQueue(false)}
|
||||
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-200"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<button
|
||||
onClick={onDiscardSelectedBcQueue}
|
||||
disabled={isSyncingBC || selectedBcQueueCount === 0}
|
||||
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-red-900/50 text-red-400 border border-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Discard {selectedBcQueueCount > 0 ? `(${selectedBcQueueCount})` : ''}
|
||||
</button>
|
||||
<button
|
||||
onClick={onPreviewSelectedBcSync}
|
||||
disabled={isSyncingBC}
|
||||
className="text-xs px-2 py-1 rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-60"
|
||||
>
|
||||
Preview {selectedBcQueueCount > 0 ? `(${selectedBcQueueCount})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{Object.entries(bcQueueEntries).map(([articleNo, entry]) => (
|
||||
<div
|
||||
key={articleNo}
|
||||
className="flex items-center gap-2 px-3 py-2.5 hover:bg-slate-700/50 border-b border-slate-700/50 last:border-0"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entry.selected}
|
||||
onChange={() => onToggleBcQueueSelection(articleNo)}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-mono text-slate-400">{articleNo}</p>
|
||||
<p className="text-sm text-white truncate">{entry.articleName}</p>
|
||||
<p className="text-[10px] text-cyan-300 truncate">
|
||||
CategorizationCode: {entry.categorizationCode || 'empty - skipped'}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-500 uppercase">
|
||||
{formatQueueStatus(entry.status)}
|
||||
{entry.error ? ` · ${entry.error}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onExport}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
hasUnsavedChanges
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'bg-slate-700 hover:bg-slate-600 text-slate-200'
|
||||
}`}
|
||||
onClick={onRefresh}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium bg-violet-600 hover:bg-violet-700 text-white transition-colors"
|
||||
title="Hard Refresh - Reload all data from source"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export Updated Excel
|
||||
{hasUnsavedChanges && <span className="w-2 h-2 rounded-full bg-red-500 ml-1 animate-pulse" />}
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onToggleMaximize}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium bg-slate-700 hover:bg-slate-600 text-slate-200 transition-colors"
|
||||
title="Maximize - View table in full screen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
MAXIMIZE
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
@@ -199,6 +344,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, Search, CheckCircle, XCircle, Trash2, Shield, Loader2, AlertCircle, Clock } from 'lucide-react';
|
||||
import { AuthSession } from '../lib/auth';
|
||||
import { safeFetch } from '../lib/supabase';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
status: 'Master' | 'Validated' | 'Pending';
|
||||
}
|
||||
|
||||
interface UserManagementViewProps {
|
||||
session: AuthSession;
|
||||
}
|
||||
|
||||
export function UserManagementView({ session }: UserManagementViewProps) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||
const [deleteConfirmUser, setDeleteConfirmUser] = useState<User | null>(null);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
try {
|
||||
const res = await safeFetch('/api/users-admin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ action: 'list' })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to fetch users.');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
setWarning(data.warning || null);
|
||||
} catch (err: any) {
|
||||
console.error('[UserManagement] fetch error:', err);
|
||||
setError(err.message || 'Error fetching user list.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [session]);
|
||||
|
||||
const handleToggleValidation = async (user: User, approve: boolean) => {
|
||||
setProcessingId(user.id);
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
try {
|
||||
const res = await safeFetch('/api/users-admin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'validate',
|
||||
targetUserId: user.id,
|
||||
email: user.email,
|
||||
validated: approve
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update user approval status.');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
// Update local state
|
||||
setUsers(prev => prev.map(u => {
|
||||
if (u.id === user.id) {
|
||||
return { ...u, status: approve ? 'Validated' : 'Pending' };
|
||||
}
|
||||
return u;
|
||||
}));
|
||||
if (data.warning) {
|
||||
setWarning(data.warning);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error updating approval status.');
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
setProcessingId(userId);
|
||||
setError(null);
|
||||
setDeleteConfirmUser(null);
|
||||
try {
|
||||
const res = await safeFetch('/api/users-admin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'delete',
|
||||
targetUserId: userId
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to delete user.');
|
||||
}
|
||||
|
||||
// Remove from local list
|
||||
setUsers(prev => prev.filter(u => u.id !== userId));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error deleting user.');
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden bg-[#041021]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/10 rounded-lg">
|
||||
<Users className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Validación de Usuarios</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
Administra los accesos y los registros de usuarios en la plataforma.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por email..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="bg-slate-900 border border-slate-800 rounded-md pl-9 pr-4 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 w-64 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-950/30 border border-red-500/30 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm text-red-300">
|
||||
<span className="font-semibold">Error:</span> {error}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-400 hover:text-red-300 text-xs font-semibold px-2 py-1 rounded"
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warning && (
|
||||
<div className="mb-6 p-4 bg-amber-950/30 border border-amber-500/30 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm text-amber-200">
|
||||
<span className="font-semibold">Aviso:</span> {warning}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setWarning(null)}
|
||||
className="text-amber-300 hover:text-amber-200 text-xs font-semibold px-2 py-1 rounded"
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-slate-400">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mb-3" />
|
||||
<p className="text-sm">Cargando lista de usuarios...</p>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="h-64 flex flex-col items-center justify-center border border-dashed border-slate-800 rounded-xl text-slate-500">
|
||||
<Users className="w-12 h-12 mb-3 opacity-20" />
|
||||
{search ? (
|
||||
<p className="text-sm">No se encontraron usuarios que coincidan con la búsqueda.</p>
|
||||
) : (
|
||||
<p className="text-sm">No hay registros de usuarios registrados.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#08152c] border border-slate-800/60 rounded-xl overflow-hidden shadow-xl">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800/80 bg-slate-900/30 text-slate-400 text-xs font-semibold uppercase tracking-wider">
|
||||
<th className="py-4 px-6">Email</th>
|
||||
<th className="py-4 px-6">Fecha de Registro</th>
|
||||
<th className="py-4 px-6">Estado</th>
|
||||
<th className="py-4 px-6 text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50 text-slate-300 text-sm">
|
||||
{filteredUsers.map(user => {
|
||||
const isProcessing = processingId === user.id;
|
||||
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-slate-900/10 transition-colors">
|
||||
<td className="py-4 px-6 font-medium text-white max-w-xs truncate">
|
||||
{user.email}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-slate-400">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
{user.status === 'Master' ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
Administrador Principal
|
||||
</span>
|
||||
) : user.status === 'Validated' ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
Validado
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Pendiente Validación
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-right">
|
||||
{user.status === 'Master' ? (
|
||||
<span className="text-slate-500 text-xs italic">Protegido</span>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{user.status === 'Pending' ? (
|
||||
<button
|
||||
onClick={() => handleToggleValidation(user, true)}
|
||||
disabled={isProcessing}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white disabled:opacity-50 text-xs font-semibold rounded-md transition-colors shadow-sm"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Validar
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleToggleValidation(user, false)}
|
||||
disabled={isProcessing}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 disabled:opacity-50 text-xs font-semibold rounded-md border border-slate-700 transition-colors"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Revocar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setDeleteConfirmUser(user)}
|
||||
disabled={isProcessing}
|
||||
className="inline-flex items-center justify-center p-1.5 bg-red-950/30 hover:bg-red-900/50 text-red-400 hover:text-red-300 border border-red-900/20 disabled:opacity-50 rounded-md transition-colors"
|
||||
title="Eliminar usuario"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{deleteConfirmUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-[#08152c] border border-slate-800 rounded-xl max-w-md w-full overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 text-red-400 mb-4">
|
||||
<Trash2 className="w-6 h-6 shrink-0" />
|
||||
<h3 className="text-lg font-bold text-white">¿Eliminar usuario definitivamente?</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 mb-2">
|
||||
Estás a punto de eliminar la cuenta del usuario:
|
||||
</p>
|
||||
<p className="text-sm font-mono bg-slate-900/60 border border-slate-800/80 p-2.5 rounded text-blue-400 break-all mb-4">
|
||||
{deleteConfirmUser.email}
|
||||
</p>
|
||||
<p className="text-xs text-red-400/90 leading-relaxed">
|
||||
Esta acción no se puede deshacer. Se eliminarán sus accesos y toda su información asociada al servicio de autenticación.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 border-t border-slate-800/60 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmUser(null)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm font-semibold rounded-md transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(deleteConfirmUser.id)}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white text-sm font-semibold rounded-md transition-colors shadow-sm"
|
||||
>
|
||||
Eliminar Cuenta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { COLUMNS, resolveColumnIndices } from '../types';
|
||||
|
||||
type ColumnIndices = typeof COLUMNS;
|
||||
|
||||
const ColumnsContext = createContext<ColumnIndices>(COLUMNS);
|
||||
|
||||
export function ColumnsProvider({ headers, children }: { headers: string[], children: React.ReactNode }) {
|
||||
const resolved = useMemo(() => {
|
||||
if (!headers || headers.length === 0) return COLUMNS;
|
||||
return resolveColumnIndices(headers);
|
||||
}, [headers]);
|
||||
|
||||
return (
|
||||
<ColumnsContext.Provider value={resolved}>
|
||||
{children}
|
||||
</ColumnsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useColumns() {
|
||||
return useContext(ColumnsContext);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
interface FilterContextType {
|
||||
states: Record<string, any>;
|
||||
setState: (key: string, value: any) => void;
|
||||
}
|
||||
|
||||
const FilterContext = createContext<FilterContextType | undefined>(undefined);
|
||||
|
||||
export function FilterProvider({ children }: { children: React.ReactNode }) {
|
||||
const [states, setStates] = useState<Record<string, any>>({});
|
||||
|
||||
const setState = useCallback((key: string, value: any) => {
|
||||
setStates(prev => ({
|
||||
...prev,
|
||||
[key]: typeof value === 'function' ? value(prev[key]) : value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FilterContext.Provider value={{ states, setState }}>
|
||||
{children}
|
||||
</FilterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePersistentState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const context = useContext(FilterContext);
|
||||
if (!context) {
|
||||
throw new Error('usePersistentState must be used within a FilterProvider');
|
||||
}
|
||||
|
||||
const state = context.states[key] !== undefined ? context.states[key] : defaultValue;
|
||||
|
||||
const setState = useCallback((value: T | ((prev: T) => T)) => {
|
||||
context.setState(key, (current: any) => {
|
||||
const actualCurrent = current !== undefined ? current : defaultValue;
|
||||
return typeof value === 'function' ? (value as any)(actualCurrent) : value;
|
||||
});
|
||||
}, [context, key, defaultValue]);
|
||||
|
||||
return [state, setState];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const SESSION_KEY = 'craze_auth_session';
|
||||
|
||||
export interface AuthSession {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: { id: string; email: string };
|
||||
}
|
||||
|
||||
@@ -39,17 +40,94 @@ export async function signIn(email: string, password: string): Promise<AuthSessi
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Validate status before signing in
|
||||
try {
|
||||
const statusRes = await fetch('/api/users-admin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${data.access_token}`
|
||||
},
|
||||
body: JSON.stringify({ action: 'check-status' })
|
||||
});
|
||||
|
||||
if (!statusRes.ok) {
|
||||
const err = await statusRes.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to verify account validation status.');
|
||||
}
|
||||
|
||||
const statusData = await statusRes.json();
|
||||
if (!statusData.validated) {
|
||||
throw new Error('Tu usuario aún no ha sido validado por un administrador.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new Error(err.message || 'Error de validación del usuario.');
|
||||
}
|
||||
|
||||
const session: AuthSession = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
user: { id: data.user.id, email: data.user.email },
|
||||
};
|
||||
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
window.dispatchEvent(new Event('session-refreshed'));
|
||||
return session;
|
||||
}
|
||||
|
||||
let activeRefreshPromise: Promise<AuthSession> | null = null;
|
||||
|
||||
export async function refreshSession(refreshToken: string): Promise<AuthSession> {
|
||||
if (activeRefreshPromise) {
|
||||
return activeRefreshPromise;
|
||||
}
|
||||
|
||||
activeRefreshPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': SUPABASE_ANON_KEY,
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
window.dispatchEvent(new Event('session-refreshed'));
|
||||
throw new Error('Session expired. Please sign in again.');
|
||||
} else {
|
||||
throw new Error(`Server error (${response.status}). Please try again later.`);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const session: AuthSession = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
user: { id: data.user.id, email: data.user.email },
|
||||
};
|
||||
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
window.dispatchEvent(new Event('session-refreshed'));
|
||||
return session;
|
||||
} catch (error) {
|
||||
// If the error was not already thrown as "Session expired", we just propagate it.
|
||||
throw error;
|
||||
} finally {
|
||||
activeRefreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return activeRefreshPromise;
|
||||
}
|
||||
|
||||
export function signOut(): void {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
window.dispatchEvent(new Event('session-refreshed'));
|
||||
}
|
||||
|
||||
export function getStoredSession(): AuthSession | null {
|
||||
|
||||
@@ -0,0 +1,974 @@
|
||||
import { ExcelRow, COLUMNS, resolveColumnIndices } from '../types';
|
||||
import {
|
||||
HistoryEntry,
|
||||
DashboardDescriptionsSnapshot,
|
||||
DashboardArticleDetailsSnapshot,
|
||||
DashboardPricingSnapshot,
|
||||
DashboardCosmeticSnapshot,
|
||||
DashboardSnapshotTabs,
|
||||
getDashboardSnapshotStore,
|
||||
ensureDashboardSnapshot,
|
||||
} from './supabase';
|
||||
|
||||
export type ControlTabId =
|
||||
| 'control_dashboard'
|
||||
| 'matrix'
|
||||
| 'descriptions'
|
||||
| 'article_details'
|
||||
| 'dimensions'
|
||||
| 'pricing'
|
||||
| 'missing_data'
|
||||
| 'cosmetic_items'
|
||||
| 'pending_validation'
|
||||
| 'history'
|
||||
| 'user_management';
|
||||
|
||||
export type DashboardDrilldownTabId = Exclude<ControlTabId, 'control_dashboard'>;
|
||||
|
||||
export interface DashboardDrilldownRequest {
|
||||
id: string;
|
||||
tabId: DashboardDrilldownTabId;
|
||||
focus: string;
|
||||
}
|
||||
|
||||
export function createDashboardDrilldownRequest(tabId: DashboardDrilldownTabId, focus: string): DashboardDrilldownRequest {
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
tabId,
|
||||
focus,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TabMetricSnapshot {
|
||||
total: number;
|
||||
ok: number;
|
||||
pending: number;
|
||||
empty: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface DescriptionsDashboardSnapshot {
|
||||
total: number;
|
||||
ok: number;
|
||||
longDeMissing: number;
|
||||
longEnMissing: number;
|
||||
shortDeMissing: number;
|
||||
shortEnMissing: number;
|
||||
}
|
||||
|
||||
export interface ArticleDetailsDashboardSnapshot {
|
||||
total: number;
|
||||
ok: number;
|
||||
detailsDeMissing: number;
|
||||
detailsEnMissing: number;
|
||||
}
|
||||
|
||||
export interface PricingDashboardSnapshot {
|
||||
total: number;
|
||||
ok: number;
|
||||
itemToLogisticMissing: number;
|
||||
uvpMissing: number;
|
||||
srpIntMissing: number;
|
||||
srpUkMissing: number;
|
||||
unitsOuterMissing: number;
|
||||
outerWMissing: number;
|
||||
outerLMissing: number;
|
||||
outerHMissing: number;
|
||||
units40fMissing: number;
|
||||
moqMissing: number;
|
||||
weightIssues: number;
|
||||
}
|
||||
|
||||
export interface CosmeticDashboardSnapshot {
|
||||
total: number;
|
||||
ok: number;
|
||||
cpnpMissing: number;
|
||||
}
|
||||
|
||||
export interface TabCardSummary {
|
||||
id: ControlTabId;
|
||||
label: string;
|
||||
accentClass: string;
|
||||
current: TabMetricSnapshot;
|
||||
historical?: TabMetricSnapshot;
|
||||
delta?: TabMetricSnapshot;
|
||||
}
|
||||
|
||||
export interface PendingRowInfo {
|
||||
rowIndex: number;
|
||||
originalData: ExcelRow;
|
||||
newData: ExcelRow;
|
||||
articleName: string;
|
||||
}
|
||||
|
||||
export interface HistorySyncRecord {
|
||||
selected: boolean;
|
||||
status: 'bc_pending' | 'previewed' | 'preview_only' | 'syncing' | 'synced' | 'failed';
|
||||
previewToken?: string;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export type HistorySyncMap = Record<string, HistorySyncRecord>;
|
||||
|
||||
export interface DashboardContext {
|
||||
data: ExcelRow[];
|
||||
pendingRows: Record<string, PendingRowInfo>;
|
||||
rowStatuses: Record<string, string>;
|
||||
historyEntries: HistoryEntry[];
|
||||
historySyncMap?: HistorySyncMap;
|
||||
}
|
||||
|
||||
export interface SnapshotStore {
|
||||
[dateKey: string]: DashboardSnapshotTabs;
|
||||
}
|
||||
|
||||
export const CONTROL_TABS: Array<{
|
||||
id: Exclude<ControlTabId, 'control_dashboard'>;
|
||||
label: string;
|
||||
accentClass: string;
|
||||
}> = [
|
||||
{ id: 'matrix', label: 'Matrix', accentClass: 'border-sky-500/30 bg-sky-500/5' },
|
||||
{ id: 'descriptions', label: 'Product Descriptions', accentClass: 'border-emerald-500/30 bg-emerald-500/5' },
|
||||
{ id: 'article_details', label: 'Article Details', accentClass: 'border-indigo-500/30 bg-indigo-500/5' },
|
||||
{ id: 'dimensions', label: 'Dimensions', accentClass: 'border-violet-500/30 bg-violet-500/5' },
|
||||
{ id: 'pricing', label: 'Pricing & Units', accentClass: 'border-amber-500/30 bg-amber-500/5' },
|
||||
{ id: 'missing_data', label: 'Missing Data', accentClass: 'border-rose-500/30 bg-rose-500/5' },
|
||||
{ id: 'cosmetic_items', label: 'Cosmetic Items', accentClass: 'border-fuchsia-500/30 bg-fuchsia-500/5' },
|
||||
{ id: 'pending_validation', label: 'Pending Validation', accentClass: 'border-orange-500/30 bg-orange-500/5' },
|
||||
{ id: 'history', label: 'Change History', accentClass: 'border-cyan-500/30 bg-cyan-500/5' },
|
||||
];
|
||||
|
||||
export const APP_TABS: Array<{
|
||||
id: ControlTabId;
|
||||
label: string;
|
||||
accentClass: string;
|
||||
}> = [
|
||||
{ id: 'control_dashboard', label: 'Control Dashboard', accentClass: 'border-slate-500/30 bg-slate-500/5' },
|
||||
...CONTROL_TABS,
|
||||
];
|
||||
|
||||
const HISTORY_SYNC_STORAGE_KEY = 'history-bcSync';
|
||||
|
||||
const COSMETIC_LINES = new Set(['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS']);
|
||||
const STATUS_PENDING = new Set(['pending', 'bc_pending', 'queued', 'previewed', 'preview_only', 'syncing']);
|
||||
const STATUS_ERROR = new Set(['failed', 'error']);
|
||||
|
||||
function normalize(value: unknown): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
return String(value).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function safeLocalStorageGet(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isBlank(value: unknown): boolean {
|
||||
return normalize(value) === '';
|
||||
}
|
||||
|
||||
function isDateLikeEmpty(value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === '') return true;
|
||||
if (typeof value === 'number') return value === 0 || value === 1;
|
||||
|
||||
const text = normalize(value);
|
||||
if (text === '' || text === '0' || text === '1') return true;
|
||||
if (text === '0001-01-01' || text.startsWith('0001-01-01T')) return true;
|
||||
if (text.endsWith('/1900')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNumericLikeEmpty(value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === '') return true;
|
||||
const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
|
||||
return Number.isNaN(n) || n === 0;
|
||||
}
|
||||
|
||||
function isTruthyNumeric(value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === '') return false;
|
||||
const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
|
||||
return !Number.isNaN(n) && n !== 0;
|
||||
}
|
||||
|
||||
function toDate(value: unknown): Date | null {
|
||||
if (isDateLikeEmpty(value)) return null;
|
||||
if (typeof value === 'number') {
|
||||
if (value >= 25569 && value <= 60000) {
|
||||
const excelEpoch = new Date(1899, 11, 30);
|
||||
return new Date(excelEpoch.getTime() + value * 86400000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = normalize(value);
|
||||
if (!text) return null;
|
||||
const iso = new Date(text);
|
||||
if (!Number.isNaN(iso.getTime())) return iso;
|
||||
|
||||
const parts = text.split('/');
|
||||
if (parts.length === 3) {
|
||||
const [dd, mm, yyyy] = parts;
|
||||
const parsed = new Date(Number(yyyy), Number(mm) - 1, Number(dd));
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function localDateKey(date: Date): string {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function shiftDate(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() - days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function findIndicesByPatterns(headers: string[], patterns: string[][]): number[] {
|
||||
const lower = headers.map(header => normalize(header).toLowerCase());
|
||||
const indices = new Set<number>();
|
||||
|
||||
patterns.forEach(pattern => {
|
||||
lower.forEach((header, index) => {
|
||||
if (pattern.every(token => header.includes(token))) {
|
||||
indices.add(index);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(indices).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function unionIndices(...groups: number[][]): number[] {
|
||||
const result = new Set<number>();
|
||||
groups.forEach(group => group.forEach(index => result.add(index)));
|
||||
return Array.from(result).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function getRowKey(row: ExcelRow): string {
|
||||
return normalize(row[COLUMNS.ARTICLE_NO]);
|
||||
}
|
||||
|
||||
export function getHistorySyncMapFromStorage(): HistorySyncMap {
|
||||
try {
|
||||
const raw = safeLocalStorageGet(HISTORY_SYNC_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
return parsed as HistorySyncMap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getTabRows(tabId: ControlTabId, headers: string[], ctx: DashboardContext): ExcelRow[] {
|
||||
if (tabId === 'pending_validation') {
|
||||
return Object.values(ctx.pendingRows).map(row => row.newData);
|
||||
}
|
||||
|
||||
if (tabId === 'history') {
|
||||
return ctx.historyEntries.map(entry => entry.new_data);
|
||||
}
|
||||
|
||||
const resolvedRows = ctx.data || [];
|
||||
|
||||
if (tabId === 'cosmetic_items') {
|
||||
return resolvedRows.filter(row => COSMETIC_LINES.has(normalize(row[COLUMNS.LINE]).toUpperCase()));
|
||||
}
|
||||
|
||||
if (tabId === 'missing_data') {
|
||||
return resolvedRows.filter(row => isMissingDataRow(row, headers));
|
||||
}
|
||||
|
||||
return resolvedRows;
|
||||
}
|
||||
|
||||
function getDescriptionRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
|
||||
const resolvedRows = ctx.data || [];
|
||||
void headers;
|
||||
return resolvedRows;
|
||||
}
|
||||
|
||||
function getArticleDetailsRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
|
||||
const resolvedRows = ctx.data || [];
|
||||
void headers;
|
||||
return resolvedRows;
|
||||
}
|
||||
|
||||
function getPricingRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
|
||||
const resolvedRows = ctx.data || [];
|
||||
void headers;
|
||||
return resolvedRows;
|
||||
}
|
||||
|
||||
function getCosmeticRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
|
||||
const resolvedRows = ctx.data || [];
|
||||
const columns = resolveColumnIndices(headers);
|
||||
return resolvedRows.filter(row => COSMETIC_LINES.has(normalize(row[columns.LINE]).toUpperCase()));
|
||||
}
|
||||
|
||||
function isMissingDataRow(row: ExcelRow, headers: string[]): boolean {
|
||||
const launchIdx = findHeaderIndexFromHeaders(headers, [['launch', 'date']]);
|
||||
const readyIdx = findHeaderIndexFromHeaders(headers, [['ready', 'to', 'order', 'date']]);
|
||||
const classificationIdx = findHeaderIndexFromHeaders(headers, [['classification']]);
|
||||
|
||||
const launch = launchIdx >= 0 ? row[launchIdx] : undefined;
|
||||
const ready = readyIdx >= 0 ? row[readyIdx] : undefined;
|
||||
const classification = classificationIdx >= 0 ? row[classificationIdx] : undefined;
|
||||
|
||||
const missingBasics = isDateLikeEmpty(launch) || isBlank(classification);
|
||||
const readyBeforeLaunch = (() => {
|
||||
const launchDate = toDate(launch);
|
||||
const readyDate = toDate(ready);
|
||||
if (!launchDate || !readyDate) return false;
|
||||
return readyDate.getTime() > launchDate.getTime();
|
||||
})();
|
||||
|
||||
const upcomingLaunch = (() => {
|
||||
const launchDate = toDate(launch);
|
||||
if (!launchDate) return false;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const diff = Math.ceil((launchDate.getTime() - today.getTime()) / 86400000);
|
||||
return diff > 0 && diff <= 180;
|
||||
})();
|
||||
|
||||
return missingBasics || readyBeforeLaunch || upcomingLaunch;
|
||||
}
|
||||
|
||||
function findHeaderIndexFromHeaders(headers: string[], patterns: string[][]): number {
|
||||
return findIndicesByPatterns(headers, patterns)[0] ?? -1;
|
||||
}
|
||||
|
||||
function getEditableIndices(tabId: ControlTabId, headers: string[]): number[] {
|
||||
const columns = resolveColumnIndices(headers);
|
||||
|
||||
const descriptions = unionIndices(
|
||||
[columns.LONG_DE, columns.LONG_EN, columns.SHORT_DE, columns.SHORT_EN].filter(i => typeof i === 'number' && i >= 0),
|
||||
findIndicesByPatterns(headers, [['long', 'description']]),
|
||||
findIndicesByPatterns(headers, [['short', 'description']]),
|
||||
);
|
||||
|
||||
const articleDetails = unionIndices(
|
||||
[columns.DETAILS_EN, columns.DETAILS_DE, columns.SHORT_DE, columns.SHORT_EN, columns.MOQ, columns.CPNP_NO].filter(i => typeof i === 'number' && i >= 0),
|
||||
findIndicesByPatterns(headers, [['article', 'details', 'english']]),
|
||||
findIndicesByPatterns(headers, [['article', 'details', 'german']]),
|
||||
findIndicesByPatterns(headers, [['launch', 'date']]),
|
||||
findIndicesByPatterns(headers, [['ready', 'to', 'order', 'date']]),
|
||||
findIndicesByPatterns(headers, [['moq']]),
|
||||
findIndicesByPatterns(headers, [['cpnp']])
|
||||
);
|
||||
|
||||
const dimensions = unionIndices(
|
||||
[columns.UNITS_OUTER, columns.INNER_W, columns.INNER_L, columns.INNER_H, columns.OUTER_W, columns.OUTER_L, columns.OUTER_H, columns.MOQ].filter(i => typeof i === 'number' && i >= 0),
|
||||
findIndicesByPatterns(headers, [['inner', 'w']]),
|
||||
findIndicesByPatterns(headers, [['inner', 'l']]),
|
||||
findIndicesByPatterns(headers, [['inner', 'h']]),
|
||||
findIndicesByPatterns(headers, [['outer', 'w']]),
|
||||
findIndicesByPatterns(headers, [['outer', 'l']]),
|
||||
findIndicesByPatterns(headers, [['outer', 'h']]),
|
||||
findIndicesByPatterns(headers, [['units', 'outer']]),
|
||||
findIndicesByPatterns(headers, [['moq']])
|
||||
);
|
||||
|
||||
const pricing = unionIndices(
|
||||
[columns.UNITS_OUTER, columns.OUTER_W, columns.OUTER_L, columns.OUTER_H, columns.MOQ].filter(i => typeof i === 'number' && i >= 0),
|
||||
findIndicesByPatterns(headers, [['uvp']]),
|
||||
findIndicesByPatterns(headers, [['srp']]),
|
||||
findIndicesByPatterns(headers, [['price']]),
|
||||
findIndicesByPatterns(headers, [['cost']]),
|
||||
findIndicesByPatterns(headers, [['net']]),
|
||||
findIndicesByPatterns(headers, [['gross']]),
|
||||
findIndicesByPatterns(headers, [['units', 'outer']]),
|
||||
findIndicesByPatterns(headers, [['outer', 'w']]),
|
||||
findIndicesByPatterns(headers, [['outer', 'l']]),
|
||||
findIndicesByPatterns(headers, [['outer', 'h']]),
|
||||
findIndicesByPatterns(headers, [['moq']])
|
||||
);
|
||||
|
||||
const missingData = unionIndices(
|
||||
findIndicesByPatterns(headers, [['classification']]),
|
||||
findIndicesByPatterns(headers, [['launch', 'date']]),
|
||||
findIndicesByPatterns(headers, [['ready', 'to', 'order', 'date']])
|
||||
);
|
||||
|
||||
const cosmetic = findIndicesByPatterns(headers, [['cpnp']]);
|
||||
|
||||
const allEditable = unionIndices(descriptions, articleDetails, dimensions, pricing, missingData, cosmetic);
|
||||
|
||||
switch (tabId) {
|
||||
case 'descriptions':
|
||||
return descriptions;
|
||||
case 'article_details':
|
||||
return articleDetails;
|
||||
case 'dimensions':
|
||||
return dimensions;
|
||||
case 'pricing':
|
||||
return pricing;
|
||||
case 'missing_data':
|
||||
return missingData;
|
||||
case 'cosmetic_items':
|
||||
return cosmetic;
|
||||
case 'pending_validation':
|
||||
case 'history':
|
||||
case 'matrix':
|
||||
default:
|
||||
return allEditable;
|
||||
}
|
||||
}
|
||||
|
||||
function isFieldEmptyForTab(tabId: ControlTabId, index: number, value: unknown): boolean {
|
||||
if (tabId === 'descriptions' || tabId === 'article_details' || tabId === 'cosmetic_items' || tabId === 'history' || tabId === 'pending_validation' || tabId === 'matrix') {
|
||||
if (index === COLUMNS.CPNP_NO) return isBlank(value);
|
||||
if (index === COLUMNS.MOQ || index === COLUMNS.UNITS_OUTER || index === COLUMNS.INNER_W || index === COLUMNS.INNER_L || index === COLUMNS.INNER_H || index === COLUMNS.OUTER_W || index === COLUMNS.OUTER_L || index === COLUMNS.OUTER_H) {
|
||||
return isNumericLikeEmpty(value);
|
||||
}
|
||||
if (tabId === 'descriptions' && (index === COLUMNS.LONG_DE || index === COLUMNS.LONG_EN || index === COLUMNS.SHORT_DE || index === COLUMNS.SHORT_EN)) {
|
||||
return isBlank(value);
|
||||
}
|
||||
if (tabId === 'article_details' && (index === COLUMNS.DETAILS_DE || index === COLUMNS.DETAILS_EN || index === COLUMNS.SHORT_DE || index === COLUMNS.SHORT_EN || index === COLUMNS.MOQ || index === COLUMNS.CPNP_NO)) {
|
||||
return isBlank(value);
|
||||
}
|
||||
if (index === COLUMNS.ARTICLE_NO || index === COLUMNS.ARTICLE_NAME || index === COLUMNS.LINE || index === COLUMNS.CLASSIFICATION) {
|
||||
return isBlank(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (tabId === 'missing_data') {
|
||||
return index === COLUMNS.CLASSIFICATION || index === COLUMNS.CPNP_NO ? isBlank(value) : isDateLikeEmpty(value);
|
||||
}
|
||||
|
||||
if (tabId === 'pricing' || tabId === 'dimensions') {
|
||||
return isNumericLikeEmpty(value) || isBlank(value);
|
||||
}
|
||||
|
||||
return isBlank(value);
|
||||
}
|
||||
|
||||
function countEmptyRows(tabId: ControlTabId, rows: ExcelRow[], headers: string[]): number {
|
||||
const indices = getEditableIndices(tabId, headers);
|
||||
return rows.filter(row => indices.some(index => isFieldEmptyForTab(tabId, index, row[index]))).length;
|
||||
}
|
||||
|
||||
function rowHasPendingStatus(articleNo: string, ctx: DashboardContext): boolean {
|
||||
const normalized = normalize(ctx.rowStatuses[articleNo]).toLowerCase();
|
||||
return STATUS_PENDING.has(normalized);
|
||||
}
|
||||
|
||||
function countPendingRows(tabId: ControlTabId, rows: ExcelRow[], ctx: DashboardContext): number {
|
||||
if (tabId === 'pending_validation') {
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
if (tabId === 'history') {
|
||||
return ctx.historyEntries.filter(entry => {
|
||||
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
|
||||
return STATUS_PENDING.has(status);
|
||||
}).length;
|
||||
}
|
||||
|
||||
return rows.filter(row => {
|
||||
const articleNo = getRowKey(row);
|
||||
return rowHasPendingStatus(articleNo, ctx) || Object.prototype.hasOwnProperty.call(ctx.pendingRows, articleNo);
|
||||
}).length;
|
||||
}
|
||||
|
||||
function collectPendingArticles(tabId: ControlTabId, rows: ExcelRow[], ctx: DashboardContext): Set<string> {
|
||||
const articles = new Set<string>();
|
||||
|
||||
if (tabId === 'pending_validation') {
|
||||
rows.forEach(row => {
|
||||
const articleNo = getRowKey(row);
|
||||
if (articleNo) articles.add(articleNo);
|
||||
});
|
||||
return articles;
|
||||
}
|
||||
|
||||
if (tabId === 'history') {
|
||||
ctx.historyEntries.forEach(entry => {
|
||||
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
|
||||
if (STATUS_PENDING.has(status)) {
|
||||
articles.add(normalize(entry.product_id));
|
||||
}
|
||||
});
|
||||
return articles;
|
||||
}
|
||||
|
||||
rows.forEach(row => {
|
||||
const articleNo = getRowKey(row);
|
||||
if (!articleNo) return;
|
||||
if (rowHasPendingStatus(articleNo, ctx) || Object.prototype.hasOwnProperty.call(ctx.pendingRows, articleNo)) {
|
||||
articles.add(articleNo);
|
||||
}
|
||||
});
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
function collectEmptyArticles(tabId: ControlTabId, rows: ExcelRow[], headers: string[]): Set<string> {
|
||||
const indices = getEditableIndices(tabId, headers);
|
||||
const articles = new Set<string>();
|
||||
|
||||
rows.forEach(row => {
|
||||
const articleNo = getRowKey(row);
|
||||
if (!articleNo) return;
|
||||
if (indices.some(index => isFieldEmptyForTab(tabId, index, row[index]))) {
|
||||
articles.add(articleNo);
|
||||
}
|
||||
});
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
function collectErrorArticles(tabId: ControlTabId, rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
|
||||
switch (tabId) {
|
||||
case 'dimensions':
|
||||
return collectDimensionErrorArticles(rows, headers, ctx);
|
||||
case 'pricing':
|
||||
return collectPricingErrorArticles(rows, headers, ctx);
|
||||
case 'missing_data':
|
||||
return collectMissingDataErrorArticles(rows, headers, ctx);
|
||||
case 'history':
|
||||
return new Set(
|
||||
ctx.historyEntries
|
||||
.filter(entry => {
|
||||
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
|
||||
return status === 'failed';
|
||||
})
|
||||
.map(entry => normalize(entry.product_id))
|
||||
.filter(Boolean)
|
||||
);
|
||||
case 'matrix':
|
||||
return new Set([
|
||||
...collectPricingErrorArticles(rows, headers, ctx),
|
||||
...collectDimensionErrorArticles(rows, headers, ctx),
|
||||
...collectMissingDataErrorArticles(rows, headers, ctx),
|
||||
...rows.filter(row => hasRowStatusError(getRowKey(row), ctx)).map(row => getRowKey(row)),
|
||||
]);
|
||||
default:
|
||||
return new Set(
|
||||
rows
|
||||
.filter(row => hasRowStatusError(getRowKey(row), ctx))
|
||||
.map(row => getRowKey(row))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function computeDescriptionsSnapshot(headers: string[], ctx: DashboardContext): DescriptionsDashboardSnapshot {
|
||||
const rows = getDescriptionRows(headers, ctx);
|
||||
const columns = resolveColumnIndices(headers);
|
||||
|
||||
const longDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.LONG_DE]) ? 1 : 0), 0);
|
||||
const longEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.LONG_EN]) ? 1 : 0), 0);
|
||||
const shortDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.SHORT_DE]) ? 1 : 0), 0);
|
||||
const shortEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.SHORT_EN]) ? 1 : 0), 0);
|
||||
const ok = rows.reduce((count, row) => {
|
||||
const complete = !isBlank(row[columns.LONG_DE])
|
||||
&& !isBlank(row[columns.LONG_EN])
|
||||
&& !isBlank(row[columns.SHORT_DE])
|
||||
&& !isBlank(row[columns.SHORT_EN]);
|
||||
return count + (complete ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
total: rows.length,
|
||||
ok,
|
||||
longDeMissing,
|
||||
longEnMissing,
|
||||
shortDeMissing,
|
||||
shortEnMissing,
|
||||
};
|
||||
}
|
||||
|
||||
function computeArticleDetailsSnapshot(headers: string[], ctx: DashboardContext): ArticleDetailsDashboardSnapshot {
|
||||
const rows = getArticleDetailsRows(headers, ctx);
|
||||
const columns = resolveColumnIndices(headers);
|
||||
|
||||
const detailsDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.DETAILS_DE]) ? 1 : 0), 0);
|
||||
const detailsEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.DETAILS_EN]) ? 1 : 0), 0);
|
||||
const ok = rows.reduce((count, row) => {
|
||||
const complete = !isBlank(row[columns.DETAILS_DE]) && !isBlank(row[columns.DETAILS_EN]);
|
||||
return count + (complete ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
total: rows.length,
|
||||
ok,
|
||||
detailsDeMissing,
|
||||
detailsEnMissing,
|
||||
};
|
||||
}
|
||||
|
||||
function findHeaderIndexByName(headers: string[], predicate: (name: string) => boolean): number {
|
||||
return headers.findIndex(header => predicate(normalize(header).toLowerCase()));
|
||||
}
|
||||
|
||||
function isWeightIssueValue(value: unknown): boolean {
|
||||
return !isBlank(value);
|
||||
}
|
||||
|
||||
function computePricingSnapshot(headers: string[], ctx: DashboardContext): PricingDashboardSnapshot {
|
||||
const rows = getPricingRows(headers, ctx);
|
||||
const columns = resolveColumnIndices(headers);
|
||||
const articleIndex = columns.ARTICLE_NO;
|
||||
const skuForRow = (row: ExcelRow) => normalize(row[articleIndex]);
|
||||
|
||||
const uvpIdx = findHeaderIndexFromHeaders(headers, [['uvp']]);
|
||||
const srpHeaders = headers
|
||||
.map((header, index) => ({ index, text: normalize(header).toLowerCase() }))
|
||||
.filter(({ text }) => text.includes('srp'));
|
||||
const srpIntIdx = srpHeaders.find(({ text }) => text.includes('int'))?.index ?? -1;
|
||||
const srpUkIdx = srpHeaders.find(({ text }) => text.includes('uk'))?.index ?? -1;
|
||||
const units40fIdx = findHeaderIndexFromHeaders(headers, [['40f']]);
|
||||
const itemToLogisticIdx = columns.ITEM_TO_LOGISTIC;
|
||||
const unitsOuterIdx = columns.UNITS_OUTER;
|
||||
const outerWIdx = columns.OUTER_W;
|
||||
const outerLIdx = columns.OUTER_L;
|
||||
const outerHIdx = columns.OUTER_H;
|
||||
const moqIdx = columns.MOQ;
|
||||
const nwIdx = findHeaderIndexFromHeaders(headers, [['nw']]);
|
||||
const gwIdx = findHeaderIndexFromHeaders(headers, [['gw']]);
|
||||
|
||||
const rowIssues = new Set<string>();
|
||||
|
||||
const itemToLogisticMissing = rows.reduce((count, row) => {
|
||||
const missing = isBlank(row[itemToLogisticIdx]);
|
||||
if (missing) rowIssues.add(skuForRow(row));
|
||||
return count + (missing ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const uvpMissing = rows.reduce((count, row) => {
|
||||
const missing = uvpIdx < 0 ? true : isBlank(row[uvpIdx]);
|
||||
if (missing) rowIssues.add(skuForRow(row));
|
||||
return count + (missing ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const srpIntMissing = rows.reduce((count, row) => {
|
||||
const missing = srpIntIdx < 0 ? true : isBlank(row[srpIntIdx]);
|
||||
if (missing) rowIssues.add(skuForRow(row));
|
||||
return count + (missing ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const srpUkMissing = rows.reduce((count, row) => {
|
||||
const missing = srpUkIdx < 0 ? true : isBlank(row[srpUkIdx]);
|
||||
if (missing) rowIssues.add(skuForRow(row));
|
||||
return count + (missing ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const unitsOuterMissing = rows.reduce((count, row) => {
|
||||
const missing = isNumericLikeEmpty(row[unitsOuterIdx]);
|
||||
if (missing) rowIssues.add(skuForRow(row));
|
||||
return count + (missing ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const outerWMissing = rows.reduce((count, row) => {
|
||||
const missing = isNumericLikeEmpty(row[outerWIdx]);
|
||||
if (missing) rowIssues.add(skuForRow(row));
|
||||
return count + (missing ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const outerLMissing = rows.reduce((count, row) => {
|
||||
const missing = isNumericLikeEmpty(row[outerLIdx]);
|
||||
if (missing) rowIssues.add(skuForRow(row));
|
||||
return count + (missing ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const outerHMissing = rows.reduce((count, row) => {
|
||||
const missing = isNumericLikeEmpty(row[outerHIdx]);
|
||||
if (missing) rowIssues.add(skuForRow(row));
|
||||
return count + (missing ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const units40fMissing = rows.reduce((count, row) => {
|
||||
const missing = units40fIdx < 0 ? true : isNumericLikeEmpty(row[units40fIdx]);
|
||||
if (missing) rowIssues.add(skuForRow(row));
|
||||
return count + (missing ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const moqMissing = rows.reduce((count, row) => {
|
||||
const missing = isNumericLikeEmpty(row[moqIdx]);
|
||||
if (missing) rowIssues.add(skuForRow(row));
|
||||
return count + (missing ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const weightIssues = rows.reduce((count, row) => {
|
||||
let issue = false;
|
||||
if (nwIdx >= 0 && gwIdx >= 0) {
|
||||
const nw = parseFloat(String(row[nwIdx] ?? '').replace(',', '.'));
|
||||
const gw = parseFloat(String(row[gwIdx] ?? '').replace(',', '.'));
|
||||
issue = !Number.isNaN(nw) && !Number.isNaN(gw) && nw > gw;
|
||||
}
|
||||
if (issue) rowIssues.add(skuForRow(row));
|
||||
return count + (issue ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
const total = rows.length;
|
||||
|
||||
return {
|
||||
total,
|
||||
ok: Math.max(total - rowIssues.size, 0),
|
||||
itemToLogisticMissing,
|
||||
uvpMissing,
|
||||
srpIntMissing,
|
||||
srpUkMissing,
|
||||
unitsOuterMissing,
|
||||
outerWMissing,
|
||||
outerLMissing,
|
||||
outerHMissing,
|
||||
units40fMissing,
|
||||
moqMissing,
|
||||
weightIssues,
|
||||
};
|
||||
}
|
||||
|
||||
function computeCosmeticSnapshot(headers: string[], ctx: DashboardContext): CosmeticDashboardSnapshot {
|
||||
const rows = getCosmeticRows(headers, ctx);
|
||||
const columns = resolveColumnIndices(headers);
|
||||
const cpnpPresent = rows.reduce((count, row) => count + (!isBlank(row[columns.CPNP_NO]) ? 1 : 0), 0);
|
||||
const cpnpMissing = rows.length - cpnpPresent;
|
||||
return {
|
||||
total: rows.length,
|
||||
ok: cpnpPresent,
|
||||
cpnpMissing,
|
||||
};
|
||||
}
|
||||
|
||||
function hasRowStatusError(articleNo: string, ctx: DashboardContext): boolean {
|
||||
const status = normalize(ctx.rowStatuses[articleNo]).toLowerCase();
|
||||
return STATUS_ERROR.has(status);
|
||||
}
|
||||
|
||||
function countDimensionErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number {
|
||||
return collectDimensionErrorArticles(rows, headers, ctx).size;
|
||||
}
|
||||
|
||||
function collectDimensionErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
|
||||
const columns = resolveColumnIndices(headers);
|
||||
const groups = new Map<string, ExcelRow[]>();
|
||||
|
||||
rows.forEach(row => {
|
||||
const innerValues = [row[columns.INNER_L], row[columns.INNER_W], row[columns.INNER_H]];
|
||||
if (innerValues.every(value => isNumericLikeEmpty(value) || isBlank(value))) return;
|
||||
|
||||
const innerKey = innerValues
|
||||
.map(value => normalize(value) || '0')
|
||||
.join('x');
|
||||
if (!groups.has(innerKey)) groups.set(innerKey, []);
|
||||
groups.get(innerKey)!.push(row);
|
||||
});
|
||||
|
||||
const errorArticles = new Set<string>();
|
||||
groups.forEach(groupRows => {
|
||||
if (groupRows.length <= 1) return;
|
||||
const signature = (row: ExcelRow) => [
|
||||
row[columns.OUTER_L],
|
||||
row[columns.OUTER_W],
|
||||
row[columns.OUTER_H],
|
||||
row[columns.UNITS_OUTER],
|
||||
row[columns.MOQ],
|
||||
].map(value => normalize(value) || '0').join('|');
|
||||
|
||||
const firstSignature = signature(groupRows[0]);
|
||||
const inconsistent = groupRows.some(row => signature(row) !== firstSignature);
|
||||
if (!inconsistent) return;
|
||||
|
||||
groupRows.forEach(row => {
|
||||
const articleNo = getRowKey(row);
|
||||
if (articleNo) errorArticles.add(articleNo);
|
||||
});
|
||||
});
|
||||
|
||||
return errorArticles;
|
||||
}
|
||||
|
||||
function countPricingErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number {
|
||||
return collectPricingErrorArticles(rows, headers, ctx).size;
|
||||
}
|
||||
|
||||
function collectPricingErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
|
||||
const columns = resolveColumnIndices(headers);
|
||||
const uvpIdx = findHeaderIndexFromHeaders(headers, [['uvp']]);
|
||||
const srpIndices = findIndicesByPatterns(headers, [['srp']]);
|
||||
const netIdx = findHeaderIndexFromHeaders(headers, [['net']]);
|
||||
const grossIdx = findHeaderIndexFromHeaders(headers, [['gross']]);
|
||||
|
||||
const articles = new Set<string>();
|
||||
rows.forEach(row => {
|
||||
const articleNo = getRowKey(row);
|
||||
if (hasRowStatusError(articleNo, ctx)) {
|
||||
if (articleNo) articles.add(articleNo);
|
||||
return;
|
||||
}
|
||||
|
||||
const pricingMissing = uvpIdx >= 0 && isBlank(row[uvpIdx]);
|
||||
const srpMissing = srpIndices.some(index => isBlank(row[index]));
|
||||
const unitsOuter = row[columns.UNITS_OUTER];
|
||||
const outerW = row[columns.OUTER_W];
|
||||
const outerL = row[columns.OUTER_L];
|
||||
const outerH = row[columns.OUTER_H];
|
||||
const unitsError = isNumericLikeEmpty(unitsOuter) || normalize(unitsOuter) === '1';
|
||||
const outerError = [outerW, outerL, outerH].some(value => isNumericLikeEmpty(value) || normalize(value) === '1');
|
||||
const weightError = netIdx >= 0 && grossIdx >= 0 && !isBlank(row[netIdx]) && !isBlank(row[grossIdx]) && Number(String(row[netIdx]).replace(',', '.')) > Number(String(row[grossIdx]).replace(',', '.'));
|
||||
|
||||
if (pricingMissing || srpMissing || unitsError || outerError || weightError) {
|
||||
if (articleNo) articles.add(articleNo);
|
||||
}
|
||||
});
|
||||
return articles;
|
||||
}
|
||||
|
||||
function countMissingDataErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number {
|
||||
return collectMissingDataErrorArticles(rows, headers, ctx).size;
|
||||
}
|
||||
|
||||
function collectMissingDataErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
|
||||
const columns = resolveColumnIndices(headers);
|
||||
const launchIdx = findHeaderIndexFromHeaders(headers, [['launch', 'date']]);
|
||||
const readyIdx = findHeaderIndexFromHeaders(headers, [['ready', 'to', 'order', 'date']]);
|
||||
|
||||
const articles = new Set<string>();
|
||||
rows.forEach(row => {
|
||||
const articleNo = getRowKey(row);
|
||||
if (hasRowStatusError(articleNo, ctx)) {
|
||||
if (articleNo) articles.add(articleNo);
|
||||
return;
|
||||
}
|
||||
|
||||
if (launchIdx < 0 || readyIdx < 0) return;
|
||||
const launch = toDate(row[launchIdx]);
|
||||
const ready = toDate(row[readyIdx]);
|
||||
if (!launch || !ready) return;
|
||||
if (ready.getTime() > launch.getTime() || isNumericLikeEmpty(row[columns.MOQ])) {
|
||||
if (articleNo) articles.add(articleNo);
|
||||
}
|
||||
});
|
||||
return articles;
|
||||
}
|
||||
|
||||
function countGenericErrors(rows: ExcelRow[], ctx: DashboardContext, extraPredicate?: (row: ExcelRow) => boolean): number {
|
||||
return rows.filter(row => {
|
||||
const articleNo = getRowKey(row);
|
||||
if (hasRowStatusError(articleNo, ctx)) return true;
|
||||
return extraPredicate ? extraPredicate(row) : false;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function countHistoryErrors(entries: HistoryEntry[], ctx: DashboardContext): number {
|
||||
return entries.filter(entry => {
|
||||
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
|
||||
return status === 'failed';
|
||||
}).length;
|
||||
}
|
||||
|
||||
function countHistoryEmptyRows(entries: HistoryEntry[], headers: string[]): number {
|
||||
const indices = getEditableIndices('history', headers);
|
||||
return entries.filter(entry => indices.some(index => isFieldEmptyForTab('history', index, entry.new_data?.[index]))).length;
|
||||
}
|
||||
|
||||
function countHistoryPendingRows(entries: HistoryEntry[], ctx: DashboardContext): number {
|
||||
return entries.filter(entry => {
|
||||
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
|
||||
return STATUS_PENDING.has(status);
|
||||
}).length;
|
||||
}
|
||||
|
||||
function createCurrentSnapshot(tabId: ControlTabId, rows: ExcelRow[], headers: string[], ctx: DashboardContext): TabMetricSnapshot {
|
||||
const total = rows.length;
|
||||
const pendingSet = collectPendingArticles(tabId, rows, ctx);
|
||||
const emptySet = collectEmptyArticles(tabId, rows, headers);
|
||||
const errorSet = collectErrorArticles(tabId, rows, headers, ctx);
|
||||
const issueSet = new Set<string>([...pendingSet, ...emptySet, ...errorSet]);
|
||||
|
||||
switch (tabId) {
|
||||
case 'dimensions':
|
||||
case 'pricing':
|
||||
case 'missing_data':
|
||||
case 'history':
|
||||
case 'pending_validation':
|
||||
case 'matrix':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
ok: Math.max(total - issueSet.size, 0),
|
||||
pending: pendingSet.size,
|
||||
empty: emptySet.size,
|
||||
error: errorSet.size,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeControlDashboardSummaries(headers: string[], ctx: DashboardContext): TabCardSummary[] {
|
||||
const historySyncMap = ctx.historySyncMap || getHistorySyncMapFromStorage();
|
||||
const effectiveCtx: DashboardContext = { ...ctx, historySyncMap };
|
||||
|
||||
return CONTROL_TABS.map(tab => {
|
||||
const rows = getTabRows(tab.id, headers, effectiveCtx);
|
||||
const current = createCurrentSnapshot(tab.id, rows, headers, effectiveCtx);
|
||||
return {
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
accentClass: tab.accentClass,
|
||||
current,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function computeDescriptionsDashboardSnapshot(headers: string[], ctx: DashboardContext): DescriptionsDashboardSnapshot {
|
||||
return computeDescriptionsSnapshot(headers, ctx);
|
||||
}
|
||||
|
||||
export function computeArticleDetailsDashboardSnapshot(headers: string[], ctx: DashboardContext): ArticleDetailsDashboardSnapshot {
|
||||
return computeArticleDetailsSnapshot(headers, ctx);
|
||||
}
|
||||
|
||||
export function computePricingDashboardSnapshot(headers: string[], ctx: DashboardContext): PricingDashboardSnapshot {
|
||||
return computePricingSnapshot(headers, ctx);
|
||||
}
|
||||
|
||||
export function computeCosmeticDashboardSnapshot(headers: string[], ctx: DashboardContext): CosmeticDashboardSnapshot {
|
||||
return computeCosmeticSnapshot(headers, ctx);
|
||||
}
|
||||
|
||||
export function getDaysAgoKey(days: number, date = new Date()): string {
|
||||
return localDateKey(shiftDate(date, days));
|
||||
}
|
||||
|
||||
export function diffSnapshots(current: TabMetricSnapshot, historical?: TabMetricSnapshot): TabMetricSnapshot | undefined {
|
||||
if (!historical) return undefined;
|
||||
return {
|
||||
total: current.total - historical.total,
|
||||
ok: current.ok - historical.ok,
|
||||
pending: current.pending - historical.pending,
|
||||
empty: current.empty - historical.empty,
|
||||
error: current.error - historical.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDashboardSnapshots(): Promise<Record<string, DashboardSnapshotTabs>> {
|
||||
const store = await getDashboardSnapshotStore();
|
||||
return store;
|
||||
}
|
||||
|
||||
export async function ensureDailyDashboardSnapshot(date: Date, snapshot: DashboardSnapshotTabs): Promise<void> {
|
||||
const key = localDateKey(date);
|
||||
await ensureDashboardSnapshot(key, {
|
||||
...snapshot,
|
||||
});
|
||||
}
|
||||
+426
-91
@@ -1,59 +1,119 @@
|
||||
import { refreshSession, getStoredSession } from './auth';
|
||||
import { COLUMNS } from '../types';
|
||||
|
||||
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||
const SUPABASE_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||
const LEGACY_CPNP_INDEX = 77;
|
||||
|
||||
export async function safeFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const session = getStoredSession();
|
||||
const token = session?.access_token || SUPABASE_ANON_KEY;
|
||||
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'apikey': SUPABASE_ANON_KEY,
|
||||
'Authorization': `Bearer ${token}`,
|
||||
};
|
||||
|
||||
let response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
const latestSession = getStoredSession();
|
||||
if (latestSession) {
|
||||
// If the access token in localStorage is already different (newer) than the one we used,
|
||||
// try retrying the request with that new token first without doing a refresh.
|
||||
if (latestSession.access_token !== token) {
|
||||
const retryHeaders = {
|
||||
...options.headers,
|
||||
'apikey': SUPABASE_ANON_KEY,
|
||||
'Authorization': `Bearer ${latestSession.access_token}`,
|
||||
};
|
||||
response = await fetch(url, { ...options, headers: retryHeaders });
|
||||
if (response.status !== 401) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// If we still get a 401, perform the refresh using the latest refresh token
|
||||
if (latestSession.refresh_token) {
|
||||
try {
|
||||
const newSession = await refreshSession(latestSession.refresh_token);
|
||||
const newHeaders = {
|
||||
...options.headers,
|
||||
'apikey': SUPABASE_ANON_KEY,
|
||||
'Authorization': `Bearer ${newSession.access_token}`,
|
||||
};
|
||||
response = await fetch(url, { ...options, headers: newHeaders });
|
||||
} catch (refreshError) {
|
||||
console.error('Session refresh failed:', refreshError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export interface ExcelRow extends Array<any> {}
|
||||
|
||||
export interface SyncedRow {
|
||||
data: ExcelRow;
|
||||
status?: 'pending' | 'synced';
|
||||
status?: 'pending' | 'edited' | 'synced' | 'excel';
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export async function getAllSyncedRows(token?: string): Promise<Record<string, SyncedRow>> {
|
||||
export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
|
||||
const PAGE_SIZE = 1000;
|
||||
const result: Record<string, SyncedRow> = {};
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${SUPABASE_URL}/rest/v1/products?select=product_id,data,status&order=updated_at.desc`,
|
||||
{
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'apikey': SUPABASE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_KEY}`
|
||||
}
|
||||
}
|
||||
);
|
||||
let offset = 0;
|
||||
// Paginate until Supabase returns fewer rows than PAGE_SIZE (no more pages).
|
||||
// Supabase REST caps a single response at 1000 rows, so we must page.
|
||||
// Safety cap at 20 pages (20k products) to avoid infinite loops.
|
||||
for (let page = 0; page < 20; page++) {
|
||||
const response = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products?select=product_id,data,status,updated_at&order=product_id.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
console.error('getAllSyncedRows failed:', response.status, errText);
|
||||
return {};
|
||||
}
|
||||
const rows = await response.json();
|
||||
const result: Record<string, SyncedRow> = {};
|
||||
for (const row of rows) {
|
||||
result[row.product_id] = { data: row.data, status: row.status };
|
||||
return result;
|
||||
}
|
||||
const rows = await response.json();
|
||||
for (const row of rows) {
|
||||
const current = result[row.product_id];
|
||||
const currentUpdatedAt = current?.updated_at ? Date.parse(current.updated_at) : -1;
|
||||
const nextUpdatedAt = row.updated_at ? Date.parse(row.updated_at) : -1;
|
||||
if (!current || nextUpdatedAt >= currentUpdatedAt) {
|
||||
result[row.product_id] = { data: row.data, status: row.status, updated_at: row.updated_at };
|
||||
}
|
||||
}
|
||||
if (rows.length < PAGE_SIZE) break;
|
||||
offset += PAGE_SIZE;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error fetching synced rows from Supabase:', error);
|
||||
return {};
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, token?: string): Promise<{ success: boolean; error?: string }> {
|
||||
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, status: 'pending' | 'edited' | 'synced' = 'pending'): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${SUPABASE_URL}/rest/v1/products`,
|
||||
const response = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products?on_conflict=product_id`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': SUPABASE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
||||
'Prefer': 'resolution=merge-duplicates'
|
||||
'Prefer': 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
product_id: articleNo,
|
||||
data: rowData,
|
||||
status: 'synced',
|
||||
status,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
@@ -61,12 +121,12 @@ export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, to
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: `${response.status} ${response.statusText}: ${err.message || err.error_description || 'Unknown error'}`
|
||||
return {
|
||||
success: false,
|
||||
error: `${response.status} ${response.statusText}: ${err.message || err.error_description || 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error('Error saving to Supabase:', error);
|
||||
@@ -75,7 +135,7 @@ export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, to
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
id?: number;
|
||||
id?: string;
|
||||
product_id: string;
|
||||
article_name: string;
|
||||
old_data: ExcelRow;
|
||||
@@ -84,24 +144,90 @@ export interface HistoryEntry {
|
||||
changed_at: string;
|
||||
}
|
||||
|
||||
export interface DashboardDescriptionsSnapshot {
|
||||
total: number;
|
||||
ok: number;
|
||||
longDeMissing: number;
|
||||
longEnMissing: number;
|
||||
shortDeMissing: number;
|
||||
shortEnMissing: number;
|
||||
}
|
||||
|
||||
export interface DashboardArticleDetailsSnapshot {
|
||||
total: number;
|
||||
ok: number;
|
||||
detailsDeMissing: number;
|
||||
detailsEnMissing: number;
|
||||
}
|
||||
|
||||
export interface DashboardPricingSnapshot {
|
||||
total: number;
|
||||
ok: number;
|
||||
itemToLogisticMissing: number;
|
||||
uvpMissing: number;
|
||||
srpIntMissing: number;
|
||||
srpUkMissing: number;
|
||||
unitsOuterMissing: number;
|
||||
outerWMissing: number;
|
||||
outerLMissing: number;
|
||||
outerHMissing: number;
|
||||
units40fMissing: number;
|
||||
moqMissing: number;
|
||||
weightIssues: number;
|
||||
}
|
||||
|
||||
export interface DashboardCosmeticSnapshot {
|
||||
total: number;
|
||||
ok: number;
|
||||
cpnpMissing: number;
|
||||
}
|
||||
|
||||
export interface DashboardSnapshotTabs {
|
||||
descriptions?: DashboardDescriptionsSnapshot;
|
||||
articleDetails?: DashboardArticleDetailsSnapshot;
|
||||
pricing?: DashboardPricingSnapshot;
|
||||
cosmeticItems?: DashboardCosmeticSnapshot;
|
||||
}
|
||||
|
||||
const CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID = '__control_dashboard__';
|
||||
|
||||
function normalizeHistoryValue(value: any): any {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function valuesEqual(a: any, b: any): boolean {
|
||||
return normalizeHistoryValue(a) === normalizeHistoryValue(b);
|
||||
}
|
||||
|
||||
function getChangedIndices(oldData: ExcelRow = [], newData: ExcelRow = []): number[] {
|
||||
const maxLen = Math.max(oldData.length, newData.length);
|
||||
const changed: number[] = [];
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
if (!valuesEqual(oldData[i], newData[i])) {
|
||||
changed.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function saveHistoryEntry(
|
||||
productId: string,
|
||||
articleName: string,
|
||||
oldData: ExcelRow,
|
||||
newData: ExcelRow,
|
||||
changedBy: string,
|
||||
token?: string
|
||||
changedBy: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products_history`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': SUPABASE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
||||
'Prefer': 'return=minimal'
|
||||
'Prefer': 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
product_id: productId,
|
||||
@@ -116,12 +242,12 @@ export async function saveHistoryEntry(
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: `History ${response.status}: ${err.message || 'Unknown error'}`
|
||||
return {
|
||||
success: false,
|
||||
error: `History ${response.status}: ${err.message || 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error('Error saving history to Supabase:', error);
|
||||
@@ -129,58 +255,269 @@ export async function saveHistoryEntry(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHistory(token?: string): Promise<HistoryEntry[]> {
|
||||
export async function getHistory(): Promise<HistoryEntry[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`,
|
||||
{
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'apikey': SUPABASE_KEY,
|
||||
// Try using only the ANON key just in case RLS or token expiry is failing silently
|
||||
'Authorization': `Bearer ${SUPABASE_KEY}`
|
||||
}
|
||||
}
|
||||
);
|
||||
const PAGE_SIZE = 1000;
|
||||
const allRows: HistoryEntry[] = [];
|
||||
for (let page = 0; page < 20; page++) {
|
||||
const offset = page * PAGE_SIZE;
|
||||
const response = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return [{
|
||||
id: 'error-' + Date.now(),
|
||||
product_id: 'ERROR',
|
||||
article_name: `Failed: ${response.status} ${err}`,
|
||||
old_data: [],
|
||||
new_data: [],
|
||||
changed_at: new Date().toISOString(),
|
||||
changed_by: 'system'
|
||||
}];
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
console.error('[getHistory] Error:', response.status, errText.substring(0, 200));
|
||||
return [{
|
||||
id: 'ERROR',
|
||||
product_id: 'ERROR',
|
||||
article_name: `Failed: ${response.status} ${errText.substring(0, 200)}`,
|
||||
old_data: [],
|
||||
new_data: [],
|
||||
changed_at: new Date().toISOString(),
|
||||
changed_by: 'system'
|
||||
}];
|
||||
}
|
||||
const batch: HistoryEntry[] = await response.json();
|
||||
allRows.push(...batch.filter(entry => entry.product_id !== CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID));
|
||||
if (batch.length < PAGE_SIZE) break;
|
||||
}
|
||||
return await response.json();
|
||||
// Return oldest-first so index+1 = natural chronological number
|
||||
return allRows;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching history:', error);
|
||||
console.error('[getHistory] Exception:', error.message);
|
||||
return [{
|
||||
id: 'error-' + Date.now(),
|
||||
product_id: 'EXCEPTION',
|
||||
article_name: `Message: ${error.message}`,
|
||||
old_data: [],
|
||||
new_data: [],
|
||||
changed_at: new Date().toISOString(),
|
||||
changed_by: 'system'
|
||||
id: 'EXCEPTION',
|
||||
product_id: 'EXCEPTION',
|
||||
article_name: `Message: ${error.message}`,
|
||||
old_data: [],
|
||||
new_data: [],
|
||||
changed_at: new Date().toISOString(),
|
||||
changed_by: 'system'
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHistoryEntry(id: string, token?: string): Promise<boolean> {
|
||||
export async function getHistoryDataForMerge(includeLegacyCpnpIndex = true): Promise<Record<string, ExcelRow>> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'apikey': SUPABASE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_KEY}`
|
||||
}
|
||||
const PAGE_SIZE = 1000;
|
||||
const entries: HistoryEntry[] = [];
|
||||
|
||||
for (let page = 0; page < 20; page++) {
|
||||
const offset = page * PAGE_SIZE;
|
||||
const response = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products_history?select=product_id,old_data,new_data,changed_at,id&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[getHistoryDataForMerge] Error:', response.status);
|
||||
return {};
|
||||
}
|
||||
|
||||
const batch: HistoryEntry[] = await response.json();
|
||||
entries.push(
|
||||
...batch.filter((entry: HistoryEntry) => entry.product_id !== CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)
|
||||
);
|
||||
|
||||
if (batch.length < PAGE_SIZE) break;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => {
|
||||
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
|
||||
if (timeDelta !== 0) return timeDelta;
|
||||
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
|
||||
const result: Record<string, ExcelRow> = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.product_id) continue;
|
||||
|
||||
if (!result[entry.product_id]) {
|
||||
result[entry.product_id] = [];
|
||||
}
|
||||
|
||||
const target = result[entry.product_id];
|
||||
const changedIndices = getChangedIndices(entry.old_data || [], entry.new_data || []);
|
||||
|
||||
for (const idx of changedIndices) {
|
||||
target[idx] = entry.new_data?.[idx];
|
||||
}
|
||||
|
||||
// CPNP values were saved through multiple code paths over time.
|
||||
// Preserve the latest non-empty value even when a given history row
|
||||
// does not surface it as a changed index. Index 77 is only a CPNP
|
||||
// location in the pre-July-2026 layout (includeLegacyCpnpIndex);
|
||||
// in the new layout it holds "Item To Root Units".
|
||||
const latestCpnp =
|
||||
entry.new_data?.[COLUMNS.CPNP_NO] ??
|
||||
(includeLegacyCpnpIndex ? entry.new_data?.[LEGACY_CPNP_INDEX] : undefined) ??
|
||||
entry.old_data?.[COLUMNS.CPNP_NO];
|
||||
const fallbackCpnp =
|
||||
latestCpnp !== undefined && latestCpnp !== null && latestCpnp !== ''
|
||||
? latestCpnp
|
||||
: includeLegacyCpnpIndex
|
||||
? entry.old_data?.[LEGACY_CPNP_INDEX]
|
||||
: undefined;
|
||||
|
||||
if (fallbackCpnp !== undefined && fallbackCpnp !== null && fallbackCpnp !== '') {
|
||||
target[COLUMNS.CPNP_NO] = fallbackCpnp;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[getHistoryDataForMerge] Exception:', error.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDashboardSnapshotStore(): Promise<Record<string, DashboardSnapshotTabs>> {
|
||||
try {
|
||||
const PAGE_SIZE = 1000;
|
||||
const rows: Array<{ changed_at: string; new_data: any }> = [];
|
||||
|
||||
for (let page = 0; page < 20; page++) {
|
||||
const offset = page * PAGE_SIZE;
|
||||
const response = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products_history?select=changed_at,new_data,product_id&product_id=eq.${encodeURIComponent(CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)}&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
console.error('[getDashboardSnapshotStore] Error:', response.status, errText.substring(0, 200));
|
||||
return {};
|
||||
}
|
||||
|
||||
const batch: Array<{ changed_at: string; new_data: any }> = await response.json();
|
||||
rows.push(...batch);
|
||||
if (batch.length < PAGE_SIZE) break;
|
||||
}
|
||||
|
||||
const store: Record<string, DashboardSnapshotTabs> = {};
|
||||
rows.forEach(row => {
|
||||
const snapshotDate = normalizeSnapshotDate(row.new_data?.snapshot_date || row.changed_at);
|
||||
const tabs = row.new_data?.tabs;
|
||||
if (!snapshotDate || !tabs || typeof tabs !== 'object') return;
|
||||
store[snapshotDate] = tabs as DashboardSnapshotTabs;
|
||||
});
|
||||
return store;
|
||||
} catch (error) {
|
||||
console.error('[getDashboardSnapshotStore] Exception:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDashboardSnapshot(dateKey: string, tabs: DashboardSnapshotTabs): Promise<{ success: boolean; error?: string; created?: boolean }> {
|
||||
try {
|
||||
const existingRes = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products_history?select=id&product_id=eq.${encodeURIComponent(CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)}&changed_at=gte.${encodeURIComponent(`${dateKey}T00:00:00.000Z`)}&changed_at=lt.${encodeURIComponent(nextUtcDateKey(dateKey))}&limit=1`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (!existingRes.ok) {
|
||||
const errText = await existingRes.text();
|
||||
return { success: false, error: `Snapshot lookup failed: ${existingRes.status} ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
const existing = await existingRes.json();
|
||||
if (Array.isArray(existing) && existing.length > 0) {
|
||||
const existingId = existing[0]?.id;
|
||||
const currentTabs = existing[0]?.new_data?.tabs ?? {};
|
||||
const mergedTabs = {
|
||||
...currentTabs,
|
||||
...tabs,
|
||||
};
|
||||
|
||||
if (JSON.stringify(currentTabs) === JSON.stringify(mergedTabs)) {
|
||||
return { success: true, created: false };
|
||||
}
|
||||
|
||||
if (!existingId) {
|
||||
return { success: true, created: false };
|
||||
}
|
||||
|
||||
const updateRes = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(existingId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Prefer': 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
new_data: {
|
||||
snapshot_date: dateKey,
|
||||
tabs: mergedTabs,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!updateRes.ok) {
|
||||
const errText = await updateRes.text();
|
||||
return { success: false, error: `Snapshot update failed: ${updateRes.status} ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
return { success: true, created: false };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
product_id: CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID,
|
||||
article_name: 'Control Dashboard Snapshot',
|
||||
old_data: [],
|
||||
new_data: {
|
||||
snapshot_date: dateKey,
|
||||
tabs,
|
||||
},
|
||||
changed_by: 'system-control-dashboard',
|
||||
changed_at: `${dateKey}T00:00:00.000Z`,
|
||||
};
|
||||
|
||||
const insertRes = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products_history`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Prefer': 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
||||
if (!insertRes.ok) {
|
||||
const errText = await insertRes.text();
|
||||
return { success: false, error: `Snapshot save failed: ${insertRes.status} ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
return { success: true, created: true };
|
||||
} catch (error: any) {
|
||||
console.error('[ensureDashboardSnapshot] Exception:', error);
|
||||
return { success: false, error: error?.message || 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSnapshotDate(value: string): string | null {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function nextUtcDateKey(dateKey: string): string {
|
||||
const date = new Date(`${dateKey}T00:00:00.000Z`);
|
||||
date.setUTCDate(date.getUTCDate() + 1);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export async function deleteHistoryEntry(id: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(String(id))}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
|
||||
return response.ok;
|
||||
@@ -190,19 +527,17 @@ export async function deleteHistoryEntry(id: string, token?: string): Promise<bo
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetAllPendingRows(token?: string): Promise<boolean> {
|
||||
export async function resetAllPendingRows(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await safeFetch(
|
||||
`${SUPABASE_URL}/rest/v1/products?status=eq.pending`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': SUPABASE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
||||
'Prefer': 'return=minimal'
|
||||
'Prefer': 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ status: 'synced' })
|
||||
body: JSON.stringify({ status: 'edited' })
|
||||
}
|
||||
);
|
||||
|
||||
@@ -211,4 +546,4 @@ export async function resetAllPendingRows(token?: string): Promise<boolean> {
|
||||
console.error('Error resetting pending rows in Supabase:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export async function generateDescription(prompt: string, systemPrompt: string): Promise<string> {
|
||||
const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY || localStorage.getItem('ANTHROPIC_API_KEY') || '';
|
||||
const apiKey =
|
||||
localStorage.getItem('ANTHROPIC_API_KEY') ||
|
||||
import.meta.env.VITE_ANTHROPIC_API_KEY ||
|
||||
'';
|
||||
if (!apiKey) {
|
||||
throw new Error('Anthropic API Key not found. Please set VITE_ANTHROPIC_API_KEY in .env or ANTHROPIC_API_KEY in localStorage.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
const BC_SYNC_PREVIEW_URL = '/api/bc-sync-preview';
|
||||
const BC_SYNC_APPLY_URL = '/api/bc-sync-apply';
|
||||
const BC_EXPORT_URL = '/api/bc-export';
|
||||
|
||||
export interface BCUpdateResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function isPreviewTokenMismatchError(error?: string): boolean {
|
||||
return Boolean(error && error.toLowerCase().includes('preview token mismatch'));
|
||||
}
|
||||
|
||||
export interface BCDownloadResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface BCFieldChange {
|
||||
sourceLabel: string;
|
||||
sourceIndex: number | null;
|
||||
targetField: string;
|
||||
before: any;
|
||||
after: any;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface BCSyncPreviewSection {
|
||||
type: 'items' | 'itemUnitsOfMeasure' | 'itemUnits40HC';
|
||||
desired: Record<string, any>;
|
||||
current: Record<string, any> | null;
|
||||
changes: BCFieldChange[];
|
||||
changedFields: string[];
|
||||
writeConfigured: boolean;
|
||||
writeMethod: string | null;
|
||||
writeUrlTemplate: string | null;
|
||||
writeBodyTemplate: string | null;
|
||||
canApply: boolean;
|
||||
supported?: boolean;
|
||||
supportReason?: string | null;
|
||||
}
|
||||
|
||||
export interface BCSyncPreviewResult {
|
||||
success: boolean;
|
||||
articleNo: string;
|
||||
items: BCSyncPreviewSection;
|
||||
itemUnitsOfMeasure: BCSyncPreviewSection;
|
||||
itemUnits40HC: BCSyncPreviewSection;
|
||||
hasChanges: boolean;
|
||||
previewToken: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BCSyncApplyResult {
|
||||
success: boolean;
|
||||
articleNo: string;
|
||||
previewToken?: string;
|
||||
preview?: BCSyncPreviewResult;
|
||||
results?: {
|
||||
items: { applied: boolean; reason?: string; url?: string };
|
||||
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string };
|
||||
itemUnits40HC: { applied: boolean; reason?: string; url?: string };
|
||||
};
|
||||
error?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
async function readResponsePayload(res: Response): Promise<{ data: any; rawText: string }> {
|
||||
const rawText = await res.text();
|
||||
if (!rawText.trim()) {
|
||||
return { data: null, rawText };
|
||||
}
|
||||
|
||||
try {
|
||||
return { data: JSON.parse(rawText), rawText };
|
||||
} catch {
|
||||
return { data: rawText, rawText };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCpnpNoInBC(articleNo: string, cpnpNo: string): Promise<BCUpdateResult> {
|
||||
try {
|
||||
const res = await fetch(BC_SYNC_APPLY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ articleNo, cpnpNo }),
|
||||
});
|
||||
|
||||
const { data, rawText } = await readResponsePayload(res);
|
||||
if (!res.ok || !data?.success) {
|
||||
const error =
|
||||
data?.error ||
|
||||
(typeof data === 'string' && data.trim()) ||
|
||||
rawText ||
|
||||
`HTTP ${res.status}`;
|
||||
return { success: false, error };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewBusinessCentralSync(headers: string[], row: any[]): Promise<BCSyncPreviewResult> {
|
||||
try {
|
||||
const res = await fetch(BC_SYNC_PREVIEW_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ headers, row }),
|
||||
});
|
||||
|
||||
const { data, rawText } = await readResponsePayload(res);
|
||||
if (!res.ok || !data?.success) {
|
||||
const error =
|
||||
data?.error ||
|
||||
(typeof data === 'string' && data.trim()) ||
|
||||
rawText ||
|
||||
`HTTP ${res.status}`;
|
||||
return { success: false, error } as BCSyncPreviewResult;
|
||||
}
|
||||
return data as BCSyncPreviewResult;
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message } as BCSyncPreviewResult;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyBusinessCentralSync(headers: string[], row: any[], previewToken?: string): Promise<BCSyncApplyResult> {
|
||||
try {
|
||||
const res = await fetch(BC_SYNC_APPLY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ headers, row, previewToken }),
|
||||
});
|
||||
|
||||
const { data, rawText } = await readResponsePayload(res);
|
||||
if (!res.ok || !data?.success) {
|
||||
const error =
|
||||
data?.error ||
|
||||
(typeof data === 'string' && data.trim()) ||
|
||||
rawText ||
|
||||
`HTTP ${res.status}`;
|
||||
return { success: false, error } as BCSyncApplyResult;
|
||||
}
|
||||
return data as BCSyncApplyResult;
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message } as BCSyncApplyResult;
|
||||
}
|
||||
}
|
||||
|
||||
function getFilenameFromDisposition(contentDisposition: string | null): string | null {
|
||||
if (!contentDisposition) return null;
|
||||
|
||||
const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1].trim().replace(/^"|"$/g, ''));
|
||||
} catch {
|
||||
return utf8Match[1].trim().replace(/^"|"$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
const filenameMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i);
|
||||
if (filenameMatch?.[1]) {
|
||||
return filenameMatch[1].trim().replace(/^"|"$/g, '');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function downloadBusinessCentralItemsExcel(): Promise<BCDownloadResult> {
|
||||
try {
|
||||
const res = await fetch(BC_EXPORT_URL, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let error = `HTTP ${res.status}`;
|
||||
try {
|
||||
const { data, rawText } = await readResponsePayload(res);
|
||||
error = data?.error || (typeof data === 'string' && data.trim()) || rawText || error;
|
||||
} catch {
|
||||
error = `HTTP ${res.status}`;
|
||||
}
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const filename =
|
||||
getFilenameFromDisposition(res.headers.get('content-disposition')) ||
|
||||
`BusinessCentral_Items_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return { success: true, filename };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { ExcelRow } from '../types';
|
||||
|
||||
export interface MappingFieldPreview {
|
||||
sourceLabel: string;
|
||||
sourceIndex: number | null;
|
||||
targetField: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface BusinessCentralMappingPreview {
|
||||
articleNo: string;
|
||||
itemsPayload: Record<string, any>;
|
||||
itemUnitsOfMeasurePayload: Record<string, any>;
|
||||
itemUnits40HCPayload: Record<string, any>;
|
||||
itemsFields: MappingFieldPreview[];
|
||||
itemUnitsFields: MappingFieldPreview[];
|
||||
itemUnits40HCFields: MappingFieldPreview[];
|
||||
}
|
||||
|
||||
function findHeaderIndex(headers: string[], patterns: string[]): number {
|
||||
const normalized = headers.map(h => String(h || '').toLowerCase());
|
||||
return normalized.findIndex(header =>
|
||||
patterns.every(pattern => header.includes(pattern.toLowerCase()))
|
||||
);
|
||||
}
|
||||
|
||||
function findCategorizationCodeIndex(headers: string[]): number {
|
||||
const normalized = headers.map(h => String(h || '').toLowerCase().trim());
|
||||
const categorizationIdx = normalized.findIndex(header => header.replace(/[\s_-]+/g, '') === 'categorizationcode');
|
||||
if (categorizationIdx >= 0) return categorizationIdx;
|
||||
|
||||
const typeIdx = normalized.findIndex(header => header === 'type');
|
||||
if (typeIdx >= 0) return typeIdx;
|
||||
|
||||
return normalized.findIndex(header => header.includes('product') && header.includes('type'));
|
||||
}
|
||||
|
||||
function formatDateForBc(value: any): string | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
|
||||
if (typeof value === 'number' && value >= 25569 && value <= 60000) {
|
||||
const excelEpoch = new Date(1899, 11, 30);
|
||||
const date = new Date(excelEpoch.getTime() + value * 86400000);
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
const str = String(value).trim();
|
||||
if (!str) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
||||
|
||||
const parsed = new Date(str);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
function getValue(row: ExcelRow, index: number | null): any {
|
||||
if (index === null || index < 0) return null;
|
||||
const value = row[index];
|
||||
return value === undefined || value === '' ? null : value;
|
||||
}
|
||||
|
||||
function toBcDecimal(value: any): number | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
|
||||
const normalized = String(value).trim().replace(/\s+/g, '').replace(',', '.');
|
||||
if (!normalized) return null;
|
||||
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function makeFieldPreview(
|
||||
sourceLabel: string,
|
||||
sourceIndex: number | null,
|
||||
targetField: string,
|
||||
value: any
|
||||
): MappingFieldPreview {
|
||||
return { sourceLabel, sourceIndex, targetField, value };
|
||||
}
|
||||
|
||||
export function buildBusinessCentralMappingPreview(headers: string[], row: ExcelRow): BusinessCentralMappingPreview {
|
||||
const articleNoIdx = findHeaderIndex(headers, ['article', 'no']);
|
||||
const articleDetailsEnIdx = findHeaderIndex(headers, ['article', 'details', 'english']);
|
||||
const articleDetailsDeIdx = findHeaderIndex(headers, ['article', 'details', 'german']);
|
||||
const launchDateIdx = findHeaderIndex(headers, ['launch']);
|
||||
const readyToOrderDateIdx = findHeaderIndex(headers, ['ready']);
|
||||
const moqIdx = findHeaderIndex(headers, ['moq']);
|
||||
const shortDeIdx = findHeaderIndex(headers, ['short', 'description', 'german']);
|
||||
const shortEnIdx = findHeaderIndex(headers, ['short', 'description', 'english']);
|
||||
const cpnpIdx = findHeaderIndex(headers, ['cpnp']);
|
||||
const categorizationCodeIdx = findCategorizationCodeIndex(headers);
|
||||
|
||||
const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']);
|
||||
const units40HqIdx = (() => {
|
||||
const hqIdx = findHeaderIndex(headers, ['40', 'hq']);
|
||||
if (hqIdx >= 0) return hqIdx;
|
||||
return findHeaderIndex(headers, ['40', 'hc']);
|
||||
})();
|
||||
const outerWIdx = findHeaderIndex(headers, ['outer', 'w']);
|
||||
const outerLIdx = findHeaderIndex(headers, ['outer', 'l']);
|
||||
const outerHIdx = findHeaderIndex(headers, ['outer', 'h']);
|
||||
|
||||
const articleNo = String(getValue(row, articleNoIdx) ?? '');
|
||||
|
||||
const categorizationCode = getValue(row, categorizationCodeIdx);
|
||||
const hasCategorizationCode = String(categorizationCode ?? '').trim() !== '';
|
||||
|
||||
const itemsPayload: Record<string, any> = {
|
||||
no: getValue(row, articleNoIdx),
|
||||
articleDetailsEnglish: getValue(row, articleDetailsEnIdx),
|
||||
articleDetailsGerman: getValue(row, articleDetailsDeIdx),
|
||||
launchDate: formatDateForBc(getValue(row, launchDateIdx)),
|
||||
readyToOrderDate: formatDateForBc(getValue(row, readyToOrderDateIdx)),
|
||||
minimumOrderQuantity: toBcDecimal(getValue(row, moqIdx)),
|
||||
shortDescriptionInGerman: getValue(row, shortDeIdx),
|
||||
shortDescriptionInEnglish: getValue(row, shortEnIdx),
|
||||
cpnpNo: getValue(row, cpnpIdx),
|
||||
};
|
||||
|
||||
if (hasCategorizationCode) {
|
||||
itemsPayload.categorizationCode = String(categorizationCode).trim();
|
||||
}
|
||||
|
||||
const itemUnitsOfMeasurePayload = {
|
||||
itemNo: getValue(row, articleNoIdx),
|
||||
code: 'OUTER',
|
||||
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, unitsOuterIdx)),
|
||||
width: toBcDecimal(getValue(row, outerWIdx)),
|
||||
length: toBcDecimal(getValue(row, outerLIdx)),
|
||||
height: toBcDecimal(getValue(row, outerHIdx)),
|
||||
};
|
||||
|
||||
const itemUnits40HCPayload = {
|
||||
itemNo: getValue(row, articleNoIdx),
|
||||
code: '40HC',
|
||||
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, units40HqIdx)),
|
||||
};
|
||||
|
||||
return {
|
||||
articleNo,
|
||||
itemsPayload,
|
||||
itemUnitsOfMeasurePayload,
|
||||
itemUnits40HCPayload,
|
||||
itemsFields: [
|
||||
makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no),
|
||||
makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish),
|
||||
makeFieldPreview('Article Details - German', articleDetailsDeIdx, 'articleDetailsGerman', itemsPayload.articleDetailsGerman),
|
||||
makeFieldPreview('Launch Date', launchDateIdx, 'launchDate', itemsPayload.launchDate),
|
||||
makeFieldPreview('Ready to Order Date', readyToOrderDateIdx, 'readyToOrderDate', itemsPayload.readyToOrderDate),
|
||||
makeFieldPreview('MOQ', moqIdx, 'minimumOrderQuantity', itemsPayload.minimumOrderQuantity),
|
||||
makeFieldPreview('Short Description - German', shortDeIdx, 'shortDescriptionInGerman', itemsPayload.shortDescriptionInGerman),
|
||||
makeFieldPreview('Short Description - English', shortEnIdx, 'shortDescriptionInEnglish', itemsPayload.shortDescriptionInEnglish),
|
||||
makeFieldPreview('CPNP', cpnpIdx, 'cpnpNo', itemsPayload.cpnpNo),
|
||||
...(hasCategorizationCode
|
||||
? [makeFieldPreview('CategorizationCode', categorizationCodeIdx, 'categorizationCode', itemsPayload.categorizationCode)]
|
||||
: []),
|
||||
],
|
||||
itemUnitsFields: [
|
||||
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnitsOfMeasurePayload.itemNo),
|
||||
makeFieldPreview('Units Outer', unitsOuterIdx, 'qtyPerUnitOfMeasure', itemUnitsOfMeasurePayload.qtyPerUnitOfMeasure),
|
||||
makeFieldPreview('Outer W (cm)', outerWIdx, 'width', itemUnitsOfMeasurePayload.width),
|
||||
makeFieldPreview('Outer L (cm)', outerLIdx, 'length', itemUnitsOfMeasurePayload.length),
|
||||
makeFieldPreview('Outer H (cm)', outerHIdx, 'height', itemUnitsOfMeasurePayload.height),
|
||||
],
|
||||
itemUnits40HCFields: [
|
||||
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnits40HCPayload.itemNo),
|
||||
makeFieldPreview('Units 40FT HQ', units40HqIdx, 'qtyPerUnitOfMeasure', itemUnits40HCPayload.qtyPerUnitOfMeasure),
|
||||
],
|
||||
};
|
||||
}
|
||||
+16
-19
@@ -1,25 +1,15 @@
|
||||
export async function generateGemini(prompt: string, systemPrompt: string): Promise<string> {
|
||||
// Try all possible ways to get the API key
|
||||
const apiKey =
|
||||
(window as any).GEMINI_API_KEY ||
|
||||
localStorage.getItem('GEMINI_API_KEY') ||
|
||||
import.meta.env.VITE_GEMINI_API_KEY ||
|
||||
import.meta.env.GEMINI_API_KEY ||
|
||||
(process.env as any).GEMINI_API_KEY ||
|
||||
'';
|
||||
// Use the fixed API key provided by the user
|
||||
const apiKey = 'AIzaSyDizFnYYnsBRBCfTuT9xhIPBXY0jFQd7HA';
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('No Gemini API Key found in env or storage');
|
||||
throw new Error('Gemini API Key not found. Please set VITE_GEMINI_API_KEY in .env or GEMINI_API_KEY in localStorage.');
|
||||
}
|
||||
|
||||
// Model fallback list - updated for March 2026
|
||||
// Model fallback list - using current stable and experimental versions
|
||||
const modelOptions = [
|
||||
'gemini-3.1-flash',
|
||||
'gemini-3.1-pro',
|
||||
'gemini-3.1-flash-lite',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.0-flash',
|
||||
'gemini-1.5-flash'
|
||||
'gemini-2.5-pro'
|
||||
];
|
||||
|
||||
let lastError: any = null;
|
||||
@@ -39,7 +29,7 @@ export async function generateGemini(prompt: string, systemPrompt: string): Prom
|
||||
}]
|
||||
}],
|
||||
generationConfig: {
|
||||
maxOutputTokens: 2048,
|
||||
maxOutputTokens: 4096,
|
||||
temperature: 0.2
|
||||
}
|
||||
})
|
||||
@@ -61,5 +51,12 @@ export async function generateGemini(prompt: string, systemPrompt: string): Prom
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(lastError?.error?.message || lastError?.message || 'All Gemini models failed. Check console for details.');
|
||||
let errorMessage = lastError?.error?.message || lastError?.message || 'All Gemini models failed.';
|
||||
|
||||
// Check if it's a quota error (429)
|
||||
if (lastError?.error?.code === 429 || lastError?.status === 429 || errorMessage.toLowerCase().includes('quota') || errorMessage.toLowerCase().includes('429')) {
|
||||
errorMessage = 'Gemini API quota exceeded. You may need to wait or switch to a paid billing plan in Google AI Studio.';
|
||||
}
|
||||
|
||||
throw new Error(errorMessage + ' Check console for details.');
|
||||
}
|
||||
|
||||
+106
-24
@@ -9,32 +9,114 @@ export interface AppState {
|
||||
asinColumnIndex: number | null;
|
||||
}
|
||||
|
||||
// Canonical column indices (defaults)
|
||||
// Aligned to the matrix layout introduced in July 2026, which added
|
||||
// EmpCO_Compliant (9), Comments (10), Categorisation Code (11) and
|
||||
// Ingredients (80), and removed PM Classification.
|
||||
export const COLUMNS = {
|
||||
ARTICLE_NO: 0,
|
||||
ARTICLE_NAME: 2,
|
||||
LINE: 7,
|
||||
LICENSE: 8,
|
||||
DETAILS_EN: 9,
|
||||
DETAILS_DE: 10,
|
||||
BARCODE: 28,
|
||||
TARIFF_CODE: 58,
|
||||
COUNTRY_ORIGIN: 60,
|
||||
LONG_DE: 62,
|
||||
LONG_EN: 63,
|
||||
SHORT_DE: 64,
|
||||
SHORT_EN: 65,
|
||||
RECOMMENDED_AGE: 67,
|
||||
CLASSIFICATION: 11,
|
||||
ITEM_AVAILABLE: 14,
|
||||
MOQ: 27,
|
||||
UNITS_INNER: 31,
|
||||
UNITS_OUTER: 32,
|
||||
ASIN: 33,
|
||||
INNER_W: 42,
|
||||
INNER_L: 43,
|
||||
INNER_H: 44,
|
||||
OUTER_W: 47,
|
||||
OUTER_L: 48,
|
||||
OUTER_H: 49,
|
||||
VERIFIED_DIMS: 100
|
||||
};
|
||||
DETAILS_EN: 12,
|
||||
DETAILS_DE: 13,
|
||||
BARCODE: 31,
|
||||
TARIFF_CODE: 62,
|
||||
COUNTRY_ORIGIN: 64,
|
||||
LONG_DE: 65,
|
||||
LONG_EN: 66,
|
||||
SHORT_DE: 67,
|
||||
SHORT_EN: 68,
|
||||
RECOMMENDED_AGE: 71,
|
||||
CLASSIFICATION: 14,
|
||||
ITEM_AVAILABLE: 18,
|
||||
MOQ: 30,
|
||||
UNITS_INNER: 34,
|
||||
UNITS_OUTER: 35,
|
||||
ASIN: 78,
|
||||
INNER_W: 45,
|
||||
INNER_L: 46,
|
||||
INNER_H: 47,
|
||||
OUTER_W: 50,
|
||||
OUTER_L: 51,
|
||||
OUTER_H: 52,
|
||||
VERIFIED_DIMS: 100,
|
||||
VALIDATED_CHECK: 101,
|
||||
VALIDATED_NOTE: 102,
|
||||
CATEGORIZATION_CODE: 103,
|
||||
PRODUCT_TYPE: 103,
|
||||
ITEM_TO_LOGISTIC: 104,
|
||||
ANNA_CHECK: 105,
|
||||
ANNA_NOTE: 106,
|
||||
CPNP_NO: 107
|
||||
};
|
||||
|
||||
// Search patterns for dynamic detection
|
||||
export const COLUMN_PATTERNS: Record<keyof typeof COLUMNS, string[]> = {
|
||||
ARTICLE_NO: ['article', 'no'],
|
||||
ARTICLE_NAME: ['article', 'name'],
|
||||
LINE: ['line'],
|
||||
LICENSE: ['license'],
|
||||
DETAILS_EN: ['details', 'english'],
|
||||
DETAILS_DE: ['details', 'german'],
|
||||
BARCODE: ['article', 'barcode'],
|
||||
TARIFF_CODE: ['tariff', 'code'],
|
||||
COUNTRY_ORIGIN: ['country', 'origin'],
|
||||
LONG_DE: ['long', 'description', 'german'],
|
||||
LONG_EN: ['long', 'description', 'english'],
|
||||
SHORT_DE: ['short', 'description', 'german'],
|
||||
SHORT_EN: ['short', 'description', 'english'],
|
||||
RECOMMENDED_AGE: ['recommended', 'age'],
|
||||
CLASSIFICATION: ['classification'],
|
||||
ITEM_AVAILABLE: ['item', 'available'],
|
||||
MOQ: ['moq'],
|
||||
UNITS_INNER: ['units', 'inner'],
|
||||
UNITS_OUTER: ['units', 'outer'],
|
||||
ASIN: ['asin'],
|
||||
INNER_W: ['inner', 'w'],
|
||||
INNER_L: ['inner', 'l'],
|
||||
INNER_H: ['inner', 'h'],
|
||||
OUTER_W: ['outer', 'w'],
|
||||
OUTER_L: ['outer', 'l'],
|
||||
OUTER_H: ['outer', 'h'],
|
||||
VERIFIED_DIMS: ['verified', 'dims'],
|
||||
VALIDATED_CHECK: ['validated', 'check'],
|
||||
VALIDATED_NOTE: ['validated', 'note'],
|
||||
CATEGORIZATION_CODE: ['categorization', 'code'],
|
||||
PRODUCT_TYPE: ['product', 'type'],
|
||||
ITEM_TO_LOGISTIC: ['item', 'logistic'],
|
||||
ANNA_CHECK: ['anna', 'check'],
|
||||
ANNA_NOTE: ['anna', 'note'],
|
||||
CPNP_NO: ['cpnp'],
|
||||
// Virtual/Extra columns stay hardcoded and are NOT auto-detected from headers
|
||||
// to prevent internal data from being shifted or overwritten by Excel column shifts.
|
||||
};
|
||||
|
||||
export function resolveColumnIndices(headers: string[]): typeof COLUMNS {
|
||||
const resolved = { ...COLUMNS };
|
||||
const h = headers.map(val => String(val || '').toLowerCase());
|
||||
|
||||
Object.entries(COLUMN_PATTERNS).forEach(([key, patterns]) => {
|
||||
if (patterns.length === 0) return;
|
||||
const idx = h.findIndex(headerText =>
|
||||
patterns.every(p => headerText.includes(p.toLowerCase()))
|
||||
);
|
||||
if (idx >= 0) {
|
||||
resolved[key as keyof typeof COLUMNS] = idx;
|
||||
}
|
||||
});
|
||||
|
||||
let categorizationIdx = h.findIndex(headerText => headerText.replace(/[\s_-]+/g, '') === 'categorizationcode');
|
||||
if (categorizationIdx < 0) {
|
||||
categorizationIdx = h.findIndex(headerText => headerText.trim() === 'type');
|
||||
}
|
||||
|
||||
if (categorizationIdx >= 0) {
|
||||
resolved.CATEGORIZATION_CODE = categorizationIdx;
|
||||
resolved.PRODUCT_TYPE = categorizationIdx;
|
||||
} else {
|
||||
resolved.CATEGORIZATION_CODE = resolved.PRODUCT_TYPE;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
+34
-3
@@ -1,6 +1,37 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{ "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" },
|
||||
{ "source": "/api/dropbox-sync", "destination": "api/dropbox-sync.js" }
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Content-Type-Options",
|
||||
"value": "nosniff"
|
||||
},
|
||||
{
|
||||
"key": "X-Frame-Options",
|
||||
"value": "DENY"
|
||||
},
|
||||
{
|
||||
"key": "X-XSS-Protection",
|
||||
"value": "1; mode=block"
|
||||
},
|
||||
{
|
||||
"key": "Referrer-Policy",
|
||||
"value": "strict-origin-when-cross-origin"
|
||||
},
|
||||
{
|
||||
"key": "Permissions-Policy",
|
||||
"value": "camera=(), microphone=(), geolocation=()"
|
||||
},
|
||||
{
|
||||
"key": "Strict-Transport-Security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"key": "Content-Security-Policy",
|
||||
"value": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' https://hwithddwaapyhnfwcesj.supabase.co https://api.dropboxapi.com https://www.dropbox.com https://generativelanguage.googleapis.com; font-src 'self'; frame-ancestors 'none';"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+179
-2
@@ -2,11 +2,188 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig, loadEnv} from 'vite';
|
||||
import { getBcConfig, getBCToken, findItem, patchItemCpnpNo, fetchAllItems, buildWorkbook } from './bc-runtime.js';
|
||||
import * as XLSX from 'xlsx';
|
||||
// @ts-ignore
|
||||
import dropboxProxyHandler from './api/dropbox-proxy.js';
|
||||
// @ts-ignore
|
||||
import dropboxSyncHandler from './api/dropbox-sync.js';
|
||||
// @ts-ignore
|
||||
import backupHandler from './api/backup.js';
|
||||
// @ts-ignore
|
||||
import usersAdminHandler from './api/users-admin.js';
|
||||
|
||||
async function readJsonBody(req: any): Promise<any> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let raw = '';
|
||||
req.on('data', (chunk: Buffer) => { raw += chunk.toString('utf8'); });
|
||||
req.on('end', () => {
|
||||
if (!raw.trim()) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function createLocalApiMiddleware(env: Record<string, string>, prodEnv: Record<string, string>) {
|
||||
return async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
const config = getBcConfig(env);
|
||||
|
||||
// Populate process.env with loaded env variables for serverless handlers.
|
||||
// The Dropbox proxy reads env at request time so preview/dev/production
|
||||
// can share the same handler implementation.
|
||||
Object.assign(process.env, prodEnv, env);
|
||||
|
||||
// Helper to adapt Node.js req/res to Vercel signature.
|
||||
const adaptVercelHandler = async (handler: any) => {
|
||||
(req as any).query = Object.fromEntries(url.searchParams.entries());
|
||||
try {
|
||||
(req as any).body = await readJsonBody(req);
|
||||
} catch {
|
||||
(req as any).body = {};
|
||||
}
|
||||
(res as any).status = (code: number) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
};
|
||||
(res as any).json = (data: any) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
(res as any).send = (data: any) => {
|
||||
if (data instanceof Buffer) {
|
||||
res.end(data);
|
||||
} else if (typeof data === 'object') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(data));
|
||||
} else {
|
||||
res.end(data);
|
||||
}
|
||||
};
|
||||
await handler(req, res);
|
||||
};
|
||||
|
||||
if (url.pathname === '/api/dropbox-proxy') {
|
||||
await adaptVercelHandler(dropboxProxyHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/dropbox-sync') {
|
||||
await adaptVercelHandler(dropboxSyncHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/backup') {
|
||||
await adaptVercelHandler(backupHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/users-admin') {
|
||||
await adaptVercelHandler(usersAdminHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/bc-proxy') {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readJsonBody(req);
|
||||
const { articleNo, cpnpNo } = body || {};
|
||||
if (!articleNo || cpnpNo === undefined) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Missing articleNo or cpnpNo' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getBCToken(config);
|
||||
const item = await findItem(config, token, articleNo);
|
||||
await patchItemCpnpNo(config, token, item, String(cpnpNo));
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ success: true, articleNo, cpnpNo }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/bc-export') {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'GET') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getBCToken(config);
|
||||
const items = await fetchAllItems(config, token);
|
||||
|
||||
if (url.searchParams.get('format') === 'json') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ success: true, count: items.length, items }));
|
||||
return;
|
||||
}
|
||||
|
||||
const workbook = buildWorkbook(items);
|
||||
const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' });
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="BusinessCentral_Items_${dateStr}.xlsx"`);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(buffer);
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
const message = err?.message || String(err);
|
||||
if ((req.url || '').startsWith('/api/bc-')) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ success: false, error: message }));
|
||||
return;
|
||||
}
|
||||
console.error('[bc-local-api]', message);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
const prodEnv = loadEnv('production', '.', '');
|
||||
const localApiMiddleware = createLocalApiMiddleware(env, prodEnv);
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: 'bc-local-api',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(localApiMiddleware);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use(localApiMiddleware);
|
||||
},
|
||||
},
|
||||
],
|
||||
define: {
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
},
|
||||
@@ -21,7 +198,7 @@ export default defineConfig(({mode}) => {
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
proxy: {
|
||||
'/dropbox-file': {
|
||||
target: 'https://dl.dropboxusercontent.com',
|
||||
target: 'https://www.dropbox.com',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/dropbox-file/, ''),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user