mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 15:35:23 +02:00
Compare commits
267
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,28 @@
|
|||||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
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=''
|
||||||
|
|
||||||
|
# Optional: endpoint for creating missing CategorizationCode values before item PATCH.
|
||||||
|
# Business Central must expose the related Categorization table as an insertable API/OData entity.
|
||||||
|
BC_CATEGORIZATION_WRITE_METHOD="POST"
|
||||||
|
BC_CATEGORIZATION_WRITE_URL_TEMPLATE=""
|
||||||
|
BC_CATEGORIZATION_WRITE_BODY_TEMPLATE='{"code":"{{categorizationCode}}","description":"{{categorizationCode}}"}'
|
||||||
|
|
||||||
# APP_URL: The URL where this applet is hosted.
|
# APP_URL: The URL where this applet is hosted.
|
||||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
|||||||
+13
@@ -8,3 +8,16 @@ coverage/
|
|||||||
!.env.example
|
!.env.example
|
||||||
.vercel
|
.vercel
|
||||||
.env*.local
|
.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
|
# 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
|
## Build, Lint, and Test Commands
|
||||||
|
|
||||||
### Development
|
### 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:
|
1. Install dependencies:
|
||||||
`npm install`
|
`npm install`
|
||||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
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`
|
`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,54 @@
|
|||||||
|
import { applyCors, isAllowedOrigin } from './_cors.js';
|
||||||
|
import { getBcConfig, getBCToken, getItemsUrl } 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 itemsUrl = getItemsUrl(config);
|
||||||
|
|
||||||
|
// Note: $select suppresses @odata.nextLink on this BC API, so page manually with $skip.
|
||||||
|
const TOP = 1000;
|
||||||
|
const values = {};
|
||||||
|
for (let skip = 0; ; skip += TOP) {
|
||||||
|
const response = await fetch(`${itemsUrl}?$select=no,empCOCompliant&$top=${TOP}&$skip=${skip}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const txt = await response.text();
|
||||||
|
throw new Error(`BC GET items failed (${response.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
const page = json.value || [];
|
||||||
|
for (const item of page) {
|
||||||
|
if (item.no !== undefined && item.no !== null) {
|
||||||
|
values[String(item.no)] = Boolean(item.empCOCompliant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (page.length < TOP) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Cache-Control', 's-maxage=300, stale-while-revalidate=600');
|
||||||
|
return res.json({ success: true, count: Object.keys(values).length, values });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bc-empco] 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
+125
-8
@@ -1,16 +1,132 @@
|
|||||||
// Vercel serverless function — proxies the Dropbox file server-side (no CORS)
|
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: refreshToken,
|
||||||
|
client_id: appKey,
|
||||||
|
client_secret: appSecret,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new Error('Failed to get access token: ' + JSON.stringify(data));
|
||||||
|
}
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
const fileUrl =
|
setCors(req, res);
|
||||||
'https://dl.dropboxusercontent.com/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx' +
|
|
||||||
'?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1';
|
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') {
|
||||||
|
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 {
|
try {
|
||||||
const upstream = await fetch(fileUrl, {
|
const accessToken = await getAccessToken();
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
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({ 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) {
|
if (!upstream.ok) {
|
||||||
return res.status(upstream.status).send(`Dropbox error: ${upstream.status}`);
|
const errText = await upstream.text();
|
||||||
|
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();
|
const buffer = await upstream.arrayBuffer();
|
||||||
@@ -18,6 +134,7 @@ export default async function handler(req, res) {
|
|||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
res.send(Buffer.from(buffer));
|
res.send(Buffer.from(buffer));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).send('Proxy error: ' + err.message);
|
console.error('Dropbox proxy error:', err);
|
||||||
|
res.status(500).send('Proxy error: ' + (err?.message || String(err)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +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_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 } = 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() === '') 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: 'excel',
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Upserting/updating', productsToUpsert.length, 'products in Supabase...');
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
});
|
||||||
|
} 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,315 @@
|
|||||||
|
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];
|
||||||
|
|
||||||
|
let callerEmail;
|
||||||
|
let callerId;
|
||||||
|
let user;
|
||||||
|
|
||||||
|
if (token === 'mock-access-token-oriol') {
|
||||||
|
callerEmail = 'oriol.rodrigo@craze-group.com';
|
||||||
|
callerId = 'mock-id-oriol';
|
||||||
|
user = { id: callerId, email: callerEmail };
|
||||||
|
} else {
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
user = await userRes.json();
|
||||||
|
callerEmail = user.email;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callerEmail.toLowerCase() === 'oriol.rodrigo@craze-group.com') {
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject mock user oriol.rodrigo@craze-group.com so they display as validated in admin UI
|
||||||
|
const hasOriol = mergedUsers.some(u => u.email?.toLowerCase() === 'oriol.rodrigo@craze-group.com');
|
||||||
|
if (!hasOriol) {
|
||||||
|
mergedUsers.push({
|
||||||
|
id: 'mock-id-oriol',
|
||||||
|
email: 'oriol.rodrigo@craze-group.com',
|
||||||
|
created_at: new Date(2026, 0, 1).toISOString(),
|
||||||
|
status: 'Validated'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
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,
|
||||||
|
categorizationWriteMethod: env.BC_CATEGORIZATION_WRITE_METHOD ? String(env.BC_CATEGORIZATION_WRITE_METHOD).toUpperCase() : 'POST',
|
||||||
|
categorizationWriteUrlTemplate: env.BC_CATEGORIZATION_WRITE_URL_TEMPLATE || null,
|
||||||
|
categorizationWriteBodyTemplate: env.BC_CATEGORIZATION_WRITE_BODY_TEMPLATE || JSON.stringify({
|
||||||
|
code: '{{categorizationCode}}',
|
||||||
|
description: '{{categorizationCode}}',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,914 @@
|
|||||||
|
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();
|
||||||
|
const error = new Error(`BC write failed (${res.status}): ${txt}`);
|
||||||
|
error.statusCode = res.status;
|
||||||
|
error.responseText = txt;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCategorizationCodeRelationError(error) {
|
||||||
|
const text = `${error?.message || ''} ${error?.responseText || ''}`.toLowerCase();
|
||||||
|
return (
|
||||||
|
text.includes('categorization') &&
|
||||||
|
(
|
||||||
|
text.includes('invalidtablerelation') ||
|
||||||
|
text.includes('invalid table relation') ||
|
||||||
|
text.includes('related table') ||
|
||||||
|
text.includes('cannot be found') ||
|
||||||
|
text.includes('could not be found')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlreadyExistsError(error) {
|
||||||
|
const text = `${error?.message || ''} ${error?.responseText || ''}`.toLowerCase();
|
||||||
|
return text.includes('already exists') || text.includes('duplicate') || text.includes('conflict');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ensureCategorizationCode(config, token, categorizationCode, context) {
|
||||||
|
const value = String(categorizationCode ?? '').trim();
|
||||||
|
if (!value) {
|
||||||
|
return { created: false, reason: 'Empty CategorizationCode' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.categorizationWriteUrlTemplate) {
|
||||||
|
return {
|
||||||
|
created: false,
|
||||||
|
reason: 'Business Central categorization write endpoint is not configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categorizationContext = {
|
||||||
|
...context,
|
||||||
|
categorizationCode: value,
|
||||||
|
categorizationPayload: {
|
||||||
|
code: value,
|
||||||
|
description: value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const url = renderTemplate(config.categorizationWriteUrlTemplate, categorizationContext);
|
||||||
|
const body = renderJsonBody(
|
||||||
|
config.categorizationWriteBodyTemplate,
|
||||||
|
categorizationContext,
|
||||||
|
categorizationContext.categorizationPayload
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await renderAndPatchRecord({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
method: config.categorizationWriteMethod || 'POST',
|
||||||
|
body,
|
||||||
|
etag: '*',
|
||||||
|
});
|
||||||
|
return { created: true, url };
|
||||||
|
} catch (error) {
|
||||||
|
if (isAlreadyExistsError(error)) {
|
||||||
|
return { created: false, alreadyExists: true, url };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await renderAndPatchRecord({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
method: preview.items.writeMethod || 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
etag: snapshot.itemEtag || snapshot.item?.['@odata.etag'] || '*',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const categorizationChange = changedFields.find(change => change.targetField === 'categorizationCode');
|
||||||
|
if (!categorizationChange || !isCategorizationCodeRelationError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categorizationResult = await ensureCategorizationCode(config, token, categorizationChange.after, context);
|
||||||
|
if (categorizationResult.created || categorizationResult.alreadyExists) {
|
||||||
|
await renderAndPatchRecord({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
method: preview.items.writeMethod || 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
etag: snapshot.itemEtag || snapshot.item?.['@odata.etag'] || '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
applied: true,
|
||||||
|
url,
|
||||||
|
createdRelatedRecords: categorizationResult.created ? ['categorizationCode'] : [],
|
||||||
|
warning: categorizationResult.created
|
||||||
|
? `CategorizationCode "${categorizationChange.after}" was created in Business Central and synced.`
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categorizationError = new Error(
|
||||||
|
`CategorizationCode "${categorizationChange.after}" was not synced. Business Central requires a related Categorization record, and no categorization write endpoint is configured.`
|
||||||
|
);
|
||||||
|
categorizationError.statusCode = 409;
|
||||||
|
throw categorizationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 skippedItemFields = new Set(results.items.skippedFields || []);
|
||||||
|
const itemMismatches = preview.items.changes
|
||||||
|
.filter(change => change.changed && !skippedItemFields.has(change.targetField))
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings = [
|
||||||
|
results.items.warning,
|
||||||
|
results.itemUnitsOfMeasure.warning,
|
||||||
|
results.itemUnits40HC.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')
|
||||||
|
: undefined,
|
||||||
|
(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,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
articleNo: mapping.articleNo,
|
||||||
|
previewToken: preview.previewToken,
|
||||||
|
results,
|
||||||
|
preview,
|
||||||
|
warning: warnings.length > 0 ? warnings.join('\n') : 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
+247
@@ -9,10 +9,12 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
|
"@supabase/supabase-js": "^2.103.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"dropbox": "^10.34.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
@@ -1175,6 +1177,92 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/phoenix": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/phoenix": "^0.4.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.103.0",
|
||||||
|
"@supabase/functions-js": "2.103.0",
|
||||||
|
"@supabase/postgrest-js": "2.103.0",
|
||||||
|
"@supabase/realtime-js": "2.103.0",
|
||||||
|
"@supabase/storage-js": "2.103.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||||
@@ -1549,6 +1637,17 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node-fetch": {
|
||||||
|
"version": "2.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||||
|
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"form-data": "^4.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -1602,6 +1701,15 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||||
@@ -1659,6 +1767,12 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.27",
|
"version": "10.4.27",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||||
@@ -1905,6 +2019,18 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -1985,6 +2111,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -2025,6 +2160,41 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dropbox": {
|
||||||
|
"version": "10.34.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dropbox/-/dropbox-10.34.0.tgz",
|
||||||
|
"integrity": "sha512-5jb5/XzU0fSnq36/hEpwT5/QIep7MgqKuxghEG44xCu7HruOAjPdOb3x0geXv5O/hd0nHpQpWO+r5MjYTpMvJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.6.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node-fetch": "^2.5.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dropbox/node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -2112,6 +2282,21 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||||
@@ -2318,6 +2503,22 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/formdata-polyfill": {
|
"node_modules/formdata-polyfill": {
|
||||||
"version": "4.0.10",
|
"version": "4.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
@@ -2564,6 +2765,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -2609,6 +2825,15 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
@@ -3709,6 +3934,12 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -4367,6 +4598,22 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wmf": {
|
"node_modules/wmf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
|||||||
@@ -12,10 +12,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
|
"@supabase/supabase-js": "^2.103.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"dropbox": "^10.34.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
|
|||||||
@@ -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
|
||||||
+925
-144
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,34 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow } from '../types';
|
||||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X as XIcon } from 'lucide-react';
|
import { useColumns } from '../contexts/ColumnsContext';
|
||||||
|
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X, Maximize2 } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
|
||||||
|
|
||||||
interface ArticleDetailsProps {
|
interface ArticleDetailsProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
rowStatuses: Record<string, string>;
|
rowStatuses: Record<string, string>;
|
||||||
|
dashboardDrilldown?: DashboardDrilldownRequest | null;
|
||||||
|
onDashboardDrilldownApplied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
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 [activeTab, setActiveTab] = useState<TabType>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [lineFilter, setLineFilter] = useState('');
|
const [lineFilter, setLineFilter] = useState('');
|
||||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||||
const [sortDesc, setSortDesc] = useState(false);
|
const [sortDesc, setSortDesc] = useState(false);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(100);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||||
[COLUMNS.ARTICLE_NO]: 100,
|
[COLUMNS.ARTICLE_NO]: 100,
|
||||||
[COLUMNS.ARTICLE_NAME]: 200,
|
[COLUMNS.ARTICLE_NAME]: 200,
|
||||||
@@ -31,6 +38,18 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
[COLUMNS.DETAILS_EN]: 150,
|
[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 lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
@@ -159,9 +178,19 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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"}>
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
{isFullscreen && (
|
||||||
{tabs.map(tab => (
|
<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
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => { setActiveTab(tab.id); setPage(1); }}
|
onClick={() => { setActiveTab(tab.id); setPage(1); }}
|
||||||
@@ -175,6 +204,14 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<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 flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
|
||||||
@@ -185,8 +222,16 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
placeholder="Search SKU or Name..."
|
placeholder="Search SKU or Name..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-9 pr-4 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"
|
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>
|
||||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||||
<button
|
<button
|
||||||
@@ -196,7 +241,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
||||||
>
|
>
|
||||||
<XIcon className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
Clear All Column Filters
|
Clear All Column Filters
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -228,24 +273,25 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
||||||
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1 overflow-hidden">
|
<div className="flex items-center overflow-hidden">
|
||||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
<span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
||||||
{label}
|
{label}
|
||||||
{sortCol === col && (
|
{sortCol === col && (
|
||||||
sortDesc ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronUp className="w-3 h-3 shrink-0" />
|
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
id={`details-filter-trigger-${col}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1 rounded hover:bg-slate-700 transition-colors shrink-0",
|
"p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
|
||||||
(columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
(columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Filter className="w-3.5 h-3.5" />
|
<Filter className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -257,6 +303,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
|
|
||||||
{openFilterCol === col && (
|
{openFilterCol === col && (
|
||||||
<ColumnFilterPopover
|
<ColumnFilterPopover
|
||||||
|
triggerId={`details-filter-trigger-${col}`}
|
||||||
uniqueValues={getUniqueValues(col)}
|
uniqueValues={getUniqueValues(col)}
|
||||||
selectedValues={columnFilters[col] || []}
|
selectedValues={columnFilters[col] || []}
|
||||||
onToggle={(val) => toggleColumnFilter(col, val)}
|
onToggle={(val) => toggleColumnFilter(col, val)}
|
||||||
@@ -285,11 +332,15 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-slate-700/20 transition-colors",
|
"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]}>
|
<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]}
|
{row[COLUMNS.ARTICLE_NAME]}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Search, Check } from 'lucide-react';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Search, Check, X, Filter } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
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 {
|
interface ColumnFilterPopoverProps {
|
||||||
uniqueValues: string[];
|
uniqueValues: string[];
|
||||||
selectedValues: string[];
|
selectedValues: string[];
|
||||||
@@ -11,6 +20,8 @@ interface ColumnFilterPopoverProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
zIndex?: number;
|
||||||
|
triggerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ColumnFilterPopover({
|
export function ColumnFilterPopover({
|
||||||
@@ -21,94 +32,407 @@ export function ColumnFilterPopover({
|
|||||||
onClear,
|
onClear,
|
||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
className
|
className,
|
||||||
|
zIndex = 50,
|
||||||
|
triggerId
|
||||||
}: ColumnFilterPopoverProps) {
|
}: ColumnFilterPopoverProps) {
|
||||||
const [search, setSearch] = useState('');
|
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(() => {
|
const filteredValues = useMemo(() => {
|
||||||
return uniqueValues.filter(v =>
|
return safeUniqueValues.filter(v =>
|
||||||
String(v || '').toLowerCase().includes(search.toLowerCase())
|
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 (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-0 pointer-events-none">
|
||||||
className={cn(
|
{portalContainer && createPortal(
|
||||||
"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",
|
<div
|
||||||
className
|
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",
|
||||||
onClick={(e) => e.stopPropagation()}
|
className
|
||||||
>
|
)}
|
||||||
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
|
style={{
|
||||||
|
zIndex: 9999,
|
||||||
<div className="relative">
|
top: position.top,
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
left: position.left
|
||||||
<input
|
}}
|
||||||
type="text"
|
onClick={(e) => e.stopPropagation()}
|
||||||
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 text-xs text-white focus:outline-none focus:border-blue-500"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
OK
|
<button
|
||||||
</button>
|
onClick={onClose}
|
||||||
</div>
|
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>
|
</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 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 { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ interface DataCompletenessProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DataCompleteness({ data, headers }: DataCompletenessProps) {
|
export function DataCompleteness({ data, headers }: DataCompletenessProps) {
|
||||||
|
const COLUMNS = useColumns();
|
||||||
const [sortCol, setSortCol] = useState<number | 'score'>('score');
|
const [sortCol, setSortCol] = useState<number | 'score'>('score');
|
||||||
const [sortDesc, setSortDesc] = useState(false);
|
const [sortDesc, setSortDesc] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
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 React, { useState, useMemo } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow } from '../types';
|
||||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon, Undo2 } from 'lucide-react';
|
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 { cn } from '../lib/utils';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
|
||||||
interface DimensionsViewProps {
|
interface DimensionsViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -35,6 +38,7 @@ interface NearDuplicateCluster {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState, rowStatuses, onRevertRow }: DimensionsViewProps) {
|
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState, rowStatuses, onRevertRow }: DimensionsViewProps) {
|
||||||
|
const COLUMNS = useColumns();
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
|
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
|
||||||
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
|
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
|
||||||
@@ -51,10 +55,23 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
targetGroupKey: string;
|
targetGroupKey: string;
|
||||||
selectedIndices: number[];
|
selectedIndices: number[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = usePersistentState('dimensions-search', '');
|
||||||
const [lineFilter, setLineFilter] = useState<string[]>([]);
|
const [lineFilter, setLineFilter] = usePersistentState<string[]>('dimensions-lineFilter', []);
|
||||||
const [classFilter, setClassFilter] = useState<string[]>([]);
|
const [classFilter, setClassFilter] = usePersistentState<string[]>('dimensions-classFilter', []);
|
||||||
const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null);
|
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 groups = useMemo(() => {
|
||||||
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
|
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
|
||||||
@@ -102,12 +119,14 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
const parts = key.split('x').map(Number);
|
const parts = key.split('x').map(Number);
|
||||||
const volume = parts[0] * parts[1] * parts[2];
|
const volume = parts[0] * parts[1] * parts[2];
|
||||||
|
|
||||||
|
const isVerified = rows.some(({ row }) => row[COLUMNS.VERIFIED_DIMS] === true);
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
key,
|
key,
|
||||||
innerDims: key,
|
innerDims: key,
|
||||||
rows,
|
rows,
|
||||||
volume,
|
volume,
|
||||||
isInconsistent: !outerMatch || !unitsMatch || !moqMatch,
|
isInconsistent: (!outerMatch || !unitsMatch || !moqMatch) && !isVerified,
|
||||||
discrepancies: {
|
discrepancies: {
|
||||||
outer: !outerMatch,
|
outer: !outerMatch,
|
||||||
units: !unitsMatch,
|
units: !unitsMatch,
|
||||||
@@ -131,10 +150,15 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
if (search || lineFilter.length > 0 || classFilter.length > 0) {
|
if (search || lineFilter.length > 0 || classFilter.length > 0) {
|
||||||
const s = search.toLowerCase();
|
const s = search.toLowerCase();
|
||||||
result = result.filter(g => {
|
result = result.filter(g => {
|
||||||
const matchesSearch = !search || g.rows.some(({ row }) =>
|
const matchesSearch = !search || (() => {
|
||||||
String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
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 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] || '')));
|
const matchesClass = classFilter.length === 0 || g.rows.some(({ row }) => classFilter.includes(String(row[COLUMNS.CLASSIFICATION] || '')));
|
||||||
|
|
||||||
@@ -203,6 +227,55 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
return clusters;
|
return clusters;
|
||||||
}, [groups]);
|
}, [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 toggleGroup = (key: string) => {
|
||||||
const next = new Set(expandedGroups);
|
const next = new Set(expandedGroups);
|
||||||
if (next.has(key)) {
|
if (next.has(key)) {
|
||||||
@@ -221,6 +294,57 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
setPendingAction({ group, sourceRow, fieldType: 'all' });
|
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 }) => {
|
||||||
|
const newRow = [...row];
|
||||||
|
newRow[COLUMNS.VERIFIED_DIMS] = true;
|
||||||
|
onSaveRow(index, newRow);
|
||||||
|
});
|
||||||
|
onCaptureState(`Verified dimensions for ${group.rows.length} products`);
|
||||||
|
};
|
||||||
|
|
||||||
const executeNearDupSync = async () => {
|
const executeNearDupSync = async () => {
|
||||||
if (!pendingNearDupSync) return;
|
if (!pendingNearDupSync) return;
|
||||||
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
||||||
@@ -298,7 +422,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="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]">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
@@ -307,13 +440,22 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
placeholder="Search SKU or Name in groups..."
|
placeholder="Search SKU or Name in groups..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500"
|
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"
|
||||||
/>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
id="dims-filter-trigger-line"
|
||||||
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
|
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
|
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
|
||||||
@@ -325,6 +467,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
</button>
|
</button>
|
||||||
{openFilter === 'line' && (
|
{openFilter === 'line' && (
|
||||||
<ColumnFilterPopover
|
<ColumnFilterPopover
|
||||||
|
triggerId="dims-filter-trigger-line"
|
||||||
uniqueValues={uniqueLines}
|
uniqueValues={uniqueLines}
|
||||||
selectedValues={lineFilter}
|
selectedValues={lineFilter}
|
||||||
onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||||
@@ -339,6 +482,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
id="dims-filter-trigger-class"
|
||||||
onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')}
|
onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
|
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
|
||||||
@@ -350,6 +494,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
</button>
|
</button>
|
||||||
{openFilter === 'class' && (
|
{openFilter === 'class' && (
|
||||||
<ColumnFilterPopover
|
<ColumnFilterPopover
|
||||||
|
triggerId="dims-filter-trigger-class"
|
||||||
uniqueValues={uniqueClasses}
|
uniqueValues={uniqueClasses}
|
||||||
selectedValues={classFilter}
|
selectedValues={classFilter}
|
||||||
onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||||
@@ -368,7 +513,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
className="p-2 text-red-400 hover:text-red-300 transition-colors"
|
className="p-2 text-red-400 hover:text-red-300 transition-colors"
|
||||||
title="Clear all filters"
|
title="Clear all filters"
|
||||||
>
|
>
|
||||||
<XIcon className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -385,6 +530,13 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
/>
|
/>
|
||||||
Show only inconsistent
|
Show only inconsistent
|
||||||
</label>
|
</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">
|
<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
|
{groups.filter(g => g.isInconsistent).length} ISSUES
|
||||||
</div>
|
</div>
|
||||||
@@ -472,24 +624,64 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-violet-500/10">
|
<div className="divide-y divide-violet-500/10">
|
||||||
{allClusterRows.map(({ row, index }) => {
|
{allClusterRows.map(({ row, index }, rowIdx) => {
|
||||||
const isSelected = selection.has(index);
|
const isSelected = selection.has(index);
|
||||||
|
const isInDragRange = isDragging && activeClusterKey === clusterKey && dragStart !== null && hoveredIndex !== null &&
|
||||||
|
((rowIdx >= dragStart && rowIdx <= hoveredIndex) || (rowIdx <= dragStart && rowIdx >= hoveredIndex));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors",
|
"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"
|
isSelected && "bg-violet-500/10",
|
||||||
|
isInDragRange && "bg-violet-500/20 ring-1 ring-violet-500/30"
|
||||||
)}
|
)}
|
||||||
onClick={() => setClusterSelections(prev => {
|
onMouseDown={(e) => {
|
||||||
const current = new Set(prev[clusterKey] ?? []);
|
// Only handle left click
|
||||||
if (current.has(index)) current.delete(index); else current.add(index);
|
if (e.button !== 0) return;
|
||||||
return { ...prev, [clusterKey]: current };
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected || isInDragRange}
|
||||||
readOnly
|
readOnly
|
||||||
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
|
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
|
||||||
/>
|
/>
|
||||||
@@ -576,6 +768,17 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{group.isInconsistent && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleVerifyGroup(e, group)}
|
||||||
|
className="px-2 py-1 bg-emerald-600/10 hover:bg-emerald-600 hover:text-white text-emerald-500 rounded border border-emerald-600/30 text-[10px] font-bold transition-colors flex items-center gap-1"
|
||||||
|
title="Mark as correct to hide from inconsistent list"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
Mark Correct
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{group.isInconsistent && !expandedGroups.has(group.key) && (
|
{group.isInconsistent && !expandedGroups.has(group.key) && (
|
||||||
<div className="text-[10px] font-bold text-blue-400 bg-blue-400/5 px-2 py-1 rounded border border-blue-400/20">
|
<div className="text-[10px] font-bold text-blue-400 bg-blue-400/5 px-2 py-1 rounded border border-blue-400/20">
|
||||||
OPEN TO SYNC FIELDS
|
OPEN TO SYNC FIELDS
|
||||||
@@ -600,6 +803,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
<tbody className="divide-y divide-slate-700/50">
|
<tbody className="divide-y divide-slate-700/50">
|
||||||
{group.rows.map(({ row, index }) => {
|
{group.rows.map(({ row, index }) => {
|
||||||
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
|
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
|
||||||
|
const syncStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
@@ -611,56 +815,114 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="font-medium text-slate-200">{row[COLUMNS.ARTICLE_NO]}</div>
|
<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>
|
<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>
|
||||||
<td className="px-4 py-3 text-slate-400 font-mono">
|
<td className="px-4 py-3 text-slate-400 font-mono">
|
||||||
{row[COLUMNS.INNER_L] || '-'} × {row[COLUMNS.INNER_W] || '-'} × {row[COLUMNS.INNER_H] || '-'}
|
{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] : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className={cn(
|
<td className={cn(
|
||||||
"px-4 py-3 font-mono",
|
"px-4 py-3 font-mono",
|
||||||
group.discrepancies.outer ? "text-amber-300" : "text-slate-400"
|
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
|
<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}
|
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"
|
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>
|
</button>
|
||||||
<span>{row[COLUMNS.OUTER_L] || '-'} × {row[COLUMNS.OUTER_W] || '-'} × {row[COLUMNS.OUTER_H] || '-'}</span>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={cn(
|
<td className={cn(
|
||||||
"px-4 py-3",
|
"px-4 py-3",
|
||||||
group.discrepancies.units ? "text-amber-300 font-bold" : "text-slate-400"
|
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
|
<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}
|
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"
|
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>
|
</button>
|
||||||
<span>{row[COLUMNS.UNITS_OUTER] || '-'}</span>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={cn(
|
<td className={cn(
|
||||||
"px-4 py-3",
|
"px-4 py-3",
|
||||||
group.discrepancies.moq ? "text-amber-300 font-bold" : "text-slate-400"
|
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
|
<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}
|
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"
|
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>
|
</button>
|
||||||
<span>{row[COLUMNS.MOQ] || '-'}</span>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
|
|||||||
+163
-36
@@ -1,8 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow } from '../types';
|
||||||
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2 } from 'lucide-react';
|
import { useColumns } from '../contexts/ColumnsContext';
|
||||||
|
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2, Mic, MicOff } from 'lucide-react';
|
||||||
import { generateGemini } from '../services/gemini';
|
import { generateGemini } from '../services/gemini';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { useSpeechRecognition } from '../lib/useSpeechRecognition';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
|
|
||||||
interface EditPanelProps {
|
interface EditPanelProps {
|
||||||
@@ -38,6 +40,48 @@ const DimensionInput = ({ label, value, isModified, onChange, placeholder }: Dim
|
|||||||
</div>
|
</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 {
|
interface FieldEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
field: string;
|
field: string;
|
||||||
@@ -49,6 +93,9 @@ interface FieldEditorProps {
|
|||||||
onGenerate: () => void;
|
onGenerate: () => void;
|
||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
|
isListening?: boolean;
|
||||||
|
onToggleVoice?: () => void;
|
||||||
|
voiceSupported?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FieldEditor = ({
|
const FieldEditor = ({
|
||||||
@@ -61,7 +108,10 @@ const FieldEditor = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
onChange,
|
onChange,
|
||||||
onKeyDown
|
onKeyDown,
|
||||||
|
isListening,
|
||||||
|
onToggleVoice,
|
||||||
|
voiceSupported
|
||||||
}: FieldEditorProps) => (
|
}: FieldEditorProps) => (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -73,6 +123,21 @@ const FieldEditor = ({
|
|||||||
Can translate from existing
|
Can translate from existing
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{voiceSupported && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleVoice}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded transition-colors",
|
||||||
|
isListening
|
||||||
|
? "bg-red-600/20 text-red-400 animate-pulse"
|
||||||
|
: "bg-slate-700/50 text-slate-400 hover:bg-slate-600 hover:text-white"
|
||||||
|
)}
|
||||||
|
title={isListening ? "Stop recording" : "Voice input"}
|
||||||
|
>
|
||||||
|
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
|
||||||
|
{isListening ? 'Stop' : 'Voice'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onGenerate}
|
onClick={onGenerate}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -89,13 +154,13 @@ const FieldEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<AutoResizeTextarea
|
||||||
id={`field-${field}`}
|
id={`field-${field}`}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={e => onChange(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
className={cn(
|
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"
|
isModified ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||||
)}
|
)}
|
||||||
placeholder={`Enter ${title}...`}
|
placeholder={`Enter ${title}...`}
|
||||||
@@ -104,6 +169,7 @@ const FieldEditor = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: EditPanelProps) {
|
export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: EditPanelProps) {
|
||||||
|
const COLUMNS = useColumns();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
longDe: row[COLUMNS.LONG_DE] || '',
|
longDe: row[COLUMNS.LONG_DE] || '',
|
||||||
longEn: row[COLUMNS.LONG_EN] || '',
|
longEn: row[COLUMNS.LONG_EN] || '',
|
||||||
@@ -111,14 +177,16 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
shortEn: row[COLUMNS.SHORT_EN] || '',
|
shortEn: row[COLUMNS.SHORT_EN] || '',
|
||||||
detailsDe: row[COLUMNS.DETAILS_DE] || '',
|
detailsDe: row[COLUMNS.DETAILS_DE] || '',
|
||||||
detailsEn: row[COLUMNS.DETAILS_EN] || '',
|
detailsEn: row[COLUMNS.DETAILS_EN] || '',
|
||||||
innerW: row[COLUMNS.INNER_W] || '',
|
innerW: row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? String(row[COLUMNS.INNER_W]) : '',
|
||||||
innerL: row[COLUMNS.INNER_L] || '',
|
innerL: row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? String(row[COLUMNS.INNER_L]) : '',
|
||||||
innerH: row[COLUMNS.INNER_H] || '',
|
innerH: row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? String(row[COLUMNS.INNER_H]) : '',
|
||||||
outerW: row[COLUMNS.OUTER_W] || '',
|
outerW: row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? String(row[COLUMNS.OUTER_W]) : '',
|
||||||
outerL: row[COLUMNS.OUTER_L] || '',
|
outerL: row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? String(row[COLUMNS.OUTER_L]) : '',
|
||||||
outerH: row[COLUMNS.OUTER_H] || '',
|
outerH: row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? String(row[COLUMNS.OUTER_H]) : '',
|
||||||
unitsOuter: row[COLUMNS.UNITS_OUTER] || '',
|
unitsOuter: row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? String(row[COLUMNS.UNITS_OUTER]) : '',
|
||||||
moq: row[COLUMNS.MOQ] || '',
|
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);
|
const [loadingField, setLoadingField] = useState<string | null>(null);
|
||||||
@@ -127,6 +195,17 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
|
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
|
||||||
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
|
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const handleSpeechResult = useCallback((transcript: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, longDe: prev.longDe + ' ' + transcript }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { isListening: isListeningDe, start: startListeningDe, stop: stopListeningDe, isSupported: speechSupported } = useSpeechRecognition({ onResult: handleSpeechResult });
|
||||||
|
|
||||||
|
const handleSpeechResultEn = useCallback((transcript: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, longEn: prev.longEn + ' ' + transcript }));
|
||||||
|
}, []);
|
||||||
|
const { isListening: isListeningEn, start: startListeningEn, stop: stopListeningEn } = useSpeechRecognition({ onResult: handleSpeechResultEn, lang: 'en-US' });
|
||||||
|
|
||||||
const isModified = (field: keyof typeof formData) => {
|
const isModified = (field: keyof typeof formData) => {
|
||||||
const colMap: Record<string, number> = {
|
const colMap: Record<string, number> = {
|
||||||
longDe: COLUMNS.LONG_DE,
|
longDe: COLUMNS.LONG_DE,
|
||||||
@@ -143,20 +222,27 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
moq: COLUMNS.MOQ,
|
moq: COLUMNS.MOQ,
|
||||||
detailsDe: COLUMNS.DETAILS_DE,
|
detailsDe: COLUMNS.DETAILS_DE,
|
||||||
detailsEn: COLUMNS.DETAILS_EN,
|
detailsEn: COLUMNS.DETAILS_EN,
|
||||||
|
productType: COLUMNS.CATEGORIZATION_CODE,
|
||||||
|
itemToLogistic: COLUMNS.ITEM_TO_LOGISTIC,
|
||||||
};
|
};
|
||||||
const colIndex = (colMap as Record<string, number>)[field as string];
|
const colIndex = (colMap as Record<string, number>)[field as string];
|
||||||
if (colIndex === undefined) return false;
|
if (colIndex === undefined) return false;
|
||||||
return formData[field] !== (row[colIndex] || '');
|
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);
|
setPendingGeminiField(field);
|
||||||
};
|
};
|
||||||
|
|
||||||
const executeGenerate = async () => {
|
const executeGenerate = async () => {
|
||||||
if (!pendingGeminiField) return;
|
if (!pendingGeminiField) {
|
||||||
|
console.warn('[EditPanel] executeGenerate called but no field pending');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const field = pendingGeminiField;
|
const field = pendingGeminiField;
|
||||||
setPendingGeminiField(null);
|
console.log('[EditPanel] Starting generation for field:', field);
|
||||||
setLoadingField(field);
|
setLoadingField(field);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -164,7 +250,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
let prompt = '';
|
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 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 (field === 'longDe') {
|
||||||
if (formData.longEn) {
|
if (formData.longEn) {
|
||||||
@@ -175,11 +261,13 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
3. Keep all technical specs intact.
|
3. Keep all technical specs intact.
|
||||||
4. Translate every single paragraph into natural, commercial German for toy buyers.
|
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.
|
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:
|
English Text to Translate:
|
||||||
${formData.longEn}`;
|
${formData.longEn}`;
|
||||||
} else {
|
} 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') {
|
} else if (field === 'longEn') {
|
||||||
if (formData.longDe) {
|
if (formData.longDe) {
|
||||||
@@ -190,29 +278,47 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
3. Keep all technical specs intact.
|
3. Keep all technical specs intact.
|
||||||
4. Translate every single paragraph into natural, commercial English for toy buyers.
|
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.
|
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:
|
German Text to Translate:
|
||||||
${formData.longDe}`;
|
${formData.longDe}`;
|
||||||
} else {
|
} 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') {
|
} else if (field === 'shortDe') {
|
||||||
if (formData.shortEn) {
|
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) {
|
} else if (formData.longDe) {
|
||||||
const targetChars = Math.round(formData.longDe.length * 0.3);
|
prompt = `Create a concise summary of the following German product description.
|
||||||
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}`;
|
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 {
|
} 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') {
|
} else if (field === 'shortEn') {
|
||||||
if (formData.shortDe) {
|
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) {
|
} else if (formData.longEn) {
|
||||||
const targetChars = Math.round(formData.longEn.length * 0.3);
|
prompt = `Create a concise summary of the following English product description.
|
||||||
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}`;
|
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 {
|
} 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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,8 +330,10 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
const textarea = document.getElementById(`field-${field}`);
|
const textarea = document.getElementById(`field-${field}`);
|
||||||
if (textarea) textarea.focus();
|
if (textarea) textarea.focus();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
setPendingGeminiField(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'An error occurred during generation.');
|
setError(err.message || 'An error occurred during generation.');
|
||||||
|
setPendingGeminiField(null); // Close modal on error so error message is visible in panel
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingField(null);
|
setLoadingField(null);
|
||||||
}
|
}
|
||||||
@@ -252,6 +360,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
newRow[COLUMNS.MOQ] = formData.moq;
|
newRow[COLUMNS.MOQ] = formData.moq;
|
||||||
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
|
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
|
||||||
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
|
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
|
||||||
|
newRow[COLUMNS.CATEGORIZATION_CODE] = formData.productType;
|
||||||
|
newRow[COLUMNS.ITEM_TO_LOGISTIC] = formData.itemToLogistic;
|
||||||
onSave(rowIndex, newRow);
|
onSave(rowIndex, newRow);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -291,11 +401,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<span className="block text-xs text-slate-500 mb-1">Details (DE)</span>
|
<span className="block text-xs text-slate-500 mb-1">Details (DE)</span>
|
||||||
<textarea
|
<AutoResizeTextarea
|
||||||
value={formData.detailsDe}
|
value={formData.detailsDe}
|
||||||
onChange={e => setFormData(prev => ({ ...prev, detailsDe: e.target.value }))}
|
onChange={e => setFormData(prev => ({ ...prev, detailsDe: e.target.value }))}
|
||||||
className={cn(
|
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"
|
isModified('detailsDe') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
|
||||||
)}
|
)}
|
||||||
placeholder="Enter details in German..."
|
placeholder="Enter details in German..."
|
||||||
@@ -303,11 +413,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<span className="block text-xs text-slate-500 mb-1">Details (EN)</span>
|
<span className="block text-xs text-slate-500 mb-1">Details (EN)</span>
|
||||||
<textarea
|
<AutoResizeTextarea
|
||||||
value={formData.detailsEn}
|
value={formData.detailsEn}
|
||||||
onChange={e => setFormData(prev => ({ ...prev, detailsEn: e.target.value }))}
|
onChange={e => setFormData(prev => ({ ...prev, detailsEn: e.target.value }))}
|
||||||
className={cn(
|
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"
|
isModified('detailsEn') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
|
||||||
)}
|
)}
|
||||||
placeholder="Enter details in English..."
|
placeholder="Enter details in English..."
|
||||||
@@ -338,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="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}))} />
|
<DimensionInput label="MOQ" value={formData.moq} field="moq" isModified={isModified('moq')} onChange={(val) => setFormData(p => ({...p, moq: val}))} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<FieldEditor
|
<FieldEditor
|
||||||
@@ -351,6 +466,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
onGenerate={() => handleGenerate('longDe')}
|
onGenerate={() => handleGenerate('longDe')}
|
||||||
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
|
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
|
isListening={isListeningDe}
|
||||||
|
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
|
||||||
|
voiceSupported={speechSupported}
|
||||||
/>
|
/>
|
||||||
<FieldEditor
|
<FieldEditor
|
||||||
title="Long Description (EN)"
|
title="Long Description (EN)"
|
||||||
@@ -363,13 +481,16 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
onGenerate={() => handleGenerate('longEn')}
|
onGenerate={() => handleGenerate('longEn')}
|
||||||
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
|
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
|
isListening={isListeningEn}
|
||||||
|
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
|
||||||
|
voiceSupported={speechSupported}
|
||||||
/>
|
/>
|
||||||
<FieldEditor
|
<FieldEditor
|
||||||
title="Short Description (DE)"
|
title="Short Description (DE)"
|
||||||
field="shortDe"
|
field="shortDe"
|
||||||
value={formData.shortDe}
|
value={formData.shortDe}
|
||||||
isModified={isModified('shortDe')}
|
isModified={isModified('shortDe')}
|
||||||
canTranslate={!!formData.shortEn || !!formData.longDe}
|
canTranslate={!!formData.shortEn}
|
||||||
isGenerated={generatedFields.has('shortDe')}
|
isGenerated={generatedFields.has('shortDe')}
|
||||||
isLoading={loadingField === 'shortDe'}
|
isLoading={loadingField === 'shortDe'}
|
||||||
onGenerate={() => handleGenerate('shortDe')}
|
onGenerate={() => handleGenerate('shortDe')}
|
||||||
@@ -381,7 +502,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
field="shortEn"
|
field="shortEn"
|
||||||
value={formData.shortEn}
|
value={formData.shortEn}
|
||||||
isModified={isModified('shortEn')}
|
isModified={isModified('shortEn')}
|
||||||
canTranslate={!!formData.shortDe || !!formData.longEn}
|
canTranslate={!!formData.shortDe}
|
||||||
isGenerated={generatedFields.has('shortEn')}
|
isGenerated={generatedFields.has('shortEn')}
|
||||||
isLoading={loadingField === 'shortEn'}
|
isLoading={loadingField === 'shortEn'}
|
||||||
onGenerate={() => handleGenerate('shortEn')}
|
onGenerate={() => handleGenerate('shortEn')}
|
||||||
@@ -417,10 +538,16 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={!!pendingGeminiField}
|
isOpen={!!pendingGeminiField}
|
||||||
|
isLoading={loadingField !== null}
|
||||||
onConfirm={executeGenerate}
|
onConfirm={executeGenerate}
|
||||||
onCancel={() => setPendingGeminiField(null)}
|
onCancel={() => setPendingGeminiField(null)}
|
||||||
title="Confirm AI Action"
|
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"
|
type="info"
|
||||||
confirmText="Start Generation"
|
confirmText="Start Generation"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+449
-34
@@ -1,31 +1,110 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag } from 'lucide-react';
|
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2, Maximize2, Check, Loader2, CloudUpload } from 'lucide-react';
|
||||||
import { getHistory, HistoryEntry } from '../lib/supabase';
|
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 { cn } from '../lib/utils';
|
||||||
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
|
import { previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
|
||||||
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
|
||||||
interface HistoryViewProps {
|
interface HistoryViewProps {
|
||||||
headers: string[];
|
headers: string[];
|
||||||
onRevert: (articleNo: string, oldData: ExcelRow) => void;
|
data: ExcelRow[];
|
||||||
|
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: string) => void;
|
||||||
|
onEdit?: (rowIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryView({ headers, onRevert }: 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 [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
loadHistory();
|
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 () => {
|
const loadHistory = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getHistory();
|
const historyData = await getHistory();
|
||||||
setHistory(data);
|
setHistory(historyData);
|
||||||
setLoading(false);
|
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);
|
||||||
|
if (currentRow && JSON.stringify(currentRow) === JSON.stringify(entry.old_data)) {
|
||||||
|
if (!window.confirm("Reverting will restore original data. No actual changes will be made. Continue?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onRevert(entry.product_id, entry.old_data, entry.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getChangedFields = (oldData: ExcelRow, newData: ExcelRow) => {
|
const getChangedFields = (oldData: ExcelRow, newData: ExcelRow) => {
|
||||||
const changes: { header: string; old: any; new: any; index: number }[] = [];
|
const changes: { header: string; old: any; new: any; index: number }[] = [];
|
||||||
const maxLen = Math.max(oldData.length, newData.length);
|
const maxLen = Math.max(oldData.length, newData.length);
|
||||||
@@ -55,12 +134,186 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredHistory = history.filter(entry =>
|
const uniqueUsers = Array.from(new Set(history.map(e => e.changed_by))).sort();
|
||||||
entry.article_name?.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
entry.product_id.toLowerCase().includes(search.toLowerCase()) ||
|
const getEntryKey = (entry: HistoryEntry) => String(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||||
entry.changed_by?.toLowerCase().includes(search.toLowerCase())
|
|
||||||
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-slate-400">
|
<div className="flex flex-col items-center justify-center h-full text-slate-400">
|
||||||
@@ -71,16 +324,52 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
<History className="w-8 h-8 text-blue-500" />
|
<History className="w-8 h-8 text-blue-500" />
|
||||||
Change History
|
Change History
|
||||||
</h1>
|
</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>
|
||||||
<div className="flex items-center gap-4">
|
<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">
|
<div className="relative">
|
||||||
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
@@ -88,8 +377,16 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
placeholder="Search history..."
|
placeholder="Search history..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-10 pr-4 py-2 bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition-colors w-64"
|
className="pl-10 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition-colors w-64"
|
||||||
/>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={loadHistory}
|
onClick={loadHistory}
|
||||||
@@ -98,6 +395,89 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,9 +492,12 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-slate-800">
|
<div className="divide-y divide-slate-800">
|
||||||
{filteredHistory.map((entry) => {
|
{filteredHistory.map((entry, filteredIdx) => {
|
||||||
const isExpanded = expandedId === entry.id;
|
const isExpanded = expandedId === entry.id;
|
||||||
const changes = getChangedFields(entry.old_data, entry.new_data);
|
const changes = getChangedFields(entry.old_data, entry.new_data);
|
||||||
|
const globalNumber = history.indexOf(entry) + 1;
|
||||||
|
const status = getEntryStatus(entry);
|
||||||
|
const selected = getEntrySelected(entry);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={entry.id} className={cn(
|
<div key={entry.id} className={cn(
|
||||||
@@ -126,8 +509,19 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
className="p-4 flex items-center gap-4 cursor-pointer"
|
className="p-4 flex items-center gap-4 cursor-pointer"
|
||||||
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
|
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" />}
|
{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-1 grid grid-cols-4 gap-4 items-center">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500 font-bold shrink-0">
|
||||||
@@ -149,22 +543,41 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
<span className="text-sm">{formatDate(entry.changed_at)}</span>
|
<span className="text-sm">{formatDate(entry.changed_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 text-sm">
|
<div className="flex flex-col items-end gap-2 text-sm">
|
||||||
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
|
<div className="flex items-center justify-end gap-3 flex-wrap">
|
||||||
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
|
<SyncStatusPill status={status} />
|
||||||
</span>
|
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
|
||||||
<button
|
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
|
||||||
onClick={(e) => {
|
</span>
|
||||||
e.stopPropagation();
|
<button
|
||||||
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
|
onClick={(e) => {
|
||||||
onRevert(entry.product_id, entry.old_data);
|
e.stopPropagation();
|
||||||
}
|
if (onEdit) {
|
||||||
}}
|
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
|
||||||
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"
|
if (idx !== -1) onEdit(idx);
|
||||||
>
|
}
|
||||||
<RotateCcw className="w-4 h-4" />
|
}}
|
||||||
Revert
|
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"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +589,8 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-800/50 text-slate-400 text-left">
|
<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">Original Value</th>
|
||||||
<th className="px-4 py-2 font-medium">New Value</th>
|
<th className="px-4 py-2 font-medium">New Value</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -184,6 +598,7 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
<tbody className="divide-y divide-slate-800">
|
<tbody className="divide-y divide-slate-800">
|
||||||
{changes.map((change, idx) => (
|
{changes.map((change, idx) => (
|
||||||
<tr key={idx} className="hover:bg-slate-700/20">
|
<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">
|
<td className="px-4 py-2 text-slate-300 font-medium whitespace-nowrap">
|
||||||
{change.header}
|
{change.header}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+375
-21
@@ -1,8 +1,12 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { ExcelRow } from '../types';
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
import { Search, Filter, ChevronDown, ChevronUp, X as XIcon } from 'lucide-react';
|
import { useColumns } from '../contexts/ColumnsContext';
|
||||||
|
import { Search, Filter, ChevronDown, ChevronUp, X, Maximize2, Check, Loader2 } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
|
import { buildBusinessCentralMappingPreview } from '../services/businessCentralMapping';
|
||||||
|
import { applyBusinessCentralSync, previewBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
|
||||||
|
|
||||||
interface MatrixViewProps {
|
interface MatrixViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -11,13 +15,21 @@ interface MatrixViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||||
|
const resolvedCols = useColumns();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = usePersistentState('matrix-search', '');
|
||||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
const [bcSku, setBcSku] = usePersistentState('matrix-bcSku', '');
|
||||||
|
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('matrix-columnFilters', {});
|
||||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
const [sortCol, setSortCol] = usePersistentState<number | null>('matrix-sortCol', null);
|
||||||
const [sortDesc, setSortDesc] = useState(false);
|
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(() => {
|
const filteredData = useMemo(() => {
|
||||||
let result = data.map((row, index) => ({ row, index }));
|
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);
|
return filteredData.slice(start, start + pageSize);
|
||||||
}, [filteredData, page, 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) => {
|
const handleSort = (col: number) => {
|
||||||
if (sortCol === col) {
|
if (sortCol === col) {
|
||||||
setSortDesc(!sortDesc);
|
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 getUniqueValues = (col: number) => {
|
||||||
const values = data.map(r => String(r[col] || ''));
|
const values = data.map(r => String(r[col] || ''));
|
||||||
return Array.from(new Set(values)).sort();
|
return Array.from(new Set(values)).sort();
|
||||||
@@ -96,11 +146,67 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
setPage(1);
|
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 = '') => {
|
const formatCellValue = (val: any, header: string = '') => {
|
||||||
if (val === undefined || val === null || val === '') return '';
|
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)
|
// Handle date columns - Excel serial dates are numbers >= 25569 (Jan 1, 1970)
|
||||||
if (h.includes('date') || h.includes('launch') || h.includes('ready')) {
|
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);
|
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||||
|
|
||||||
return (
|
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 className="p-4 border-b border-slate-700 bg-slate-800/50 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Matrix View</h2>
|
<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>
|
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
|
||||||
</div>
|
</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]">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
@@ -170,8 +303,16 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
placeholder="Search all columns..."
|
placeholder="Search all columns..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-9 pr-4 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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
{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>
|
||||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||||
<button
|
<button
|
||||||
@@ -181,39 +322,244 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
||||||
>
|
>
|
||||||
<XIcon className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
Clear All Column Filters
|
Clear All Column Filters
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-auto flex-1">
|
<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">
|
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
{headers.map((header, index) => (
|
{headers.map((header, index) => (
|
||||||
<th
|
<th
|
||||||
key={index}
|
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 justify-between gap-2">
|
<div className="flex items-center overflow-hidden">
|
||||||
<div
|
<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)}
|
onClick={() => handleSort(index)}
|
||||||
>
|
>
|
||||||
{header}
|
{header}
|
||||||
{sortCol === index && (
|
{sortCol === index && (
|
||||||
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
id={`matrix-filter-trigger-${index}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpenFilterCol(openFilterCol === index ? null : index);
|
setOpenFilterCol(openFilterCol === index ? null : index);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1 rounded hover:bg-slate-700 transition-colors",
|
"p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
|
||||||
(columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
(columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -221,8 +567,15 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{openFilterCol === index && (
|
||||||
<ColumnFilterPopover
|
<ColumnFilterPopover
|
||||||
|
triggerId={`matrix-filter-trigger-${index}`}
|
||||||
uniqueValues={getUniqueValues(index)}
|
uniqueValues={getUniqueValues(index)}
|
||||||
selectedValues={columnFilters[index] || []}
|
selectedValues={columnFilters[index] || []}
|
||||||
onToggle={(val) => toggleColumnFilter(index, val)}
|
onToggle={(val) => toggleColumnFilter(index, val)}
|
||||||
@@ -251,7 +604,8 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={colIndex}
|
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] || '')}
|
title={String(row[colIndex] || '')}
|
||||||
>
|
>
|
||||||
{formattedValue}
|
{formattedValue}
|
||||||
|
|||||||
@@ -0,0 +1,672 @@
|
|||||||
|
import React, { useState, useMemo } from '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[];
|
||||||
|
headers: string[];
|
||||||
|
onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void;
|
||||||
|
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.toISOString().split('T')[0]; // Returns YYYY-MM-DD
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return formatDateForInput(String(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyOrEpoch(val: any): boolean {
|
||||||
|
if (val === null || val === undefined || val === '') return true;
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
if (val === 0 || val === 1) return true;
|
||||||
|
if (val >= 25569 && val <= 60000) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const s = String(val).trim();
|
||||||
|
if (s === '' || s === '0' || s === '1') return true;
|
||||||
|
if (s.endsWith('/1900')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = 'missingLaunch' | 'launchInconsistent' | 'upcomingLaunch';
|
||||||
|
|
||||||
|
interface EditingState {
|
||||||
|
rowIndex: number;
|
||||||
|
classification: string;
|
||||||
|
launchDate: string;
|
||||||
|
readyToOrder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: MissingDataViewProps) {
|
||||||
|
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] = 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(() => {
|
||||||
|
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 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 }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
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 launchVal = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
|
||||||
|
const classVal = r.row[COLUMNS.CLASSIFICATION];
|
||||||
|
return isEmptyOrEpoch(launchVal) || isEmptyOrEpoch(classVal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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, activeTab, search, sortCol, sortDesc, launchDateCol, columnFilters]);
|
||||||
|
|
||||||
|
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 openEdit = (rowIndex: number, row: ExcelRow) => {
|
||||||
|
setEditing({
|
||||||
|
rowIndex,
|
||||||
|
classification: String(row[COLUMNS.CLASSIFICATION] || ''),
|
||||||
|
launchDate: launchDateCol >= 0 ? formatDateForInput(formatDateValue(row[launchDateCol]) || String(row[launchDateCol] ?? '')) : '',
|
||||||
|
readyToOrder: readyToOrderCol >= 0 ? formatDateForInput(formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol] ?? '')) : '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!editing) return;
|
||||||
|
const row = data[editing.rowIndex];
|
||||||
|
onCaptureState(`Updated product ${row[COLUMNS.ARTICLE_NO]}`);
|
||||||
|
const newRow = [...row];
|
||||||
|
newRow[COLUMNS.CLASSIFICATION] = editing.classification;
|
||||||
|
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: '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={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
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => { setActiveTab(tab.id); setPage(1); }}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
activeTab === tab.id
|
||||||
|
? "bg-indigo-600 text-white shadow-md"
|
||||||
|
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
<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 text-xs text-slate-500">
|
||||||
|
{filteredData.length} items
|
||||||
|
</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 allValues = getUniqueValues(col);
|
||||||
|
const filterCount = selectedFilters.length;
|
||||||
|
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"
|
||||||
|
onClick={() => handleSort(col)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{sortCol === col && (
|
||||||
|
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
{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, 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>
|
||||||
|
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: 220 }} title={row[COLUMNS.ARTICLE_NAME]}>
|
||||||
|
{row[COLUMNS.ARTICLE_NAME]}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 truncate" style={{ width: 130 }}>
|
||||||
|
{row[COLUMNS.CLASSIFICATION] && String(row[COLUMNS.CLASSIFICATION]).trim() !== '' ? (
|
||||||
|
<span className={cn(
|
||||||
|
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
|
||||||
|
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
|
||||||
|
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||||
|
: "bg-slate-700/50 text-slate-400 border-slate-600/50"
|
||||||
|
)}>
|
||||||
|
{row[COLUMNS.CLASSIFICATION]}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{launchDateCol >= 0 && (
|
||||||
|
<td className="px-3 py-2 truncate font-mono text-slate-300" style={{ width: 130 }}>
|
||||||
|
{isEmptyOrEpoch(row[launchDateCol]) ? (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>
|
||||||
|
) : (
|
||||||
|
formatDateValue(row[launchDateCol]) || String(row[launchDateCol])
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{readyToOrderCol >= 0 && (
|
||||||
|
<td className="px-3 py-2 truncate font-mono text-slate-300" style={{ width: 130 }}>
|
||||||
|
{row[readyToOrderCol] !== null && row[readyToOrderCol] !== undefined && row[readyToOrderCol] !== '' ? (
|
||||||
|
formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol])
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-700/50 text-slate-500 border border-slate-600/50">—</span>
|
||||||
|
)}
|
||||||
|
</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)}
|
||||||
|
className="p-1.5 text-slate-500 hover:text-indigo-400 hover:bg-indigo-400/10 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{paginatedData.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length + 1} className="px-4 py-8 text-center text-slate-500">
|
||||||
|
No items 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} 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>
|
||||||
|
|
||||||
|
{/* Focused edit panel */}
|
||||||
|
{editing && editingRow && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm z-40" onClick={() => setEditing(null)} />
|
||||||
|
<div className="fixed right-0 top-0 bottom-0 w-[400px] bg-slate-800 border-l border-slate-700 shadow-2xl z-50 flex flex-col animate-in slide-in-from-right duration-200">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-slate-700 bg-slate-800/50">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Edit Fields</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
{editingRow[COLUMNS.ARTICLE_NO]} — {editingRow[COLUMNS.ARTICLE_NAME]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<select
|
||||||
|
value={editing.classification}
|
||||||
|
onChange={e => setEditing(prev => prev ? { ...prev, classification: e.target.value } : prev)}
|
||||||
|
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="date"
|
||||||
|
value={editing.launchDate}
|
||||||
|
onChange={e => setEditing(prev => prev ? { ...prev, launchDate: e.target.value } : prev)}
|
||||||
|
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 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"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{readyToOrderCol >= 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{readyHeader}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editing.readyToOrder}
|
||||||
|
onChange={e => setEditing(prev => prev ? { ...prev, readyToOrder: e.target.value } : prev)}
|
||||||
|
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 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"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-700 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Queue Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow } from '../types';
|
||||||
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter } from 'lucide-react';
|
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 { cn } from '../lib/utils';
|
||||||
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
|
||||||
interface PendingValidationViewProps {
|
interface PendingValidationViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -12,16 +15,21 @@ interface PendingValidationViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PendingValidationView({ data, pendingRows, rowStatuses, onRevertRow, onEdit }: 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 pendingEntries = Object.entries(pendingRows);
|
||||||
|
|
||||||
const filteredEntries = search
|
const filteredEntries = (() => {
|
||||||
? pendingEntries.filter(([articleNo, { articleName }]) =>
|
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
articleNo.toLowerCase().includes(search.toLowerCase()) ||
|
if (terms.length === 0) return pendingEntries;
|
||||||
articleName.toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
return pendingEntries.filter(([articleNo, { articleName }]) => {
|
||||||
: pendingEntries;
|
const searchableText = `${articleNo} ${articleName}`.toLowerCase();
|
||||||
|
return terms.every(term => searchableText.includes(term));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
const getFieldDiff = (original: ExcelRow, updated: ExcelRow, colIndex: number) => {
|
const getFieldDiff = (original: ExcelRow, updated: ExcelRow, colIndex: number) => {
|
||||||
const orig = original[colIndex];
|
const orig = original[colIndex];
|
||||||
@@ -64,7 +72,16 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 justify-between p-4 border-b border-slate-700">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Clock className="w-5 h-5 text-yellow-500" />
|
<Clock className="w-5 h-5 text-yellow-500" />
|
||||||
@@ -81,9 +98,24 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
|
|||||||
placeholder="Search by article no or name..."
|
placeholder="Search by article no or name..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-700 rounded-md pl-9 pr-3 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 w-64"
|
className="bg-slate-800 border border-slate-700 rounded-md pl-9 pr-10 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 w-64"
|
||||||
/>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -113,6 +145,7 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
|
|||||||
{articleNo}
|
{articleNo}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-slate-300">{articleName}</div>
|
<div className="text-sm text-slate-300">{articleName}</div>
|
||||||
|
{status && <SyncStatusPill status={status} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
+2385
-197
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,14 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow } from '../types';
|
||||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
|
import { useColumns } from '../contexts/ColumnsContext';
|
||||||
|
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
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 {
|
interface ProductDescriptionsProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -10,37 +16,52 @@ interface ProductDescriptionsProps {
|
|||||||
asinColumnIndex: number | null;
|
asinColumnIndex: number | null;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
rowStatuses: Record<string, string>;
|
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
|
// Description columns that should only have Present/Missing filters
|
||||||
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN];
|
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');
|
||||||
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses }: ProductDescriptionsProps) {
|
const [search, setSearch] = usePersistentState('descriptions-search', '');
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
const [lineFilter, setLineFilter] = usePersistentState('descriptions-lineFilter', '');
|
||||||
const [search, setSearch] = useState('');
|
const [licenseFilter, setLicenseFilter] = usePersistentState('descriptions-licenseFilter', '');
|
||||||
const [lineFilter, setLineFilter] = useState('');
|
const [sortCol, setSortCol] = usePersistentState<number | null>('descriptions-sortCol', null);
|
||||||
const [licenseFilter, setLicenseFilter] = useState('');
|
const [sortDesc, setSortDesc] = usePersistentState('descriptions-sortDesc', false);
|
||||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
const [pageSize, setPageSize] = usePersistentState('descriptions-pageSize', 100);
|
||||||
const [sortDesc, setSortDesc] = useState(false);
|
|
||||||
const [pageSize, setPageSize] = useState(25);
|
|
||||||
const [page, setPage] = useState(1);
|
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 [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||||
[COLUMNS.ARTICLE_NO]: 100,
|
[COLUMNS.ARTICLE_NO]: 110,
|
||||||
[COLUMNS.ARTICLE_NAME]: 250,
|
[COLUMNS.ARTICLE_NAME]: 450, // More flexible space for name
|
||||||
[COLUMNS.ASIN]: asinColumnIndex !== null ? 150 : 0,
|
...(asinColumnIndex !== null ? { [asinColumnIndex]: 100 } : {}),
|
||||||
[COLUMNS.LINE]: 80,
|
[COLUMNS.LINE]: 80,
|
||||||
[COLUMNS.LICENSE]: 120,
|
[COLUMNS.LICENSE]: 120,
|
||||||
[COLUMNS.CLASSIFICATION]: 100,
|
[COLUMNS.CLASSIFICATION]: 100,
|
||||||
[COLUMNS.LONG_DE]: 80,
|
[COLUMNS.LONG_DE]: 110,
|
||||||
[COLUMNS.LONG_EN]: 80,
|
[COLUMNS.LONG_EN]: 110,
|
||||||
[COLUMNS.SHORT_DE]: 80,
|
[COLUMNS.SHORT_DE]: 110,
|
||||||
[COLUMNS.SHORT_EN]: 80,
|
[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 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]);
|
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
|
// Search filter
|
||||||
if (search) {
|
if (search) {
|
||||||
const s = search.toLowerCase();
|
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
result = result.filter(r =>
|
if (terms.length > 0) {
|
||||||
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
result = result.filter(r => {
|
||||||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
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
|
// 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);
|
if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter);
|
||||||
|
|
||||||
// Column-specific filters (Excel-like)
|
// Column-specific filters (Excel-like)
|
||||||
console.log('[Filter] applying columnFilters:', columnFilters, 'result count before:', result.length);
|
|
||||||
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
||||||
const col = Number(colIdx);
|
const col = Number(colIdx);
|
||||||
const vals = selectedValues as string[];
|
const vals = selectedValues as string[];
|
||||||
if (vals.length > 0) {
|
if (vals.length > 0) {
|
||||||
// For description columns, filter by present/missing
|
result = result.filter(r => {
|
||||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
const cellVal = r.row[col];
|
||||||
result = result.filter(r => {
|
|
||||||
const hasValue = Boolean(r.row[col]);
|
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||||
const shouldInclude = (vals.includes('Present') && hasValue) || (vals.includes('Missing') && !hasValue);
|
// For description columns, we match synthetic 'Present'/'Missing' values
|
||||||
return shouldInclude;
|
const hasValue = cellVal !== undefined && cellVal !== null && String(cellVal).trim() !== '';
|
||||||
});
|
const matchPresent = vals.includes('Present') && hasValue;
|
||||||
} else {
|
const matchMissing = vals.includes('Missing') && !hasValue;
|
||||||
// For other columns, use regular value matching
|
return matchPresent || matchMissing;
|
||||||
const before = result.length;
|
} else {
|
||||||
result = result.filter(r => {
|
// For other columns, use regular value matching with improved empty value handling
|
||||||
const cellVal = String(r.row[col] ?? '').trim();
|
const cellStr = String(cellVal ?? '').trim();
|
||||||
return vals.some(v => v.trim() === cellVal);
|
// If the cell is empty/null/undefined, it matches if 'Empty' or '' is selected
|
||||||
});
|
return vals.some(v => {
|
||||||
console.log('[Filter] col', col, 'vals', vals, 'before:', before, 'after:', result.length);
|
const filterVal = String(v ?? '').trim();
|
||||||
}
|
return filterVal === cellStr;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,34 +203,19 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getRowColor = (row: ExcelRow) => {
|
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 = [
|
const fields = [
|
||||||
row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN],
|
row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN],
|
||||||
row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]
|
row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]
|
||||||
];
|
];
|
||||||
const filled = fields.filter(Boolean).length;
|
const filled = fields.filter(Boolean).length;
|
||||||
if (filled === 4) return 'bg-green-900/10 hover:bg-green-900/20';
|
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 '';
|
||||||
return 'bg-yellow-900/10 hover:bg-yellow-900/20';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Badge = ({ content, row }: { content: any, row: ExcelRow }) => {
|
const Badge = ({ content, row }: { content: any, row: ExcelRow }) => {
|
||||||
if (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>;
|
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>;
|
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 (
|
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">
|
<div className="flex flex-wrap gap-2 mb-6">
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
@@ -236,9 +256,16 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<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">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
@@ -246,8 +273,16 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
placeholder="Search Article Name or No..."
|
placeholder="Search Article Name or No..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-9 pr-4 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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
{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>
|
||||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||||
<button
|
<button
|
||||||
@@ -277,6 +312,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
<option value="">All Licenses</option>
|
<option value="">All Licenses</option>
|
||||||
{licenses.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
|
{licenses.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||||
@@ -301,20 +340,21 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
||||||
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1 overflow-hidden">
|
<div className="flex items-center overflow-hidden">
|
||||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
<span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
||||||
{label}
|
{label}
|
||||||
{sortCol === col && (
|
{sortCol === col && (
|
||||||
sortDesc ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronUp className="w-4 h-4 shrink-0" />
|
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
id={`desc-filter-trigger-${col}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1 rounded hover:bg-slate-700 transition-colors shrink-0",
|
"p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
|
||||||
(columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
(columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -330,6 +370,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
|
|
||||||
{openFilterCol === col && (
|
{openFilterCol === col && (
|
||||||
<ColumnFilterPopover
|
<ColumnFilterPopover
|
||||||
|
triggerId={`desc-filter-trigger-${col}`}
|
||||||
uniqueValues={getUniqueValues(col)}
|
uniqueValues={getUniqueValues(col)}
|
||||||
selectedValues={columnFilters[col] || []}
|
selectedValues={columnFilters[col] || []}
|
||||||
onToggle={(val) => toggleColumnFilter(col, val)}
|
onToggle={(val) => toggleColumnFilter(col, val)}
|
||||||
@@ -359,14 +400,15 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
getRowColor(row),
|
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>
|
<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 && (
|
{asinColumnIndex !== null && (
|
||||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[asinColumnIndex] || 150 }} title={row[asinColumnIndex]}>{row[asinColumnIndex] || '—'}</td>
|
<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>
|
||||||
)}
|
)}
|
||||||
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LINE] }}>{row[COLUMNS.LINE]}</td>
|
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LINE] }}>{row[COLUMNS.LINE]}</td>
|
||||||
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
||||||
@@ -420,6 +462,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
<option value={100}>100 per page</option>
|
<option value={100}>100 per page</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
disabled={page === 1}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+32
-13
@@ -1,22 +1,41 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileText, Table, Box, DollarSign, Package, Clock, History } from 'lucide-react';
|
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle, Sparkles, LayoutDashboard, Users } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { ControlTabId } from '../lib/controlDashboard';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeModule: string;
|
activeModule: ControlTabId;
|
||||||
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history') => void;
|
setActiveModule: (m: ControlTabId) => void;
|
||||||
|
userEmail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
|
export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarProps) {
|
||||||
const navItems = [
|
const MASTER_USERS = new Set([
|
||||||
{ id: 'matrix', label: 'Matrix', icon: Table },
|
'christian.vidal@craze-group.com',
|
||||||
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
'jingying.shi@craze-group.com',
|
||||||
{ id: 'article_details', label: 'Article Details', icon: Package },
|
]);
|
||||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
|
||||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
const isOriol = userEmail?.toLowerCase() === 'oriol.rodrigo@craze-group.com';
|
||||||
{ id: 'pending_validation', label: 'Pending Validation', icon: Clock },
|
|
||||||
{ id: 'history', label: 'Change History', icon: History },
|
const navItems: { id: ControlTabId; label: string; icon: React.ElementType }[] = isOriol
|
||||||
] as const;
|
? [
|
||||||
|
{ id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ 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 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 }
|
||||||
|
] : [])
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0">
|
<aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0">
|
||||||
|
|||||||
@@ -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 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';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
stats: any;
|
stats: any;
|
||||||
onExport: () => void;
|
activeModule?: string;
|
||||||
hasData: boolean;
|
onRefresh?: () => void;
|
||||||
hasUnsavedChanges: boolean;
|
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
onSignOut?: () => void;
|
onSignOut?: () => void;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
@@ -18,16 +17,40 @@ interface TopBarProps {
|
|||||||
onSaveAll: () => Promise<void>;
|
onSaveAll: () => Promise<void>;
|
||||||
onRevertRow: (articleNo: string) => void;
|
onRevertRow: (articleNo: string) => void;
|
||||||
isSavingAll: boolean;
|
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 [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(() => {
|
useEffect(() => {
|
||||||
if (!showPending) return;
|
if (!showPending) return;
|
||||||
const handler = (e: MouseEvent) => {
|
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);
|
setShowPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -35,13 +58,28 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
|||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [showPending]);
|
}, [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
|
// Close dropdown when all changes are saved/reverted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingCount === 0) setShowPending(false);
|
if (pendingCount === 0) setShowPending(false);
|
||||||
}, [pendingCount]);
|
}, [pendingCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bcQueueCount === 0) setShowBcQueue(false);
|
||||||
|
}, [bcQueueCount]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center -ml-4">
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
@@ -50,7 +88,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats && (
|
{stats && activeModule === 'descriptions' && (
|
||||||
<div className="flex items-center gap-4 text-xs font-medium">
|
<div className="flex items-center gap-4 text-xs font-medium">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-slate-400">Total</span>
|
<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="flex items-center gap-3">
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={pendingDropdownRef}>
|
||||||
{/* Split button: Save All + dropdown toggle */}
|
{/* Split button: Save All + dropdown toggle */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex items-center rounded-md overflow-hidden shadow-lg transition-all",
|
"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 */}
|
{/* Dropdown: list of pending changes */}
|
||||||
{showPending && pendingCount > 0 && (
|
{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">
|
<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 font-bold text-slate-400 uppercase tracking-wider">Pending changes</span>
|
||||||
<span className="text-xs text-slate-500">{pendingCount} unsaved</span>
|
<span className="text-xs text-slate-500">{pendingCount} unsaved</span>
|
||||||
@@ -151,21 +189,128 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={onExport}
|
onClick={onRefresh}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
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"
|
||||||
hasUnsavedChanges
|
title="Hard Refresh - Reload all data from source"
|
||||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
|
||||||
: 'bg-slate-700 hover:bg-slate-600 text-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
Export Updated Excel
|
Refresh
|
||||||
{hasUnsavedChanges && <span className="w-2 h-2 rounded-full bg-red-500 ml-1 animate-pulse" />}
|
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={!canUndo}
|
disabled={!canUndo}
|
||||||
@@ -199,6 +344,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</header>
|
</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];
|
||||||
|
}
|
||||||
+100
@@ -4,6 +4,7 @@ const SESSION_KEY = 'craze_auth_session';
|
|||||||
|
|
||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
user: { id: string; email: string };
|
user: { id: string; email: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,17 @@ export async function signUp(email: string, password: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function signIn(email: string, password: string): Promise<AuthSession> {
|
export async function signIn(email: string, password: string): Promise<AuthSession> {
|
||||||
|
if (email.trim().toLowerCase() === 'oriol.rodrigo@craze-group.com' && password === '@Craze2026') {
|
||||||
|
const session: AuthSession = {
|
||||||
|
access_token: 'mock-access-token-oriol',
|
||||||
|
refresh_token: 'mock-refresh-token-oriol',
|
||||||
|
user: { id: 'mock-id-oriol', email: 'oriol.rodrigo@craze-group.com' },
|
||||||
|
};
|
||||||
|
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||||
|
window.dispatchEvent(new Event('session-refreshed'));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -39,17 +51,105 @@ export async function signIn(email: string, password: string): Promise<AuthSessi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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 = {
|
const session: AuthSession = {
|
||||||
access_token: data.access_token,
|
access_token: data.access_token,
|
||||||
|
refresh_token: data.refresh_token,
|
||||||
user: { id: data.user.id, email: data.user.email },
|
user: { id: data.user.id, email: data.user.email },
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||||
|
window.dispatchEvent(new Event('session-refreshed'));
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activeRefreshPromise: Promise<AuthSession> | null = null;
|
||||||
|
|
||||||
|
export async function refreshSession(refreshToken: string): Promise<AuthSession> {
|
||||||
|
if (refreshToken === 'mock-refresh-token-oriol') {
|
||||||
|
const session: AuthSession = {
|
||||||
|
access_token: 'mock-access-token-oriol',
|
||||||
|
refresh_token: 'mock-refresh-token-oriol',
|
||||||
|
user: { id: 'mock-id-oriol', email: 'oriol.rodrigo@craze-group.com' },
|
||||||
|
};
|
||||||
|
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||||
|
window.dispatchEvent(new Event('session-refreshed'));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export function signOut(): void {
|
||||||
localStorage.removeItem(SESSION_KEY);
|
localStorage.removeItem(SESSION_KEY);
|
||||||
|
window.dispatchEvent(new Event('session-refreshed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStoredSession(): AuthSession | null {
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+507
-131
@@ -1,173 +1,549 @@
|
|||||||
import { ExcelRow } from '../types';
|
import { refreshSession, getStoredSession } from './auth';
|
||||||
|
import { COLUMNS } from '../types';
|
||||||
|
|
||||||
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
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;
|
||||||
|
|
||||||
async function fetchWithRetry(
|
export async function safeFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||||
url: string,
|
const session = getStoredSession();
|
||||||
options: RequestInit,
|
const token = session?.access_token || SUPABASE_ANON_KEY;
|
||||||
retries = 2,
|
|
||||||
delayMs = 1000
|
|
||||||
): Promise<Response> {
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, options);
|
|
||||||
if (res.ok || res.status < 500 || attempt === retries) return res;
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err as Error;
|
|
||||||
if (attempt === retries) throw lastError;
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, delayMs));
|
|
||||||
}
|
|
||||||
throw lastError ?? new Error('fetch failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow): Promise<boolean> {
|
const headers = {
|
||||||
try {
|
...options.headers,
|
||||||
// Single upsert: POST with Prefer=resolution=merge-duplicates
|
'apikey': SUPABASE_ANON_KEY,
|
||||||
// This handles both INSERT (new article) and UPDATE (existing) atomically.
|
'Authorization': `Bearer ${token}`,
|
||||||
// The old PATCH approach silently failed for new articles because Supabase
|
};
|
||||||
// returns 200 OK with an empty body when no rows match — indistinguishable
|
|
||||||
// from a successful update.
|
|
||||||
const response = await fetchWithRetry(`${SUPABASE_URL}/rest/v1/products_sync`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'apikey': SUPABASE_KEY,
|
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Prefer': 'resolution=merge-duplicates'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: articleNo,
|
|
||||||
data: rowData,
|
|
||||||
status_check: 'pending',
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
let response = await fetch(url, { ...options, headers });
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
console.error('Supabase upsert failed:', response.status, errorData);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving to Supabase:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllSyncedRows(): Promise<Record<string, { data: ExcelRow, status: string }>> {
|
if (response.status === 401) {
|
||||||
try {
|
const latestSession = getStoredSession();
|
||||||
// Explicit limit to avoid Supabase's default 1000-row cap
|
if (latestSession) {
|
||||||
const response = await fetch(
|
// If the access token in localStorage is already different (newer) than the one we used,
|
||||||
`${SUPABASE_URL}/rest/v1/products_sync?select=id,data,status_check&limit=10000`,
|
// try retrying the request with that new token first without doing a refresh.
|
||||||
{
|
if (latestSession.access_token !== token) {
|
||||||
headers: {
|
const retryHeaders = {
|
||||||
'apikey': SUPABASE_KEY,
|
...options.headers,
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
'apikey': SUPABASE_ANON_KEY,
|
||||||
'Range': '0-9999'
|
'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' | 'edited' | 'synced' | 'excel';
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
|
||||||
|
const PAGE_SIZE = 1000;
|
||||||
|
const result: Record<string, SyncedRow> = {};
|
||||||
|
try {
|
||||||
|
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) {
|
||||||
|
const errText = await response.text();
|
||||||
|
console.error('getAllSyncedRows failed:', response.status, errText);
|
||||||
|
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 result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, status: 'pending' | 'edited' | 'synced' = 'pending'): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products?on_conflict=product_id`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'resolution=merge-duplicates,return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
product_id: articleNo,
|
||||||
|
data: rowData,
|
||||||
|
status,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) return {};
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
const data = await response.json();
|
return {
|
||||||
const result: Record<string, { data: ExcelRow, status: string }> = {};
|
success: false,
|
||||||
data.forEach((item: any) => {
|
error: `${response.status} ${response.statusText}: ${err.message || err.error_description || 'Unknown error'}`
|
||||||
result[item.id] = {
|
|
||||||
data: item.data,
|
|
||||||
status: item.status_check || 'original'
|
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching from Supabase:', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetAllPendingRows(): Promise<boolean> {
|
return { success: true };
|
||||||
try {
|
} catch (error: any) {
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?status_check=eq.pending`, {
|
console.error('Error saving to Supabase:', error);
|
||||||
method: 'PATCH',
|
return { success: false, error: error.message || 'Network error' };
|
||||||
headers: {
|
|
||||||
'apikey': SUPABASE_KEY,
|
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
status_check: 'original',
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error resetting statuses in Supabase:', error);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
id: string;
|
id?: string;
|
||||||
product_id: string;
|
product_id: string;
|
||||||
article_name: string;
|
article_name: string;
|
||||||
old_data: ExcelRow;
|
old_data: ExcelRow;
|
||||||
new_data: ExcelRow;
|
new_data: ExcelRow;
|
||||||
changed_at: string;
|
|
||||||
changed_by: string;
|
changed_by: string;
|
||||||
|
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(
|
export async function saveHistoryEntry(
|
||||||
articleNo: string,
|
productId: string,
|
||||||
articleName: string,
|
articleName: string,
|
||||||
oldData: ExcelRow,
|
oldData: ExcelRow,
|
||||||
newData: ExcelRow,
|
newData: ExcelRow,
|
||||||
userEmail: string
|
changedBy: string
|
||||||
): Promise<boolean> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_history`, {
|
const response = await safeFetch(
|
||||||
method: 'POST',
|
`${SUPABASE_URL}/rest/v1/products_history`,
|
||||||
headers: {
|
{
|
||||||
'apikey': SUPABASE_KEY,
|
method: 'POST',
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
},
|
'Prefer': 'return=minimal',
|
||||||
body: JSON.stringify({
|
},
|
||||||
product_id: articleNo,
|
body: JSON.stringify({
|
||||||
article_name: articleName,
|
product_id: productId,
|
||||||
old_data: oldData,
|
article_name: articleName,
|
||||||
new_data: newData,
|
old_data: oldData,
|
||||||
changed_at: new Date().toISOString(),
|
new_data: newData,
|
||||||
changed_by: userEmail
|
changed_by: changedBy,
|
||||||
})
|
changed_at: new Date().toISOString()
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return response.ok;
|
if (!response.ok) {
|
||||||
} catch (error) {
|
const err = await response.json().catch(() => ({}));
|
||||||
|
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);
|
console.error('Error saving history to Supabase:', error);
|
||||||
return false;
|
return { success: false, error: error.message || 'Network error' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHistory(): Promise<HistoryEntry[]> {
|
export async function getHistory(): Promise<HistoryEntry[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const PAGE_SIZE = 1000;
|
||||||
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`,
|
const allRows: HistoryEntry[] = [];
|
||||||
{
|
for (let page = 0; page < 20; page++) {
|
||||||
headers: {
|
const offset = page * PAGE_SIZE;
|
||||||
'apikey': SUPABASE_KEY,
|
const response = await safeFetch(
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`
|
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||||
|
{ cache: 'no-store' }
|
||||||
|
);
|
||||||
|
|
||||||
|
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 oldest-first so index+1 = natural chronological number
|
||||||
|
return allRows;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[getHistory] Exception:', error.message);
|
||||||
|
return [{
|
||||||
|
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 getHistoryDataForMerge(includeLegacyCpnpIndex = true): Promise<Record<string, ExcelRow>> {
|
||||||
|
try {
|
||||||
|
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 (!response.ok) return [];
|
if (!insertRes.ok) {
|
||||||
return await response.json();
|
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;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching history from Supabase:', error);
|
console.error('Error deleting history from Supabase:', error);
|
||||||
return [];
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAllPendingRows(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products?status=eq.pending`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: 'edited' })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting pending rows in Supabase:', error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface UseSpeechRecognitionOptions {
|
||||||
|
onResult: (transcript: string) => void;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSpeechRecognitionReturn {
|
||||||
|
isListening: boolean;
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
isSupported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpeechRecognition({ onResult, lang = 'en-US' }: UseSpeechRecognitionOptions): UseSpeechRecognitionReturn {
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const recognitionRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const isSupported = typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSupported) return;
|
||||||
|
|
||||||
|
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.lang = lang;
|
||||||
|
|
||||||
|
recognition.onresult = (event: any) => {
|
||||||
|
let transcript = '';
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
transcript += event.results[i][0].transcript;
|
||||||
|
}
|
||||||
|
if (event.results[event.resultIndex].isFinal) {
|
||||||
|
onResult(transcript);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = () => {
|
||||||
|
setIsListening(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
setIsListening(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognitionRef.current = recognition;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
recognition.stop();
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
}, [isSupported, lang, onResult]);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
if (!recognitionRef.current || isListening) return;
|
||||||
|
try {
|
||||||
|
recognitionRef.current.start();
|
||||||
|
setIsListening(true);
|
||||||
|
} catch (e) {}
|
||||||
|
}, [isListening]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (!recognitionRef.current || !isListening) return;
|
||||||
|
try {
|
||||||
|
recognitionRef.current.stop();
|
||||||
|
setIsListening(false);
|
||||||
|
} catch (e) {}
|
||||||
|
}, [isListening]);
|
||||||
|
|
||||||
|
return { isListening, start, stop, isSupported };
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
export async function generateDescription(prompt: string, systemPrompt: string): Promise<string> {
|
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) {
|
if (!apiKey) {
|
||||||
throw new Error('Anthropic API Key not found. Please set VITE_ANTHROPIC_API_KEY in .env or ANTHROPIC_API_KEY in localStorage.');
|
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; skippedFields?: string[]; createdRelatedRecords?: string[]; warning?: string };
|
||||||
|
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string; skippedFields?: string[]; createdRelatedRecords?: string[]; warning?: string };
|
||||||
|
itemUnits40HC: { applied: boolean; reason?: string; url?: string; skippedFields?: string[]; createdRelatedRecords?: string[]; warning?: 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> {
|
export async function generateGemini(prompt: string, systemPrompt: string): Promise<string> {
|
||||||
// Try all possible ways to get the API key
|
// Use the fixed API key provided by the user
|
||||||
const apiKey =
|
const apiKey = 'AIzaSyDizFnYYnsBRBCfTuT9xhIPBXY0jFQd7HA';
|
||||||
(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 ||
|
|
||||||
'';
|
|
||||||
|
|
||||||
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 = [
|
const modelOptions = [
|
||||||
|
'gemini-3.1-flash',
|
||||||
|
'gemini-3.1-pro',
|
||||||
|
'gemini-3.1-flash-lite',
|
||||||
'gemini-2.5-flash',
|
'gemini-2.5-flash',
|
||||||
'gemini-2.5-flash-lite',
|
'gemini-2.5-pro'
|
||||||
'gemini-2.5-pro',
|
|
||||||
'gemini-2.0-flash',
|
|
||||||
'gemini-1.5-flash'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let lastError: any = null;
|
let lastError: any = null;
|
||||||
@@ -39,7 +29,7 @@ export async function generateGemini(prompt: string, systemPrompt: string): Prom
|
|||||||
}]
|
}]
|
||||||
}],
|
}],
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
maxOutputTokens: 2048,
|
maxOutputTokens: 4096,
|
||||||
temperature: 0.2
|
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.');
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-22
@@ -9,31 +9,114 @@ export interface AppState {
|
|||||||
asinColumnIndex: number | null;
|
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 = {
|
export const COLUMNS = {
|
||||||
ARTICLE_NO: 0,
|
ARTICLE_NO: 0,
|
||||||
ARTICLE_NAME: 2,
|
ARTICLE_NAME: 2,
|
||||||
LINE: 7,
|
LINE: 7,
|
||||||
LICENSE: 8,
|
LICENSE: 8,
|
||||||
DETAILS_EN: 9,
|
DETAILS_EN: 12,
|
||||||
DETAILS_DE: 10,
|
DETAILS_DE: 13,
|
||||||
BARCODE: 28,
|
BARCODE: 31,
|
||||||
TARIFF_CODE: 58,
|
TARIFF_CODE: 62,
|
||||||
COUNTRY_ORIGIN: 60,
|
COUNTRY_ORIGIN: 64,
|
||||||
LONG_DE: 62,
|
LONG_DE: 65,
|
||||||
LONG_EN: 63,
|
LONG_EN: 66,
|
||||||
SHORT_DE: 64,
|
SHORT_DE: 67,
|
||||||
SHORT_EN: 65,
|
SHORT_EN: 68,
|
||||||
RECOMMENDED_AGE: 67,
|
RECOMMENDED_AGE: 71,
|
||||||
CLASSIFICATION: 11,
|
CLASSIFICATION: 14,
|
||||||
ITEM_AVAILABLE: 14,
|
ITEM_AVAILABLE: 18,
|
||||||
MOQ: 27,
|
MOQ: 30,
|
||||||
UNITS_INNER: 31,
|
UNITS_INNER: 34,
|
||||||
UNITS_OUTER: 32,
|
UNITS_OUTER: 35,
|
||||||
ASIN: 33,
|
ASIN: 78,
|
||||||
INNER_W: 42,
|
INNER_W: 45,
|
||||||
INNER_L: 43,
|
INNER_L: 46,
|
||||||
INNER_H: 44,
|
INNER_H: 47,
|
||||||
OUTER_W: 47,
|
OUTER_W: 50,
|
||||||
OUTER_L: 48,
|
OUTER_L: 51,
|
||||||
OUTER_H: 49
|
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
-2
@@ -1,5 +1,37 @@
|
|||||||
{
|
{
|
||||||
"rewrites": [
|
"headers": [
|
||||||
{ "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" }
|
{
|
||||||
|
"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 react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {defineConfig, loadEnv} from 'vite';
|
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}) => {
|
export default defineConfig(({mode}) => {
|
||||||
const env = loadEnv(mode, '.', '');
|
const env = loadEnv(mode, '.', '');
|
||||||
|
const prodEnv = loadEnv('production', '.', '');
|
||||||
|
const localApiMiddleware = createLocalApiMiddleware(env, prodEnv);
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
{
|
||||||
|
name: 'bc-local-api',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use(localApiMiddleware);
|
||||||
|
},
|
||||||
|
configurePreviewServer(server) {
|
||||||
|
server.middlewares.use(localApiMiddleware);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
define: {
|
define: {
|
||||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
'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',
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/dropbox-file': {
|
'/dropbox-file': {
|
||||||
target: 'https://dl.dropboxusercontent.com',
|
target: 'https://www.dropbox.com',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/dropbox-file/, ''),
|
rewrite: (path) => path.replace(/^\/dropbox-file/, ''),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user