mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 17:45:23 +02:00
Compare commits
292
Commits
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
name: find-skills
|
||||||
|
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Find Skills
|
||||||
|
|
||||||
|
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when the user:
|
||||||
|
|
||||||
|
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||||
|
- Says "find a skill for X" or "is there a skill for X"
|
||||||
|
- Asks "can you do X" where X is a specialized capability
|
||||||
|
- Expresses interest in extending agent capabilities
|
||||||
|
- Wants to search for tools, templates, or workflows
|
||||||
|
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||||
|
|
||||||
|
## What is the Skills CLI?
|
||||||
|
|
||||||
|
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||||
|
|
||||||
|
**Key commands:**
|
||||||
|
|
||||||
|
- `npx skills find [query]` - Search for skills interactively or by keyword
|
||||||
|
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
||||||
|
- `npx skills check` - Check for skill updates
|
||||||
|
- `npx skills update` - Update all installed skills
|
||||||
|
|
||||||
|
**Browse skills at:** https://skills.sh/
|
||||||
|
|
||||||
|
## How to Help Users Find Skills
|
||||||
|
|
||||||
|
### Step 1: Understand What They Need
|
||||||
|
|
||||||
|
When a user asks for help with something, identify:
|
||||||
|
|
||||||
|
1. The domain (e.g., React, testing, design, deployment)
|
||||||
|
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||||
|
3. Whether this is a common enough task that a skill likely exists
|
||||||
|
|
||||||
|
### Step 2: Check the Leaderboard First
|
||||||
|
|
||||||
|
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
|
||||||
|
|
||||||
|
For example, top skills for web development include:
|
||||||
|
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
|
||||||
|
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
|
||||||
|
|
||||||
|
### Step 3: Search for Skills
|
||||||
|
|
||||||
|
If the leaderboard doesn't cover the user's need, run the find command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills find [query]
|
||||||
|
```
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||||
|
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||||
|
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||||
|
|
||||||
|
### Step 4: Verify Quality Before Recommending
|
||||||
|
|
||||||
|
**Do not recommend a skill based solely on search results.** Always verify:
|
||||||
|
|
||||||
|
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
|
||||||
|
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
|
||||||
|
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
|
||||||
|
|
||||||
|
### Step 5: Present Options to the User
|
||||||
|
|
||||||
|
When you find relevant skills, present them to the user with:
|
||||||
|
|
||||||
|
1. The skill name and what it does
|
||||||
|
2. The install count and source
|
||||||
|
3. The install command they can run
|
||||||
|
4. A link to learn more at skills.sh
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```
|
||||||
|
I found a skill that might help! The "react-best-practices" skill provides
|
||||||
|
React and Next.js performance optimization guidelines from Vercel Engineering.
|
||||||
|
(185K installs)
|
||||||
|
|
||||||
|
To install it:
|
||||||
|
npx skills add vercel-labs/agent-skills@react-best-practices
|
||||||
|
|
||||||
|
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Offer to Install
|
||||||
|
|
||||||
|
If the user wants to proceed, you can install the skill for them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add <owner/repo@skill> -g -y
|
||||||
|
```
|
||||||
|
|
||||||
|
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
||||||
|
|
||||||
|
## Common Skill Categories
|
||||||
|
|
||||||
|
When searching, consider these common categories:
|
||||||
|
|
||||||
|
| Category | Example Queries |
|
||||||
|
| --------------- | ---------------------------------------- |
|
||||||
|
| Web Development | react, nextjs, typescript, css, tailwind |
|
||||||
|
| Testing | testing, jest, playwright, e2e |
|
||||||
|
| DevOps | deploy, docker, kubernetes, ci-cd |
|
||||||
|
| Documentation | docs, readme, changelog, api-docs |
|
||||||
|
| Code Quality | review, lint, refactor, best-practices |
|
||||||
|
| Design | ui, ux, design-system, accessibility |
|
||||||
|
| Productivity | workflow, automation, git |
|
||||||
|
|
||||||
|
## Tips for Effective Searches
|
||||||
|
|
||||||
|
1. **Use specific keywords**: "react testing" is better than just "testing"
|
||||||
|
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
||||||
|
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
||||||
|
|
||||||
|
## When No Skills Are Found
|
||||||
|
|
||||||
|
If no relevant skills exist:
|
||||||
|
|
||||||
|
1. Acknowledge that no existing skill was found
|
||||||
|
2. Offer to help with the task directly using your general capabilities
|
||||||
|
3. Suggest the user could create their own skill with `npx skills init`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
I searched for skills related to "xyz" but didn't find any matches.
|
||||||
|
I can still help you with this task directly! Would you like me to proceed?
|
||||||
|
|
||||||
|
If this is something you do often, you could create your own skill:
|
||||||
|
npx skills init my-xyz-skill
|
||||||
|
```
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" pre-bash'",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" pre-edit'",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" post-edit'",
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" post-bash'",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" route'",
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-restore'",
|
||||||
|
"timeout": 15000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/auto-memory-hook.mjs\" import'",
|
||||||
|
"timeout": 8000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-end'",
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/auto-memory-hook.mjs\" sync'",
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"matcher": "manual",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" compact-manual'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-end'",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "auto",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" compact-auto'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-end'",
|
||||||
|
"timeout": 6000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" status'",
|
||||||
|
"timeout": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" post-task'",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" notify'",
|
||||||
|
"timeout": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"statusLine": {
|
||||||
|
"type": "command",
|
||||||
|
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/statusline.cjs\"'"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx @claude-flow*)",
|
||||||
|
"Bash(npx claude-flow*)",
|
||||||
|
"Bash(node .claude/*)",
|
||||||
|
"mcp__claude-flow__:*"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Read(./.env)",
|
||||||
|
"Read(./.env.*)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"attribution": {
|
||||||
|
"commit": "Co-Authored-By: claude-flow <ruv@ruv.net>",
|
||||||
|
"pr": "🤖 Generated with [claude-flow](https://github.com/ruvnet/claude-flow)"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
|
||||||
|
"CLAUDE_FLOW_V3_ENABLED": "true",
|
||||||
|
"CLAUDE_FLOW_HOOKS_ENABLED": "true"
|
||||||
|
},
|
||||||
|
"claudeFlow": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"enabled": true,
|
||||||
|
"platform": {
|
||||||
|
"os": "darwin",
|
||||||
|
"arch": "arm64",
|
||||||
|
"shell": "zsh"
|
||||||
|
},
|
||||||
|
"modelPreferences": {
|
||||||
|
"default": "claude-opus-4-6",
|
||||||
|
"routing": "claude-haiku-4-5-20251001"
|
||||||
|
},
|
||||||
|
"agentTeams": {
|
||||||
|
"enabled": true,
|
||||||
|
"teammateMode": "auto",
|
||||||
|
"taskListEnabled": true,
|
||||||
|
"mailboxEnabled": true,
|
||||||
|
"coordination": {
|
||||||
|
"autoAssignOnIdle": true,
|
||||||
|
"trainPatternsOnComplete": true,
|
||||||
|
"notifyLeadOnComplete": true,
|
||||||
|
"sharedMemoryNamespace": "agent-teams"
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"teammateIdle": {
|
||||||
|
"enabled": true,
|
||||||
|
"autoAssign": true,
|
||||||
|
"checkTaskList": true
|
||||||
|
},
|
||||||
|
"taskCompleted": {
|
||||||
|
"enabled": true,
|
||||||
|
"trainPatterns": true,
|
||||||
|
"notifyLead": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"swarm": {
|
||||||
|
"topology": "hierarchical-mesh",
|
||||||
|
"maxAgents": 15
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"backend": "hybrid",
|
||||||
|
"enableHNSW": true,
|
||||||
|
"learningBridge": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"memoryGraph": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"agentScopes": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"neural": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"daemon": {
|
||||||
|
"autoStart": false,
|
||||||
|
"workers": [
|
||||||
|
"map",
|
||||||
|
"audit",
|
||||||
|
"optimize"
|
||||||
|
],
|
||||||
|
"schedules": {
|
||||||
|
"audit": {
|
||||||
|
"interval": "4h",
|
||||||
|
"priority": "critical"
|
||||||
|
},
|
||||||
|
"optimize": {
|
||||||
|
"interval": "2h",
|
||||||
|
"priority": "high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"learning": {
|
||||||
|
"enabled": true,
|
||||||
|
"autoTrain": true,
|
||||||
|
"patterns": [
|
||||||
|
"coordination",
|
||||||
|
"optimization",
|
||||||
|
"prediction"
|
||||||
|
],
|
||||||
|
"retention": {
|
||||||
|
"shortTerm": "24h",
|
||||||
|
"longTerm": "30d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"adr": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"directory": "/docs/adr",
|
||||||
|
"template": "madr"
|
||||||
|
},
|
||||||
|
"ddd": {
|
||||||
|
"trackDomains": true,
|
||||||
|
"validateBoundedContexts": true,
|
||||||
|
"directory": "/docs/ddd"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"autoScan": true,
|
||||||
|
"scanOnEdit": true,
|
||||||
|
"cveCheck": true,
|
||||||
|
"threatModel": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/find-skills
|
||||||
@@ -3,6 +3,22 @@
|
|||||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
# 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=''
|
||||||
|
|
||||||
# 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(git checkout *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(npx *)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(git show *)",
|
||||||
|
"Bash(diff *)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"$version": 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
@@ -15,7 +26,7 @@ npm run clean # Remove dist folder
|
|||||||
npm run lint # TypeScript type check only (tsc --noEmit)
|
npm run lint # TypeScript type check only (tsc --noEmit)
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: This project does not have a separate test framework configured. To add tests, consider installing Vitest or Jest.
|
Note: No test framework configured. To add tests, install Vitest or Jest.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,10 +38,8 @@ Note: This project does not have a separate test framework configured. To add te
|
|||||||
- Keep components focused and modular
|
- Keep components focused and modular
|
||||||
- Use meaningful variable and function names
|
- Use meaningful variable and function names
|
||||||
|
|
||||||
### Imports
|
### Imports (order top to bottom)
|
||||||
|
1. React (`react`)
|
||||||
**Order (top to bottom):**
|
|
||||||
1. React imports (`react`)
|
|
||||||
2. External libraries (`lucide-react`, `xlsx`, etc.)
|
2. External libraries (`lucide-react`, `xlsx`, etc.)
|
||||||
3. Internal components (`./components/...`)
|
3. Internal components (`./components/...`)
|
||||||
4. Internal lib/utils (`./lib/...`)
|
4. Internal lib/utils (`./lib/...`)
|
||||||
@@ -44,19 +53,16 @@ import { cn } from '../lib/utils';
|
|||||||
```
|
```
|
||||||
|
|
||||||
### TypeScript Conventions
|
### TypeScript Conventions
|
||||||
|
|
||||||
- Use explicit types for props and function parameters
|
- Use explicit types for props and function parameters
|
||||||
- Use `any` sparingly; prefer union types or interfaces
|
- Use `any` sparingly; prefer union types or interfaces
|
||||||
- Define column indices in a centralized `COLUMNS` object (see `src/types.ts`)
|
- Define column indices in a centralized `COLUMNS` object (see `src/types.ts`)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Good
|
|
||||||
interface ProductDescriptionsProps {
|
interface ProductDescriptionsProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Good - centralized constants
|
|
||||||
export const COLUMNS = {
|
export const COLUMNS = {
|
||||||
ARTICLE_NO: 0,
|
ARTICLE_NO: 0,
|
||||||
ARTICLE_NAME: 2,
|
ARTICLE_NAME: 2,
|
||||||
@@ -76,119 +82,58 @@ export const COLUMNS = {
|
|||||||
| Types | PascalCase | `TabType`, `SortDirection` |
|
| Types | PascalCase | `TabType`, `SortDirection` |
|
||||||
|
|
||||||
### React Patterns
|
### React Patterns
|
||||||
|
|
||||||
- Destructure props in function signature
|
- Destructure props in function signature
|
||||||
- Use `useMemo` for expensive computations
|
- Use `useMemo` for expensive computations
|
||||||
- Use `useCallback` for event handlers passed to child components
|
- Use `useCallback` for event handlers passed to child components
|
||||||
- Keep `useState` calls at the top of component
|
- Keep `useState` calls at the top of component
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
// expensive computation
|
|
||||||
}, [data, activeTab, search]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- Use TypeScript's type system for runtime safety
|
- Use TypeScript's type system for runtime safety
|
||||||
- Use optional chaining (`?.`) and nullish coalescing (`??`)
|
- Use optional chaining (`?.`) and nullish coalescing (`??`)
|
||||||
- Validate file uploads with proper type checks
|
- Validate file uploads with proper type checks
|
||||||
|
|
||||||
```typescript
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Validate Excel data
|
|
||||||
if (data.length > 0) {
|
|
||||||
const rawHeaders = data[0];
|
|
||||||
const rawRows = data.slice(1);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UI/Styling
|
### UI/Styling
|
||||||
|
|
||||||
- Use Tailwind CSS for all styling
|
- Use Tailwind CSS for all styling
|
||||||
- Use `cn()` utility from `lib/utils` for conditional classes
|
- Use `cn()` utility from `lib/utils` for conditional classes
|
||||||
- Follow existing color scheme (slate, blue, green, red for status)
|
- Follow existing color scheme (slate, blue, green, red for status)
|
||||||
- Use `lucide-react` for icons
|
- Use `lucide-react` for icons
|
||||||
- Keep responsive design in mind
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { cn } from '../lib/utils';
|
<button className={cn("px-4 py-2 rounded-md", isActive ? "bg-blue-600" : "bg-slate-800")}>
|
||||||
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-2 rounded-md text-sm font-medium",
|
|
||||||
isActive
|
|
||||||
? "bg-blue-600 text-white"
|
|
||||||
: "bg-slate-800 text-slate-400"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### File Organization
|
### File Organization
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── components/ # React components
|
├── components/ # React components
|
||||||
│ ├── Sidebar.tsx
|
├── lib/ # Utilities (utils.ts, auth.ts, supabase.ts)
|
||||||
│ ├── MatrixView.tsx
|
├── services/ # External API integrations (gemini.ts, anthropic.ts)
|
||||||
│ └── ...
|
├── types.ts # TypeScript types and constants
|
||||||
├── lib/ # Utilities and helpers
|
├── App.tsx # Main application
|
||||||
│ ├── utils.ts # cn(), helpers
|
└── main.tsx # Entry point
|
||||||
│ ├── auth.ts # Authentication
|
|
||||||
│ └── supabase.ts # Database operations
|
|
||||||
├── services/ # External API integrations
|
|
||||||
│ ├── gemini.ts
|
|
||||||
│ └── anthropic.ts
|
|
||||||
├── types.ts # TypeScript types and constants
|
|
||||||
├── App.tsx # Main application
|
|
||||||
└── main.tsx # Entry point
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Processing
|
### Data Processing
|
||||||
|
- Handle both string and number types when processing Excel data
|
||||||
- When processing Excel data, handle both string and number types
|
|
||||||
- Use centralized column index constants
|
- Use centralized column index constants
|
||||||
- Format numbers consistently (2 decimal places for prices/weights)
|
- Format numbers consistently (2 decimal places for prices/weights)
|
||||||
- Handle Excel date serial numbers properly (convert to readable dates)
|
- Handle Excel date serial numbers properly
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Handle date columns from Excel
|
// Handle date columns from Excel
|
||||||
if (header.includes('date') || header.includes('launch')) {
|
if (typeof val === 'number' && val >= 25569 && val <= 60000) {
|
||||||
if (typeof val === 'number' && val >= 25569 && val <= 60000) {
|
const excelEpoch = new Date(1899, 11, 30);
|
||||||
const excelEpoch = new Date(1899, 11, 30);
|
return new Date(excelEpoch.getTime() + val * 86400000).toLocaleDateString('en-GB');
|
||||||
const date = new Date(excelEpoch.getTime() + val * 86400000);
|
|
||||||
return date.toLocaleDateString('en-GB');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Git Workflow
|
### Git Workflow
|
||||||
|
|
||||||
- Make small, focused commits
|
- Make small, focused commits
|
||||||
- Write clear commit messages describing what changed
|
- Write clear commit messages describing what changed
|
||||||
- Push to main to trigger Vercel deployment automatically
|
- Push to main to trigger Vercel deployment automatically
|
||||||
|
|
||||||
### Running Single Components
|
|
||||||
|
|
||||||
When testing or developing specific features:
|
|
||||||
```bash
|
|
||||||
npm run dev # Start dev server - access at http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
- Use `.env` file for local development
|
- Use `.env` file for local development
|
||||||
- Never commit secrets - use Vercel dashboard for production env vars
|
- Never commit secrets - use Vercel dashboard for production env vars
|
||||||
|
|
||||||
Required environment variables (production):
|
Required: `VITE_SUPABASE_URL`, `VITE_SUPABASE_ANON_KEY`, `VITE_GEMINI_API_KEY`, `VITE_ANTHROPIC_API_KEY`
|
||||||
- `VITE_SUPABASE_URL`
|
|
||||||
- `VITE_SUPABASE_ANON_KEY`
|
|
||||||
- `VITE_GEMINI_API_KEY`
|
|
||||||
- `VITE_ANTHROPIC_API_KEY`
|
|
||||||
|
|||||||
@@ -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,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,290 @@
|
|||||||
|
import { applyCors, isAllowedOrigin } from './_cors.js';
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||||
|
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
||||||
|
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||||
|
|
||||||
|
const MASTER_USERS = new Set([
|
||||||
|
'christian.vidal@craze-group.com',
|
||||||
|
'jingying.shi@craze-group.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function getValidatedFromMetadata(user) {
|
||||||
|
return user?.app_metadata?.validated === true || user?.user_metadata?.validated === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAdminUser(userId) {
|
||||||
|
const res = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${userId}`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text();
|
||||||
|
throw new Error(`Failed to fetch auth user: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await res.json();
|
||||||
|
return payload?.user || payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAuthValidationMetadata(userId, validated) {
|
||||||
|
const currentUser = await fetchAdminUser(userId);
|
||||||
|
const res = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
app_metadata: {
|
||||||
|
...(currentUser?.app_metadata || {}),
|
||||||
|
validated,
|
||||||
|
},
|
||||||
|
user_metadata: currentUser?.user_metadata || {},
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text();
|
||||||
|
throw new Error(`Failed to update auth metadata: ${errText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchApprovalsMap() {
|
||||||
|
const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?select=*`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!approvalsRes.ok) {
|
||||||
|
const errText = await approvalsRes.text();
|
||||||
|
console.error('Failed to fetch approvals:', errText);
|
||||||
|
return {
|
||||||
|
approvalMap: null,
|
||||||
|
warning: 'Validation table unavailable; using Auth metadata fallback.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvals = await approvalsRes.json();
|
||||||
|
return {
|
||||||
|
approvalMap: new Map(approvals.map(a => [a.id, a.validated])),
|
||||||
|
warning: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCors(req, res) {
|
||||||
|
applyCors(req, res, 'POST, OPTIONS');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
setCors(req, res);
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (origin && !isAllowedOrigin(origin)) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Authenticate caller
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: Missing token' });
|
||||||
|
}
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_ANON_KEY,
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userRes.ok) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userRes.json();
|
||||||
|
const callerEmail = user.email;
|
||||||
|
const callerId = user.id;
|
||||||
|
|
||||||
|
if (!callerEmail) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: Invalid user payload' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMaster = MASTER_USERS.has(callerEmail.toLowerCase());
|
||||||
|
const { action } = req.body;
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return res.status(400).json({ error: 'Missing action' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action: Check Validation Status ---
|
||||||
|
if (action === 'check-status') {
|
||||||
|
if (isMaster) {
|
||||||
|
return res.json({ validated: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${callerId}&select=validated`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!approvalsRes.ok) {
|
||||||
|
const errText = await approvalsRes.text();
|
||||||
|
console.error('Failed to query user approvals:', errText);
|
||||||
|
return res.json({ validated: getValidatedFromMetadata(user) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvals = await approvalsRes.json();
|
||||||
|
const isApproved = approvals.length > 0
|
||||||
|
? approvals[0].validated === true
|
||||||
|
: getValidatedFromMetadata(user);
|
||||||
|
return res.json({ validated: isApproved });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin-only Actions ---
|
||||||
|
if (!isMaster) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'list') {
|
||||||
|
// Fetch all users from GoTrue Admin API
|
||||||
|
const usersRes = await fetch(`${SUPABASE_URL}/auth/v1/admin/users`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!usersRes.ok) {
|
||||||
|
const errText = await usersRes.text();
|
||||||
|
console.error('Failed to fetch auth users:', errText);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch users from authentication' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersData = await usersRes.json();
|
||||||
|
const authUsers = usersData.users || [];
|
||||||
|
|
||||||
|
const { approvalMap, warning } = await fetchApprovalsMap();
|
||||||
|
|
||||||
|
const mergedUsers = authUsers.map(u => {
|
||||||
|
const email = u.email;
|
||||||
|
const id = u.id;
|
||||||
|
const createdAt = u.created_at;
|
||||||
|
|
||||||
|
let status = 'Pending';
|
||||||
|
if (MASTER_USERS.has(email?.toLowerCase())) {
|
||||||
|
status = 'Master';
|
||||||
|
} else if (approvalMap?.has(id)) {
|
||||||
|
status = approvalMap.get(id) ? 'Validated' : 'Pending';
|
||||||
|
} else if (getValidatedFromMetadata(u)) {
|
||||||
|
status = 'Validated';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, email, created_at: createdAt, status };
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ users: mergedUsers, warning });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'validate') {
|
||||||
|
const { targetUserId, email, validated } = req.body;
|
||||||
|
if (!targetUserId || !email) {
|
||||||
|
return res.status(400).json({ error: 'Missing targetUserId or email' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let tableWarning = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upsertRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'resolution=merge-duplicates,return=representation'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: targetUserId,
|
||||||
|
email,
|
||||||
|
validated,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upsertRes.ok) {
|
||||||
|
const errText = await upsertRes.text();
|
||||||
|
console.error('Failed to upsert approval:', errText);
|
||||||
|
tableWarning = 'Validation table unavailable; Auth metadata was updated instead.';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Approval table update threw:', err);
|
||||||
|
tableWarning = 'Validation table unavailable; Auth metadata was updated instead.';
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAuthValidationMetadata(targetUserId, validated);
|
||||||
|
return res.json({ success: true, warning: tableWarning });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
const { targetUserId } = req.body;
|
||||||
|
if (!targetUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing targetUserId' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent master user self-deletion via API
|
||||||
|
const targetUserRes = await fetchAdminUser(targetUserId).catch(() => null);
|
||||||
|
|
||||||
|
if (targetUserRes && MASTER_USERS.has(targetUserRes.email?.toLowerCase())) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete a master user account' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Delete user from auth
|
||||||
|
const deleteAuthRes = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${targetUserId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deleteAuthRes.ok) {
|
||||||
|
const errText = await deleteAuthRes.text();
|
||||||
|
console.error('Failed to delete auth user:', errText);
|
||||||
|
return res.status(500).json({ error: 'Failed to delete user from authentication' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete user approval record from public.user_approvals if exists
|
||||||
|
await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${targetUserId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json({ error: 'Invalid action' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in users-admin function:', err);
|
||||||
|
return res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
export function getBcConfig(env = process.env) {
|
||||||
|
const legacyWriteMethod = String(env.BC_WRITE_METHOD || 'PATCH').toUpperCase();
|
||||||
|
const legacyWriteUrlTemplate = env.BC_WRITE_URL_TEMPLATE || `{{itemsUrl}}('{{itemNo}}')`;
|
||||||
|
const legacyWriteBodyTemplate = env.BC_WRITE_BODY_TEMPLATE || JSON.stringify({ cpnpNo: '{{cpnpNo}}' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId: env.BC_TENANT_ID || 'fab724f7-6b6d-4e3b-86e3-8c1e05e36b2a',
|
||||||
|
clientId: env.BC_CLIENT_ID || '6f832138-cb48-43e7-8601-efca120b45dc',
|
||||||
|
clientSecret: env.BC_CLIENT_SECRET,
|
||||||
|
companyId: env.BC_COMPANY_ID || '2acec35c-7d06-ed11-82f8-0022485ceea3',
|
||||||
|
writeMethod: legacyWriteMethod,
|
||||||
|
writeUrlTemplate: legacyWriteUrlTemplate,
|
||||||
|
writeBodyTemplate: legacyWriteBodyTemplate,
|
||||||
|
itemsWriteMethod: String(env.BC_ITEMS_WRITE_METHOD || legacyWriteMethod).toUpperCase(),
|
||||||
|
itemsWriteUrlTemplate: env.BC_ITEMS_WRITE_URL_TEMPLATE || legacyWriteUrlTemplate,
|
||||||
|
itemsWriteBodyTemplate: env.BC_ITEMS_WRITE_BODY_TEMPLATE || null,
|
||||||
|
itemUnitsCode: env.BC_UOM_CODE || 'OUTER',
|
||||||
|
itemUnitsWriteMethod: env.BC_UOM_WRITE_METHOD ? String(env.BC_UOM_WRITE_METHOD).toUpperCase() : 'PATCH',
|
||||||
|
itemUnitsWriteUrlTemplate: env.BC_UOM_WRITE_URL_TEMPLATE || `{{itemUnitsOfMeasureUrl}}(itemNo='{{itemNo}}',code='{{itemUnitsCode}}')`,
|
||||||
|
itemUnitsWriteBodyTemplate: env.BC_UOM_WRITE_BODY_TEMPLATE || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenUrl(config) {
|
||||||
|
return `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemsUrl(config) {
|
||||||
|
return `https://api.businesscentral.dynamics.com/v2.0/${config.tenantId}/production/api/craze/integrations/v1.0/companies(${config.companyId})/items`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemUnitsOfMeasureUrl(config) {
|
||||||
|
return `https://api.businesscentral.dynamics.com/v2.0/${config.tenantId}/production/api/craze/integrations/v1.0/companies(${config.companyId})/itemUnitsOfMeasure2`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokenCache = { token: null, expiresAt: 0 };
|
||||||
|
|
||||||
|
export async function getBCToken(config) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (tokenCache.token && now < tokenCache.expiresAt) {
|
||||||
|
return tokenCache.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.clientSecret) {
|
||||||
|
throw new Error('BC_CLIENT_SECRET env var not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
scope: 'https://api.businesscentral.dynamics.com/.default',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(getTokenUrl(config), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new Error('BC token error: ' + JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenCache = { token: data.access_token, expiresAt: now + 55 * 60 * 1000 };
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllItems(config, token) {
|
||||||
|
const items = [];
|
||||||
|
let url = `${getItemsUrl(config)}?$top=1000`;
|
||||||
|
|
||||||
|
while (url) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`BC GET items failed (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (Array.isArray(json.value)) {
|
||||||
|
items.push(...json.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
url = json['@odata.nextLink'] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findItem(config, token, articleNo) {
|
||||||
|
const filter = encodeURIComponent(`no eq '${articleNo}'`);
|
||||||
|
const url = `${getItemsUrl(config)}?$filter=${filter}&$select=systemId,no,cpnpNo`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`BC GET items failed (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
const items = json.value || [];
|
||||||
|
console.log('[bc-proxy] GET items result:', JSON.stringify(items));
|
||||||
|
if (items.length === 0) throw new Error(`Item not found in BC: ${articleNo}`);
|
||||||
|
return items[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchItemCpnpNo(config, token, item, cpnpNo) {
|
||||||
|
const etag = item['@odata.etag'] || '*';
|
||||||
|
const itemsUrl = getItemsUrl(config);
|
||||||
|
const url = config.writeUrlTemplate
|
||||||
|
.replaceAll('{{itemsUrl}}', itemsUrl)
|
||||||
|
.replaceAll('{{tenantId}}', config.tenantId)
|
||||||
|
.replaceAll('{{companyId}}', config.companyId)
|
||||||
|
.replaceAll('{{itemNo}}', encodeURIComponent(String(item.no)))
|
||||||
|
.replaceAll('{{systemId}}', encodeURIComponent(String(item.systemId || '')))
|
||||||
|
.replaceAll('{{cpnpNo}}', String(cpnpNo));
|
||||||
|
const bodyText = config.writeBodyTemplate
|
||||||
|
.replaceAll('{{itemsUrl}}', itemsUrl)
|
||||||
|
.replaceAll('{{tenantId}}', config.tenantId)
|
||||||
|
.replaceAll('{{companyId}}', config.companyId)
|
||||||
|
.replaceAll('{{itemNo}}', String(item.no))
|
||||||
|
.replaceAll('{{systemId}}', String(item.systemId || ''))
|
||||||
|
.replaceAll('{{cpnpNo}}', String(cpnpNo));
|
||||||
|
console.log('[bc-proxy] WRITE url:', url);
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if ((config.writeMethod || 'PATCH') === 'PATCH') {
|
||||||
|
headers['If-Match'] = etag;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: config.writeMethod || 'PATCH',
|
||||||
|
headers,
|
||||||
|
body: bodyText,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`BC write failed (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeValue(value) {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkbook(items) {
|
||||||
|
const headers = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
Object.keys(item || {}).forEach(key => {
|
||||||
|
if (key.startsWith('@odata.')) return;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
headers.push(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = items.map(item => {
|
||||||
|
const row = {};
|
||||||
|
headers.forEach(key => {
|
||||||
|
row[key] = normalizeValue(item?.[key]);
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ws = XLSX.utils.json_to_sheet(rows, { header: headers });
|
||||||
|
ws['!autofilter'] = {
|
||||||
|
ref: XLSX.utils.encode_range({
|
||||||
|
s: { c: 0, r: 0 },
|
||||||
|
e: { c: Math.max(headers.length - 1, 0), r: Math.max(rows.length, 0) },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Items');
|
||||||
|
return wb;
|
||||||
|
}
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { getBcConfig, getBCToken, getItemsUrl, getItemUnitsOfMeasureUrl } from './bc-runtime.js';
|
||||||
|
|
||||||
|
function findHeaderIndex(headers, patterns) {
|
||||||
|
const normalized = headers.map(h => String(h || '').toLowerCase());
|
||||||
|
return normalized.findIndex(header =>
|
||||||
|
patterns.every(pattern => header.includes(pattern.toLowerCase()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCategorizationCodeIndex(headers) {
|
||||||
|
const normalized = headers.map(h => String(h || '').toLowerCase().trim());
|
||||||
|
const categorizationIdx = normalized.findIndex(header => header.replace(/[\s_-]+/g, '') === 'categorizationcode');
|
||||||
|
if (categorizationIdx >= 0) return categorizationIdx;
|
||||||
|
|
||||||
|
const typeIdx = normalized.findIndex(header => header === 'type');
|
||||||
|
if (typeIdx >= 0) return typeIdx;
|
||||||
|
|
||||||
|
return normalized.findIndex(header => header.includes('product') && header.includes('type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForBc(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
|
||||||
|
if (typeof value === 'number' && value >= 25569 && value <= 60000) {
|
||||||
|
const excelEpoch = new Date(1899, 11, 30);
|
||||||
|
const date = new Date(excelEpoch.getTime() + value * 86400000);
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = String(value).trim();
|
||||||
|
if (!str) return null;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
||||||
|
|
||||||
|
const parsed = new Date(str);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
|
return parsed.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValue(row, index) {
|
||||||
|
if (index === null || index < 0) return null;
|
||||||
|
const value = row[index];
|
||||||
|
return value === undefined || value === '' ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBcDecimal(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
|
||||||
|
const normalized = String(value).trim().replace(/\s+/g, '').replace(',', '.');
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFieldPreview(sourceLabel, sourceIndex, targetField, value) {
|
||||||
|
return { sourceLabel, sourceIndex, targetField, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBusinessCentralMappingPreview(headers, row) {
|
||||||
|
const articleNoIdx = findHeaderIndex(headers, ['article', 'no']);
|
||||||
|
const articleDetailsEnIdx = findHeaderIndex(headers, ['article', 'details', 'english']);
|
||||||
|
const articleDetailsDeIdx = findHeaderIndex(headers, ['article', 'details', 'german']);
|
||||||
|
const launchDateIdx = findHeaderIndex(headers, ['launch']);
|
||||||
|
const readyToOrderDateIdx = findHeaderIndex(headers, ['ready']);
|
||||||
|
const moqIdx = findHeaderIndex(headers, ['moq']);
|
||||||
|
const shortDeIdx = findHeaderIndex(headers, ['short', 'description', 'german']);
|
||||||
|
const shortEnIdx = findHeaderIndex(headers, ['short', 'description', 'english']);
|
||||||
|
const cpnpIdx = findHeaderIndex(headers, ['cpnp']);
|
||||||
|
const categorizationCodeIdx = findCategorizationCodeIndex(headers);
|
||||||
|
|
||||||
|
const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']);
|
||||||
|
const units40HqIdx = (() => {
|
||||||
|
const hqIdx = findHeaderIndex(headers, ['40', 'hq']);
|
||||||
|
if (hqIdx >= 0) return hqIdx;
|
||||||
|
return findHeaderIndex(headers, ['40', 'hc']);
|
||||||
|
})();
|
||||||
|
const innerWIdx = findHeaderIndex(headers, ['inner', 'w']);
|
||||||
|
const innerLIdx = findHeaderIndex(headers, ['inner', 'l']);
|
||||||
|
const innerHIdx = findHeaderIndex(headers, ['inner', 'h']);
|
||||||
|
const outerWIdx = findHeaderIndex(headers, ['outer', 'w']);
|
||||||
|
const outerLIdx = findHeaderIndex(headers, ['outer', 'l']);
|
||||||
|
const outerHIdx = findHeaderIndex(headers, ['outer', 'h']);
|
||||||
|
|
||||||
|
const articleNo = String(getValue(row, articleNoIdx) ?? '');
|
||||||
|
|
||||||
|
const categorizationCode = getValue(row, categorizationCodeIdx);
|
||||||
|
const hasCategorizationCode = String(categorizationCode ?? '').trim() !== '';
|
||||||
|
|
||||||
|
const itemsPayload = {
|
||||||
|
no: getValue(row, articleNoIdx),
|
||||||
|
articleDetailsEnglish: getValue(row, articleDetailsEnIdx),
|
||||||
|
articleDetailsGerman: getValue(row, articleDetailsDeIdx),
|
||||||
|
launchDate: formatDateForBc(getValue(row, launchDateIdx)),
|
||||||
|
readyToOrderDate: formatDateForBc(getValue(row, readyToOrderDateIdx)),
|
||||||
|
minimumOrderQuantity: toBcDecimal(getValue(row, moqIdx)),
|
||||||
|
shortDescriptionInGerman: getValue(row, shortDeIdx),
|
||||||
|
shortDescriptionInEnglish: getValue(row, shortEnIdx),
|
||||||
|
cpnpNo: getValue(row, cpnpIdx),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasCategorizationCode) {
|
||||||
|
itemsPayload.categorizationCode = String(categorizationCode).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemUnitsOfMeasurePayload = {
|
||||||
|
itemNo: getValue(row, articleNoIdx),
|
||||||
|
code: 'OUTER',
|
||||||
|
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, unitsOuterIdx)),
|
||||||
|
width: toBcDecimal(getValue(row, outerWIdx)),
|
||||||
|
length: toBcDecimal(getValue(row, outerLIdx)),
|
||||||
|
height: toBcDecimal(getValue(row, outerHIdx)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemUnits40HCPayload = {
|
||||||
|
itemNo: getValue(row, articleNoIdx),
|
||||||
|
code: '40HC',
|
||||||
|
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, units40HqIdx)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
articleNo,
|
||||||
|
itemsPayload,
|
||||||
|
itemUnitsOfMeasurePayload,
|
||||||
|
itemUnits40HCPayload,
|
||||||
|
itemsFields: [
|
||||||
|
makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no),
|
||||||
|
makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish),
|
||||||
|
makeFieldPreview('Article Details - German', articleDetailsDeIdx, 'articleDetailsGerman', itemsPayload.articleDetailsGerman),
|
||||||
|
makeFieldPreview('Launch Date', launchDateIdx, 'launchDate', itemsPayload.launchDate),
|
||||||
|
makeFieldPreview('Ready to Order Date', readyToOrderDateIdx, 'readyToOrderDate', itemsPayload.readyToOrderDate),
|
||||||
|
makeFieldPreview('MOQ', moqIdx, 'minimumOrderQuantity', itemsPayload.minimumOrderQuantity),
|
||||||
|
makeFieldPreview('Short Description - German', shortDeIdx, 'shortDescriptionInGerman', itemsPayload.shortDescriptionInGerman),
|
||||||
|
makeFieldPreview('Short Description - English', shortEnIdx, 'shortDescriptionInEnglish', itemsPayload.shortDescriptionInEnglish),
|
||||||
|
makeFieldPreview('CPNP', cpnpIdx, 'cpnpNo', itemsPayload.cpnpNo),
|
||||||
|
...(hasCategorizationCode
|
||||||
|
? [makeFieldPreview('CategorizationCode', categorizationCodeIdx, 'categorizationCode', itemsPayload.categorizationCode)]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
itemUnitsFields: [
|
||||||
|
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnitsOfMeasurePayload.itemNo),
|
||||||
|
makeFieldPreview('Units Outer', unitsOuterIdx, 'qtyPerUnitOfMeasure', itemUnitsOfMeasurePayload.qtyPerUnitOfMeasure),
|
||||||
|
makeFieldPreview('Outer W (cm)', outerWIdx, 'width', itemUnitsOfMeasurePayload.width),
|
||||||
|
makeFieldPreview('Outer L (cm)', outerLIdx, 'length', itemUnitsOfMeasurePayload.length),
|
||||||
|
makeFieldPreview('Outer H (cm)', outerHIdx, 'height', itemUnitsOfMeasurePayload.height),
|
||||||
|
],
|
||||||
|
itemUnits40HCFields: [
|
||||||
|
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnits40HCPayload.itemNo),
|
||||||
|
makeFieldPreview('Units 40FT HQ', units40HqIdx, 'qtyPerUnitOfMeasure', itemUnits40HCPayload.qtyPerUnitOfMeasure),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForComparison(field, value) {
|
||||||
|
const strField = String(field || '').toLowerCase();
|
||||||
|
|
||||||
|
if (DECIMAL_FIELDS.has(field) && (value === null || value === undefined || value === '')) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strField.includes('date')) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '0001-01-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDate = formatDateForBc(value);
|
||||||
|
return normalizedDate === '0001-01-01' ? '0001-01-01' : normalizedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.replace(/\r\n/g, '\n').trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldChanges(fields, currentRecord, desiredPayload) {
|
||||||
|
return fields
|
||||||
|
.filter(field => field.targetField !== 'itemNo' && field.targetField !== 'no')
|
||||||
|
.map(field => {
|
||||||
|
const before = currentRecord ? currentRecord[field.targetField] : undefined;
|
||||||
|
const after = desiredPayload[field.targetField];
|
||||||
|
const normalizedBefore = normalizeForComparison(field.targetField, before);
|
||||||
|
const normalizedAfter = normalizeForComparison(field.targetField, after);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceLabel: field.sourceLabel,
|
||||||
|
sourceIndex: field.sourceIndex,
|
||||||
|
targetField: field.targetField,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
changed: normalizedBefore !== normalizedAfter,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeUnappliedChanges(sectionLabel, changes) {
|
||||||
|
return changes
|
||||||
|
.filter(change => change.changed)
|
||||||
|
.map(change => `${change.sourceLabel} (${change.targetField}): expected ${JSON.stringify(change.after)} but BC has ${JSON.stringify(change.before)}`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChangedPayload(changes) {
|
||||||
|
const payload = {};
|
||||||
|
|
||||||
|
changes
|
||||||
|
.filter(change => change.changed)
|
||||||
|
.forEach(change => {
|
||||||
|
const field = change.targetField;
|
||||||
|
const value = change.after;
|
||||||
|
|
||||||
|
if (DECIMAL_FIELDS.has(field)) {
|
||||||
|
payload[field] = toBcDecimal(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EMPTY_STRING_FIELDS.has(field)) {
|
||||||
|
payload[field] = value === null || value === undefined ? '' : String(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(field || '').toLowerCase().includes('date')) {
|
||||||
|
payload[field] = formatDateForBc(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload[field] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewHash(payload) {
|
||||||
|
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemsSelect() {
|
||||||
|
return [
|
||||||
|
'systemId',
|
||||||
|
'no',
|
||||||
|
'articleDetailsEnglish',
|
||||||
|
'articleDetailsGerman',
|
||||||
|
'launchDate',
|
||||||
|
'readyToOrderDate',
|
||||||
|
'minimumOrderQuantity',
|
||||||
|
'shortDescriptionInGerman',
|
||||||
|
'shortDescriptionInEnglish',
|
||||||
|
'cpnpNo',
|
||||||
|
'categorizationCode',
|
||||||
|
].join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemUnitsSelect() {
|
||||||
|
return [
|
||||||
|
'itemNo',
|
||||||
|
'code',
|
||||||
|
'qtyPerUnitOfMeasure',
|
||||||
|
'qtyRoundingPrecision',
|
||||||
|
'length',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'cubage',
|
||||||
|
'weight',
|
||||||
|
'layerPerPalet2CRZ',
|
||||||
|
'outerPerLayer2CRZ',
|
||||||
|
'barCodeCRZ',
|
||||||
|
'layerPerPaletCRZ',
|
||||||
|
'outerPerLayerCRZ',
|
||||||
|
'netWeightCRZ',
|
||||||
|
'innerTypeBCT',
|
||||||
|
].join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJsonOrThrow(url, token, label) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`${label} failed (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: await res.json(),
|
||||||
|
etag: res.headers.get('etag') || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBusinessCentralSnapshot(config, token, articleNo) {
|
||||||
|
const itemsFilter = encodeURIComponent(`no eq '${articleNo}'`);
|
||||||
|
const itemsUrl = `${getItemsUrl(config)}?$filter=${itemsFilter}&$select=${getItemsSelect()}`;
|
||||||
|
const { json: itemsJson, etag: itemsEtag } = await fetchJsonOrThrow(itemsUrl, token, 'BC GET items');
|
||||||
|
const item = Array.isArray(itemsJson.value) && itemsJson.value.length > 0 ? itemsJson.value[0] : null;
|
||||||
|
|
||||||
|
const outerCode = config.itemUnitsCode || 'OUTER';
|
||||||
|
const uomUrl = getItemUnitsOfMeasureUrl(config);
|
||||||
|
|
||||||
|
const outerFilter = encodeURIComponent(`itemNo eq '${articleNo}' and code eq '${outerCode}'`);
|
||||||
|
const outerUrl = `${uomUrl}?$filter=${outerFilter}&$select=${getItemUnitsSelect()}`;
|
||||||
|
const { json: outerJson, etag: outerEtag } = await fetchJsonOrThrow(outerUrl, token, 'BC GET itemUnitsOfMeasure OUTER');
|
||||||
|
const itemUnitsOfMeasure = Array.isArray(outerJson.value) && outerJson.value.length > 0 ? outerJson.value[0] : null;
|
||||||
|
|
||||||
|
const hcFilter = encodeURIComponent(`itemNo eq '${articleNo}' and code eq '40HC'`);
|
||||||
|
const hcUrl = `${uomUrl}?$filter=${hcFilter}&$select=${getItemUnitsSelect()}`;
|
||||||
|
const { json: hcJson, etag: hcEtag } = await fetchJsonOrThrow(hcUrl, token, 'BC GET itemUnitsOfMeasure 40HC');
|
||||||
|
const itemUnits40HC = Array.isArray(hcJson.value) && hcJson.value.length > 0 ? hcJson.value[0] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
itemUnitsOfMeasure,
|
||||||
|
itemUnits40HC,
|
||||||
|
itemEtag: item?.['@odata.etag'] || itemsEtag || '*',
|
||||||
|
itemUnitsEtag: itemUnitsOfMeasure?.['@odata.etag'] || outerEtag || '*',
|
||||||
|
itemUnits40HCEtag: itemUnits40HC?.['@odata.etag'] || hcEtag || '*',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePreviewSection({
|
||||||
|
type,
|
||||||
|
desiredPayload,
|
||||||
|
currentRecord,
|
||||||
|
fieldPreviews,
|
||||||
|
writeMethod,
|
||||||
|
writeUrlTemplate,
|
||||||
|
writeBodyTemplate,
|
||||||
|
supported = true,
|
||||||
|
supportReason = null,
|
||||||
|
}) {
|
||||||
|
const changes = buildFieldChanges(fieldPreviews, currentRecord, desiredPayload);
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
desired: desiredPayload,
|
||||||
|
current: currentRecord || null,
|
||||||
|
changes,
|
||||||
|
changedFields: changes.filter(change => change.changed).map(change => change.targetField),
|
||||||
|
writeConfigured: Boolean(writeUrlTemplate),
|
||||||
|
writeMethod: writeMethod || null,
|
||||||
|
writeUrlTemplate: writeUrlTemplate || null,
|
||||||
|
writeBodyTemplate: writeBodyTemplate || null,
|
||||||
|
canApply: Boolean(writeUrlTemplate) && supported,
|
||||||
|
supported,
|
||||||
|
supportReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBusinessCentralSyncPreview(config, mapping, snapshot) {
|
||||||
|
const itemsSection = makePreviewSection({
|
||||||
|
type: 'items',
|
||||||
|
desiredPayload: mapping.itemsPayload,
|
||||||
|
currentRecord: snapshot.item,
|
||||||
|
fieldPreviews: mapping.itemsFields,
|
||||||
|
writeMethod: config.itemsWriteMethod || config.writeMethod,
|
||||||
|
writeUrlTemplate: config.itemsWriteUrlTemplate || config.writeUrlTemplate,
|
||||||
|
writeBodyTemplate: config.itemsWriteBodyTemplate || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemUnitsSection = makePreviewSection({
|
||||||
|
type: 'itemUnitsOfMeasure',
|
||||||
|
desiredPayload: mapping.itemUnitsOfMeasurePayload,
|
||||||
|
currentRecord: snapshot.itemUnitsOfMeasure,
|
||||||
|
fieldPreviews: mapping.itemUnitsFields,
|
||||||
|
writeMethod: config.itemUnitsWriteMethod || null,
|
||||||
|
writeUrlTemplate: config.itemUnitsWriteUrlTemplate || null,
|
||||||
|
writeBodyTemplate: config.itemUnitsWriteBodyTemplate || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemUnits40HCSection = makePreviewSection({
|
||||||
|
type: 'itemUnits40HC',
|
||||||
|
desiredPayload: mapping.itemUnits40HCPayload,
|
||||||
|
currentRecord: snapshot.itemUnits40HC,
|
||||||
|
fieldPreviews: mapping.itemUnits40HCFields,
|
||||||
|
writeMethod: config.itemUnits40HCWriteMethod || config.itemUnitsWriteMethod || null,
|
||||||
|
writeUrlTemplate: config.itemUnits40HCWriteUrlTemplate || config.itemUnitsWriteUrlTemplate || null,
|
||||||
|
writeBodyTemplate: config.itemUnits40HCWriteBodyTemplate || config.itemUnitsWriteBodyTemplate || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewPayload = {
|
||||||
|
articleNo: mapping.articleNo,
|
||||||
|
items: itemsSection,
|
||||||
|
itemUnitsOfMeasure: itemUnitsSection,
|
||||||
|
itemUnits40HC: itemUnits40HCSection,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previewPayload,
|
||||||
|
hasChanges:
|
||||||
|
itemsSection.changes.some(change => change.changed) ||
|
||||||
|
itemUnitsSection.changes.some(change => change.changed) ||
|
||||||
|
itemUnits40HCSection.changes.some(change => change.changed),
|
||||||
|
previewToken: buildPreviewHash({
|
||||||
|
articleNo: mapping.articleNo,
|
||||||
|
items: itemsSection.changes,
|
||||||
|
itemUnitsOfMeasure: itemUnitsSection.changes,
|
||||||
|
itemUnits40HC: itemUnits40HCSection.changes,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewBusinessCentralSync(config, token, headers, row) {
|
||||||
|
const mapping = buildBusinessCentralMappingPreview(headers, row);
|
||||||
|
const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
|
||||||
|
return buildBusinessCentralSyncPreview(config, mapping, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAndPatchRecord({ token, url, method, body, etag }) {
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (String(method || 'PATCH').toUpperCase() === 'PATCH') {
|
||||||
|
headers['If-Match'] = etag || '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: method || 'PATCH',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`BC write failed (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(template, context) {
|
||||||
|
return String(template).replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, key) => {
|
||||||
|
const value = context[key];
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DECIMAL_FIELDS = new Set([
|
||||||
|
'minimumOrderQuantity',
|
||||||
|
'qtyPerUnitOfMeasure',
|
||||||
|
'length',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'cubage',
|
||||||
|
'weight',
|
||||||
|
'layerPerPalet2CRZ',
|
||||||
|
'outerPerLayer2CRZ',
|
||||||
|
'netWeightCRZ',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EMPTY_STRING_FIELDS = new Set([
|
||||||
|
'articleDetailsEnglish',
|
||||||
|
'articleDetailsGerman',
|
||||||
|
'shortDescriptionInGerman',
|
||||||
|
'shortDescriptionInEnglish',
|
||||||
|
'cpnpNo',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeDecimalPayload(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(normalizeDecimalPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = {};
|
||||||
|
for (const [key, nested] of Object.entries(value)) {
|
||||||
|
if (EMPTY_STRING_FIELDS.has(key) && (nested === null || nested === undefined)) {
|
||||||
|
next[key] = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DECIMAL_FIELDS.has(key)) {
|
||||||
|
next[key] = toBcDecimal(nested);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next[key] = normalizeDecimalPayload(nested);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJsonBody(template, context, fallbackPayload) {
|
||||||
|
const rendered = template ? renderTemplate(template, context) : JSON.stringify(fallbackPayload);
|
||||||
|
if (typeof rendered !== 'string') {
|
||||||
|
return JSON.stringify(normalizeDecimalPayload(rendered));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rendered);
|
||||||
|
return JSON.stringify(normalizeDecimalPayload(parsed));
|
||||||
|
} catch {
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyItemsSection(config, token, snapshot, preview, context) {
|
||||||
|
const changedFields = preview.items.changes.filter(change => change.changed);
|
||||||
|
if (changedFields.length === 0) {
|
||||||
|
return { applied: false, reason: 'No item changes' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.itemsWriteUrlTemplate) {
|
||||||
|
return { applied: false, reason: 'Items write template not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = renderTemplate(config.itemsWriteUrlTemplate, context);
|
||||||
|
const payload = renderJsonBody(preview.items.writeBodyTemplate, context, buildChangedPayload(preview.items.changes));
|
||||||
|
|
||||||
|
await renderAndPatchRecord({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
method: preview.items.writeMethod || 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
etag: snapshot.itemEtag || snapshot.item?.['@odata.etag'] || '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { applied: true, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyItemUnitsSection(config, token, snapshot, preview, context) {
|
||||||
|
const changedFields = preview.itemUnitsOfMeasure.changes.filter(change => change.changed);
|
||||||
|
if (changedFields.length === 0) {
|
||||||
|
return { applied: false, reason: 'No itemUnitsOfMeasure changes' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preview.itemUnitsOfMeasure.supported) {
|
||||||
|
return { applied: false, reason: preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.itemUnitsWriteUrlTemplate) {
|
||||||
|
return { applied: false, reason: 'itemUnitsOfMeasure write template not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = renderTemplate(config.itemUnitsWriteUrlTemplate, context);
|
||||||
|
const payload = renderJsonBody(preview.itemUnitsOfMeasure.writeBodyTemplate, context, buildChangedPayload(preview.itemUnitsOfMeasure.changes));
|
||||||
|
|
||||||
|
await renderAndPatchRecord({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
method: preview.itemUnitsOfMeasure.writeMethod || 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
etag: snapshot.itemUnitsEtag || snapshot.itemUnitsOfMeasure?.['@odata.etag'] || '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { applied: true, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyItemUnits40HCSection(config, token, snapshot, preview, context) {
|
||||||
|
const changedFields = preview.itemUnits40HC.changes.filter(change => change.changed);
|
||||||
|
if (changedFields.length === 0) {
|
||||||
|
return { applied: false, reason: 'No itemUnits40HC changes' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.itemUnits40HCWriteUrlTemplate && !config.itemUnitsWriteUrlTemplate) {
|
||||||
|
return { applied: false, reason: 'itemUnits40HC write template not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlTemplate = config.itemUnits40HCWriteUrlTemplate || config.itemUnitsWriteUrlTemplate;
|
||||||
|
const writeMethod = preview.itemUnits40HC.writeMethod || 'PATCH';
|
||||||
|
const hcContext = {
|
||||||
|
...context,
|
||||||
|
itemUnitsCode: '40HC',
|
||||||
|
itemUnits40HCCode: '40HC',
|
||||||
|
itemUnitsPayload: preview.itemUnits40HC.desired,
|
||||||
|
};
|
||||||
|
const url = renderTemplate(urlTemplate, hcContext);
|
||||||
|
const payload = renderJsonBody(preview.itemUnits40HC.writeBodyTemplate, hcContext, buildChangedPayload(preview.itemUnits40HC.changes));
|
||||||
|
|
||||||
|
await renderAndPatchRecord({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
method: writeMethod,
|
||||||
|
body: payload,
|
||||||
|
etag: snapshot.itemUnits40HCEtag || snapshot.itemUnits40HC?.['@odata.etag'] || '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { applied: true, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyBusinessCentralSync(config, token, headers, row, previewToken) {
|
||||||
|
const mapping = buildBusinessCentralMappingPreview(headers, row);
|
||||||
|
const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
|
||||||
|
const preview = buildBusinessCentralSyncPreview(config, mapping, snapshot);
|
||||||
|
|
||||||
|
const hasItemUnitsChanges = preview.itemUnitsOfMeasure.changes.some(change => change.changed);
|
||||||
|
if (hasItemUnitsChanges && !snapshot.itemUnitsOfMeasure) {
|
||||||
|
const error = new Error(`BC itemUnitsOfMeasure row missing for ${mapping.articleNo}. This BC API cannot update these fields until the row exists or BC exposes an upsert action.`);
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasItemUnits40HCChanges = preview.itemUnits40HC.changes.some(change => change.changed);
|
||||||
|
if (hasItemUnits40HCChanges && !snapshot.itemUnits40HC) {
|
||||||
|
const error = new Error(`BC itemUnits40HC row missing for ${mapping.articleNo}. This BC API cannot update these fields until the row exists or BC exposes an upsert action.`);
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewToken && previewToken !== preview.previewToken) {
|
||||||
|
const error = new Error('Preview token mismatch. BC data changed or preview is stale.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
itemsUrl: getItemsUrl(config),
|
||||||
|
itemUnitsOfMeasureUrl: getItemUnitsOfMeasureUrl(config),
|
||||||
|
articleNo: mapping.articleNo,
|
||||||
|
itemNo: mapping.articleNo,
|
||||||
|
itemUnitsCode: config.itemUnitsCode || 'OUTER',
|
||||||
|
itemUnits40HCCode: '40HC',
|
||||||
|
systemId: snapshot.item?.systemId || '',
|
||||||
|
cpnpNo: mapping.itemsPayload.cpnpNo,
|
||||||
|
itemsPayload: preview.items.desired,
|
||||||
|
itemUnitsPayload: preview.itemUnitsOfMeasure.desired,
|
||||||
|
itemUnits40HCPayload: preview.itemUnits40HC.desired,
|
||||||
|
...preview.items.desired,
|
||||||
|
...preview.itemUnitsOfMeasure.desired,
|
||||||
|
...preview.itemUnits40HC.desired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
items: await applyItemsSection(config, token, snapshot, preview, context),
|
||||||
|
itemUnitsOfMeasure: await applyItemUnitsSection(config, token, snapshot, preview, context),
|
||||||
|
itemUnits40HC: await applyItemUnits40HCSection(config, token, snapshot, preview, context),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (results.items.applied || results.itemUnitsOfMeasure.applied || results.itemUnits40HC.applied) {
|
||||||
|
const verificationSnapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
|
||||||
|
|
||||||
|
if (results.items.applied) {
|
||||||
|
const itemMismatches = preview.items.changes
|
||||||
|
.filter(change => change.changed)
|
||||||
|
.map(change => ({
|
||||||
|
...change,
|
||||||
|
before: verificationSnapshot.item ? verificationSnapshot.item[change.targetField] : undefined,
|
||||||
|
}))
|
||||||
|
.filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after));
|
||||||
|
if (itemMismatches.length > 0) {
|
||||||
|
const error = new Error(`BC verification failed for items: ${describeUnappliedChanges('items', itemMismatches)}`);
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.itemUnitsOfMeasure.applied) {
|
||||||
|
const uomMismatches = preview.itemUnitsOfMeasure.changes
|
||||||
|
.filter(change => change.changed)
|
||||||
|
.map(change => ({
|
||||||
|
...change,
|
||||||
|
before: verificationSnapshot.itemUnitsOfMeasure ? verificationSnapshot.itemUnitsOfMeasure[change.targetField] : undefined,
|
||||||
|
}))
|
||||||
|
.filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after));
|
||||||
|
if (uomMismatches.length > 0) {
|
||||||
|
const error = new Error(`BC verification failed for itemUnitsOfMeasure: ${describeUnappliedChanges('itemUnitsOfMeasure', uomMismatches)}`);
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.itemUnits40HC.applied) {
|
||||||
|
const hcMismatches = preview.itemUnits40HC.changes
|
||||||
|
.filter(change => change.changed)
|
||||||
|
.map(change => ({
|
||||||
|
...change,
|
||||||
|
before: verificationSnapshot.itemUnits40HC ? verificationSnapshot.itemUnits40HC[change.targetField] : undefined,
|
||||||
|
}))
|
||||||
|
.filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after));
|
||||||
|
if (hcMismatches.length > 0) {
|
||||||
|
const error = new Error(`BC verification failed for itemUnits40HC: ${describeUnappliedChanges('itemUnits40HC', hcMismatches)}`);
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
articleNo: mapping.articleNo,
|
||||||
|
previewToken: preview.previewToken,
|
||||||
|
results,
|
||||||
|
preview,
|
||||||
|
warning: (preview.itemUnitsOfMeasure.changes.some(change => change.changed) && !preview.itemUnitsOfMeasure.supported)
|
||||||
|
? (preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only')
|
||||||
|
: (preview.itemUnits40HC.changes.some(change => change.changed) && !preview.itemUnits40HC.supported)
|
||||||
|
? (preview.itemUnits40HC.supportReason || 'itemUnits40HC sync is not supported by this BC API yet; preview only')
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewBusinessCentralCpnp(config, token, articleNo, cpnpNo) {
|
||||||
|
const { json } = await fetchJsonOrThrow(
|
||||||
|
`${getItemsUrl(config)}?$filter=${encodeURIComponent(`no eq '${articleNo}'`)}&$select=${getItemsSelect()}`,
|
||||||
|
token,
|
||||||
|
'BC GET items'
|
||||||
|
);
|
||||||
|
const item = Array.isArray(json.value) && json.value.length > 0 ? json.value[0] : null;
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`Item not found in BC: ${articleNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const desired = { cpnpNo: String(cpnpNo) };
|
||||||
|
const current = { cpnpNo: item.cpnpNo };
|
||||||
|
const changes = buildFieldChanges(
|
||||||
|
[{ sourceLabel: 'CPNP', sourceIndex: null, targetField: 'cpnpNo' }],
|
||||||
|
current,
|
||||||
|
desired
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
articleNo,
|
||||||
|
items: {
|
||||||
|
type: 'items',
|
||||||
|
desired,
|
||||||
|
current,
|
||||||
|
changes,
|
||||||
|
changedFields: changes.filter(change => change.changed).map(change => change.targetField),
|
||||||
|
writeConfigured: Boolean(config.writeUrlTemplate),
|
||||||
|
writeMethod: config.writeMethod || 'PATCH',
|
||||||
|
writeUrlTemplate: config.writeUrlTemplate || null,
|
||||||
|
writeBodyTemplate: config.writeBodyTemplate || null,
|
||||||
|
canApply: Boolean(config.writeUrlTemplate),
|
||||||
|
},
|
||||||
|
itemUnitsOfMeasure: {
|
||||||
|
type: 'itemUnitsOfMeasure',
|
||||||
|
desired: {},
|
||||||
|
current: null,
|
||||||
|
changes: [],
|
||||||
|
changedFields: [],
|
||||||
|
writeConfigured: Boolean(config.itemUnitsWriteUrlTemplate),
|
||||||
|
writeMethod: config.itemUnitsWriteMethod || null,
|
||||||
|
writeUrlTemplate: config.itemUnitsWriteUrlTemplate || null,
|
||||||
|
writeBodyTemplate: config.itemUnitsWriteBodyTemplate || null,
|
||||||
|
canApply: false,
|
||||||
|
},
|
||||||
|
hasChanges: changes.some(change => change.changed),
|
||||||
|
previewToken: buildPreviewHash({
|
||||||
|
articleNo,
|
||||||
|
cpnpNo: String(cpnpNo),
|
||||||
|
current: item.cpnpNo,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyBusinessCentralCpnp(config, token, articleNo, cpnpNo, previewToken) {
|
||||||
|
const preview = await previewBusinessCentralCpnp(config, token, articleNo, cpnpNo);
|
||||||
|
if (previewToken && previewToken !== preview.previewToken) {
|
||||||
|
const error = new Error('Preview token mismatch. BC data changed or preview is stale.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = preview.items.current;
|
||||||
|
const url = renderTemplate(config.writeUrlTemplate || `{{itemsUrl}}('{{itemNo}}')`, {
|
||||||
|
itemsUrl: getItemsUrl(config),
|
||||||
|
articleNo,
|
||||||
|
itemNo: articleNo,
|
||||||
|
cpnpNo: String(cpnpNo),
|
||||||
|
systemId: item?.systemId || '',
|
||||||
|
itemsPayload: { cpnpNo: String(cpnpNo) },
|
||||||
|
});
|
||||||
|
const body = config.writeBodyTemplate
|
||||||
|
? renderTemplate(config.writeBodyTemplate, {
|
||||||
|
itemsUrl: getItemsUrl(config),
|
||||||
|
articleNo,
|
||||||
|
itemNo: articleNo,
|
||||||
|
cpnpNo: String(cpnpNo),
|
||||||
|
systemId: item?.systemId || '',
|
||||||
|
itemsPayload: { cpnpNo: String(cpnpNo) },
|
||||||
|
})
|
||||||
|
: JSON.stringify({ cpnpNo: String(cpnpNo) });
|
||||||
|
|
||||||
|
await renderAndPatchRecord({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
method: config.writeMethod || 'PATCH',
|
||||||
|
body,
|
||||||
|
etag: item?.['@odata.etag'] || '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
articleNo,
|
||||||
|
cpnpNo: String(cpnpNo),
|
||||||
|
previewToken: preview.previewToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getBcConfig, getBCToken, getItemsUrl, getItemUnitsOfMeasureUrl };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Credit balance is too low
|
||||||
|
SessionEnd hook [node "${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs"] failed: /bin/sh: node: command not found
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,171 @@
|
|||||||
|
# Maximize Tab View — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add fullscreen maximize mode to each tab view, hiding Sidebar and TopBar for maximum data visibility.
|
||||||
|
|
||||||
|
**Architecture:** Each view component manages its own `isFullscreen` state locally. No global state. Toggle between normal and fullscreen modes with conditional rendering.
|
||||||
|
|
||||||
|
**Tech Stack:** React useState, lucide-react icons, Tailwind CSS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: ProductDescriptions.tsx
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/ProductDescriptions.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports and state**
|
||||||
|
|
||||||
|
Add `Maximize2` to lucide-react imports.
|
||||||
|
Add `useState` if not present.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add maximize button**
|
||||||
|
|
||||||
|
Find the component's header section (where stats/controls are rendered).
|
||||||
|
Add maximize button with `Maximize2` icon next to existing controls.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(true)}
|
||||||
|
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 rounded transition-colors"
|
||||||
|
title="Fullscreen"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wrap content in conditional render**
|
||||||
|
|
||||||
|
After existing return statement, wrap in:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<div className="fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
|
||||||
|
title="Exit fullscreen"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
{/* Existing JSX content - remove any overflow constraints */}
|
||||||
|
<div className="[content goes here]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="...">
|
||||||
|
{/* Existing JSX content - keep current overflow settings */}
|
||||||
|
<div className="[content goes here]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: MatrixView.tsx
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/MatrixView.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports and state**
|
||||||
|
|
||||||
|
Add `Maximize2`, `X` to lucide-react imports.
|
||||||
|
Add `useState` if not present.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add maximize button**
|
||||||
|
|
||||||
|
Add button in the header area (search bar section).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add conditional render**
|
||||||
|
|
||||||
|
Same pattern as Task 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: DimensionsView.tsx
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/DimensionsView.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports and state**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add maximize button**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add conditional render**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: PricingView.tsx
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/PricingView.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports and state**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add maximize button**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add conditional render**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: ArticleDetails.tsx
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/ArticleDetails.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports and state**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add maximize button**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add conditional render**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: PendingValidationView.tsx
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/PendingValidationView.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports and state**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add maximize button**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add conditional render**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: MissingDataView.tsx
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/MissingDataView.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports and state**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add maximize button**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add conditional render**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: HistoryView.tsx
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/HistoryView.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports and state**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add maximize button**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add conditional render**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Verification:** Run `npm run lint` to ensure no TypeScript errors.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Maximize Tab View — Spec
|
||||||
|
|
||||||
|
## Concept & Vision
|
||||||
|
|
||||||
|
Each tab view (Matrix, Product Descriptions, Dimensions, etc.) can be maximized to fullscreen mode, hiding all navigation chrome (Sidebar, TopBar) to maximize data visibility. A floating close button allows returning to normal view. The effect is immersive and focused — like pressing F11 in a browser.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Normal Mode
|
||||||
|
- Sidebar (240px) visible on left
|
||||||
|
- TopBar visible on top
|
||||||
|
- Main content fills remaining space
|
||||||
|
|
||||||
|
### Maximized Mode
|
||||||
|
- Sidebar: `display: none`
|
||||||
|
- TopBar: `display: none`
|
||||||
|
- Fullscreen overlay covers entire viewport
|
||||||
|
- Background: `#041021` (matches main content bg)
|
||||||
|
- Content area: max-height `100vh`, overflow scroll
|
||||||
|
- Floating close button: top-right corner, fixed position
|
||||||
|
|
||||||
|
### Close Button
|
||||||
|
- Position: fixed, top-right
|
||||||
|
- Size: 40x40px
|
||||||
|
- Background: `rgba(30, 41, 59, 0.9)` with blur
|
||||||
|
- Border: 1px `slate-600`
|
||||||
|
- Icon: `X` from lucide-react, white, 20px
|
||||||
|
- Hover: bg `slate-700`, scale 1.05
|
||||||
|
- Z-index: 50
|
||||||
|
- Tooltip: "Exit fullscreen"
|
||||||
|
|
||||||
|
## Layout & Structure
|
||||||
|
|
||||||
|
### Implementation Pattern
|
||||||
|
|
||||||
|
Each view component receives `isFullscreen?: boolean` prop and renders:
|
||||||
|
|
||||||
|
```
|
||||||
|
{maximizeButton && (
|
||||||
|
<button onClick={toggleFullscreen} className="..." title="Fullscreen">
|
||||||
|
<Maximize2 />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFullscreen ? (
|
||||||
|
<div className="fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto">
|
||||||
|
<button onClick={toggleFullscreen} className="fixed top-4 right-4 ...">
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
{/* Tab content rendered at full height */}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="...">
|
||||||
|
{/* Tab content normal */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Each tab manages its own `isFullscreen` state locally
|
||||||
|
- No global state needed — each tab remembers its own fullscreen mode
|
||||||
|
- State resets to normal when switching tabs (intentional)
|
||||||
|
|
||||||
|
## Features & Interactions
|
||||||
|
|
||||||
|
### Toggle Button
|
||||||
|
- Location: Each tab view has a maximize button (icon) in its header area
|
||||||
|
- Icon: `Maximize2` from lucide-react
|
||||||
|
- Click: Enter fullscreen mode
|
||||||
|
- Hover: Slight scale increase, cursor pointer
|
||||||
|
|
||||||
|
### Exit Fullscreen
|
||||||
|
- Click floating X button
|
||||||
|
- Instantly returns to normal layout
|
||||||
|
- No animation needed
|
||||||
|
|
||||||
|
## Component Inventory
|
||||||
|
|
||||||
|
### MaximizeButton (in each tab header)
|
||||||
|
- States: default, hover
|
||||||
|
- Color: `slate-400` default, `slate-200` hover
|
||||||
|
|
||||||
|
### FullscreenCloseButton (floating)
|
||||||
|
- States: default, hover, active
|
||||||
|
- Default: semi-transparent dark bg, subtle border
|
||||||
|
- Hover: lighter bg, slight scale
|
||||||
|
- Z-index ensures it's above all content
|
||||||
|
|
||||||
|
### FullscreenOverlay
|
||||||
|
- Fixed positioning
|
||||||
|
- Matches app background color
|
||||||
|
- Contains scrollable content
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
- Add `isFullscreen` state to each view component
|
||||||
|
- Add toggle function
|
||||||
|
- Conditional rendering based on state
|
||||||
|
- Icons from `lucide-react`: `Maximize2`, `X`
|
||||||
|
- All styling via Tailwind classes
|
||||||
|
- No additional dependencies
|
||||||
|
|
||||||
|
## Affected Components
|
||||||
|
|
||||||
|
1. `ProductDescriptions.tsx`
|
||||||
|
2. `MatrixView.tsx`
|
||||||
|
3. `DimensionsView.tsx`
|
||||||
|
4. `PricingView.tsx`
|
||||||
|
5. `ArticleDetails.tsx`
|
||||||
|
6. `PendingValidationView.tsx`
|
||||||
|
7. `MissingDataView.tsx`
|
||||||
|
8. `HistoryView.tsx`
|
||||||
Generated
+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
|
||||||
+1007
-112
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,54 @@
|
|||||||
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>;
|
||||||
|
dashboardDrilldown?: DashboardDrilldownRequest | null;
|
||||||
|
onDashboardDrilldownApplied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
||||||
|
|
||||||
export function ArticleDetails({ data, onEdit }: 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>>({
|
||||||
|
[COLUMNS.ARTICLE_NO]: 100,
|
||||||
|
[COLUMNS.ARTICLE_NAME]: 200,
|
||||||
|
[COLUMNS.CLASSIFICATION]: 80,
|
||||||
|
[COLUMNS.ITEM_AVAILABLE]: 80,
|
||||||
|
[COLUMNS.DETAILS_DE]: 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]);
|
||||||
|
|
||||||
@@ -70,7 +98,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [data, activeTab, search, lineFilter, sortCol, sortDesc]);
|
}, [data, activeTab, search, lineFilter, columnFilters, sortCol, sortDesc]);
|
||||||
|
|
||||||
const paginatedData = useMemo(() => {
|
const paginatedData = useMemo(() => {
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
@@ -88,6 +116,25 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResize = (colIndex: number, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startX = e.pageX;
|
||||||
|
const startWidth = columnWidths[colIndex] || 100;
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -131,9 +178,19 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
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); }}
|
||||||
@@ -147,6 +204,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
{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">
|
||||||
@@ -157,8 +222,16 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
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
|
||||||
@@ -168,7 +241,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
}}
|
}}
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
@@ -184,7 +257,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
|
|
||||||
<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">
|
||||||
<div className="overflow-x-auto flex-1">
|
<div className="overflow-x-auto flex-1">
|
||||||
<table className="w-full text-left text-xs">
|
<table className="w-full text-left text-xs" style={{ tableLayout: 'fixed' }}>
|
||||||
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
{[
|
{[
|
||||||
@@ -197,31 +270,40 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
].map(({ col, label }) => (
|
].map(({ col, label }) => (
|
||||||
<th
|
<th
|
||||||
key={col}
|
key={col}
|
||||||
className="px-3 py-3 font-medium transition-colors select-none group relative"
|
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' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center overflow-hidden">
|
||||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white" 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" /> : <ChevronUp className="w-3 h-3" />
|
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",
|
"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>
|
||||||
|
|
||||||
|
{/* Resizer handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={(e) => handleResize(col, e)}
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
|
||||||
|
/>
|
||||||
|
|
||||||
{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)}
|
||||||
@@ -239,17 +321,30 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
<th className="px-3 py-3 font-medium text-right">Edit</th>
|
<th className="px-3 py-3 font-medium text-right" style={{ width: 80 }}>Edit</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-700/30">
|
<tbody className="divide-y divide-slate-700/30">
|
||||||
{paginatedData.map(({ row, index }) => (
|
{paginatedData.map(({ row, index }) => {
|
||||||
<tr key={index} className="hover:bg-slate-700/20 transition-colors">
|
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||||
<td className="px-3 py-2 font-mono text-indigo-400">{row[COLUMNS.ARTICLE_NO]}</td>
|
return (
|
||||||
<td className="px-3 py-2 font-medium text-slate-200 max-w-[150px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-slate-700/20 transition-colors",
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>{row[COLUMNS.ARTICLE_NO]}</span>
|
||||||
|
{saveStatus && <SyncStatusPill status={saveStatus} className="self-start" />}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>
|
||||||
{row[COLUMNS.ARTICLE_NAME]}
|
{row[COLUMNS.ARTICLE_NAME]}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
|
"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"
|
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"
|
||||||
@@ -257,7 +352,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.ITEM_AVAILABLE] }}>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"font-mono font-bold",
|
"font-mono font-bold",
|
||||||
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
|
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
|
||||||
@@ -265,14 +360,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
{row[COLUMNS.ITEM_AVAILABLE] || 0}
|
{row[COLUMNS.ITEM_AVAILABLE] || 0}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_DE] }}>
|
||||||
{row[COLUMNS.DETAILS_DE] ? (
|
{row[COLUMNS.DETAILS_DE] ? (
|
||||||
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
|
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
|
||||||
) : getBadge(null)}
|
) : getBadge(null)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_EN] }}>
|
||||||
{row[COLUMNS.DETAILS_EN] ? (
|
{row[COLUMNS.DETAILS_EN] ? (
|
||||||
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
|
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
|
||||||
) : getBadge(null)}
|
) : getBadge(null)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
@@ -284,7 +379,8 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{paginatedData.length === 0 && (
|
{paginatedData.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Search, Check, X } 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,89 +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 className={cn(
|
<div className="fixed inset-0 z-0 pointer-events-none">
|
||||||
"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",
|
{portalContainer && createPortal(
|
||||||
className
|
<div
|
||||||
)}>
|
className={cn(
|
||||||
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
|
"absolute w-72 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100 pointer-events-auto",
|
||||||
|
className
|
||||||
<div className="relative">
|
)}
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
style={{
|
||||||
<input
|
zIndex: 9999,
|
||||||
type="text"
|
top: position.top,
|
||||||
placeholder="Filter values..."
|
left: position.left
|
||||||
value={search}
|
}}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onClick={(e) => e.stopPropagation()}
|
||||||
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 => (
|
|
||||||
<label key={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>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="hidden"
|
|
||||||
checked={selectedValues.includes(val)}
|
|
||||||
onChange={() => onToggle(val)}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
{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 } 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[];
|
||||||
@@ -11,6 +14,8 @@ interface DimensionsViewProps {
|
|||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
|
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
|
||||||
onCaptureState: (message: string) => void;
|
onCaptureState: (message: string) => void;
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
|
onRevertRow: (articleNo: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DimensionGroup {
|
interface DimensionGroup {
|
||||||
@@ -32,7 +37,8 @@ interface NearDuplicateCluster {
|
|||||||
maxDiffPct: number;
|
maxDiffPct: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState }: 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);
|
||||||
@@ -45,14 +51,27 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
|
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
|
||||||
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
|
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
|
||||||
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
|
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
|
||||||
clusterIndex: number;
|
clusterKey: string;
|
||||||
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 }[]>();
|
||||||
@@ -100,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,
|
||||||
@@ -119,15 +140,25 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
|
|
||||||
const filteredGroups = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
let result = groups;
|
let result = groups;
|
||||||
if (showOnlyInconsistent) result = result.filter(g => g.isInconsistent);
|
if (showOnlyInconsistent) {
|
||||||
|
result = result.filter(g =>
|
||||||
|
g.isInconsistent ||
|
||||||
|
g.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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] || '')));
|
||||||
|
|
||||||
@@ -136,7 +167,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter]);
|
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter, rowStatuses]);
|
||||||
|
|
||||||
const uniqueLines = useMemo(() =>
|
const uniqueLines = useMemo(() =>
|
||||||
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
|
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
|
||||||
@@ -196,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)) {
|
||||||
@@ -214,11 +294,63 @@ 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 { clusterIndex, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
||||||
|
|
||||||
const cluster = nearDuplicateClusters[clusterIndex];
|
const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey);
|
||||||
|
if (!cluster) return;
|
||||||
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
|
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
|
||||||
if (!targetGroup) return;
|
if (!targetGroup) return;
|
||||||
|
|
||||||
@@ -241,7 +373,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
|
|
||||||
setClusterSelections(prev => {
|
setClusterSelections(prev => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[clusterIndex];
|
delete next[pendingNearDupSync.clusterKey];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -290,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" />
|
||||||
@@ -299,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",
|
||||||
@@ -317,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])}
|
||||||
@@ -331,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",
|
||||||
@@ -342,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])}
|
||||||
@@ -360,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>
|
||||||
@@ -377,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>
|
||||||
@@ -394,11 +554,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
</div>
|
</div>
|
||||||
{nearDuplicateClusters.map((cluster, ci) => {
|
{nearDuplicateClusters.map((cluster, ci) => {
|
||||||
const allClusterRows = cluster.groups.flatMap(g => g.rows);
|
const allClusterRows = cluster.groups.flatMap(g => g.rows);
|
||||||
const selection = clusterSelections[ci] ?? new Set<number>();
|
const clusterKey = cluster.groups[0].key;
|
||||||
const targetKey = clusterSyncTargets[ci] ?? cluster.groups[0].key;
|
const selection = clusterSelections[clusterKey] ?? new Set<number>();
|
||||||
|
const targetKey = clusterSyncTargets[clusterKey] ?? cluster.groups[0].key;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
<div key={clusterKey} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = new Set(expandedNearDuplicates);
|
const next = new Set(expandedNearDuplicates);
|
||||||
@@ -422,12 +583,11 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
|
|
||||||
{expandedNearDuplicates.has(ci) && (
|
{expandedNearDuplicates.has(ci) && (
|
||||||
<div className="border-t border-violet-500/20">
|
<div className="border-t border-violet-500/20">
|
||||||
{/* Sync toolbar */}
|
|
||||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-violet-500/5 border-b border-violet-500/10 flex-wrap">
|
<div className="flex items-center gap-3 px-4 py-2.5 bg-violet-500/5 border-b border-violet-500/10 flex-wrap">
|
||||||
<span className="text-xs text-slate-400">Sync selected to:</span>
|
<span className="text-xs text-slate-400">Sync selected to:</span>
|
||||||
<select
|
<select
|
||||||
value={targetKey}
|
value={targetKey}
|
||||||
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [ci]: e.target.value }))}
|
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [clusterKey]: e.target.value }))}
|
||||||
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-violet-500"
|
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-violet-500"
|
||||||
>
|
>
|
||||||
{cluster.groups.map(g => {
|
{cluster.groups.map(g => {
|
||||||
@@ -441,21 +601,21 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
disabled={selection.size === 0}
|
disabled={selection.size === 0}
|
||||||
onClick={() => setPendingNearDupSync({ clusterIndex: ci, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
|
onClick={() => setPendingNearDupSync({ clusterKey, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
|
||||||
className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Layers className="w-3 h-3" />
|
<Layers className="w-3 h-3" />
|
||||||
Sync {selection.size} selected
|
Sync {selection.size} selected
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set(allClusterRows.map(r => r.index)) }))}
|
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set(allClusterRows.map(r => r.index)) }))}
|
||||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Select all
|
Select all
|
||||||
</button>
|
</button>
|
||||||
{selection.size > 0 && (
|
{selection.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set() }))}
|
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set() }))}
|
||||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
@@ -463,29 +623,67 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flat product list */}
|
|
||||||
<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[ci] ?? []);
|
// Only handle left click
|
||||||
if (current.has(index)) current.delete(index); else current.add(index);
|
if (e.button !== 0) return;
|
||||||
return { ...prev, [ci]: 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}
|
||||||
onChange={() => {}}
|
readOnly
|
||||||
onClick={e => e.stopPropagation()}
|
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"
|
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -509,11 +707,14 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredGroups.map(group => (
|
{filteredGroups.map(group => {
|
||||||
<div key={group.key} className={cn(
|
const hasPending = group.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending');
|
||||||
"border rounded-lg overflow-hidden transition-all",
|
return (
|
||||||
group.isInconsistent ? "border-amber-500/30 bg-amber-500/5" : "border-slate-700 bg-slate-800/30"
|
<div key={group.key} className={cn(
|
||||||
)}>
|
"border rounded-lg overflow-hidden transition-all",
|
||||||
|
hasPending ? "border-yellow-500/50 bg-yellow-500/5 ring-1 ring-yellow-500/20" :
|
||||||
|
group.isInconsistent ? "border-amber-500/30 bg-amber-500/5" : "border-slate-700 bg-slate-800/30"
|
||||||
|
)}>
|
||||||
<div className="flex items-center justify-between bg-slate-800/20 pr-4">
|
<div className="flex items-center justify-between bg-slate-800/20 pr-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleGroup(group.key)}
|
onClick={() => toggleGroup(group.key)}
|
||||||
@@ -525,17 +726,22 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
<span className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-0.5 rounded">
|
<span className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-0.5 rounded">
|
||||||
Inner: {group.innerDims} cm
|
Inner: {group.innerDims} cm
|
||||||
</span>
|
</span>
|
||||||
|
{hasPending && (
|
||||||
|
<span className="flex items-center gap-1 text-xs font-medium text-yellow-500 bg-yellow-500/10 px-2 py-0.5 rounded ring-1 ring-yellow-500/20">
|
||||||
|
Pending Validation
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{group.isInconsistent ? (
|
{group.isInconsistent ? (
|
||||||
<span className="flex items-center gap-1 text-xs font-medium text-amber-500 bg-amber-500/10 px-2 py-0.5 rounded ring-1 ring-amber-500/20">
|
<span className="flex items-center gap-1 text-xs font-medium text-amber-500 bg-amber-500/10 px-2 py-0.5 rounded ring-1 ring-amber-500/20">
|
||||||
<AlertTriangle className="w-3 h-3" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
Inconsistent
|
Inconsistent
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : !hasPending ? (
|
||||||
<span className="flex items-center gap-1 text-xs font-medium text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded ring-1 ring-emerald-500/20">
|
<span className="flex items-center gap-1 text-xs font-medium text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded ring-1 ring-emerald-500/20">
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
Consistent
|
Consistent
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 mt-1">
|
<div className="text-xs text-slate-500 mt-1">
|
||||||
{group.rows.length} product{group.rows.length !== 1 ? 's' : ''} in this dimension group
|
{group.rows.length} product{group.rows.length !== 1 ? 's' : ''} in this dimension group
|
||||||
@@ -562,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
|
||||||
@@ -584,90 +801,166 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<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 }) => {
|
||||||
<tr key={index} className="hover:bg-slate-700/20 group transition-colors">
|
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
|
||||||
|
const syncStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-slate-700/20 group transition-colors",
|
||||||
|
isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
<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">
|
||||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleFullSync(group, row)}
|
onClick={() => handleFullSync(group, row)}
|
||||||
title="FULL SYNC: Apply ALL packaging measures labels to all in group"
|
title="FULL SYNC: Apply ALL packaging measures labels to all in group"
|
||||||
disabled={syncing?.key === group.key}
|
disabled={syncing?.key === group.key}
|
||||||
className="p-1.5 hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 rounded transition-all"
|
className="p-1.5 hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 rounded transition-all opacity-0 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
{syncing?.key === group.key && syncing?.field === 'all' ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
{syncing?.key === group.key && syncing?.field === 'all' ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRevertRow(String(row[COLUMNS.ARTICLE_NO]))}
|
||||||
|
title="Undo pending changes"
|
||||||
|
className="p-1.5 hover:bg-red-600/20 text-yellow-500 hover:text-red-400 rounded transition-all"
|
||||||
|
>
|
||||||
|
<Undo2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(index)}
|
onClick={() => onEdit(index)}
|
||||||
title="Edit product"
|
title="Edit product"
|
||||||
className="p-1.5 hover:bg-slate-600/20 text-slate-500 hover:text-slate-300 rounded transition-all"
|
className="p-1.5 hover:bg-slate-600/20 text-slate-500 hover:text-slate-300 rounded transition-all opacity-0 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{filteredGroups.length === 0 && (
|
{filteredGroups.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 bg-slate-800/20 border border-dashed border-slate-700 rounded-xl">
|
<div className="flex flex-col items-center justify-center py-20 bg-slate-800/20 border border-dashed border-slate-700 rounded-xl">
|
||||||
|
|||||||
+315
-101
@@ -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 {
|
||||||
@@ -13,7 +15,161 @@ interface EditPanelProps {
|
|||||||
onCaptureState: (message: string) => void;
|
onCaptureState: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DimensionInputProps {
|
||||||
|
label: string;
|
||||||
|
field: string;
|
||||||
|
value: string;
|
||||||
|
isModified: boolean;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DimensionInput = ({ label, value, isModified, onChange, placeholder }: DimensionInputProps) => (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">{label}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-slate-900 border rounded p-2 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"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AutoResizeTextarea = ({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onKeyDown,
|
||||||
|
className,
|
||||||
|
placeholder
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||||
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}) => {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const adjustHeight = useCallback(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adjustHeight();
|
||||||
|
}, [value, adjustHeight]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
className={cn(className, "overflow-hidden resize-none")}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FieldEditorProps {
|
||||||
|
title: string;
|
||||||
|
field: string;
|
||||||
|
value: string;
|
||||||
|
isModified: boolean;
|
||||||
|
canTranslate: boolean;
|
||||||
|
isGenerated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
onGenerate: () => void;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
|
isListening?: boolean;
|
||||||
|
onToggleVoice?: () => void;
|
||||||
|
voiceSupported?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldEditor = ({
|
||||||
|
title,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
isModified,
|
||||||
|
canTranslate,
|
||||||
|
isGenerated,
|
||||||
|
isLoading,
|
||||||
|
onGenerate,
|
||||||
|
onChange,
|
||||||
|
onKeyDown,
|
||||||
|
isListening,
|
||||||
|
onToggleVoice,
|
||||||
|
voiceSupported
|
||||||
|
}: FieldEditorProps) => (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-slate-300">{title}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canTranslate && (
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-slate-500 italic">
|
||||||
|
<Languages className="w-3 h-3" />
|
||||||
|
Can translate from existing
|
||||||
|
</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
|
||||||
|
onClick={onGenerate}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-2 py-1 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
||||||
|
{canTranslate ? 'Translate with Gemini' : 'Generate with Gemini'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isGenerated && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
AI Generated - You can still edit manually
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AutoResizeTextarea
|
||||||
|
id={`field-${field}`}
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
className={cn(
|
||||||
|
"w-full min-h-[128px] bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
||||||
|
isModified ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||||
|
)}
|
||||||
|
placeholder={`Enter ${title}...`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
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] || '',
|
||||||
@@ -21,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);
|
||||||
@@ -37,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,
|
||||||
@@ -53,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);
|
||||||
|
|
||||||
@@ -74,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. You excel at translating product descriptions between German and English, maintaining the commercial and professional tone while ensuring all technical toy details are accurate. Use clear, engaging language.";
|
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) {
|
||||||
@@ -84,11 +260,14 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
2. Maintain the EXACT same length and detailed information.
|
2. Maintain the EXACT same length and detailed information.
|
||||||
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.
|
||||||
|
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) {
|
||||||
@@ -98,37 +277,63 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
2. Maintain the EXACT same length and detailed information.
|
2. Maintain the EXACT same length and detailed information.
|
||||||
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.
|
||||||
|
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:\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:\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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const generatedText = await generateGemini(prompt, systemPrompt);
|
const generatedText = await generateGemini(prompt, systemPrompt);
|
||||||
setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
|
setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
|
||||||
setGeneratedFields(prev => new Set(prev).add(field));
|
setGeneratedFields(prev => new Set(prev).add(field));
|
||||||
|
// Focus the textarea so user can edit immediately after AI generation
|
||||||
|
setTimeout(() => {
|
||||||
|
const textarea = document.getElementById(`field-${field}`);
|
||||||
|
if (textarea) textarea.focus();
|
||||||
|
}, 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);
|
||||||
}
|
}
|
||||||
@@ -155,66 +360,14 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DimensionInput = ({ label, field, placeholder }: { label: string, field: keyof typeof formData, placeholder?: string }) => (
|
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
||||||
<div className="flex flex-col gap-1.5">
|
e.stopPropagation();
|
||||||
<label className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">{label}</label>
|
};
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData[field]}
|
|
||||||
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={cn(
|
|
||||||
"w-full bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
|
||||||
isModified(field) ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FieldEditor = ({ title, field }: { title: string, field: keyof typeof formData }) => (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium text-slate-300">{title}</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{((field === 'longDe' && formData.longEn) ||
|
|
||||||
(field === 'longEn' && formData.longDe) ||
|
|
||||||
(field === 'shortDe' && (formData.longDe || formData.shortEn)) ||
|
|
||||||
(field === 'shortEn' && (formData.shortDe || formData.longEn))) && (
|
|
||||||
<div className="flex items-center gap-1 text-[10px] text-slate-500 italic">
|
|
||||||
<Languages className="w-3 h-3" />
|
|
||||||
Can translate from existing
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleGenerate(field)}
|
|
||||||
disabled={loadingField !== null}
|
|
||||||
className="flex items-center gap-1.5 text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-2 py-1 rounded transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loadingField === field ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
|
||||||
{((field === 'longDe' && formData.longEn) || (field === 'longEn' && formData.longDe) || (field === 'shortDe' && formData.shortEn) || (field === 'shortEn' && formData.shortDe)) ? 'Translate with Gemini' : 'Generate with Gemini'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{generatedFields.has(field) && (
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
|
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
|
||||||
AI Generated - You can still edit manually
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={formData[field]}
|
|
||||||
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
|
|
||||||
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",
|
|
||||||
isModified(field) ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
|
||||||
)}
|
|
||||||
placeholder={`Enter ${title}...`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -248,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..."
|
||||||
@@ -260,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..."
|
||||||
@@ -279,28 +432,83 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="col-span-3 text-[10px] text-slate-500 font-medium">INNER BOX (L × W × H) cm</div>
|
<div className="col-span-3 text-[10px] text-slate-500 font-medium">INNER BOX (L × W × H) cm</div>
|
||||||
<DimensionInput label="Length" field="innerL" placeholder="L" />
|
<DimensionInput label="Length" value={formData.innerL} field="innerL" isModified={isModified('innerL')} onChange={(val) => setFormData(p => ({...p, innerL: val}))} placeholder="L" />
|
||||||
<DimensionInput label="Width" field="innerW" placeholder="W" />
|
<DimensionInput label="Width" value={formData.innerW} field="innerW" isModified={isModified('innerW')} onChange={(val) => setFormData(p => ({...p, innerW: val}))} placeholder="W" />
|
||||||
<DimensionInput label="Height" field="innerH" placeholder="H" />
|
<DimensionInput label="Height" value={formData.innerH} field="innerH" isModified={isModified('innerH')} onChange={(val) => setFormData(p => ({...p, innerH: val}))} placeholder="H" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="col-span-3 text-[10px] text-slate-500 font-medium">OUTER BOX (L × W × H) cm</div>
|
<div className="col-span-3 text-[10px] text-slate-500 font-medium">OUTER BOX (L × W × H) cm</div>
|
||||||
<DimensionInput label="Length" field="outerL" placeholder="L" />
|
<DimensionInput label="Length" value={formData.outerL} field="outerL" isModified={isModified('outerL')} onChange={(val) => setFormData(p => ({...p, outerL: val}))} placeholder="L" />
|
||||||
<DimensionInput label="Width" field="outerW" placeholder="W" />
|
<DimensionInput label="Width" value={formData.outerW} field="outerW" isModified={isModified('outerW')} onChange={(val) => setFormData(p => ({...p, outerW: val}))} placeholder="W" />
|
||||||
<DimensionInput label="Height" field="outerH" placeholder="H" />
|
<DimensionInput label="Height" value={formData.outerH} field="outerH" isModified={isModified('outerH')} onChange={(val) => setFormData(p => ({...p, outerH: val}))} placeholder="H" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<DimensionInput label="Units per Outer" field="unitsOuter" />
|
<DimensionInput label="Units per Outer" value={formData.unitsOuter} field="unitsOuter" isModified={isModified('unitsOuter')} onChange={(val) => setFormData(p => ({...p, unitsOuter: val}))} />
|
||||||
<DimensionInput label="MOQ" field="moq" />
|
<DimensionInput label="MOQ" value={formData.moq} field="moq" isModified={isModified('moq')} onChange={(val) => setFormData(p => ({...p, moq: val}))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-2 border-t border-slate-700/30">
|
||||||
|
<DimensionInput label="CategorizationCode" value={formData.productType} field="productType" isModified={isModified('productType')} onChange={(val) => setFormData(p => ({...p, productType: val}))} />
|
||||||
|
<DimensionInput label="Item to Logistic" value={formData.itemToLogistic} field="itemToLogistic" isModified={isModified('itemToLogistic')} onChange={(val) => setFormData(p => ({...p, itemToLogistic: val}))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldEditor title="Long Description (DE)" field="longDe" />
|
<FieldEditor
|
||||||
<FieldEditor title="Long Description (EN)" field="longEn" />
|
title="Long Description (DE)"
|
||||||
<FieldEditor title="Short Description (DE)" field="shortDe" />
|
field="longDe"
|
||||||
<FieldEditor title="Short Description (EN)" field="shortEn" />
|
value={formData.longDe}
|
||||||
|
isModified={isModified('longDe')}
|
||||||
|
canTranslate={!!formData.longEn}
|
||||||
|
isGenerated={generatedFields.has('longDe')}
|
||||||
|
isLoading={loadingField === 'longDe'}
|
||||||
|
onGenerate={() => handleGenerate('longDe')}
|
||||||
|
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
isListening={isListeningDe}
|
||||||
|
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
|
||||||
|
voiceSupported={speechSupported}
|
||||||
|
/>
|
||||||
|
<FieldEditor
|
||||||
|
title="Long Description (EN)"
|
||||||
|
field="longEn"
|
||||||
|
value={formData.longEn}
|
||||||
|
isModified={isModified('longEn')}
|
||||||
|
canTranslate={!!formData.longDe}
|
||||||
|
isGenerated={generatedFields.has('longEn')}
|
||||||
|
isLoading={loadingField === 'longEn'}
|
||||||
|
onGenerate={() => handleGenerate('longEn')}
|
||||||
|
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
isListening={isListeningEn}
|
||||||
|
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
|
||||||
|
voiceSupported={speechSupported}
|
||||||
|
/>
|
||||||
|
<FieldEditor
|
||||||
|
title="Short Description (DE)"
|
||||||
|
field="shortDe"
|
||||||
|
value={formData.shortDe}
|
||||||
|
isModified={isModified('shortDe')}
|
||||||
|
canTranslate={!!formData.shortEn}
|
||||||
|
isGenerated={generatedFields.has('shortDe')}
|
||||||
|
isLoading={loadingField === 'shortDe'}
|
||||||
|
onGenerate={() => handleGenerate('shortDe')}
|
||||||
|
onChange={(val) => setFormData(p => ({...p, shortDe: val}))}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
/>
|
||||||
|
<FieldEditor
|
||||||
|
title="Short Description (EN)"
|
||||||
|
field="shortEn"
|
||||||
|
value={formData.shortEn}
|
||||||
|
isModified={isModified('shortEn')}
|
||||||
|
canTranslate={!!formData.shortDe}
|
||||||
|
isGenerated={generatedFields.has('shortEn')}
|
||||||
|
isLoading={loadingField === 'shortEn'}
|
||||||
|
onGenerate={() => handleGenerate('shortEn')}
|
||||||
|
onChange={(val) => setFormData(p => ({...p, shortEn: val}))}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
|
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
|
||||||
@@ -315,7 +523,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
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"
|
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" />
|
<Save className="w-4 h-4" />
|
||||||
Save to Memory
|
Queue Changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@@ -330,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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,639 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2, Maximize2, Check, Loader2, CloudUpload } from 'lucide-react';
|
||||||
|
import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
|
||||||
|
import { ExcelRow } from '../types';
|
||||||
|
import { useColumns } from '../contexts/ColumnsContext';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
|
import { previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
|
||||||
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
|
||||||
|
interface HistoryViewProps {
|
||||||
|
headers: string[];
|
||||||
|
data: ExcelRow[];
|
||||||
|
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: string) => void;
|
||||||
|
onEdit?: (rowIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistorySyncStatus = 'bc_pending' | 'previewed' | 'preview_only' | 'syncing' | 'synced' | 'failed';
|
||||||
|
|
||||||
|
interface HistorySyncRecord {
|
||||||
|
selected: boolean;
|
||||||
|
status: HistorySyncStatus;
|
||||||
|
previewToken?: string;
|
||||||
|
error?: string;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistorySyncMap = Record<string, HistorySyncRecord>;
|
||||||
|
|
||||||
|
function readStoredHistorySyncMap(): HistorySyncMap {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('history-bcSync');
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||||
|
return parsed as HistorySyncMap;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProps) {
|
||||||
|
const COLUMNS = useColumns();
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = usePersistentState('history-search', '');
|
||||||
|
const [userFilter, setUserFilter] = usePersistentState('history-userFilter', '');
|
||||||
|
const [statusFilter, setStatusFilter] = usePersistentState<'all' | 'bc_pending' | 'previewed' | 'preview_only' | 'synced' | 'failed'>('history-statusFilter', 'all');
|
||||||
|
const [bcHistorySync, setBcHistorySync] = useState<HistorySyncMap>(() => readStoredHistorySyncMap());
|
||||||
|
const [bcSyncBusy, setBcSyncBusy] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length > 0) {
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('history-bcSync', JSON.stringify(bcHistorySync));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage quota or serialization errors.
|
||||||
|
}
|
||||||
|
}, [bcHistorySync]);
|
||||||
|
|
||||||
|
const loadHistory = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const historyData = await getHistory();
|
||||||
|
setHistory(historyData);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (history.length === 0) return;
|
||||||
|
const validKeys = new Set(history.map(entry => String(entry.id || `${entry.product_id}-${entry.changed_at}`)));
|
||||||
|
setBcHistorySync(prev => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, HistorySyncRecord> = {};
|
||||||
|
for (const [key, value] of Object.entries(prev)) {
|
||||||
|
if (!validKeys.has(key)) {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next[key] = value as HistorySyncRecord;
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const handleRevert = (entry: HistoryEntry) => {
|
||||||
|
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
|
||||||
|
const currentRow = data.find(r => String(r[0]) === entry.product_id);
|
||||||
|
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 changes: { header: string; old: any; new: any; index: number }[] = [];
|
||||||
|
const maxLen = Math.max(oldData.length, newData.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
if (oldData[i] !== newData[i]) {
|
||||||
|
changes.push({
|
||||||
|
header: headers[i] || `Col ${i}`,
|
||||||
|
old: oldData[i],
|
||||||
|
new: newData[i],
|
||||||
|
index: i
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uniqueUsers = Array.from(new Set(history.map(e => e.changed_by))).sort();
|
||||||
|
|
||||||
|
const getEntryKey = (entry: HistoryEntry) => String(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||||
|
|
||||||
|
const getEntryStatus = (entry: HistoryEntry): HistorySyncStatus => {
|
||||||
|
return bcHistorySync[getEntryKey(entry)]?.status || 'bc_pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEntrySelected = (entry: HistoryEntry): boolean => {
|
||||||
|
return bcHistorySync[getEntryKey(entry)]?.selected || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// history is asc (oldest first) so index+1 = chronological #
|
||||||
|
// display newest first by reversing for render only
|
||||||
|
const filteredHistory = [...history].reverse().filter(entry => {
|
||||||
|
if (userFilter && entry.changed_by !== userFilter) return false;
|
||||||
|
const status = getEntryStatus(entry);
|
||||||
|
if (statusFilter !== 'all' && status !== statusFilter) return false;
|
||||||
|
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if (terms.length === 0) return true;
|
||||||
|
const searchableText = `${entry.article_name} ${entry.product_id} ${entry.changed_by}`.toLowerCase();
|
||||||
|
return terms.every(term => searchableText.includes(term));
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedEntries = useMemo(
|
||||||
|
() => history.filter(entry => getEntrySelected(entry)),
|
||||||
|
[history, bcHistorySync]
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusCounts = useMemo(() => {
|
||||||
|
const counts = { bc_pending: 0, previewed: 0, preview_only: 0, synced: 0, failed: 0 };
|
||||||
|
history.forEach(entry => {
|
||||||
|
const status = getEntryStatus(entry);
|
||||||
|
if (status in counts) counts[status as keyof typeof counts] += 1;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [history, bcHistorySync]);
|
||||||
|
|
||||||
|
const updateEntry = (entry: HistoryEntry, patch: Partial<HistorySyncRecord>) => {
|
||||||
|
const key = getEntryKey(entry);
|
||||||
|
setBcHistorySync(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: {
|
||||||
|
selected: prev[key]?.selected ?? false,
|
||||||
|
status: prev[key]?.status ?? 'bc_pending',
|
||||||
|
previewToken: prev[key]?.previewToken,
|
||||||
|
error: prev[key]?.error,
|
||||||
|
...prev[key],
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllVisible = (selected: boolean) => {
|
||||||
|
setBcHistorySync(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
filteredHistory.forEach(entry => {
|
||||||
|
const key = getEntryKey(entry);
|
||||||
|
next[key] = {
|
||||||
|
selected,
|
||||||
|
status: next[key]?.status ?? 'bc_pending',
|
||||||
|
previewToken: next[key]?.previewToken,
|
||||||
|
error: next[key]?.error,
|
||||||
|
warning: next[key]?.warning,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const discardSelected = () => {
|
||||||
|
if (selectedEntries.length === 0) return;
|
||||||
|
if (!window.confirm(`Discard ${selectedEntries.length} pending sync(s)? They will be reset to "Pending BC" and will not be synced to Business Central.`)) return;
|
||||||
|
setBcHistorySync(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
selectedEntries.forEach(entry => {
|
||||||
|
const key = getEntryKey(entry);
|
||||||
|
next[key] = { selected: false, status: 'bc_pending' };
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewSelected = async () => {
|
||||||
|
if (selectedEntries.length === 0) return;
|
||||||
|
setBcSyncBusy(true);
|
||||||
|
try {
|
||||||
|
for (const entry of [...selectedEntries].sort((a, b) => {
|
||||||
|
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
|
||||||
|
if (timeDelta !== 0) return timeDelta;
|
||||||
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||||
|
})) {
|
||||||
|
const preview = await previewBusinessCentralSync(headers, entry.new_data);
|
||||||
|
if (!preview.success) {
|
||||||
|
updateEntry(entry, { status: 'failed', error: preview.error || 'Preview failed', warning: undefined, selected: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
updateEntry(entry, {
|
||||||
|
status: 'previewed',
|
||||||
|
previewToken: preview.previewToken,
|
||||||
|
error: undefined,
|
||||||
|
warning: undefined,
|
||||||
|
selected: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBcSyncBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncSelected = async () => {
|
||||||
|
if (selectedEntries.length === 0) return;
|
||||||
|
setBcSyncBusy(true);
|
||||||
|
try {
|
||||||
|
const orderedEntries = [...selectedEntries].sort((a, b) => {
|
||||||
|
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
|
||||||
|
if (timeDelta !== 0) return timeDelta;
|
||||||
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of orderedEntries) {
|
||||||
|
updateEntry(entry, { status: 'syncing', error: undefined, selected: true });
|
||||||
|
const key = getEntryKey(entry);
|
||||||
|
const currentRecord = bcHistorySync[key];
|
||||||
|
let previewToken = currentRecord?.previewToken;
|
||||||
|
|
||||||
|
if (!previewToken) {
|
||||||
|
const preview = await previewBusinessCentralSync(headers, entry.new_data);
|
||||||
|
if (!preview.success) {
|
||||||
|
updateEntry(entry, { status: 'failed', error: preview.error || 'Preview failed', warning: undefined, selected: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
previewToken = preview.previewToken;
|
||||||
|
updateEntry(entry, { status: 'previewed', previewToken, error: undefined, warning: undefined, selected: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apply = await applyBusinessCentralSync(headers, entry.new_data, previewToken);
|
||||||
|
if (!apply.success) {
|
||||||
|
if (isPreviewTokenMismatchError(apply.error)) {
|
||||||
|
const refreshedPreview = await previewBusinessCentralSync(headers, entry.new_data);
|
||||||
|
if (refreshedPreview.success) {
|
||||||
|
updateEntry(entry, {
|
||||||
|
status: 'previewed',
|
||||||
|
previewToken: refreshedPreview.previewToken,
|
||||||
|
error: undefined,
|
||||||
|
warning: undefined,
|
||||||
|
selected: true,
|
||||||
|
});
|
||||||
|
const retryApply = await applyBusinessCentralSync(headers, entry.new_data, refreshedPreview.previewToken);
|
||||||
|
if (retryApply.success) {
|
||||||
|
updateEntry(entry, {
|
||||||
|
status: 'synced',
|
||||||
|
error: undefined,
|
||||||
|
warning: retryApply.warning,
|
||||||
|
selected: false,
|
||||||
|
previewToken: retryApply.previewToken || refreshedPreview.previewToken,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
updateEntry(entry, { status: 'failed', error: retryApply.error || 'BC sync failed', warning: retryApply.warning, selected: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateEntry(entry, { status: 'failed', error: apply.error || 'BC sync failed', warning: apply.warning, selected: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEntry(entry, {
|
||||||
|
status: 'synced',
|
||||||
|
error: undefined,
|
||||||
|
warning: apply.warning,
|
||||||
|
selected: false,
|
||||||
|
previewToken: apply.previewToken || previewToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBcSyncBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-slate-400">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
|
||||||
|
<p className="text-lg">Fetching history...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500"}>
|
||||||
|
{isFullscreen && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
className="fixed top-4 right-4 z-50 p-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors border border-slate-700"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<History className="w-8 h-8 text-blue-500" />
|
||||||
|
Change History
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 mt-1">
|
||||||
|
{history.length > 0 ? (
|
||||||
|
<><span className="text-white font-semibold">{history.length}</span> total records{filteredHistory.length !== history.length && <> — showing <span className="text-white font-semibold">{filteredHistory.length}</span></>}</>
|
||||||
|
) : 'Review and revert any changes made to products.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
||||||
|
<select
|
||||||
|
value={userFilter}
|
||||||
|
onChange={e => setUserFilter(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"pl-9 pr-8 py-2 bg-slate-800 border rounded-md text-sm focus:outline-none focus:border-blue-500 transition-colors w-52 appearance-none",
|
||||||
|
userFilter ? "border-blue-500 text-white" : "border-slate-700 text-slate-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">All users</option>
|
||||||
|
{uniqueUsers.map(u => (
|
||||||
|
<option key={u} value={u}>{u}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{userFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setUserFilter('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search history..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={loadHistory}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors flex items-center gap-2 border border-slate-700"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||||
|
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors border border-slate-700"
|
||||||
|
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/70 border border-slate-800 rounded-xl p-4 flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<span className="font-semibold text-slate-200">{selectedEntries.length}</span> selected
|
||||||
|
<span className="text-slate-600">·</span>
|
||||||
|
<span>{statusCounts.bc_pending} pending</span>
|
||||||
|
<span>{statusCounts.previewed} previewed</span>
|
||||||
|
<span>{statusCounts.preview_only} preview only</span>
|
||||||
|
<span>{statusCounts.synced} synced</span>
|
||||||
|
<span>{statusCounts.failed} failed</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2 flex-wrap">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value as any)}
|
||||||
|
className="bg-slate-800 border border-slate-700 rounded-md px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
<option value="bc_pending">Pending BC</option>
|
||||||
|
<option value="previewed">Previewed</option>
|
||||||
|
<option value="preview_only">Preview only</option>
|
||||||
|
<option value="synced">Synced BC</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => selectAllVisible(true)}
|
||||||
|
className="px-3 py-2 rounded-md bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs border border-slate-700"
|
||||||
|
>
|
||||||
|
Select all visible
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectAllVisible(false)}
|
||||||
|
className="px-3 py-2 rounded-md bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs border border-slate-700"
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={discardSelected}
|
||||||
|
disabled={selectedEntries.length === 0}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 rounded-md text-xs border transition-colors",
|
||||||
|
selectedEntries.length === 0
|
||||||
|
? "bg-slate-700 text-slate-500 border-slate-600 cursor-not-allowed"
|
||||||
|
: "bg-slate-800 hover:bg-red-900/40 text-red-400 border-red-500/30 hover:border-red-500/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Discard selected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={previewSelected}
|
||||||
|
disabled={bcSyncBusy || selectedEntries.length === 0}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-xs font-semibold border transition-colors",
|
||||||
|
bcSyncBusy || selectedEntries.length === 0
|
||||||
|
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
|
||||||
|
: "bg-indigo-600 hover:bg-indigo-500 text-white border-indigo-500/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bcSyncBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||||
|
Preview selected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={syncSelected}
|
||||||
|
disabled={bcSyncBusy || selectedEntries.length === 0}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-xs font-semibold border transition-colors",
|
||||||
|
bcSyncBusy || selectedEntries.length === 0
|
||||||
|
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
|
||||||
|
: "bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-500/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bcSyncBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CloudUpload className="w-3.5 h-3.5" />}
|
||||||
|
Sync selected to BC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0a1628] border border-slate-800 rounded-xl overflow-hidden shadow-2xl">
|
||||||
|
{filteredHistory.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<History className="w-12 h-12 text-slate-700 mx-auto mb-4" />
|
||||||
|
<p className="text-slate-500 text-lg">No history records found.</p>
|
||||||
|
<p className="text-slate-600 text-sm mt-1">
|
||||||
|
{search ? "Try adjusting your search filters." : "Changes are recorded when you 'Save All' pending modifications."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-800">
|
||||||
|
{filteredHistory.map((entry, filteredIdx) => {
|
||||||
|
const isExpanded = expandedId === entry.id;
|
||||||
|
const changes = getChangedFields(entry.old_data, entry.new_data);
|
||||||
|
const globalNumber = history.indexOf(entry) + 1;
|
||||||
|
const status = getEntryStatus(entry);
|
||||||
|
const selected = getEntrySelected(entry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
isExpanded ? "bg-blue-600/5" : "hover:bg-slate-800/30"
|
||||||
|
)}>
|
||||||
|
{/* Summary Row */}
|
||||||
|
<div
|
||||||
|
className="p-4 flex items-center gap-4 cursor-pointer"
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onChange={() => updateEntry(entry, { selected: !selected })}
|
||||||
|
className="accent-blue-500"
|
||||||
|
/>
|
||||||
|
{isExpanded ? <ChevronDown className="w-5 h-5 text-slate-500" /> : <ChevronRight className="w-5 h-5 text-slate-500" />}
|
||||||
|
|
||||||
|
<div className="w-8 text-right shrink-0">
|
||||||
|
<span className="text-xs font-mono text-slate-500">#{globalNumber}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-4 gap-4 items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500 font-bold shrink-0">
|
||||||
|
{entry.product_id.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-slate-200 truncate">{entry.article_name}</div>
|
||||||
|
<div className="text-xs text-slate-500 font-mono">ID: {entry.product_id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span className="text-sm truncate">{entry.changed_by}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{formatDate(entry.changed_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-2 text-sm">
|
||||||
|
<div className="flex items-center justify-end gap-3 flex-wrap">
|
||||||
|
<SyncStatusPill status={status} />
|
||||||
|
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
|
||||||
|
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onEdit) {
|
||||||
|
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
|
||||||
|
if (idx !== -1) onEdit(idx);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRevert(entry);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{status === 'failed' && bcHistorySync[getEntryKey(entry)]?.error && (
|
||||||
|
<div className="max-w-[40rem] rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||||
|
<span className="font-semibold">BC error:</span> {bcHistorySync[getEntryKey(entry)].error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details View */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-14 pb-6 pt-2 animate-in slide-in-from-top-2 duration-300">
|
||||||
|
<div className="bg-[#040d1a] border border-slate-700/50 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-800/50 text-slate-400 text-left">
|
||||||
|
<th className="px-4 py-2 font-medium w-8">#</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Column</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Original Value</th>
|
||||||
|
<th className="px-4 py-2 font-medium">New Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{changes.map((change, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-slate-700/20">
|
||||||
|
<td className="px-4 py-2 text-slate-600 text-xs font-mono">{idx + 1}</td>
|
||||||
|
<td className="px-4 py-2 text-slate-300 font-medium whitespace-nowrap">
|
||||||
|
{change.header}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-red-400/80 line-through decoration-red-500/50">
|
||||||
|
{String(change.old ?? '-')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-emerald-400 font-medium">
|
||||||
|
{String(change.new ?? '-')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between px-2">
|
||||||
|
<div className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
Row Index Reference: {entry.product_id}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 italic">
|
||||||
|
Reverting will move this record to 'Pending Validation' for final approval before re-syncing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
|||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
placeholder="you@craze-group.com"
|
placeholder="you@example.com"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
className="w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
||||||
@@ -157,7 +157,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-slate-600 text-xs mt-6">
|
<p className="text-center text-slate-600 text-xs mt-6">
|
||||||
Access restricted to @craze-group.com accounts
|
Create an account to get started
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+377
-22
@@ -1,22 +1,35 @@
|
|||||||
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[];
|
||||||
headers: string[];
|
headers: string[];
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatrixView({ data, headers }: 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 }));
|
||||||
@@ -65,6 +78,25 @@ export function MatrixView({ data, headers }: 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);
|
||||||
@@ -74,6 +106,25 @@ export function MatrixView({ data, headers }: 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();
|
||||||
@@ -95,11 +146,67 @@ export function MatrixView({ data, headers }: 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')) {
|
||||||
@@ -155,13 +262,40 @@ export function MatrixView({ data, headers }: 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
|
||||||
@@ -169,8 +303,16 @@ export function MatrixView({ data, headers }: 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
|
||||||
@@ -180,39 +322,244 @@ export function MatrixView({ data, headers }: 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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -220,8 +567,15 @@ export function MatrixView({ data, headers }: 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)}
|
||||||
@@ -250,7 +604,8 @@ export function MatrixView({ data, headers }: 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ExcelRow } from '../types';
|
||||||
|
import { useColumns } from '../contexts/ColumnsContext';
|
||||||
|
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X, Maximize2 } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
|
||||||
|
interface PendingValidationViewProps {
|
||||||
|
data: ExcelRow[];
|
||||||
|
pendingRows: Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>;
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
|
onRevertRow: (articleNo: string) => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingValidationView({ data, pendingRows, rowStatuses, onRevertRow, onEdit }: PendingValidationViewProps) {
|
||||||
|
const COLUMNS = useColumns();
|
||||||
|
const [search, setSearch] = usePersistentState('pending-search', '');
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const pendingEntries = Object.entries(pendingRows);
|
||||||
|
|
||||||
|
const filteredEntries = (() => {
|
||||||
|
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if (terms.length === 0) return pendingEntries;
|
||||||
|
|
||||||
|
return pendingEntries.filter(([articleNo, { articleName }]) => {
|
||||||
|
const searchableText = `${articleNo} ${articleName}`.toLowerCase();
|
||||||
|
return terms.every(term => searchableText.includes(term));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
const getFieldDiff = (original: ExcelRow, updated: ExcelRow, colIndex: number) => {
|
||||||
|
const orig = original[colIndex];
|
||||||
|
const upd = updated[colIndex];
|
||||||
|
if (orig !== upd) {
|
||||||
|
return { from: String(orig ?? ''), to: String(upd ?? '') };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangedFields = (original: ExcelRow, updated: ExcelRow) => {
|
||||||
|
const changes: { field: string; from: string; to: string }[] = [];
|
||||||
|
|
||||||
|
const fieldConfigs = [
|
||||||
|
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
|
||||||
|
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
|
||||||
|
{ col: COLUMNS.INNER_L, label: 'Inner L' },
|
||||||
|
{ col: COLUMNS.INNER_W, label: 'Inner W' },
|
||||||
|
{ col: COLUMNS.INNER_H, label: 'Inner H' },
|
||||||
|
{ col: COLUMNS.OUTER_L, label: 'Outer L' },
|
||||||
|
{ col: COLUMNS.OUTER_W, label: 'Outer W' },
|
||||||
|
{ col: COLUMNS.OUTER_H, label: 'Outer H' },
|
||||||
|
{ col: COLUMNS.UNITS_INNER, label: 'Units Inner' },
|
||||||
|
{ col: COLUMNS.UNITS_OUTER, label: 'Units Outer' },
|
||||||
|
{ col: COLUMNS.MOQ, label: 'MOQ' },
|
||||||
|
{ col: COLUMNS.BARCODE, label: 'Barcode' },
|
||||||
|
{ col: COLUMNS.TARIFF_CODE, label: 'Tariff Code' },
|
||||||
|
{ col: COLUMNS.COUNTRY_ORIGIN, label: 'Country' },
|
||||||
|
{ col: COLUMNS.RECOMMENDED_AGE, label: 'Recommended Age' },
|
||||||
|
];
|
||||||
|
|
||||||
|
fieldConfigs.forEach(({ col, label }) => {
|
||||||
|
const diff = getFieldDiff(original, updated, col);
|
||||||
|
if (diff) {
|
||||||
|
changes.push({ field: label, ...diff });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex-1 flex flex-col h-full overflow-hidden"}>
|
||||||
|
{isFullscreen && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
|
||||||
|
title="Exit fullscreen"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="w-5 h-5 text-yellow-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">Pending Validation</h2>
|
||||||
|
<span className="text-sm text-slate-500">
|
||||||
|
{pendingEntries.length} change{pendingEntries.length !== 1 ? 's' : ''} pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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="Search by article no or name..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{filteredEntries.length === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-slate-500">
|
||||||
|
<Clock className="w-16 h-16 mb-4 opacity-30" />
|
||||||
|
{search ? (
|
||||||
|
<p>No pending changes match your search</p>
|
||||||
|
) : (
|
||||||
|
<p>No pending validation changes</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{filteredEntries.map(([articleNo, { rowIndex, originalData, newData, articleName }]) => {
|
||||||
|
const changes = getChangedFields(originalData, newData);
|
||||||
|
const status = rowStatuses[articleNo];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={articleNo}
|
||||||
|
className="border border-yellow-500/30 bg-yellow-500/5 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-yellow-500/10 border-b border-yellow-500/20">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
|
||||||
|
{articleNo}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-300">{articleName}</div>
|
||||||
|
{status && <SyncStatusPill status={status} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(rowIndex)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
View/Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRevertRow(articleNo)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-900/30 hover:bg-red-900/50 text-red-400 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Undo2 className="w-3 h-3" />
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-3">
|
||||||
|
Changes ({changes.length})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{changes.map((change, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-slate-400 w-28 shrink-0">{change.field}:</span>
|
||||||
|
<span className="text-red-400 line-through opacity-70">{change.from}</span>
|
||||||
|
<span className="text-slate-500">→</span>
|
||||||
|
<span className="text-green-400 font-medium">{change.to}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2318
-192
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,66 @@
|
|||||||
import React, { useState, useMemo, useCallback } 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[];
|
||||||
|
headers: string[];
|
||||||
|
asinColumnIndex: number | null;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
|
dashboardDrilldown?: DashboardDrilldownRequest | null;
|
||||||
|
onDashboardDrilldownApplied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingLongAny' | 'missingShortDE' | 'missingShortEN' | 'missingShortAny' | '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, onEdit }: 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>>({
|
||||||
|
[COLUMNS.ARTICLE_NO]: 110,
|
||||||
|
[COLUMNS.ARTICLE_NAME]: 450, // More flexible space for name
|
||||||
|
...(asinColumnIndex !== null ? { [asinColumnIndex]: 100 } : {}),
|
||||||
|
[COLUMNS.LINE]: 80,
|
||||||
|
[COLUMNS.LICENSE]: 120,
|
||||||
|
[COLUMNS.CLASSIFICATION]: 100,
|
||||||
|
[COLUMNS.LONG_DE]: 110,
|
||||||
|
[COLUMNS.LONG_EN]: 110,
|
||||||
|
[COLUMNS.SHORT_DE]: 110,
|
||||||
|
[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]);
|
||||||
@@ -35,10 +71,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
// Tab filter
|
// Tab filter
|
||||||
if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
||||||
if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
|
if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
|
||||||
if (activeTab === 'missingLongAny') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN]);
|
|
||||||
if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
|
if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
|
||||||
if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
|
if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
|
||||||
if (activeTab === 'missingShortAny') result = result.filter(r => !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]);
|
|
||||||
|
|
||||||
if (activeTab === 'complete') result = result.filter(r =>
|
if (activeTab === 'complete') result = result.filter(r =>
|
||||||
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
|
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
|
||||||
@@ -51,11 +85,14 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
|
|
||||||
// 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
|
||||||
@@ -67,17 +104,25 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
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;
|
||||||
result = result.filter(r => vals.includes(String(r.row[col] || '')));
|
} else {
|
||||||
}
|
// For other columns, use regular value matching with improved empty value handling
|
||||||
|
const cellStr = String(cellVal ?? '').trim();
|
||||||
|
// If the cell is empty/null/undefined, it matches if 'Empty' or '' is selected
|
||||||
|
return vals.some(v => {
|
||||||
|
const filterVal = String(v ?? '').trim();
|
||||||
|
return filterVal === cellStr;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +136,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [data, activeTab, search, lineFilter, licenseFilter, sortCol, sortDesc]);
|
}, [data, activeTab, search, lineFilter, licenseFilter, columnFilters, sortCol, sortDesc]);
|
||||||
|
|
||||||
const paginatedData = useMemo(() => {
|
const paginatedData = useMemo(() => {
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
@@ -109,6 +154,25 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResize = (colIndex: number, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startX = e.pageX;
|
||||||
|
const startWidth = columnWidths[colIndex] || 100;
|
||||||
|
|
||||||
|
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) => {
|
||||||
// For description columns, return only 'Present' and 'Missing'
|
// For description columns, return only 'Present' and 'Missing'
|
||||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||||
@@ -120,12 +184,15 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleColumnFilter = (col: number, value: string) => {
|
const toggleColumnFilter = (col: number, value: string) => {
|
||||||
|
console.log('[Filter] toggleColumnFilter called, col:', col, 'value:', JSON.stringify(value));
|
||||||
setColumnFilters(prev => {
|
setColumnFilters(prev => {
|
||||||
const current = prev[col] || [];
|
const current = prev[col] || [];
|
||||||
const next = current.includes(value)
|
const next = current.includes(value)
|
||||||
? current.filter(v => v !== value)
|
? current.filter(v => v !== value)
|
||||||
: [...current, value];
|
: [...current, value];
|
||||||
return { ...prev, [col]: next };
|
const updated = { ...prev, [col]: next };
|
||||||
|
console.log('[Filter] new columnFilters:', updated);
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
@@ -136,34 +203,19 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
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>;
|
||||||
};
|
};
|
||||||
@@ -172,16 +224,23 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
{ id: 'all', label: 'All Products' },
|
{ id: 'all', label: 'All Products' },
|
||||||
{ id: 'missingLongDE', label: 'Missing Long DE' },
|
{ id: 'missingLongDE', label: 'Missing Long DE' },
|
||||||
{ id: 'missingLongEN', label: 'Missing Long EN' },
|
{ id: 'missingLongEN', label: 'Missing Long EN' },
|
||||||
{ id: 'missingLongAny', label: 'Missing Long DE/EN' },
|
|
||||||
{ id: 'missingShortDE', label: 'Missing Short DE' },
|
{ id: 'missingShortDE', label: 'Missing Short DE' },
|
||||||
{ id: 'missingShortEN', label: 'Missing Short EN' },
|
{ id: 'missingShortEN', label: 'Missing Short EN' },
|
||||||
{ id: 'missingShortAny', label: 'Missing Short DE/EN' },
|
|
||||||
{ id: 'complete', label: 'Complete' },
|
{ id: 'complete', label: 'Complete' },
|
||||||
{ id: 'incomplete', label: 'Incomplete' },
|
{ id: 'incomplete', label: 'Incomplete' },
|
||||||
];
|
];
|
||||||
|
|
||||||
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
|
||||||
@@ -197,9 +256,16 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
{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
|
||||||
@@ -207,8 +273,16 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
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
|
||||||
@@ -238,16 +312,21 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
<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">
|
||||||
<div className="overflow-x-auto flex-1">
|
<div className="overflow-x-auto flex-1">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm" 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>
|
||||||
{[
|
{[
|
||||||
{ col: COLUMNS.ARTICLE_NO, label: 'Article No.' },
|
{ col: COLUMNS.ARTICLE_NO, label: 'Article No.' },
|
||||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
|
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
|
||||||
|
...(asinColumnIndex !== null ? [{ col: asinColumnIndex, label: 'ASIN' }] : []),
|
||||||
{ col: COLUMNS.LINE, label: 'Line' },
|
{ col: COLUMNS.LINE, label: 'Line' },
|
||||||
{ col: COLUMNS.LICENSE, label: 'License' },
|
{ col: COLUMNS.LICENSE, label: 'License' },
|
||||||
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' },
|
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' },
|
||||||
@@ -258,22 +337,24 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
].map(({ col, label }) => (
|
].map(({ col, label }) => (
|
||||||
<th
|
<th
|
||||||
key={col}
|
key={col}
|
||||||
className="px-4 py-3 font-medium transition-colors select-none group relative"
|
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' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center overflow-hidden">
|
||||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white" 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" /> : <ChevronUp className="w-4 h-4" />
|
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",
|
"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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -281,8 +362,15 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resizer handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={(e) => handleResize(col, 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 === 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)}
|
||||||
@@ -300,17 +388,31 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
<th className="px-4 py-3 font-medium text-right" style={{ width: 100 }}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-700/50">
|
<tbody className="divide-y divide-slate-700/50">
|
||||||
{paginatedData.map(({ row, index }) => (
|
{paginatedData.map(({ row, index }) => {
|
||||||
<tr key={index} className={cn("transition-colors", getRowColor(row))}>
|
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs">{row[COLUMNS.ARTICLE_NO]}</td>
|
return (
|
||||||
<td className="px-4 py-3 font-medium text-white max-w-[200px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
<tr
|
||||||
<td className="px-4 py-3 text-slate-300 text-xs">{row[COLUMNS.LINE]}</td>
|
key={index}
|
||||||
<td className="px-4 py-3 text-slate-300 text-xs truncate max-w-[120px]" title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
className={cn(
|
||||||
<td className="px-4 py-3">
|
"transition-colors",
|
||||||
|
getRowColor(row),
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>
|
||||||
|
{row[COLUMNS.ARTICLE_NO]}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-white truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
||||||
|
{asinColumnIndex !== null && (
|
||||||
|
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[asinColumnIndex] || 100 }} title={row[asinColumnIndex]}>{row[asinColumnIndex] || '—'}</td>
|
||||||
|
)}
|
||||||
|
<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 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"px-2 py-0.5 rounded text-[10px] font-bold border",
|
"px-2 py-0.5 rounded text-[10px] font-bold border",
|
||||||
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
|
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
|
||||||
@@ -320,10 +422,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
|
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_DE] }}><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
|
||||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
|
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_EN] }}><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
|
||||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
|
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_DE] }}><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
|
||||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td>
|
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_EN] }}><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(index)}
|
onClick={() => onEdit(index)}
|
||||||
@@ -334,10 +436,11 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{paginatedData.length === 0 && (
|
{paginatedData.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="px-4 py-8 text-center text-slate-500">
|
<td colSpan={asinColumnIndex !== null ? 10 : 9} className="px-4 py-8 text-center text-slate-500">
|
||||||
No products found matching the criteria.
|
No products found matching the criteria.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -359,6 +462,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,36 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileText, Table, Box, DollarSign, Package } 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') => 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([
|
||||||
|
'christian.vidal@craze-group.com',
|
||||||
|
'jingying.shi@craze-group.com',
|
||||||
|
]);
|
||||||
|
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
|
||||||
|
|
||||||
|
const navItems: { id: ControlTabId; label: string; icon: React.ElementType }[] = [
|
||||||
|
{ id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'matrix', label: 'Matrix', icon: Table },
|
{ id: 'matrix', label: 'Matrix', icon: Table },
|
||||||
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
||||||
{ id: 'article_details', label: 'Article Details', icon: Package },
|
{ id: 'article_details', label: 'Article Details', icon: Package },
|
||||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
||||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
||||||
] as const;
|
{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+279
-33
@@ -1,22 +1,85 @@
|
|||||||
import React from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Download, Database, LogOut, Undo2 } from 'lucide-react';
|
import { LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw, Maximize2, X } from 'lucide-react';
|
||||||
|
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;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
undoMessage?: string;
|
undoMessage?: string;
|
||||||
undoSteps: number;
|
undoSteps: number;
|
||||||
|
pendingCount: number;
|
||||||
|
pendingChanges: Record<string, { articleName: string }>;
|
||||||
|
onSaveAll: () => Promise<void>;
|
||||||
|
onRevertRow: (articleNo: string) => void;
|
||||||
|
isSavingAll: boolean;
|
||||||
|
bcQueueCount: number;
|
||||||
|
bcQueueEntries: Record<string, { articleName: string; selected: boolean; status: string; error?: string; warning?: string; categorizationCode?: string }>;
|
||||||
|
onToggleBcQueueSelection: (articleNo: string) => void;
|
||||||
|
onSelectAllBcQueue: (selected: boolean) => void;
|
||||||
|
onPreviewSelectedBcSync: () => Promise<void>;
|
||||||
|
onSyncSelectedBcSync: () => Promise<void>;
|
||||||
|
onDiscardSelectedBcQueue: () => void;
|
||||||
|
isSyncingBC: boolean;
|
||||||
|
isMaximized: boolean;
|
||||||
|
onToggleMaximize: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps }: 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 [showBcQueue, setShowBcQueue] = useState(false);
|
||||||
|
const pendingDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bcQueueDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selectedBcQueueCount = Object.values(bcQueueEntries).filter(entry => entry.selected).length;
|
||||||
|
|
||||||
|
const formatQueueStatus = (status: string) => {
|
||||||
|
const normalized = String(status || '').toLowerCase();
|
||||||
|
if (normalized === 'preview_only') return 'Preview only';
|
||||||
|
if (normalized === 'previewed') return 'Previewed';
|
||||||
|
if (normalized === 'syncing') return 'Syncing';
|
||||||
|
if (normalized === 'synced') return 'Synced';
|
||||||
|
if (normalized === 'failed') return 'Failed';
|
||||||
|
if (normalized === 'queued') return 'Queued';
|
||||||
|
return String(status || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPending) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (pendingDropdownRef.current && !pendingDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setShowPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [showPending]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showBcQueue) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (bcQueueDropdownRef.current && !bcQueueDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setShowBcQueue(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [showBcQueue]);
|
||||||
|
|
||||||
|
// Close dropdown when all changes are saved/reverted
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingCount === 0) setShowPending(false);
|
||||||
|
}, [pendingCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bcQueueCount === 0) setShowBcQueue(false);
|
||||||
|
}, [bcQueueCount]);
|
||||||
|
|
||||||
return (
|
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"
|
||||||
@@ -25,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>
|
||||||
@@ -55,36 +118,218 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{hasData && (
|
<div className="relative" ref={pendingDropdownRef}>
|
||||||
|
{/* Split button: Save All + dropdown toggle */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center rounded-md overflow-hidden shadow-lg transition-all",
|
||||||
|
pendingCount > 0 ? "shadow-green-900/30" : "shadow-none opacity-40"
|
||||||
|
)}>
|
||||||
|
<button
|
||||||
|
onClick={onSaveAll}
|
||||||
|
disabled={isSavingAll || pendingCount === 0}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-sm font-bold transition-all text-white",
|
||||||
|
pendingCount > 0
|
||||||
|
? "bg-green-600 hover:bg-green-500 disabled:opacity-60"
|
||||||
|
: "bg-slate-700 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSavingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudUpload className="w-4 h-4" />}
|
||||||
|
{isSavingAll
|
||||||
|
? 'Saving...'
|
||||||
|
: pendingCount > 0
|
||||||
|
? `Save ${pendingCount} change${pendingCount > 1 ? 's' : ''}`
|
||||||
|
: 'No pending changes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => pendingCount > 0 && setShowPending(v => !v)}
|
||||||
|
disabled={isSavingAll || pendingCount === 0}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-2 py-2 text-white border-l transition-all",
|
||||||
|
pendingCount > 0
|
||||||
|
? "bg-green-700 hover:bg-green-600 border-green-500/40"
|
||||||
|
: "bg-slate-700 cursor-not-allowed border-slate-600"
|
||||||
|
)}
|
||||||
|
title="View pending changes"
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn("w-4 h-4 transition-transform", showPending && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown: list of pending changes */}
|
||||||
|
{showPending && pendingCount > 0 && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-[9999] overflow-hidden">
|
||||||
|
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Pending changes</span>
|
||||||
|
<span className="text-xs text-slate-500">{pendingCount} unsaved</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{Object.entries(pendingChanges).map(([articleNo, { articleName }]) => (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-yellow-400 shrink-0" />
|
||||||
|
<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">{articleName}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onRevertRow(articleNo)}
|
||||||
|
title="Discard this change"
|
||||||
|
className="shrink-0 flex items-center gap-1 px-2 py-1 text-xs text-red-400 hover:text-white hover:bg-red-600 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canUndo && (
|
<button
|
||||||
<button
|
onClick={onToggleMaximize}
|
||||||
onClick={onUndo}
|
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={`Undo: ${undoMessage}`}
|
title="Maximize - View table in full screen"
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-md text-sm font-bold transition-all shadow-lg shadow-amber-900/40 animate-in fade-in zoom-in duration-300 relative group"
|
>
|
||||||
>
|
<Maximize2 className="w-4 h-4" />
|
||||||
<Undo2 className="w-4 h-4" />
|
MAXIMIZE
|
||||||
BACK / UNDO
|
</button>
|
||||||
{undoSteps > 1 && (
|
|
||||||
<span className="absolute -top-1 -right-1 bg-white text-amber-700 text-[10px] w-4 h-4 rounded-full flex items-center justify-center shadow-md">
|
|
||||||
{undoSteps}
|
|
||||||
</span>
|
<button
|
||||||
)}
|
onClick={onUndo}
|
||||||
</button>
|
disabled={!canUndo}
|
||||||
)}
|
title={canUndo ? `Undo: ${undoMessage}` : 'No changes to undo'}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all shadow-lg relative group",
|
||||||
|
canUndo
|
||||||
|
? "bg-amber-600 hover:bg-amber-700 text-white shadow-amber-900/40 cursor-pointer"
|
||||||
|
: "bg-slate-800 text-slate-600 shadow-none cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Undo2 className="w-4 h-4" />
|
||||||
|
BACK / UNDO
|
||||||
|
{canUndo && undoSteps > 1 && (
|
||||||
|
<span className="absolute -top-1 -right-1 bg-white text-amber-700 text-[10px] w-4 h-4 rounded-full flex items-center justify-center shadow-md font-bold">
|
||||||
|
{undoSteps}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{userEmail && (
|
{userEmail && (
|
||||||
<div className="flex items-center gap-2 border-l border-slate-700 pl-3">
|
<div className="flex items-center gap-2 border-l border-slate-700 pl-3">
|
||||||
@@ -99,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];
|
||||||
|
}
|
||||||
+78
-9
@@ -1,18 +1,14 @@
|
|||||||
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||||
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||||
const ALLOWED_DOMAIN = '@craze-group.com';
|
|
||||||
const SESSION_KEY = 'craze_auth_session';
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signUp(email: string, password: string): Promise<void> {
|
export async function signUp(email: string, password: string): Promise<void> {
|
||||||
if (!email.toLowerCase().endsWith(ALLOWED_DOMAIN)) {
|
|
||||||
throw new Error(`Only ${ALLOWED_DOMAIN} email addresses are allowed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/auth/v1/signup`, {
|
const response = await fetch(`${SUPABASE_URL}/auth/v1/signup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -29,10 +25,6 @@ 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.toLowerCase().endsWith(ALLOWED_DOMAIN)) {
|
|
||||||
throw new Error(`Only ${ALLOWED_DOMAIN} email addresses are allowed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
||||||
@@ -48,17 +40,94 @@ 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 (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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+527
-47
@@ -1,69 +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;
|
||||||
|
|
||||||
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) {
|
export async function safeFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||||
|
const session = getStoredSession();
|
||||||
|
const token = session?.access_token || SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...options.headers,
|
||||||
|
'apikey': SUPABASE_ANON_KEY,
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
const latestSession = getStoredSession();
|
||||||
|
if (latestSession) {
|
||||||
|
// If the access token in localStorage is already different (newer) than the one we used,
|
||||||
|
// try retrying the request with that new token first without doing a refresh.
|
||||||
|
if (latestSession.access_token !== token) {
|
||||||
|
const retryHeaders = {
|
||||||
|
...options.headers,
|
||||||
|
'apikey': SUPABASE_ANON_KEY,
|
||||||
|
'Authorization': `Bearer ${latestSession.access_token}`,
|
||||||
|
};
|
||||||
|
response = await fetch(url, { ...options, headers: retryHeaders });
|
||||||
|
if (response.status !== 401) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we still get a 401, perform the refresh using the latest refresh token
|
||||||
|
if (latestSession.refresh_token) {
|
||||||
|
try {
|
||||||
|
const newSession = await refreshSession(latestSession.refresh_token);
|
||||||
|
const newHeaders = {
|
||||||
|
...options.headers,
|
||||||
|
'apikey': SUPABASE_ANON_KEY,
|
||||||
|
'Authorization': `Bearer ${newSession.access_token}`,
|
||||||
|
};
|
||||||
|
response = await fetch(url, { ...options, headers: newHeaders });
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error('Session refresh failed:', refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExcelRow extends Array<any> {}
|
||||||
|
|
||||||
|
export interface SyncedRow {
|
||||||
|
data: ExcelRow;
|
||||||
|
status?: 'pending' | 'edited' | 'synced' | 'excel';
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
|
||||||
|
const PAGE_SIZE = 1000;
|
||||||
|
const result: Record<string, SyncedRow> = {};
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?id=eq.${encodeURIComponent(articleNo)}`, {
|
let offset = 0;
|
||||||
method: 'PATCH',
|
// Paginate until Supabase returns fewer rows than PAGE_SIZE (no more pages).
|
||||||
headers: {
|
// Supabase REST caps a single response at 1000 rows, so we must page.
|
||||||
'apikey': SUPABASE_KEY,
|
// Safety cap at 20 pages (20k products) to avoid infinite loops.
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
for (let page = 0; page < 20; page++) {
|
||||||
'Content-Type': 'application/json',
|
const response = await safeFetch(
|
||||||
'Prefer': 'resolution=merge-duplicates'
|
`${SUPABASE_URL}/rest/v1/products?select=product_id,data,status,updated_at&order=product_id.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||||
},
|
{ cache: 'no-store' }
|
||||||
body: JSON.stringify({
|
);
|
||||||
id: articleNo,
|
|
||||||
data: rowData,
|
if (!response.ok) {
|
||||||
updated_at: new Date().toISOString()
|
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) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `${response.status} ${response.statusText}: ${err.message || err.error_description || 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving to Supabase:', error);
|
||||||
|
return { success: false, error: error.message || 'Network error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
id?: string;
|
||||||
|
product_id: string;
|
||||||
|
article_name: string;
|
||||||
|
old_data: ExcelRow;
|
||||||
|
new_data: ExcelRow;
|
||||||
|
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(
|
||||||
|
productId: string,
|
||||||
|
articleName: string,
|
||||||
|
oldData: ExcelRow,
|
||||||
|
newData: ExcelRow,
|
||||||
|
changedBy: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
product_id: productId,
|
||||||
|
article_name: articleName,
|
||||||
|
old_data: oldData,
|
||||||
|
new_data: newData,
|
||||||
|
changed_by: changedBy,
|
||||||
|
changed_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
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);
|
||||||
|
return { success: false, error: error.message || 'Network error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHistory(): Promise<HistoryEntry[]> {
|
||||||
|
try {
|
||||||
|
const PAGE_SIZE = 1000;
|
||||||
|
const allRows: HistoryEntry[] = [];
|
||||||
|
for (let page = 0; page < 20; page++) {
|
||||||
|
const offset = page * PAGE_SIZE;
|
||||||
|
const response = await safeFetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||||
|
{ cache: 'no-store' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const 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 || ''));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 204 || response.ok) {
|
const result: Record<string, ExcelRow> = {};
|
||||||
// If PATCH didn't find the record, try UPSERT
|
|
||||||
const upsertResponse = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, {
|
for (const entry of entries) {
|
||||||
method: 'POST',
|
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: {
|
headers: {
|
||||||
'apikey': SUPABASE_KEY,
|
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Prefer': 'resolution=merge-duplicates'
|
'Prefer': 'return=minimal',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: articleNo,
|
new_data: {
|
||||||
data: rowData,
|
snapshot_date: dateKey,
|
||||||
updated_at: new Date().toISOString()
|
tabs: mergedTabs,
|
||||||
})
|
},
|
||||||
});
|
}),
|
||||||
return upsertResponse.ok;
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
const payload = {
|
||||||
|
product_id: CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID,
|
||||||
|
article_name: 'Control Dashboard Snapshot',
|
||||||
|
old_data: [],
|
||||||
|
new_data: {
|
||||||
|
snapshot_date: dateKey,
|
||||||
|
tabs,
|
||||||
|
},
|
||||||
|
changed_by: 'system-control-dashboard',
|
||||||
|
changed_at: `${dateKey}T00:00:00.000Z`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertRes = await safeFetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!insertRes.ok) {
|
||||||
|
const errText = await insertRes.text();
|
||||||
|
return { success: false, error: `Snapshot save failed: ${insertRes.status} ${errText.substring(0, 200)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, created: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[ensureDashboardSnapshot] Exception:', error);
|
||||||
|
return { success: false, error: error?.message || 'Network error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSnapshotDate(value: string): string | null {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextUtcDateKey(dateKey: string): string {
|
||||||
|
const date = new Date(`${dateKey}T00:00:00.000Z`);
|
||||||
|
date.setUTCDate(date.getUTCDate() + 1);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHistoryEntry(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(String(id))}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving to Supabase:', error);
|
console.error('Error deleting history from Supabase:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllSyncedRows(): Promise<Record<string, ExcelRow>> {
|
export async function resetAllPendingRows(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, {
|
const response = await safeFetch(
|
||||||
headers: {
|
`${SUPABASE_URL}/rest/v1/products?status=eq.pending`,
|
||||||
'apikey': SUPABASE_KEY,
|
{
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: 'edited' })
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!response.ok) return {};
|
return response.ok;
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const result: Record<string, ExcelRow> = {};
|
|
||||||
data.forEach((item: any) => {
|
|
||||||
result[item.id] = item.data;
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching from Supabase:', error);
|
console.error('Error resetting pending rows in Supabase:', error);
|
||||||
return {};
|
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 };
|
||||||
|
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string };
|
||||||
|
itemUnits40HC: { applied: boolean; reason?: string; url?: string };
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readResponsePayload(res: Response): Promise<{ data: any; rawText: string }> {
|
||||||
|
const rawText = await res.text();
|
||||||
|
if (!rawText.trim()) {
|
||||||
|
return { data: null, rawText };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { data: JSON.parse(rawText), rawText };
|
||||||
|
} catch {
|
||||||
|
return { data: rawText, rawText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCpnpNoInBC(articleNo: string, cpnpNo: string): Promise<BCUpdateResult> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(BC_SYNC_APPLY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ articleNo, cpnpNo }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, rawText } = await readResponsePayload(res);
|
||||||
|
if (!res.ok || !data?.success) {
|
||||||
|
const error =
|
||||||
|
data?.error ||
|
||||||
|
(typeof data === 'string' && data.trim()) ||
|
||||||
|
rawText ||
|
||||||
|
`HTTP ${res.status}`;
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewBusinessCentralSync(headers: string[], row: any[]): Promise<BCSyncPreviewResult> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(BC_SYNC_PREVIEW_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ headers, row }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, rawText } = await readResponsePayload(res);
|
||||||
|
if (!res.ok || !data?.success) {
|
||||||
|
const error =
|
||||||
|
data?.error ||
|
||||||
|
(typeof data === 'string' && data.trim()) ||
|
||||||
|
rawText ||
|
||||||
|
`HTTP ${res.status}`;
|
||||||
|
return { success: false, error } as BCSyncPreviewResult;
|
||||||
|
}
|
||||||
|
return data as BCSyncPreviewResult;
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message } as BCSyncPreviewResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyBusinessCentralSync(headers: string[], row: any[], previewToken?: string): Promise<BCSyncApplyResult> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(BC_SYNC_APPLY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ headers, row, previewToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, rawText } = await readResponsePayload(res);
|
||||||
|
if (!res.ok || !data?.success) {
|
||||||
|
const error =
|
||||||
|
data?.error ||
|
||||||
|
(typeof data === 'string' && data.trim()) ||
|
||||||
|
rawText ||
|
||||||
|
`HTTP ${res.status}`;
|
||||||
|
return { success: false, error } as BCSyncApplyResult;
|
||||||
|
}
|
||||||
|
return data as BCSyncApplyResult;
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message } as BCSyncApplyResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilenameFromDisposition(contentDisposition: string | null): string | null {
|
||||||
|
if (!contentDisposition) return null;
|
||||||
|
|
||||||
|
const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||||
|
if (utf8Match?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(utf8Match[1].trim().replace(/^"|"$/g, ''));
|
||||||
|
} catch {
|
||||||
|
return utf8Match[1].trim().replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filenameMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i);
|
||||||
|
if (filenameMatch?.[1]) {
|
||||||
|
return filenameMatch[1].trim().replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadBusinessCentralItemsExcel(): Promise<BCDownloadResult> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(BC_EXPORT_URL, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let error = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const { data, rawText } = await readResponsePayload(res);
|
||||||
|
error = data?.error || (typeof data === 'string' && data.trim()) || rawText || error;
|
||||||
|
} catch {
|
||||||
|
error = `HTTP ${res.status}`;
|
||||||
|
}
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const filename =
|
||||||
|
getFilenameFromDisposition(res.headers.get('content-disposition')) ||
|
||||||
|
`BusinessCentral_Items_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.rel = 'noopener';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
return { success: true, filename };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { ExcelRow } from '../types';
|
||||||
|
|
||||||
|
export interface MappingFieldPreview {
|
||||||
|
sourceLabel: string;
|
||||||
|
sourceIndex: number | null;
|
||||||
|
targetField: string;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessCentralMappingPreview {
|
||||||
|
articleNo: string;
|
||||||
|
itemsPayload: Record<string, any>;
|
||||||
|
itemUnitsOfMeasurePayload: Record<string, any>;
|
||||||
|
itemUnits40HCPayload: Record<string, any>;
|
||||||
|
itemsFields: MappingFieldPreview[];
|
||||||
|
itemUnitsFields: MappingFieldPreview[];
|
||||||
|
itemUnits40HCFields: MappingFieldPreview[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHeaderIndex(headers: string[], patterns: string[]): number {
|
||||||
|
const normalized = headers.map(h => String(h || '').toLowerCase());
|
||||||
|
return normalized.findIndex(header =>
|
||||||
|
patterns.every(pattern => header.includes(pattern.toLowerCase()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCategorizationCodeIndex(headers: string[]): number {
|
||||||
|
const normalized = headers.map(h => String(h || '').toLowerCase().trim());
|
||||||
|
const categorizationIdx = normalized.findIndex(header => header.replace(/[\s_-]+/g, '') === 'categorizationcode');
|
||||||
|
if (categorizationIdx >= 0) return categorizationIdx;
|
||||||
|
|
||||||
|
const typeIdx = normalized.findIndex(header => header === 'type');
|
||||||
|
if (typeIdx >= 0) return typeIdx;
|
||||||
|
|
||||||
|
return normalized.findIndex(header => header.includes('product') && header.includes('type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForBc(value: any): string | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
|
||||||
|
if (typeof value === 'number' && value >= 25569 && value <= 60000) {
|
||||||
|
const excelEpoch = new Date(1899, 11, 30);
|
||||||
|
const date = new Date(excelEpoch.getTime() + value * 86400000);
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = String(value).trim();
|
||||||
|
if (!str) return null;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
||||||
|
|
||||||
|
const parsed = new Date(str);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
|
return parsed.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValue(row: ExcelRow, index: number | null): any {
|
||||||
|
if (index === null || index < 0) return null;
|
||||||
|
const value = row[index];
|
||||||
|
return value === undefined || value === '' ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBcDecimal(value: any): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
|
||||||
|
const normalized = String(value).trim().replace(/\s+/g, '').replace(',', '.');
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFieldPreview(
|
||||||
|
sourceLabel: string,
|
||||||
|
sourceIndex: number | null,
|
||||||
|
targetField: string,
|
||||||
|
value: any
|
||||||
|
): MappingFieldPreview {
|
||||||
|
return { sourceLabel, sourceIndex, targetField, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBusinessCentralMappingPreview(headers: string[], row: ExcelRow): BusinessCentralMappingPreview {
|
||||||
|
const articleNoIdx = findHeaderIndex(headers, ['article', 'no']);
|
||||||
|
const articleDetailsEnIdx = findHeaderIndex(headers, ['article', 'details', 'english']);
|
||||||
|
const articleDetailsDeIdx = findHeaderIndex(headers, ['article', 'details', 'german']);
|
||||||
|
const launchDateIdx = findHeaderIndex(headers, ['launch']);
|
||||||
|
const readyToOrderDateIdx = findHeaderIndex(headers, ['ready']);
|
||||||
|
const moqIdx = findHeaderIndex(headers, ['moq']);
|
||||||
|
const shortDeIdx = findHeaderIndex(headers, ['short', 'description', 'german']);
|
||||||
|
const shortEnIdx = findHeaderIndex(headers, ['short', 'description', 'english']);
|
||||||
|
const cpnpIdx = findHeaderIndex(headers, ['cpnp']);
|
||||||
|
const categorizationCodeIdx = findCategorizationCodeIndex(headers);
|
||||||
|
|
||||||
|
const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']);
|
||||||
|
const units40HqIdx = (() => {
|
||||||
|
const hqIdx = findHeaderIndex(headers, ['40', 'hq']);
|
||||||
|
if (hqIdx >= 0) return hqIdx;
|
||||||
|
return findHeaderIndex(headers, ['40', 'hc']);
|
||||||
|
})();
|
||||||
|
const outerWIdx = findHeaderIndex(headers, ['outer', 'w']);
|
||||||
|
const outerLIdx = findHeaderIndex(headers, ['outer', 'l']);
|
||||||
|
const outerHIdx = findHeaderIndex(headers, ['outer', 'h']);
|
||||||
|
|
||||||
|
const articleNo = String(getValue(row, articleNoIdx) ?? '');
|
||||||
|
|
||||||
|
const categorizationCode = getValue(row, categorizationCodeIdx);
|
||||||
|
const hasCategorizationCode = String(categorizationCode ?? '').trim() !== '';
|
||||||
|
|
||||||
|
const itemsPayload: Record<string, any> = {
|
||||||
|
no: getValue(row, articleNoIdx),
|
||||||
|
articleDetailsEnglish: getValue(row, articleDetailsEnIdx),
|
||||||
|
articleDetailsGerman: getValue(row, articleDetailsDeIdx),
|
||||||
|
launchDate: formatDateForBc(getValue(row, launchDateIdx)),
|
||||||
|
readyToOrderDate: formatDateForBc(getValue(row, readyToOrderDateIdx)),
|
||||||
|
minimumOrderQuantity: toBcDecimal(getValue(row, moqIdx)),
|
||||||
|
shortDescriptionInGerman: getValue(row, shortDeIdx),
|
||||||
|
shortDescriptionInEnglish: getValue(row, shortEnIdx),
|
||||||
|
cpnpNo: getValue(row, cpnpIdx),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasCategorizationCode) {
|
||||||
|
itemsPayload.categorizationCode = String(categorizationCode).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemUnitsOfMeasurePayload = {
|
||||||
|
itemNo: getValue(row, articleNoIdx),
|
||||||
|
code: 'OUTER',
|
||||||
|
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, unitsOuterIdx)),
|
||||||
|
width: toBcDecimal(getValue(row, outerWIdx)),
|
||||||
|
length: toBcDecimal(getValue(row, outerLIdx)),
|
||||||
|
height: toBcDecimal(getValue(row, outerHIdx)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemUnits40HCPayload = {
|
||||||
|
itemNo: getValue(row, articleNoIdx),
|
||||||
|
code: '40HC',
|
||||||
|
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, units40HqIdx)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
articleNo,
|
||||||
|
itemsPayload,
|
||||||
|
itemUnitsOfMeasurePayload,
|
||||||
|
itemUnits40HCPayload,
|
||||||
|
itemsFields: [
|
||||||
|
makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no),
|
||||||
|
makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish),
|
||||||
|
makeFieldPreview('Article Details - German', articleDetailsDeIdx, 'articleDetailsGerman', itemsPayload.articleDetailsGerman),
|
||||||
|
makeFieldPreview('Launch Date', launchDateIdx, 'launchDate', itemsPayload.launchDate),
|
||||||
|
makeFieldPreview('Ready to Order Date', readyToOrderDateIdx, 'readyToOrderDate', itemsPayload.readyToOrderDate),
|
||||||
|
makeFieldPreview('MOQ', moqIdx, 'minimumOrderQuantity', itemsPayload.minimumOrderQuantity),
|
||||||
|
makeFieldPreview('Short Description - German', shortDeIdx, 'shortDescriptionInGerman', itemsPayload.shortDescriptionInGerman),
|
||||||
|
makeFieldPreview('Short Description - English', shortEnIdx, 'shortDescriptionInEnglish', itemsPayload.shortDescriptionInEnglish),
|
||||||
|
makeFieldPreview('CPNP', cpnpIdx, 'cpnpNo', itemsPayload.cpnpNo),
|
||||||
|
...(hasCategorizationCode
|
||||||
|
? [makeFieldPreview('CategorizationCode', categorizationCodeIdx, 'categorizationCode', itemsPayload.categorizationCode)]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
itemUnitsFields: [
|
||||||
|
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnitsOfMeasurePayload.itemNo),
|
||||||
|
makeFieldPreview('Units Outer', unitsOuterIdx, 'qtyPerUnitOfMeasure', itemUnitsOfMeasurePayload.qtyPerUnitOfMeasure),
|
||||||
|
makeFieldPreview('Outer W (cm)', outerWIdx, 'width', itemUnitsOfMeasurePayload.width),
|
||||||
|
makeFieldPreview('Outer L (cm)', outerLIdx, 'length', itemUnitsOfMeasurePayload.length),
|
||||||
|
makeFieldPreview('Outer H (cm)', outerHIdx, 'height', itemUnitsOfMeasurePayload.height),
|
||||||
|
],
|
||||||
|
itemUnits40HCFields: [
|
||||||
|
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnits40HCPayload.itemNo),
|
||||||
|
makeFieldPreview('Units 40FT HQ', units40HqIdx, 'qtyPerUnitOfMeasure', itemUnits40HCPayload.qtyPerUnitOfMeasure),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
+16
-19
@@ -1,25 +1,15 @@
|
|||||||
export async function generateGemini(prompt: string, systemPrompt: string): Promise<string> {
|
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.');
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-21
@@ -6,32 +6,117 @@ export interface AppState {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
fileDate: Date | null;
|
fileDate: Date | null;
|
||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
|
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, // Column L (index 11)
|
CLASSIFICATION: 14,
|
||||||
ITEM_AVAILABLE: 14, // Column O (index 14)
|
ITEM_AVAILABLE: 18,
|
||||||
MOQ: 27,
|
MOQ: 30,
|
||||||
UNITS_INNER: 31,
|
UNITS_INNER: 34,
|
||||||
UNITS_OUTER: 32,
|
UNITS_OUTER: 35,
|
||||||
INNER_W: 42,
|
ASIN: 78,
|
||||||
INNER_L: 43,
|
INNER_W: 45,
|
||||||
INNER_H: 44,
|
INNER_L: 46,
|
||||||
OUTER_W: 47,
|
INNER_H: 47,
|
||||||
OUTER_L: 48,
|
OUTER_W: 50,
|
||||||
OUTER_H: 49
|
OUTER_L: 51,
|
||||||
|
OUTER_H: 52,
|
||||||
|
VERIFIED_DIMS: 100,
|
||||||
|
VALIDATED_CHECK: 101,
|
||||||
|
VALIDATED_NOTE: 102,
|
||||||
|
CATEGORIZATION_CODE: 103,
|
||||||
|
PRODUCT_TYPE: 103,
|
||||||
|
ITEM_TO_LOGISTIC: 104,
|
||||||
|
ANNA_CHECK: 105,
|
||||||
|
ANNA_NOTE: 106,
|
||||||
|
CPNP_NO: 107
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Search patterns for dynamic detection
|
||||||
|
export const COLUMN_PATTERNS: Record<keyof typeof COLUMNS, string[]> = {
|
||||||
|
ARTICLE_NO: ['article', 'no'],
|
||||||
|
ARTICLE_NAME: ['article', 'name'],
|
||||||
|
LINE: ['line'],
|
||||||
|
LICENSE: ['license'],
|
||||||
|
DETAILS_EN: ['details', 'english'],
|
||||||
|
DETAILS_DE: ['details', 'german'],
|
||||||
|
BARCODE: ['article', 'barcode'],
|
||||||
|
TARIFF_CODE: ['tariff', 'code'],
|
||||||
|
COUNTRY_ORIGIN: ['country', 'origin'],
|
||||||
|
LONG_DE: ['long', 'description', 'german'],
|
||||||
|
LONG_EN: ['long', 'description', 'english'],
|
||||||
|
SHORT_DE: ['short', 'description', 'german'],
|
||||||
|
SHORT_EN: ['short', 'description', 'english'],
|
||||||
|
RECOMMENDED_AGE: ['recommended', 'age'],
|
||||||
|
CLASSIFICATION: ['classification'],
|
||||||
|
ITEM_AVAILABLE: ['item', 'available'],
|
||||||
|
MOQ: ['moq'],
|
||||||
|
UNITS_INNER: ['units', 'inner'],
|
||||||
|
UNITS_OUTER: ['units', 'outer'],
|
||||||
|
ASIN: ['asin'],
|
||||||
|
INNER_W: ['inner', 'w'],
|
||||||
|
INNER_L: ['inner', 'l'],
|
||||||
|
INNER_H: ['inner', 'h'],
|
||||||
|
OUTER_W: ['outer', 'w'],
|
||||||
|
OUTER_L: ['outer', 'l'],
|
||||||
|
OUTER_H: ['outer', 'h'],
|
||||||
|
VERIFIED_DIMS: ['verified', 'dims'],
|
||||||
|
VALIDATED_CHECK: ['validated', 'check'],
|
||||||
|
VALIDATED_NOTE: ['validated', 'note'],
|
||||||
|
CATEGORIZATION_CODE: ['categorization', 'code'],
|
||||||
|
PRODUCT_TYPE: ['product', 'type'],
|
||||||
|
ITEM_TO_LOGISTIC: ['item', 'logistic'],
|
||||||
|
ANNA_CHECK: ['anna', 'check'],
|
||||||
|
ANNA_NOTE: ['anna', 'note'],
|
||||||
|
CPNP_NO: ['cpnp'],
|
||||||
|
// Virtual/Extra columns stay hardcoded and are NOT auto-detected from headers
|
||||||
|
// to prevent internal data from being shifted or overwritten by Excel column shifts.
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveColumnIndices(headers: string[]): typeof COLUMNS {
|
||||||
|
const resolved = { ...COLUMNS };
|
||||||
|
const h = headers.map(val => String(val || '').toLowerCase());
|
||||||
|
|
||||||
|
Object.entries(COLUMN_PATTERNS).forEach(([key, patterns]) => {
|
||||||
|
if (patterns.length === 0) return;
|
||||||
|
const idx = h.findIndex(headerText =>
|
||||||
|
patterns.every(p => headerText.includes(p.toLowerCase()))
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
resolved[key as keyof typeof COLUMNS] = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let categorizationIdx = h.findIndex(headerText => headerText.replace(/[\s_-]+/g, '') === 'categorizationcode');
|
||||||
|
if (categorizationIdx < 0) {
|
||||||
|
categorizationIdx = h.findIndex(headerText => headerText.trim() === 'type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categorizationIdx >= 0) {
|
||||||
|
resolved.CATEGORIZATION_CODE = categorizationIdx;
|
||||||
|
resolved.PRODUCT_TYPE = categorizationIdx;
|
||||||
|
} else {
|
||||||
|
resolved.CATEGORIZATION_CODE = resolved.PRODUCT_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|||||||
+34
-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