Compare commits
62 Commits
improvemen
...
deploy-202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f982ae3410 | ||
|
|
2a3bc5fdd8 | ||
|
|
063ef4f096 | ||
|
|
d55d0e5e02 | ||
|
|
cff73c1381 | ||
|
|
f564f944f7 | ||
|
|
629b9c1756 | ||
|
|
c5a7666ce6 | ||
|
|
70b7713f2e | ||
|
|
da79307461 | ||
|
|
3e4f576fd5 | ||
|
|
b24a1fea27 | ||
|
|
93df263214 | ||
|
|
e50f361b07 | ||
|
|
9594ce56cb | ||
|
|
11dbea3536 | ||
|
|
6d04e16cf6 | ||
|
|
85481512c8 | ||
|
|
1e95455139 | ||
|
|
f929c384c1 | ||
|
|
291997caa5 | ||
|
|
0741bd0d0e | ||
|
|
746f0925d0 | ||
|
|
2985ea4457 | ||
|
|
f2ae608b01 | ||
|
|
65ae493d54 | ||
|
|
ff6abdeef0 | ||
|
|
e9afe8bc35 | ||
|
|
a854fd5524 | ||
|
|
eff60436eb | ||
|
|
6dda083ee9 | ||
|
|
bd27f7a9e4 | ||
|
|
145c2d4781 | ||
|
|
28ba314404 | ||
|
|
0cfee1bda7 | ||
|
|
7cd2058613 | ||
|
|
c837af6015 | ||
|
|
58c165749d | ||
|
|
b7f5417e23 | ||
|
|
17edfc8794 | ||
|
|
f6f9da9c5b | ||
|
|
b1dfa2352a | ||
|
|
987039b0f7 | ||
|
|
6bc1c8d16d | ||
|
|
d3e001707b | ||
|
|
c0ca1e4bb3 | ||
|
|
1ce127c51c | ||
|
|
fc95dc4ed2 | ||
|
|
543e51cc3c | ||
|
|
56a21b27fe | ||
|
|
f99a132e7b | ||
|
|
990221792a | ||
|
|
6f75abf6ee | ||
|
|
01a24e781b | ||
|
|
92b186b6bc | ||
|
|
06b0a20bef | ||
|
|
747d15f1c3 | ||
|
|
6d534352b2 | ||
|
|
9f2dade0e3 | ||
|
|
fd3ba36ae2 | ||
|
|
52f93df34a | ||
|
|
470cf63b83 |
15
.eslintrc.json
Normal file
15
.eslintrc.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaFeatures": { "jsx": true }
|
||||||
|
},
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["node_modules/", "out/", ".next/", "api/"]
|
||||||
|
}
|
||||||
288
.gitea/workflows/deploy.yml
Normal file
288
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
AWS_REGION: eu-central-1
|
||||||
|
S3_BUCKET: filamenteka-frontend
|
||||||
|
INSTANCE_ID: i-03956ecf32292d7d9
|
||||||
|
NEXT_PUBLIC_API_URL: https://api.filamenteka.rs/api
|
||||||
|
NEXT_PUBLIC_MATOMO_URL: https://analytics.demirix.dev
|
||||||
|
NEXT_PUBLIC_MATOMO_SITE_ID: "7"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Job 1: Change Detection ──────────────────────────────────────────
|
||||||
|
detect:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
frontend: ${{ steps.changes.outputs.frontend }}
|
||||||
|
api: ${{ steps.changes.outputs.api }}
|
||||||
|
migrations: ${{ steps.changes.outputs.migrations }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect changes since last deploy
|
||||||
|
id: changes
|
||||||
|
run: |
|
||||||
|
# Find the latest deploy tag
|
||||||
|
LAST_TAG=$(git tag -l 'deploy-*' --sort=-creatordate | head -n 1)
|
||||||
|
|
||||||
|
if [ -z "$LAST_TAG" ]; then
|
||||||
|
echo "No deploy tag found — deploying everything"
|
||||||
|
echo "frontend=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "api=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "migrations=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Last deploy tag: $LAST_TAG"
|
||||||
|
DIFF_FILES=$(git diff --name-only "$LAST_TAG"..HEAD)
|
||||||
|
echo "Changed files since $LAST_TAG:"
|
||||||
|
echo "$DIFF_FILES"
|
||||||
|
|
||||||
|
FRONTEND=false
|
||||||
|
API=false
|
||||||
|
MIGRATIONS=false
|
||||||
|
|
||||||
|
if echo "$DIFF_FILES" | grep -qvE '^(api/|database/)'; then
|
||||||
|
FRONTEND=true
|
||||||
|
fi
|
||||||
|
if echo "$DIFF_FILES" | grep -qE '^api/'; then
|
||||||
|
API=true
|
||||||
|
fi
|
||||||
|
if echo "$DIFF_FILES" | grep -qE '^database/migrations/'; then
|
||||||
|
MIGRATIONS=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "frontend=$FRONTEND" >> $GITHUB_OUTPUT
|
||||||
|
echo "api=$API" >> $GITHUB_OUTPUT
|
||||||
|
echo "migrations=$MIGRATIONS" >> $GITHUB_OUTPUT
|
||||||
|
echo "Frontend: $FRONTEND | API: $API | Migrations: $MIGRATIONS"
|
||||||
|
|
||||||
|
# ── Job 2: Frontend Deploy ───────────────────────────────────────────
|
||||||
|
deploy-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: detect
|
||||||
|
if: needs.detect.outputs.frontend == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Security check & tests
|
||||||
|
run: |
|
||||||
|
npm run security:check
|
||||||
|
npm test -- --passWithNoTests
|
||||||
|
|
||||||
|
- name: Build Next.js
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Verify build output
|
||||||
|
run: |
|
||||||
|
if [ ! -d "out" ]; then
|
||||||
|
echo "ERROR: out/ directory not found after build"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Build output verified: $(find out -type f | wc -l) files"
|
||||||
|
|
||||||
|
- name: Setup AWS
|
||||||
|
run: |
|
||||||
|
pip3 install -q --break-system-packages awscli
|
||||||
|
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
|
||||||
|
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
|
||||||
|
aws configure set region ${{ env.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Deploy to S3 & invalidate CloudFront
|
||||||
|
run: |
|
||||||
|
# HTML files — no cache
|
||||||
|
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||||
|
--delete \
|
||||||
|
--exclude "*" \
|
||||||
|
--include "*.html" \
|
||||||
|
--cache-control "public, max-age=0, must-revalidate" \
|
||||||
|
--content-type "text/html"
|
||||||
|
|
||||||
|
# Next.js assets — immutable
|
||||||
|
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
|
||||||
|
--cache-control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# Everything else — 24h cache
|
||||||
|
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||||
|
--exclude "*.html" \
|
||||||
|
--exclude "_next/*" \
|
||||||
|
--cache-control "public, max-age=86400"
|
||||||
|
|
||||||
|
aws cloudfront create-invalidation \
|
||||||
|
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
|
||||||
|
--paths "/*"
|
||||||
|
|
||||||
|
# ── Job 3: API & Migrations Deploy ──────────────────────────────────
|
||||||
|
deploy-api:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: detect
|
||||||
|
if: needs.detect.outputs.api == 'true' || needs.detect.outputs.migrations == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Setup AWS
|
||||||
|
run: |
|
||||||
|
pip3 install -q --break-system-packages awscli
|
||||||
|
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
|
||||||
|
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
|
||||||
|
aws configure set region ${{ env.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
if: needs.detect.outputs.migrations == 'true'
|
||||||
|
run: |
|
||||||
|
cat > /tmp/migrate-params.json << 'PARAMS'
|
||||||
|
{"commands":["set -e","cd /tmp","rm -rf repo.tar.gz filamenteka","curl -sf -o repo.tar.gz https://git.demirix.dev/dax/Filamenteka/archive/main.tar.gz","tar xzf repo.tar.gz","docker exec filamenteka-api rm -rf /database","docker cp filamenteka/database filamenteka-api:/database","docker cp filamenteka/api/migrate.js filamenteka-api:/app/migrate.js","rm -rf repo.tar.gz filamenteka","docker exec filamenteka-api ls /database/schema.sql /database/migrations/","docker exec -w /app filamenteka-api node migrate.js"]}
|
||||||
|
PARAMS
|
||||||
|
CMD_ID=$(aws ssm send-command \
|
||||||
|
--region $AWS_REGION \
|
||||||
|
--instance-ids "$INSTANCE_ID" \
|
||||||
|
--document-name "AWS-RunShellScript" \
|
||||||
|
--parameters file:///tmp/migrate-params.json \
|
||||||
|
--query "Command.CommandId" --output text)
|
||||||
|
|
||||||
|
echo "Migration SSM command: $CMD_ID"
|
||||||
|
|
||||||
|
# Poll for completion (max 2 minutes)
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
sleep 5
|
||||||
|
STATUS=$(aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "Status" --output text 2>/dev/null || echo "Pending")
|
||||||
|
|
||||||
|
echo "Attempt $i: $STATUS"
|
||||||
|
|
||||||
|
if [ "$STATUS" = "Success" ]; then
|
||||||
|
echo "Migrations completed successfully"
|
||||||
|
aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "StandardOutputContent" --output text
|
||||||
|
break
|
||||||
|
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Cancelled" ] || [ "$STATUS" = "TimedOut" ]; then
|
||||||
|
echo "Migration failed with status: $STATUS"
|
||||||
|
aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "StandardErrorContent" --output text
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$STATUS" != "Success" ]; then
|
||||||
|
echo "Migration timed out after 2 minutes"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy server.js
|
||||||
|
id: deploy
|
||||||
|
run: |
|
||||||
|
cat > /tmp/deploy-params.json << 'PARAMS'
|
||||||
|
{"commands":["set -e","docker exec filamenteka-api cp /app/server.js /app/server.js.backup","curl -sf -o /tmp/server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js","docker cp /tmp/server.js filamenteka-api:/app/server.js","rm -f /tmp/server.js","docker restart filamenteka-api","echo API deployed and restarted"]}
|
||||||
|
PARAMS
|
||||||
|
CMD_ID=$(aws ssm send-command \
|
||||||
|
--region $AWS_REGION \
|
||||||
|
--instance-ids "$INSTANCE_ID" \
|
||||||
|
--document-name "AWS-RunShellScript" \
|
||||||
|
--parameters file:///tmp/deploy-params.json \
|
||||||
|
--query "Command.CommandId" --output text)
|
||||||
|
|
||||||
|
echo "Deploy SSM command: $CMD_ID"
|
||||||
|
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
sleep 5
|
||||||
|
STATUS=$(aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "Status" --output text 2>/dev/null || echo "Pending")
|
||||||
|
|
||||||
|
echo "Attempt $i: $STATUS"
|
||||||
|
|
||||||
|
if [ "$STATUS" = "Success" ]; then
|
||||||
|
echo "API deploy completed"
|
||||||
|
break
|
||||||
|
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Cancelled" ] || [ "$STATUS" = "TimedOut" ]; then
|
||||||
|
echo "API deploy failed with status: $STATUS"
|
||||||
|
aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "StandardErrorContent" --output text
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$STATUS" != "Success" ]; then
|
||||||
|
echo "API deploy timed out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
id: health
|
||||||
|
run: |
|
||||||
|
echo "Waiting for API to be ready..."
|
||||||
|
for i in $(seq 1 6); do
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://api.filamenteka.rs/api/filaments || echo "000")
|
||||||
|
echo "Health check attempt $i: HTTP $HTTP_CODE"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "API is healthy"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Health check failed after 6 attempts"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Rollback on failure
|
||||||
|
if: failure() && steps.deploy.outcome == 'success'
|
||||||
|
run: |
|
||||||
|
echo "Rolling back to server.js.backup..."
|
||||||
|
cat > /tmp/rollback-params.json << 'PARAMS'
|
||||||
|
{"commands":["docker exec filamenteka-api sh -c 'if [ -f /app/server.js.backup ]; then cp /app/server.js.backup /app/server.js; fi'","docker restart filamenteka-api","echo Rollback complete"]}
|
||||||
|
PARAMS
|
||||||
|
aws ssm send-command \
|
||||||
|
--region $AWS_REGION \
|
||||||
|
--instance-ids "$INSTANCE_ID" \
|
||||||
|
--document-name "AWS-RunShellScript" \
|
||||||
|
--parameters file:///tmp/rollback-params.json \
|
||||||
|
--output json
|
||||||
|
echo "Rollback command sent"
|
||||||
|
|
||||||
|
# ── Job 4: Tag Successful Deploy ────────────────────────────────────
|
||||||
|
tag-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [detect, deploy-frontend, deploy-api]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
needs.detect.result == 'success' &&
|
||||||
|
(needs.deploy-frontend.result == 'success' || needs.deploy-frontend.result == 'skipped') &&
|
||||||
|
(needs.deploy-api.result == 'success' || needs.deploy-api.result == 'skipped') &&
|
||||||
|
!(needs.deploy-frontend.result == 'skipped' && needs.deploy-api.result == 'skipped')
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
token: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Create deploy tag
|
||||||
|
run: |
|
||||||
|
TAG="deploy-$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
git tag "$TAG"
|
||||||
|
git push origin "$TAG"
|
||||||
|
echo "Tagged successful deploy: $TAG"
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -57,4 +57,10 @@ temp-*.sh
|
|||||||
|
|
||||||
# Lambda packages
|
# Lambda packages
|
||||||
lambda/*.zip
|
lambda/*.zip
|
||||||
lambda/**/node_modules/
|
lambda/**/node_modules/
|
||||||
|
|
||||||
|
# MCP config (local dev tooling)
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
|
# Images/screenshots
|
||||||
|
*.png
|
||||||
208
CLAUDE.md
208
CLAUDE.md
@@ -1,168 +1,134 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance for AI-assisted development in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments. It consists of:
|
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments. It consists of:
|
||||||
- **Frontend**: Next.js app with React, TypeScript, and Tailwind CSS (static export)
|
- **Frontend**: Next.js app with React, TypeScript, and Tailwind CSS (static export to `/out`)
|
||||||
- **Backend**: Node.js API server with PostgreSQL database
|
- **Backend**: Node.js Express API server (`/api/server.js`) with PostgreSQL database
|
||||||
- **Infrastructure**: AWS (Amplify for frontend, EC2 for API, RDS for database)
|
- **Infrastructure**: AWS (CloudFront + S3 for frontend, EC2 for API, RDS for database)
|
||||||
|
- **Repository**: `git.demirix.dev/dax/Filamenteka`
|
||||||
|
|
||||||
## Critical Rules
|
## Critical Rules
|
||||||
|
|
||||||
- NEVER mention ANY author in commits. No author tags or attribution
|
- NEVER mention ANY author in commits. No author tags or attribution of any kind
|
||||||
- NEVER mention AI/assistant names anywhere
|
|
||||||
- Keep commit messages clean and professional with NO attribution
|
|
||||||
- Build for AMD64 Linux when deploying (development is on ARM macOS)
|
- Build for AMD64 Linux when deploying (development is on ARM macOS)
|
||||||
- Always run security checks before commits
|
- Always run security checks before commits (Husky pre-commit hook does this automatically)
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
npm run dev # Start Next.js development server (port 3000)
|
npm run dev # Start Next.js dev server (port 3000)
|
||||||
npm run build # Build static export to /out directory
|
npm run build # Build static export to /out
|
||||||
npm run lint # Run ESLint
|
npm run lint # ESLint
|
||||||
npm test # Run Jest tests
|
npm test # Jest tests
|
||||||
|
npm run test:watch # Jest watch mode
|
||||||
|
npm test -- --testPathPattern=__tests__/components/ColorCell # Run single test file
|
||||||
|
|
||||||
# Security & Quality
|
# API Development
|
||||||
npm run security:check # Check for credential leaks
|
cd api && npm run dev # Start API with nodemon (port 4000)
|
||||||
npm run test:build # Test if build succeeds
|
|
||||||
|
|
||||||
# Database Migrations
|
# Quality Gates
|
||||||
npm run migrate # Run pending migrations
|
npm run security:check # Credential leak detection
|
||||||
npm run migrate:clear # Clear migration history
|
npm run test:build # Verify build succeeds without deploying
|
||||||
|
npx tsc --noEmit # TypeScript type checking
|
||||||
|
|
||||||
# Pre-commit Hook
|
# Database
|
||||||
./scripts/pre-commit.sh # Runs security, build, and test checks
|
npm run migrate # Run pending migrations locally
|
||||||
|
scripts/update-db-via-aws.sh # Run migrations on production RDS via EC2
|
||||||
|
|
||||||
|
# Deployment (auto via CI/CD on push to main, or manual)
|
||||||
|
scripts/deploy-api-update.sh # Deploy API to EC2 via AWS SSM
|
||||||
|
scripts/deploy-frontend.sh # Manual frontend deploy to S3/CloudFront
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Frontend Structure (Next.js App Router)
|
### Frontend (Next.js App Router)
|
||||||
- `/app` - Next.js 13+ app directory structure
|
- `/app/page.tsx` — Public filament inventory table (home page)
|
||||||
- `/page.tsx` - Public filament inventory table
|
- `/app/upadaj/` — Admin panel: login, `/dashboard` (filament CRUD), `/colors` (color management), `/customers` (customer management), `/sales` (sales recording), `/requests` (customer color requests), `/analytics` (revenue/inventory dashboards)
|
||||||
- `/upadaj` - Admin panel (password protected)
|
- `/src/services/api.ts` — Axios instance with auth interceptors (auto token injection, 401/403 redirect)
|
||||||
- `/page.tsx` - Admin login
|
- `/src/data/bambuLabColors.ts` — Primary color-to-hex mapping with gradient support (used by `ColorCell` component)
|
||||||
- `/dashboard/page.tsx` - Filament CRUD operations
|
- `/src/data/bambuLabColorsComplete.ts` — Extended color database grouped by finish type (used by filters)
|
||||||
- `/colors/page.tsx` - Color management
|
|
||||||
|
|
||||||
### API Structure
|
### API (`/api/server.js`)
|
||||||
- `/api` - Node.js Express server (runs on EC2)
|
Single-file Express server running on EC2 (port 80). All routes inline.
|
||||||
- `/server.js` - Main server file
|
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/customers`, `/api/sales`, `/api/color-requests`, `/api/analytics/*`
|
||||||
- `/routes` - API endpoints for filaments, colors, auth
|
- Auth: JWT tokens with 24h expiry, single admin user (password from `ADMIN_PASSWORD` env var)
|
||||||
- Database: PostgreSQL on AWS RDS
|
|
||||||
|
|
||||||
### Key Components
|
### Database (PostgreSQL on RDS)
|
||||||
- `FilamentTableV2` - Main inventory display with sorting/filtering
|
Key schemas and constraints:
|
||||||
- `SaleManager` - Bulk sale management interface
|
|
||||||
- `ColorCell` - Smart color rendering with gradient support
|
|
||||||
- `EnhancedFilters` - Advanced filtering system
|
|
||||||
|
|
||||||
### Data Models
|
```
|
||||||
|
filaments: id, tip (material), finish, boja (color), refill, spulna, kolicina, cena, sale_*
|
||||||
#### Filament Schema (PostgreSQL)
|
colors: id, name, hex, cena_refill (default 3499), cena_spulna (default 3999)
|
||||||
```sql
|
customers: id, name, phone (unique), city, notes, created_at
|
||||||
filaments: {
|
sales: id, customer_id (FK→customers), total_amount, notes, created_at + sale_items join table
|
||||||
id: UUID,
|
color_requests: id, color_name, message, contact_name, contact_phone, status
|
||||||
tip: VARCHAR(50), # Material type (PLA, PETG, ABS)
|
|
||||||
finish: VARCHAR(50), # Finish type (Basic, Matte, Silk)
|
|
||||||
boja: VARCHAR(100), # Color name
|
|
||||||
refill: INTEGER, # Refill spool count
|
|
||||||
spulna: INTEGER, # Regular spool count
|
|
||||||
kolicina: INTEGER, # Total quantity (refill + spulna)
|
|
||||||
cena: VARCHAR(50), # Price
|
|
||||||
sale_active: BOOLEAN, # Sale status
|
|
||||||
sale_percentage: INTEGER,# Sale discount
|
|
||||||
sale_end_date: TIMESTAMP # Sale expiry
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Color Schema
|
**Critical constraints:**
|
||||||
```sql
|
- `filaments.boja` → FK to `colors.name` (ON UPDATE CASCADE) — colors must exist before filaments reference them
|
||||||
colors: {
|
- `kolicina = refill + spulna` — enforced by check constraint
|
||||||
id: UUID,
|
- Migrations in `/database/migrations/` with sequential numbering (currently up to `023_`)
|
||||||
name: VARCHAR(100), # Color name (must match filament.boja)
|
|
||||||
hex: VARCHAR(7), # Hex color code
|
|
||||||
cena_refill: INTEGER, # Refill price (default: 3499)
|
|
||||||
cena_spulna: INTEGER # Regular price (default: 3999)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
## Key Patterns
|
||||||
|
|
||||||
### Frontend (AWS Amplify)
|
### Color Management (two-layer system)
|
||||||
- Automatic deployment on push to main branch
|
1. **Database `colors` table**: Defines valid color names + pricing. Must be populated first due to FK constraint.
|
||||||
- Build output: Static files in `/out` directory
|
2. **Frontend `bambuLabColors.ts`**: Maps color names → hex codes for UI display. Supports solid (`hex: '#FF0000'`) and gradient (`hex: ['#HEX1', '#HEX2'], isGradient: true`).
|
||||||
- Config: `amplify.yml`, `next.config.js` (output: 'export')
|
3. Color names must match exactly between `colors.name`, `filaments.boja`, and `bambuLabColors.ts` keys.
|
||||||
|
4. The `ColorCell` component renders table row backgrounds from these mappings.
|
||||||
|
|
||||||
### API Server (EC2)
|
### Adding New Colors
|
||||||
- Manual deployment via `scripts/deploy-api.sh`
|
1. Add to database `colors` table (via admin panel at `/upadaj/colors` or migration)
|
||||||
- Server: `3.71.161.51`
|
2. Add hex mapping to `src/data/bambuLabColors.ts`
|
||||||
- Domain: `api.filamenteka.rs`
|
3. Optionally add to `src/data/bambuLabColorsComplete.ts` finish groups
|
||||||
|
|
||||||
### Database (RDS PostgreSQL)
|
|
||||||
- Host: `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com`
|
|
||||||
- Migrations in `/database/migrations/`
|
|
||||||
- Schema in `/database/schema.sql`
|
|
||||||
|
|
||||||
## Important Patterns
|
|
||||||
|
|
||||||
### API Communication
|
### API Communication
|
||||||
- All API calls use axios interceptors for auth (`src/services/api.ts`)
|
- Service modules in `src/services/api.ts`: `authService`, `colorService`, `filamentService`, `colorRequestService`, `customerService`, `saleService`, `analyticsService`
|
||||||
- Auth token stored in localStorage
|
- Auth token stored in localStorage with 24h expiry
|
||||||
- Automatic redirect on 401/403 in admin routes
|
- Cache busting on filament fetches via timestamp query param
|
||||||
|
|
||||||
### Color Management
|
## CI/CD (Gitea Actions)
|
||||||
- Colors defined in `src/data/bambuLabColors.ts` and `bambuLabColorsComplete.ts`
|
|
||||||
- Automatic row coloring based on filament color
|
|
||||||
- Special handling for gradient filaments
|
|
||||||
|
|
||||||
### State Management
|
`.gitea/workflows/deploy.yml` triggers on push to `main`. Auto-detects changes via `git diff HEAD~1`:
|
||||||
- React hooks for local state
|
- **Frontend changes** (anything outside `api/`): security check → tests → build → S3 deploy (3-tier cache: HTML no-cache, `_next/` immutable, rest 24h) → CloudFront invalidation
|
||||||
- No global state management library
|
- **API changes** (`api/` files): SSM command to EC2 to pull `server.js` from Gitea and restart `node-api` service
|
||||||
- Data fetching in components with useEffect
|
- Both run if both changed in a single push
|
||||||
|
|
||||||
### Testing
|
## Deployment Details
|
||||||
- Jest + React Testing Library
|
|
||||||
- Tests in `__tests__/` directory
|
| Component | Target | Method |
|
||||||
- Coverage goal: >80%
|
|-----------|--------|--------|
|
||||||
|
| Frontend | S3 `filamenteka-frontend` → CloudFront | Static export, OAC-protected |
|
||||||
|
| API | EC2 `i-03956ecf32292d7d9` | SSM, systemd `node-api` service |
|
||||||
|
| Database | RDS `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com` | Migrations via `scripts/update-db-via-aws.sh` |
|
||||||
|
| DNS | Cloudflare | `api.filamenteka.rs` → EC2 |
|
||||||
|
| IaC | `/terraform/` | VPC, EC2, RDS, ALB, CloudFront, Cloudflare |
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Frontend (.env.local)
|
# Frontend (.env.local)
|
||||||
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
|
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
|
||||||
|
NEXT_PUBLIC_MATOMO_URL=https://analytics.demirix.dev
|
||||||
|
NEXT_PUBLIC_MATOMO_SITE_ID=7
|
||||||
|
|
||||||
# API Server
|
# API Server (.env in /api)
|
||||||
DATABASE_URL=postgresql://...
|
DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka
|
||||||
JWT_SECRET=...
|
JWT_SECRET=...
|
||||||
|
ADMIN_PASSWORD=...
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
PORT=80
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Considerations
|
## Pre-commit Hooks (Husky)
|
||||||
|
|
||||||
- Admin routes protected by JWT authentication
|
`scripts/pre-commit.sh` runs automatically and blocks commits that fail:
|
||||||
- Password hashing with bcrypt
|
1. Author mention check (blocks attribution)
|
||||||
- SQL injection prevention via parameterized queries
|
2. Security check (credential leaks)
|
||||||
- Credential leak detection in pre-commit hooks
|
3. Build test (ensures compilation)
|
||||||
- CORS configured for production domains only
|
4. Unit tests (`jest --passWithNoTests`)
|
||||||
|
|
||||||
## Database Operations
|
|
||||||
|
|
||||||
When modifying the database:
|
|
||||||
1. Create migration file in `/database/migrations/`
|
|
||||||
2. Test locally first
|
|
||||||
3. Run migration on production via scripts
|
|
||||||
4. Update corresponding TypeScript types
|
|
||||||
|
|
||||||
## Terraform Infrastructure
|
|
||||||
|
|
||||||
Infrastructure as Code in `/terraform/`:
|
|
||||||
- VPC and networking setup
|
|
||||||
- EC2 instance for API
|
|
||||||
- RDS PostgreSQL database
|
|
||||||
- Application Load Balancer
|
|
||||||
- ECR for Docker images
|
|
||||||
- Cloudflare DNS integration
|
|
||||||
|
|||||||
55
README.md
55
README.md
@@ -4,21 +4,22 @@ A web application for tracking Bambu Lab filament inventory with automatic color
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🎨 **Automatic Color Coding** - Table rows are automatically colored based on filament colors
|
- Automatic Color Coding - Table rows are automatically colored based on filament colors
|
||||||
- 🔍 **Search & Filter** - Quick search across all filament properties
|
- Search & Filter - Quick search across all filament properties
|
||||||
- 📊 **Sortable Columns** - Click headers to sort by any column
|
- Sortable Columns - Click headers to sort by any column
|
||||||
- 🌈 **Gradient Support** - Special handling for gradient filaments like Cotton Candy Cloud
|
- Gradient Support - Special handling for gradient filaments like Cotton Candy Cloud
|
||||||
- 📱 **Responsive Design** - Works on desktop and mobile devices
|
- Responsive Design - Works on desktop and mobile devices
|
||||||
- 💰 **Sale Management** - Bulk sale pricing with countdown timers
|
- Sale Management - Bulk sale pricing with countdown timers
|
||||||
- 🔐 **Admin Panel** - Protected dashboard for inventory management
|
- Admin Panel - Protected dashboard for inventory management
|
||||||
- 📦 **Spool Types** - Support for both regular and refill spools
|
- Spool Types - Support for both regular and refill spools
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
- **Frontend**: Next.js + React + TypeScript + Tailwind CSS
|
- **Frontend**: Next.js + React + TypeScript + Tailwind CSS
|
||||||
- **Backend**: Node.js API server (Express)
|
- **Backend**: Node.js API server (Express)
|
||||||
- **Database**: PostgreSQL (AWS RDS)
|
- **Database**: PostgreSQL (AWS RDS)
|
||||||
- **Infrastructure**: AWS Amplify (Frontend), EC2 (API), RDS (Database)
|
- **Infrastructure**: AWS CloudFront + S3 (Frontend), EC2 (API), RDS (Database)
|
||||||
|
- **CI/CD**: Gitea Actions
|
||||||
- **IaC**: Terraform
|
- **IaC**: Terraform
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -27,15 +28,14 @@ A web application for tracking Bambu Lab filament inventory with automatic color
|
|||||||
- PostgreSQL (for local development)
|
- PostgreSQL (for local development)
|
||||||
- AWS Account (for deployment)
|
- AWS Account (for deployment)
|
||||||
- Terraform 1.0+ (for infrastructure)
|
- Terraform 1.0+ (for infrastructure)
|
||||||
- GitHub account
|
|
||||||
|
|
||||||
## Setup Instructions
|
## Setup Instructions
|
||||||
|
|
||||||
### 1. Clone the Repository
|
### 1. Clone the Repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/filamenteka.git
|
git clone https://git.demirix.dev/DaX/Filamenteka.git
|
||||||
cd filamenteka
|
cd Filamenteka
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Install Dependencies
|
### 2. Install Dependencies
|
||||||
@@ -90,7 +90,7 @@ The filament table displays these columns:
|
|||||||
- **Boja** - Color name (e.g., Mistletoe Green, Hot Pink)
|
- **Boja** - Color name (e.g., Mistletoe Green, Hot Pink)
|
||||||
- **Refill** - Number of refill spools
|
- **Refill** - Number of refill spools
|
||||||
- **Spulna** - Number of regular spools
|
- **Spulna** - Number of regular spools
|
||||||
- **Količina** - Total quantity (refill + spulna)
|
- **Kolicina** - Total quantity (refill + spulna)
|
||||||
- **Cena** - Price per unit
|
- **Cena** - Price per unit
|
||||||
- **Sale** - Active sale percentage and end date
|
- **Sale** - Active sale percentage and end date
|
||||||
|
|
||||||
@@ -106,15 +106,19 @@ Unknown colors default to light gray.
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Push to the main branch to trigger automatic deployment:
|
Pushing to `main` triggers automatic deployment via Gitea Actions:
|
||||||
|
- Frontend changes are built, tested, and deployed to S3/CloudFront
|
||||||
|
- API changes are deployed to EC2 via AWS SSM
|
||||||
|
|
||||||
|
Manual deployment is also available:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .
|
# Frontend
|
||||||
git commit -m "Update filament colors"
|
./scripts/deploy-frontend.sh
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
Amplify will automatically build and deploy your changes.
|
# API
|
||||||
|
./scripts/deploy-api-update.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Admin Panel
|
## Admin Panel
|
||||||
|
|
||||||
@@ -155,9 +159,7 @@ Deploy API server updates:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Use the deployment script
|
# Use the deployment script
|
||||||
./scripts/deploy-api.sh
|
./scripts/deploy-api-update.sh
|
||||||
|
|
||||||
# Or deploy manually to EC2 instance
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -181,12 +183,3 @@ Deploy API server updates:
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch
|
|
||||||
3. Commit your changes
|
|
||||||
4. Push to the branch
|
|
||||||
5. Create a Pull Request
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ describe('Bambu Lab Colors Data', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have Unknown fallback color', () => {
|
it('should return fallback for unknown colors', () => {
|
||||||
expect(bambuLabColors).toHaveProperty('Unknown');
|
const result = getFilamentColor('NonExistentColor');
|
||||||
expect(bambuLabColors.Unknown.hex).toBe('#CCCCCC');
|
expect(result.hex).toBe('#CCCCCC');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ describe('Bambu Lab Colors Complete Data', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should have matte colors', () => {
|
it('should have matte colors', () => {
|
||||||
expect(bambuLabColors).toHaveProperty('Matte Black');
|
expect(bambuLabColors).toHaveProperty('Matte Ivory White');
|
||||||
expect(bambuLabColors).toHaveProperty('Matte White');
|
expect(bambuLabColors).toHaveProperty('Matte Charcoal');
|
||||||
expect(bambuLabColors).toHaveProperty('Matte Red');
|
expect(bambuLabColors).toHaveProperty('Matte Scarlet Red');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have silk colors', () => {
|
it('should have silk colors', () => {
|
||||||
expect(bambuLabColors).toHaveProperty('Silk White');
|
expect(bambuLabColors).toHaveProperty('Aurora Purple');
|
||||||
expect(bambuLabColors).toHaveProperty('Silk Black');
|
expect(bambuLabColors).toHaveProperty('Phantom Blue');
|
||||||
expect(bambuLabColors).toHaveProperty('Silk Gold');
|
expect(bambuLabColors).toHaveProperty('Mystic Magenta');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have valid hex colors', () => {
|
it('should have valid hex colors', () => {
|
||||||
@@ -40,17 +40,17 @@ describe('Bambu Lab Colors Complete Data', () => {
|
|||||||
it('should have finish categories', () => {
|
it('should have finish categories', () => {
|
||||||
expect(colorsByFinish).toHaveProperty('Basic');
|
expect(colorsByFinish).toHaveProperty('Basic');
|
||||||
expect(colorsByFinish).toHaveProperty('Matte');
|
expect(colorsByFinish).toHaveProperty('Matte');
|
||||||
expect(colorsByFinish).toHaveProperty('Silk');
|
expect(colorsByFinish).toHaveProperty('Silk+');
|
||||||
expect(colorsByFinish).toHaveProperty('Metal');
|
expect(colorsByFinish).toHaveProperty('Metal');
|
||||||
expect(colorsByFinish).toHaveProperty('Sparkle');
|
expect(colorsByFinish).toHaveProperty('Sparkle');
|
||||||
expect(colorsByFinish).toHaveProperty('Glow');
|
expect(colorsByFinish).toHaveProperty('Glow');
|
||||||
expect(colorsByFinish).toHaveProperty('Transparent');
|
expect(colorsByFinish).toHaveProperty('Translucent');
|
||||||
expect(colorsByFinish).toHaveProperty('Support');
|
expect(colorsByFinish).toHaveProperty('CF');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return valid hex for getColorHex', () => {
|
it('should return valid hex for getColorHex', () => {
|
||||||
const hex = getColorHex('Black');
|
const hex = getColorHex('Black');
|
||||||
expect(hex).toBe('#000000');
|
expect(hex).toBe('#1A1A1A');
|
||||||
|
|
||||||
const unknownHex = getColorHex('Unknown Color');
|
const unknownHex = getColorHex('Unknown Color');
|
||||||
expect(unknownHex).toBe('#000000');
|
expect(unknownHex).toBe('#000000');
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ describe('No Mock Data Tests', () => {
|
|||||||
|
|
||||||
it('should use API service in all components', () => {
|
it('should use API service in all components', () => {
|
||||||
const pagePath = join(process.cwd(), 'app', 'page.tsx');
|
const pagePath = join(process.cwd(), 'app', 'page.tsx');
|
||||||
const adminPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
const adminPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||||
|
|
||||||
const pageContent = readFileSync(pagePath, 'utf-8');
|
const pageContent = readFileSync(pagePath, 'utf-8');
|
||||||
const adminContent = readFileSync(adminPath, 'utf-8');
|
const adminContent = readFileSync(adminPath, 'utf-8');
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { join } from 'path';
|
|||||||
|
|
||||||
describe('UI Features Tests', () => {
|
describe('UI Features Tests', () => {
|
||||||
it('should have color hex input in admin form', () => {
|
it('should have color hex input in admin form', () => {
|
||||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||||
|
|
||||||
// Check for color input
|
// Check for color input
|
||||||
@@ -22,19 +22,19 @@ describe('UI Features Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should have number inputs for quantity fields', () => {
|
it('should have number inputs for quantity fields', () => {
|
||||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||||
|
|
||||||
// Check for number inputs for quantities
|
// Check for number inputs for quantities
|
||||||
expect(adminContent).toMatch(/type="number"[\s\S]*?name="refill"/);
|
expect(adminContent).toMatch(/type="number"[\s\S]*?name="refill"/);
|
||||||
expect(adminContent).toMatch(/type="number"[\s\S]*?name="spulna"/);
|
expect(adminContent).toMatch(/type="number"[\s\S]*?name="spulna"/);
|
||||||
expect(adminContent).toContain('Refill');
|
expect(adminContent).toContain('Refil');
|
||||||
expect(adminContent).toContain('Spulna');
|
expect(adminContent).toContain('Špulna');
|
||||||
expect(adminContent).toContain('Ukupna količina');
|
expect(adminContent).toContain('Ukupna količina');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have number input for quantity', () => {
|
it('should have number input for quantity', () => {
|
||||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||||
|
|
||||||
// Check for number input
|
// Check for number input
|
||||||
@@ -44,23 +44,20 @@ describe('UI Features Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should have predefined material options', () => {
|
it('should have predefined material options', () => {
|
||||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||||
|
|
||||||
// Check for material select dropdown
|
// Check for material select dropdown (now generated from catalog)
|
||||||
expect(adminContent).toContain('<option value="PLA">PLA</option>');
|
expect(adminContent).toContain('getMaterialOptions()');
|
||||||
expect(adminContent).toContain('<option value="PETG">PETG</option>');
|
expect(adminContent).toContain('Izaberi tip');
|
||||||
expect(adminContent).toContain('<option value="ABS">ABS</option>');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have admin header with navigation', () => {
|
it('should have admin sidebar and header', () => {
|
||||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||||
|
|
||||||
const dashboardContent = readFileSync(adminDashboardPath, 'utf-8');
|
const dashboardContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||||
|
|
||||||
// Check for admin header
|
// Check for admin sidebar and header
|
||||||
expect(dashboardContent).toContain('Admin');
|
expect(dashboardContent).toContain('AdminSidebar');
|
||||||
expect(dashboardContent).toContain('Nazad na sajt');
|
|
||||||
expect(dashboardContent).toContain('Odjava');
|
expect(dashboardContent).toContain('Odjava');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
18
amplify.yml
18
amplify.yml
@@ -1,18 +0,0 @@
|
|||||||
version: 1
|
|
||||||
frontend:
|
|
||||||
phases:
|
|
||||||
preBuild:
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- npm run security:check
|
|
||||||
build:
|
|
||||||
commands:
|
|
||||||
- npm run build
|
|
||||||
artifacts:
|
|
||||||
baseDirectory: out
|
|
||||||
files:
|
|
||||||
- '**/*'
|
|
||||||
cache:
|
|
||||||
paths:
|
|
||||||
- node_modules/**/*
|
|
||||||
- .next/cache/**/*
|
|
||||||
257
api/package-lock.json
generated
257
api/package-lock.json
generated
@@ -79,23 +79,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.4",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "3.1.2",
|
"bytes": "~3.1.2",
|
||||||
"content-type": "~1.0.5",
|
"content-type": "~1.0.5",
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"depd": "2.0.0",
|
"depd": "2.0.0",
|
||||||
"destroy": "1.2.0",
|
"destroy": "~1.2.0",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "~2.0.1",
|
||||||
"iconv-lite": "0.4.24",
|
"iconv-lite": "~0.4.24",
|
||||||
"on-finished": "2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"qs": "6.13.0",
|
"qs": "~6.14.0",
|
||||||
"raw-body": "2.5.2",
|
"raw-body": "~2.5.3",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"unpipe": "1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8",
|
"node": ">= 0.8",
|
||||||
@@ -224,24 +224,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cors": {
|
"node_modules/cors": {
|
||||||
"version": "2.8.5",
|
"version": "2.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"object-assign": "^4",
|
"object-assign": "^4",
|
||||||
@@ -249,6 +249,10 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -280,9 +284,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.5.0",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -375,39 +379,39 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.21.2",
|
"version": "4.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "1.20.3",
|
"body-parser": "~1.20.3",
|
||||||
"content-disposition": "0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "0.7.1",
|
"cookie": "~0.7.1",
|
||||||
"cookie-signature": "1.0.6",
|
"cookie-signature": "~1.0.6",
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"depd": "2.0.0",
|
"depd": "2.0.0",
|
||||||
"encodeurl": "~2.0.0",
|
"encodeurl": "~2.0.0",
|
||||||
"escape-html": "~1.0.3",
|
"escape-html": "~1.0.3",
|
||||||
"etag": "~1.8.1",
|
"etag": "~1.8.1",
|
||||||
"finalhandler": "1.3.1",
|
"finalhandler": "~1.3.1",
|
||||||
"fresh": "0.5.2",
|
"fresh": "~0.5.2",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "~2.0.0",
|
||||||
"merge-descriptors": "1.0.3",
|
"merge-descriptors": "1.0.3",
|
||||||
"methods": "~1.1.2",
|
"methods": "~1.1.2",
|
||||||
"on-finished": "2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "6.13.0",
|
"qs": "~6.14.0",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "0.19.0",
|
"send": "~0.19.0",
|
||||||
"serve-static": "1.16.2",
|
"serve-static": "~1.16.2",
|
||||||
"setprototypeof": "1.2.0",
|
"setprototypeof": "1.2.0",
|
||||||
"statuses": "2.0.1",
|
"statuses": "~2.0.1",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"utils-merge": "1.0.1",
|
"utils-merge": "1.0.1",
|
||||||
"vary": "~1.1.2"
|
"vary": "~1.1.2"
|
||||||
@@ -434,17 +438,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"encodeurl": "~2.0.0",
|
"encodeurl": "~2.0.0",
|
||||||
"escape-html": "~1.0.3",
|
"escape-html": "~1.0.3",
|
||||||
"on-finished": "2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"statuses": "2.0.1",
|
"statuses": "~2.0.2",
|
||||||
"unpipe": "~1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -590,19 +594,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"depd": "2.0.0",
|
"depd": "~2.0.0",
|
||||||
"inherits": "2.0.4",
|
"inherits": "~2.0.4",
|
||||||
"setprototypeof": "1.2.0",
|
"setprototypeof": "~1.2.0",
|
||||||
"statuses": "2.0.1",
|
"statuses": "~2.0.2",
|
||||||
"toidentifier": "1.0.1"
|
"toidentifier": "~1.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
@@ -686,12 +694,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsonwebtoken": {
|
"node_modules/jsonwebtoken": {
|
||||||
"version": "9.0.2",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jws": "^3.2.2",
|
"jws": "^4.0.1",
|
||||||
"lodash.includes": "^4.3.0",
|
"lodash.includes": "^4.3.0",
|
||||||
"lodash.isboolean": "^3.0.3",
|
"lodash.isboolean": "^3.0.3",
|
||||||
"lodash.isinteger": "^4.0.4",
|
"lodash.isinteger": "^4.0.4",
|
||||||
@@ -714,9 +722,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "1.4.2",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-equal-constant-time": "^1.0.1",
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
@@ -725,12 +733,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jws": {
|
"node_modules/jws": {
|
||||||
"version": "3.2.2",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jwa": "^1.4.1",
|
"jwa": "^2.0.1",
|
||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -874,9 +882,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.10",
|
"version": "3.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
||||||
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
|
"integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -903,9 +911,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/debug": {
|
"node_modules/nodemon/node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -986,14 +994,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.16.2",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
|
||||||
"integrity": "sha512-OtLWF0mKLmpxelOt9BqVq83QV6bTfsS0XLegIeAKqKjurRnRKie1Dc1iL89MugmSLhftxw6NNCyZhm1yQFLMEQ==",
|
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.11.0",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.11.0",
|
||||||
"pg-protocol": "^1.10.2",
|
"pg-protocol": "^1.11.0",
|
||||||
"pg-types": "2.2.0",
|
"pg-types": "2.2.0",
|
||||||
"pgpass": "1.0.5"
|
"pgpass": "1.0.5"
|
||||||
},
|
},
|
||||||
@@ -1001,7 +1009,7 @@
|
|||||||
"node": ">= 16.0.0"
|
"node": ">= 16.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"pg-cloudflare": "^1.2.6"
|
"pg-cloudflare": "^1.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg-native": ">=3.0.1"
|
"pg-native": ">=3.0.1"
|
||||||
@@ -1013,16 +1021,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-cloudflare": {
|
"node_modules/pg-cloudflare": {
|
||||||
"version": "1.2.6",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||||
"integrity": "sha512-uxmJAnmIgmYgnSFzgOf2cqGQBzwnRYcrEgXuFjJNEkpedEIPBSEzxY7ph4uA9k1mI+l/GR0HjPNS6FKNZe8SBQ==",
|
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/pg-connection-string": {
|
"node_modules/pg-connection-string": {
|
||||||
"version": "2.9.1",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
|
||||||
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-int8": {
|
"node_modules/pg-int8": {
|
||||||
@@ -1035,18 +1043,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-pool": {
|
"node_modules/pg-pool": {
|
||||||
"version": "3.10.1",
|
"version": "3.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
|
||||||
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg": ">=8.0"
|
"pg": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-protocol": {
|
"node_modules/pg-protocol": {
|
||||||
"version": "1.10.2",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
|
||||||
"integrity": "sha512-Ci7jy8PbaWxfsck2dwZdERcDG2A0MG8JoQILs+uZNjABFuBuItAZCWUNz8sXRDMoui24rJw7WlXqgpMdBSN/vQ==",
|
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-types": {
|
"node_modules/pg-types": {
|
||||||
@@ -1097,9 +1105,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postgres-bytea": {
|
"node_modules/postgres-bytea": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -1147,12 +1155,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.0.6"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -1171,15 +1179,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/raw-body": {
|
"node_modules/raw-body": {
|
||||||
"version": "2.5.2",
|
"version": "2.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "3.1.2",
|
"bytes": "~3.1.2",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "~2.0.1",
|
||||||
"iconv-lite": "0.4.24",
|
"iconv-lite": "~0.4.24",
|
||||||
"unpipe": "1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
@@ -1225,9 +1233,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.2",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -1237,38 +1245,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "0.19.0",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"depd": "2.0.0",
|
"depd": "2.0.0",
|
||||||
"destroy": "1.2.0",
|
"destroy": "1.2.0",
|
||||||
"encodeurl": "~1.0.2",
|
"encodeurl": "~2.0.0",
|
||||||
"escape-html": "~1.0.3",
|
"escape-html": "~1.0.3",
|
||||||
"etag": "~1.8.1",
|
"etag": "~1.8.1",
|
||||||
"fresh": "0.5.2",
|
"fresh": "~0.5.2",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "~2.0.1",
|
||||||
"mime": "1.6.0",
|
"mime": "1.6.0",
|
||||||
"ms": "2.1.3",
|
"ms": "2.1.3",
|
||||||
"on-finished": "2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"statuses": "2.0.1"
|
"statuses": "~2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send/node_modules/encodeurl": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/send/node_modules/ms": {
|
"node_modules/send/node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -1276,15 +1275,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "1.16.2",
|
"version": "1.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encodeurl": "~2.0.0",
|
"encodeurl": "~2.0.0",
|
||||||
"escape-html": "~1.0.3",
|
"escape-html": "~1.0.3",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"send": "0.19.0"
|
"send": "~0.19.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
@@ -1391,9 +1390,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
|
|||||||
589
api/server.js
589
api/server.js
@@ -154,24 +154,43 @@ app.post('/api/filaments', authenticateToken, async (req, res) => {
|
|||||||
app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
|
app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { tip, finish, boja, boja_hex, refill, spulna, cena, sale_percentage, sale_active, sale_start_date, sale_end_date } = req.body;
|
const { tip, finish, boja, boja_hex, refill, spulna, cena, sale_percentage, sale_active, sale_start_date, sale_end_date } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure refill and spulna are numbers
|
// Ensure refill and spulna are numbers
|
||||||
const refillNum = parseInt(refill) || 0;
|
const refillNum = parseInt(refill) || 0;
|
||||||
const spulnaNum = parseInt(spulna) || 0;
|
const spulnaNum = parseInt(spulna) || 0;
|
||||||
const kolicina = refillNum + spulnaNum;
|
const kolicina = refillNum + spulnaNum;
|
||||||
|
|
||||||
const result = await pool.query(
|
// Check if sale fields are provided in the request
|
||||||
`UPDATE filaments
|
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
|
||||||
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
|
'sale_start_date' in req.body || 'sale_end_date' in req.body;
|
||||||
refill = $5, spulna = $6, kolicina = $7, cena = $8,
|
|
||||||
sale_percentage = $9, sale_active = $10,
|
let result;
|
||||||
sale_start_date = $11, sale_end_date = $12,
|
if (hasSaleFields) {
|
||||||
updated_at = CURRENT_TIMESTAMP
|
// Update with sale fields if they are provided
|
||||||
WHERE id = $13 RETURNING *`,
|
result = await pool.query(
|
||||||
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
|
`UPDATE filaments
|
||||||
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
|
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
|
||||||
);
|
refill = $5, spulna = $6, kolicina = $7, cena = $8,
|
||||||
|
sale_percentage = $9, sale_active = $10,
|
||||||
|
sale_start_date = $11, sale_end_date = $12,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $13 RETURNING *`,
|
||||||
|
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
|
||||||
|
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Update without touching sale fields if they are not provided
|
||||||
|
result = await pool.query(
|
||||||
|
`UPDATE filaments
|
||||||
|
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
|
||||||
|
refill = $5, spulna = $6, kolicina = $7, cena = $8,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $9 RETURNING *`,
|
||||||
|
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating filament:', error);
|
console.error('Error updating filament:', error);
|
||||||
@@ -236,6 +255,550 @@ app.post('/api/filaments/sale/bulk', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Color request endpoints
|
||||||
|
|
||||||
|
// Get all color requests (admin only)
|
||||||
|
app.get('/api/color-requests', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM color_requests ORDER BY created_at DESC'
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching color requests:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch color requests' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit a new color request (public)
|
||||||
|
app.post('/api/color-requests', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
color_name,
|
||||||
|
material_type,
|
||||||
|
finish_type,
|
||||||
|
user_email,
|
||||||
|
user_phone,
|
||||||
|
user_name,
|
||||||
|
description,
|
||||||
|
reference_url
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!color_name || !material_type || !user_email || !user_phone) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Color name, material type, email, and phone are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if similar request already exists
|
||||||
|
const existingRequest = await pool.query(
|
||||||
|
`SELECT id, request_count FROM color_requests
|
||||||
|
WHERE LOWER(color_name) = LOWER($1)
|
||||||
|
AND material_type = $2
|
||||||
|
AND (finish_type = $3 OR (finish_type IS NULL AND $3 IS NULL))
|
||||||
|
AND status = 'pending'`,
|
||||||
|
[color_name, material_type, finish_type]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingRequest.rows.length > 0) {
|
||||||
|
// Increment request count for existing request
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE color_requests
|
||||||
|
SET request_count = request_count + 1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`,
|
||||||
|
[existingRequest.rows[0].id]
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
message: 'Your request has been added to an existing request for this color',
|
||||||
|
request: result.rows[0]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new request
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO color_requests
|
||||||
|
(color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url]
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
message: 'Color request submitted successfully',
|
||||||
|
request: result.rows[0]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating color request:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to submit color request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update color request status (admin only)
|
||||||
|
app.put('/api/color-requests/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status, admin_notes } = req.body;
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE color_requests
|
||||||
|
SET status = $1,
|
||||||
|
admin_notes = $2,
|
||||||
|
processed_at = CURRENT_TIMESTAMP,
|
||||||
|
processed_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4
|
||||||
|
RETURNING *`,
|
||||||
|
[status, admin_notes, req.user.username, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Color request not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating color request:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update color request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete color request (admin only)
|
||||||
|
app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
'DELETE FROM color_requests WHERE id = $1 RETURNING *',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Color request not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Color request deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting color request:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete color request' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Customer endpoints (admin-only)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
app.get('/api/customers', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT * FROM customers ORDER BY name');
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customers:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch customers' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/customers/search', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
if (!q) return res.json([]);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM customers WHERE name ILIKE $1 OR phone ILIKE $1 ORDER BY name LIMIT 10`,
|
||||||
|
[`%${q}%`]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching customers:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to search customers' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/customers/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const customerResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||||
|
if (customerResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Customer not found' });
|
||||||
|
}
|
||||||
|
const salesResult = await pool.query(
|
||||||
|
`SELECT s.*,
|
||||||
|
(SELECT COUNT(*) FROM sale_items si WHERE si.sale_id = s.id) as item_count
|
||||||
|
FROM sales s WHERE s.customer_id = $1 ORDER BY s.created_at DESC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json({ ...customerResult.rows[0], sales: salesResult.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customer:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch customer' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/customers', authenticateToken, async (req, res) => {
|
||||||
|
const { name, phone, city, notes } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO customers (name, phone, city, notes) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||||
|
[name, phone || null, city || null, notes || null]
|
||||||
|
);
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'Customer with this phone already exists' });
|
||||||
|
}
|
||||||
|
console.error('Error creating customer:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create customer' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/customers/:id', authenticateToken, async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, phone, city, notes } = req.body;
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE customers SET name = $1, phone = $2, city = $3, notes = $4, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $5 RETURNING *`,
|
||||||
|
[name, phone || null, city || null, notes || null, id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating customer:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update customer' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Sale endpoints (admin-only)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
app.post('/api/sales', authenticateToken, async (req, res) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const { customer, items, notes } = req.body;
|
||||||
|
|
||||||
|
if (!customer || !customer.name) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: 'Customer name is required' });
|
||||||
|
}
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: 'At least one item is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find-or-create customer by phone
|
||||||
|
let customerId;
|
||||||
|
if (customer.phone) {
|
||||||
|
const existing = await client.query(
|
||||||
|
'SELECT id FROM customers WHERE phone = $1', [customer.phone]
|
||||||
|
);
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
customerId = existing.rows[0].id;
|
||||||
|
// Update name/city if provided
|
||||||
|
await client.query(
|
||||||
|
`UPDATE customers SET name = $1, city = COALESCE($2, city), notes = COALESCE($3, notes), updated_at = CURRENT_TIMESTAMP WHERE id = $4`,
|
||||||
|
[customer.name, customer.city, customer.notes, customerId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!customerId) {
|
||||||
|
const newCust = await client.query(
|
||||||
|
'INSERT INTO customers (name, phone, city, notes) VALUES ($1, $2, $3, $4) RETURNING id',
|
||||||
|
[customer.name, customer.phone || null, customer.city || null, customer.notes || null]
|
||||||
|
);
|
||||||
|
customerId = newCust.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process items
|
||||||
|
let totalAmount = 0;
|
||||||
|
const saleItems = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const { filament_id, item_type, quantity } = item;
|
||||||
|
if (!['refill', 'spulna'].includes(item_type)) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: `Invalid item_type: ${item_type}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filament with stock check
|
||||||
|
const filamentResult = await client.query(
|
||||||
|
'SELECT f.*, c.cena_refill, c.cena_spulna FROM filaments f JOIN colors c ON f.boja = c.name WHERE f.id = $1 FOR UPDATE',
|
||||||
|
[filament_id]
|
||||||
|
);
|
||||||
|
if (filamentResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: `Filament ${filament_id} not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filament = filamentResult.rows[0];
|
||||||
|
if (filament[item_type] < quantity) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Insufficient stock for ${filament.boja} ${item_type}: have ${filament[item_type]}, need ${quantity}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine price (apply sale discount if active)
|
||||||
|
let unitPrice = item_type === 'refill' ? (filament.cena_refill || 3499) : (filament.cena_spulna || 3999);
|
||||||
|
if (filament.sale_active && filament.sale_percentage > 0) {
|
||||||
|
unitPrice = Math.round(unitPrice * (1 - filament.sale_percentage / 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrement inventory
|
||||||
|
const updateField = item_type === 'refill' ? 'refill' : 'spulna';
|
||||||
|
await client.query(
|
||||||
|
`UPDATE filaments SET ${updateField} = ${updateField} - $1, kolicina = kolicina - $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
|
||||||
|
[quantity, filament_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
totalAmount += unitPrice * quantity;
|
||||||
|
saleItems.push({ filament_id, item_type, quantity, unit_price: unitPrice });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert sale
|
||||||
|
const saleResult = await client.query(
|
||||||
|
'INSERT INTO sales (customer_id, total_amount, notes) VALUES ($1, $2, $3) RETURNING *',
|
||||||
|
[customerId, totalAmount, notes || null]
|
||||||
|
);
|
||||||
|
const sale = saleResult.rows[0];
|
||||||
|
|
||||||
|
// Insert sale items
|
||||||
|
for (const si of saleItems) {
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO sale_items (sale_id, filament_id, item_type, quantity, unit_price) VALUES ($1, $2, $3, $4, $5)',
|
||||||
|
[sale.id, si.filament_id, si.item_type, si.quantity, si.unit_price]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
// Fetch full sale with items
|
||||||
|
const fullSale = await pool.query(
|
||||||
|
`SELECT s.*, c.name as customer_name, c.phone as customer_phone
|
||||||
|
FROM sales s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.id = $1`,
|
||||||
|
[sale.id]
|
||||||
|
);
|
||||||
|
const fullItems = await pool.query(
|
||||||
|
`SELECT si.*, f.tip as filament_tip, f.finish as filament_finish, f.boja as filament_boja
|
||||||
|
FROM sale_items si JOIN filaments f ON si.filament_id = f.id WHERE si.sale_id = $1`,
|
||||||
|
[sale.id]
|
||||||
|
);
|
||||||
|
res.json({ ...fullSale.rows[0], items: fullItems.rows });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error creating sale:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create sale' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/sales', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page) || 1;
|
||||||
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const countResult = await pool.query('SELECT COUNT(*) FROM sales');
|
||||||
|
const total = parseInt(countResult.rows[0].count);
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT s.*, c.name as customer_name, c.phone as customer_phone,
|
||||||
|
(SELECT COUNT(*) FROM sale_items si WHERE si.sale_id = s.id) as item_count
|
||||||
|
FROM sales s LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
|
ORDER BY s.created_at DESC LIMIT $1 OFFSET $2`,
|
||||||
|
[limit, offset]
|
||||||
|
);
|
||||||
|
res.json({ sales: result.rows, total });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching sales:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch sales' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/sales/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const saleResult = await pool.query(
|
||||||
|
`SELECT s.*, c.name as customer_name, c.phone as customer_phone, c.city as customer_city
|
||||||
|
FROM sales s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (saleResult.rows.length === 0) return res.status(404).json({ error: 'Sale not found' });
|
||||||
|
|
||||||
|
const itemsResult = await pool.query(
|
||||||
|
`SELECT si.*, f.tip as filament_tip, f.finish as filament_finish, f.boja as filament_boja
|
||||||
|
FROM sale_items si JOIN filaments f ON si.filament_id = f.id WHERE si.sale_id = $1 ORDER BY si.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json({ ...saleResult.rows[0], items: itemsResult.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching sale:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch sale' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/sales/:id', authenticateToken, async (req, res) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Get sale items to restore inventory
|
||||||
|
const itemsResult = await client.query('SELECT * FROM sale_items WHERE sale_id = $1', [id]);
|
||||||
|
if (itemsResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'Sale not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore inventory for each item
|
||||||
|
for (const item of itemsResult.rows) {
|
||||||
|
const updateField = item.item_type === 'refill' ? 'refill' : 'spulna';
|
||||||
|
await client.query(
|
||||||
|
`UPDATE filaments SET ${updateField} = ${updateField} + $1, kolicina = kolicina + $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
|
||||||
|
[item.quantity, item.filament_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete sale (cascade deletes sale_items)
|
||||||
|
await client.query('DELETE FROM sales WHERE id = $1', [id]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error deleting sale:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete sale' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Analytics endpoints (admin-only)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function getPeriodInterval(period) {
|
||||||
|
const map = {
|
||||||
|
'7d': '7 days', '30d': '30 days', '90d': '90 days',
|
||||||
|
'6m': '6 months', '1y': '1 year', 'all': '100 years'
|
||||||
|
};
|
||||||
|
return map[period] || '30 days';
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/analytics/overview', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const interval = getPeriodInterval(req.query.period);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(total_amount), 0) as revenue,
|
||||||
|
COUNT(*) as sales_count,
|
||||||
|
COALESCE(ROUND(AVG(total_amount)), 0) as avg_order_value,
|
||||||
|
COUNT(DISTINCT customer_id) as unique_customers
|
||||||
|
FROM sales WHERE created_at >= NOW() - $1::interval`,
|
||||||
|
[interval]
|
||||||
|
);
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching analytics overview:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch analytics' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/analytics/top-sellers', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const interval = getPeriodInterval(req.query.period);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT f.boja, f.tip, f.finish,
|
||||||
|
SUM(si.quantity) as total_qty,
|
||||||
|
SUM(si.quantity * si.unit_price) as total_revenue
|
||||||
|
FROM sale_items si
|
||||||
|
JOIN filaments f ON si.filament_id = f.id
|
||||||
|
JOIN sales s ON si.sale_id = s.id
|
||||||
|
WHERE s.created_at >= NOW() - $1::interval
|
||||||
|
GROUP BY f.boja, f.tip, f.finish
|
||||||
|
ORDER BY total_qty DESC LIMIT 10`,
|
||||||
|
[interval]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching top sellers:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch top sellers' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/analytics/inventory-alerts', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT f.id, f.boja, f.tip, f.finish, f.refill, f.spulna, f.kolicina,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT SUM(si.quantity)::float / GREATEST(EXTRACT(EPOCH FROM (NOW() - MIN(s.created_at))) / 86400, 1)
|
||||||
|
FROM sale_items si JOIN sales s ON si.sale_id = s.id
|
||||||
|
WHERE si.filament_id = f.id AND s.created_at >= NOW() - INTERVAL '90 days'),
|
||||||
|
0
|
||||||
|
) as avg_daily_sales
|
||||||
|
FROM filaments f
|
||||||
|
WHERE f.kolicina <= 5
|
||||||
|
ORDER BY f.kolicina ASC, f.boja`
|
||||||
|
);
|
||||||
|
const rows = result.rows.map(r => ({
|
||||||
|
...r,
|
||||||
|
avg_daily_sales: parseFloat(r.avg_daily_sales) || 0,
|
||||||
|
days_until_stockout: r.avg_daily_sales > 0
|
||||||
|
? Math.round(r.kolicina / r.avg_daily_sales)
|
||||||
|
: null
|
||||||
|
}));
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching inventory alerts:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch inventory alerts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/analytics/revenue-chart', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const interval = getPeriodInterval(req.query.period || '6m');
|
||||||
|
const group = req.query.group || 'month';
|
||||||
|
const truncTo = group === 'day' ? 'day' : group === 'week' ? 'week' : 'month';
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DATE_TRUNC($1, created_at) as period,
|
||||||
|
SUM(total_amount) as revenue,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM sales
|
||||||
|
WHERE created_at >= NOW() - $2::interval
|
||||||
|
GROUP BY DATE_TRUNC($1, created_at)
|
||||||
|
ORDER BY period`,
|
||||||
|
[truncTo, interval]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching revenue chart:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch revenue chart' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/analytics/type-breakdown', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const interval = getPeriodInterval(req.query.period);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT si.item_type,
|
||||||
|
SUM(si.quantity) as total_qty,
|
||||||
|
SUM(si.quantity * si.unit_price) as total_revenue
|
||||||
|
FROM sale_items si
|
||||||
|
JOIN sales s ON si.sale_id = s.id
|
||||||
|
WHERE s.created_at >= NOW() - $1::interval
|
||||||
|
GROUP BY si.item_type`,
|
||||||
|
[interval]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching type breakdown:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch type breakdown' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server running on port ${PORT}`);
|
console.log(`Server running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
@@ -6,55 +6,41 @@ import { filamentService, colorService } from '@/src/services/api';
|
|||||||
import { Filament } from '@/src/types/filament';
|
import { Filament } from '@/src/types/filament';
|
||||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||||
import { SaleManager } from '@/src/components/SaleManager';
|
import { SaleManager } from '@/src/components/SaleManager';
|
||||||
// Removed unused imports for Bambu Lab color categorization
|
import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
|
import {
|
||||||
|
getFinishesForMaterial,
|
||||||
|
getColorsForMaterialFinish,
|
||||||
|
catalogIsSpoolOnly,
|
||||||
|
catalogIsRefillOnly,
|
||||||
|
getMaterialOptions,
|
||||||
|
} from '@/src/data/bambuLabCatalog';
|
||||||
import '@/src/styles/select.css';
|
import '@/src/styles/select.css';
|
||||||
|
|
||||||
// Colors that only come as refills (no spools)
|
// Catalog-driven helpers
|
||||||
const REFILL_ONLY_COLORS = [
|
const isSpoolOnly = (finish?: string, type?: string, color?: string): boolean => {
|
||||||
'Beige',
|
if (!type || !finish) return false;
|
||||||
'Light Gray',
|
if (color) return catalogIsSpoolOnly(type, finish, color);
|
||||||
'Yellow',
|
// If no specific color, check if ALL colors in this finish are spool-only
|
||||||
'Orange',
|
const colors = getColorsForMaterialFinish(type, finish);
|
||||||
'Gold',
|
return colors.length > 0 && colors.every(c => c.spool && !c.refill);
|
||||||
'Bright Green',
|
|
||||||
'Pink',
|
|
||||||
'Magenta',
|
|
||||||
'Maroon Red',
|
|
||||||
'Purple',
|
|
||||||
'Turquoise',
|
|
||||||
'Cobalt Blue',
|
|
||||||
'Brown',
|
|
||||||
'Bronze',
|
|
||||||
'Silver',
|
|
||||||
'Blue Grey',
|
|
||||||
'Dark Gray'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Helper function to check if a filament is spool-only
|
|
||||||
const isSpoolOnly = (finish?: string, type?: string): boolean => {
|
|
||||||
return finish === 'Translucent' || finish === 'Metal' || finish === 'Silk+' || (type === 'PPA' && finish === 'CF') || type === 'PA6' || type === 'PC';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to check if a filament should be refill-only
|
|
||||||
const isRefillOnly = (color: string, finish?: string, type?: string): boolean => {
|
const isRefillOnly = (color: string, finish?: string, type?: string): boolean => {
|
||||||
// If the finish/type combination is spool-only, then it's not refill-only
|
if (!type || !finish || !color) return false;
|
||||||
if (isSpoolOnly(finish, type)) {
|
return catalogIsRefillOnly(type, finish, color);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Translucent finish always has spool option
|
|
||||||
if (finish === 'Translucent') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return REFILL_ONLY_COLORS.includes(color);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to filter colors based on material and finish
|
const getFilteredColors = (
|
||||||
const getFilteredColors = (colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>, type?: string, finish?: string) => {
|
colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>,
|
||||||
// PPA CF only has black color
|
type?: string,
|
||||||
if (type === 'PPA' && finish === 'CF') {
|
finish?: string
|
||||||
return colors.filter(color => color.name.toLowerCase() === 'black');
|
) => {
|
||||||
}
|
if (!type || !finish) return colors;
|
||||||
return colors;
|
const catalogColors = getColorsForMaterialFinish(type, finish);
|
||||||
|
if (catalogColors.length === 0) return colors;
|
||||||
|
const catalogNames = new Set(catalogColors.map(c => c.name.toLowerCase()));
|
||||||
|
return colors.filter(c => catalogNames.has(c.name.toLowerCase()));
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FilamentWithId extends Filament {
|
interface FilamentWithId extends Filament {
|
||||||
@@ -64,21 +50,6 @@ interface FilamentWithId extends Filament {
|
|||||||
boja_hex?: string;
|
boja_hex?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finish options by filament type
|
|
||||||
const FINISH_OPTIONS_BY_TYPE: Record<string, string[]> = {
|
|
||||||
'ABS': ['GF', 'Bez Finisha'],
|
|
||||||
'PLA': ['85A', '90A', '95A HF', 'Aero', 'Basic', 'Basic Gradient', 'CF', 'FR', 'Galaxy', 'GF', 'Glow', 'HF', 'Marble', 'Matte', 'Metal', 'Silk Multi-Color', 'Silk+', 'Sparkle', 'Translucent', 'Wood'],
|
|
||||||
'TPU': ['85A', '90A', '95A HF'],
|
|
||||||
'PETG': ['Basic', 'CF', 'FR', 'HF', 'Translucent'],
|
|
||||||
'PC': ['CF', 'FR', 'Bez Finisha'],
|
|
||||||
'ASA': ['Bez Finisha'],
|
|
||||||
'PA': ['CF', 'GF', 'Bez Finisha'],
|
|
||||||
'PA6': ['CF', 'GF'],
|
|
||||||
'PPA': ['CF'],
|
|
||||||
'PVA': ['Bez Finisha'],
|
|
||||||
'HIPS': ['Bez Finisha']
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
|
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
|
||||||
@@ -119,13 +90,16 @@ export default function AdminDashboard() {
|
|||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Wait for component to mount to avoid SSR issues with localStorage
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
const token = localStorage.getItem('authToken');
|
const token = localStorage.getItem('authToken');
|
||||||
const expiry = localStorage.getItem('tokenExpiry');
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
|
||||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||||
router.push('/upadaj');
|
window.location.href = '/upadaj';
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [mounted]);
|
||||||
|
|
||||||
// Fetch filaments
|
// Fetch filaments
|
||||||
const fetchFilaments = async () => {
|
const fetchFilaments = async () => {
|
||||||
@@ -340,8 +314,10 @@ export default function AdminDashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
{/* Main Content - Full Screen */}
|
<div className="flex">
|
||||||
<div className="w-full">
|
<AdminSidebar />
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1">
|
||||||
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 lg:py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 lg:py-6">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
@@ -372,17 +348,15 @@ export default function AdminDashboard() {
|
|||||||
Obriši izabrane ({selectedFilaments.size})
|
Obriši izabrane ({selectedFilaments.size})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<SaleManager
|
<SaleManager
|
||||||
filaments={filaments}
|
filaments={filaments}
|
||||||
selectedFilaments={selectedFilaments}
|
selectedFilaments={selectedFilaments}
|
||||||
onSaleUpdate={fetchFilaments}
|
onSaleUpdate={fetchFilaments}
|
||||||
/>
|
/>
|
||||||
<button
|
<BulkFilamentPriceEditor
|
||||||
onClick={() => router.push('/')}
|
filaments={filaments}
|
||||||
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm sm:text-base"
|
onUpdate={fetchFilaments}
|
||||||
>
|
/>
|
||||||
Nazad na sajt
|
|
||||||
</button>
|
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setDarkMode(!darkMode)}
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
@@ -448,8 +422,8 @@ export default function AdminDashboard() {
|
|||||||
<option value="boja-desc">Sortiraj po: Boja (Z-A)</option>
|
<option value="boja-desc">Sortiraj po: Boja (Z-A)</option>
|
||||||
<option value="tip-asc">Sortiraj po: Tip (A-Z)</option>
|
<option value="tip-asc">Sortiraj po: Tip (A-Z)</option>
|
||||||
<option value="tip-desc">Sortiraj po: Tip (Z-A)</option>
|
<option value="tip-desc">Sortiraj po: Tip (Z-A)</option>
|
||||||
<option value="finish-asc">Sortiraj po: Finish (A-Z)</option>
|
<option value="finish-asc">Sortiraj po: Finiš (A-Z)</option>
|
||||||
<option value="finish-desc">Sortiraj po: Finish (Z-A)</option>
|
<option value="finish-desc">Sortiraj po: Finiš (Z-A)</option>
|
||||||
<option value="created_at-desc">Sortiraj po: Poslednje dodano</option>
|
<option value="created_at-desc">Sortiraj po: Poslednje dodano</option>
|
||||||
<option value="created_at-asc">Sortiraj po: Prvo dodano</option>
|
<option value="created_at-asc">Sortiraj po: Prvo dodano</option>
|
||||||
<option value="updated_at-desc">Sortiraj po: Poslednje ažurirano</option>
|
<option value="updated_at-desc">Sortiraj po: Poslednje ažurirano</option>
|
||||||
@@ -495,16 +469,16 @@ export default function AdminDashboard() {
|
|||||||
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th onClick={() => handleSort('finish')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
<th onClick={() => handleSort('finish')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Finiš {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th onClick={() => handleSort('boja')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
<th onClick={() => handleSort('boja')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th onClick={() => handleSort('refill')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
<th onClick={() => handleSort('refill')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th onClick={() => handleSort('spulna')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
<th onClick={() => handleSort('spulna')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Špulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th onClick={() => handleSort('kolicina')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
<th onClick={() => handleSort('kolicina')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
@@ -650,6 +624,7 @@ export default function AdminDashboard() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -674,7 +649,7 @@ function FilamentForm({
|
|||||||
finish: filament.finish || (filament.id ? '' : 'Basic'), // Default to Basic for new filaments
|
finish: filament.finish || (filament.id ? '' : 'Basic'), // Default to Basic for new filaments
|
||||||
boja: filament.boja || '',
|
boja: filament.boja || '',
|
||||||
boja_hex: filament.boja_hex || '',
|
boja_hex: filament.boja_hex || '',
|
||||||
refill: isSpoolOnly(filament.finish, filament.tip) ? 0 : (filament.refill || 0), // Store as number
|
refill: isSpoolOnly(filament.finish, filament.tip, filament.boja) ? 0 : (filament.refill || 0), // Store as number
|
||||||
spulna: isRefillOnly(filament.boja || '', filament.finish, filament.tip) ? 0 : (filament.spulna || 0), // Store as number
|
spulna: isRefillOnly(filament.boja || '', filament.finish, filament.tip) ? 0 : (filament.spulna || 0), // Store as number
|
||||||
kolicina: filament.kolicina || 0, // Default to 0, stored as number
|
kolicina: filament.kolicina || 0, // Default to 0, stored as number
|
||||||
cena: '', // Price is now determined by color selection
|
cena: '', // Price is now determined by color selection
|
||||||
@@ -682,23 +657,26 @@ function FilamentForm({
|
|||||||
cena_spulna: 0,
|
cena_spulna: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track if this is the initial load to prevent price override
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
|
|
||||||
// Update form when filament prop changes
|
// Update form when filament prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Extract prices from the cena field if it exists (format: "3499/3999" or just "3499")
|
// Extract prices from the cena field if it exists (format: "3499/3999" or just "3499")
|
||||||
let refillPrice = 0;
|
let refillPrice = 0;
|
||||||
let spulnaPrice = 0;
|
let spulnaPrice = 0;
|
||||||
|
|
||||||
if (filament.cena) {
|
if (filament.cena) {
|
||||||
const prices = filament.cena.split('/');
|
const prices = filament.cena.split('/');
|
||||||
refillPrice = parseInt(prices[0]) || 0;
|
refillPrice = parseInt(prices[0]) || 0;
|
||||||
spulnaPrice = prices.length > 1 ? parseInt(prices[1]) || 0 : parseInt(prices[0]) || 0;
|
spulnaPrice = prices.length > 1 ? parseInt(prices[1]) || 0 : parseInt(prices[0]) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get default prices from color
|
// Get default prices from color
|
||||||
const colorData = availableColors.find(c => c.name === filament.boja);
|
const colorData = availableColors.find(c => c.name === filament.boja);
|
||||||
if (!refillPrice && colorData?.cena_refill) refillPrice = colorData.cena_refill;
|
if (!refillPrice && colorData?.cena_refill) refillPrice = colorData.cena_refill;
|
||||||
if (!spulnaPrice && colorData?.cena_spulna) spulnaPrice = colorData.cena_spulna;
|
if (!spulnaPrice && colorData?.cena_spulna) spulnaPrice = colorData.cena_spulna;
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
tip: filament.tip || (filament.id ? '' : 'PLA'), // Default to PLA for new filaments
|
tip: filament.tip || (filament.id ? '' : 'PLA'), // Default to PLA for new filaments
|
||||||
finish: filament.finish || (filament.id ? '' : 'Basic'), // Default to Basic for new filaments
|
finish: filament.finish || (filament.id ? '' : 'Basic'), // Default to Basic for new filaments
|
||||||
@@ -711,10 +689,19 @@ function FilamentForm({
|
|||||||
cena_refill: refillPrice || 3499,
|
cena_refill: refillPrice || 3499,
|
||||||
cena_spulna: spulnaPrice || 3999,
|
cena_spulna: spulnaPrice || 3999,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset initial load flag when filament changes
|
||||||
|
setIsInitialLoad(true);
|
||||||
}, [filament]);
|
}, [filament]);
|
||||||
|
|
||||||
// Update prices when color selection changes
|
// Update prices when color selection changes (but not on initial load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip price update on initial load to preserve existing filament prices
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.boja && availableColors.length > 0) {
|
if (formData.boja && availableColors.length > 0) {
|
||||||
const colorData = availableColors.find(c => c.name === formData.boja);
|
const colorData = availableColors.find(c => c.name === formData.boja);
|
||||||
if (colorData) {
|
if (colorData) {
|
||||||
@@ -739,9 +726,9 @@ function FilamentForm({
|
|||||||
});
|
});
|
||||||
} else if (name === 'tip') {
|
} else if (name === 'tip') {
|
||||||
// If changing filament type, reset finish if it's not compatible
|
// If changing filament type, reset finish if it's not compatible
|
||||||
const newTypeFinishes = FINISH_OPTIONS_BY_TYPE[value] || [];
|
const newTypeFinishes = getFinishesForMaterial(value);
|
||||||
const resetFinish = !newTypeFinishes.includes(formData.finish);
|
const resetFinish = !newTypeFinishes.includes(formData.finish);
|
||||||
const spoolOnly = isSpoolOnly(formData.finish, value);
|
const spoolOnly = isSpoolOnly(formData.finish, value, formData.boja);
|
||||||
// If changing to PPA with CF finish and current color is not black, reset color
|
// If changing to PPA with CF finish and current color is not black, reset color
|
||||||
const needsColorReset = value === 'PPA' && formData.finish === 'CF' && formData.boja.toLowerCase() !== 'black';
|
const needsColorReset = value === 'PPA' && formData.finish === 'CF' && formData.boja.toLowerCase() !== 'black';
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -762,7 +749,7 @@ function FilamentForm({
|
|||||||
} else if (name === 'finish') {
|
} else if (name === 'finish') {
|
||||||
// If changing to Translucent finish, enable spool option and disable refill
|
// If changing to Translucent finish, enable spool option and disable refill
|
||||||
const refillOnly = isRefillOnly(formData.boja, value, formData.tip);
|
const refillOnly = isRefillOnly(formData.boja, value, formData.tip);
|
||||||
const spoolOnly = isSpoolOnly(value, formData.tip);
|
const spoolOnly = isSpoolOnly(value, formData.tip, formData.boja);
|
||||||
// If changing to PPA CF and current color is not black, reset color
|
// If changing to PPA CF and current color is not black, reset color
|
||||||
const needsColorReset = formData.tip === 'PPA' && value === 'CF' && formData.boja.toLowerCase() !== 'black';
|
const needsColorReset = formData.tip === 'PPA' && value === 'CF' && formData.boja.toLowerCase() !== 'black';
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -839,22 +826,14 @@ function FilamentForm({
|
|||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Izaberi tip</option>
|
<option value="">Izaberi tip</option>
|
||||||
<option value="ABS">ABS</option>
|
{getMaterialOptions().map(mat => (
|
||||||
<option value="ASA">ASA</option>
|
<option key={mat} value={mat}>{mat}</option>
|
||||||
<option value="PA6">PA6</option>
|
))}
|
||||||
<option value="PAHT">PAHT</option>
|
|
||||||
<option value="PC">PC</option>
|
|
||||||
<option value="PET">PET</option>
|
|
||||||
<option value="PETG">PETG</option>
|
|
||||||
<option value="PLA">PLA</option>
|
|
||||||
<option value="PPA">PPA</option>
|
|
||||||
<option value="PPS">PPS</option>
|
|
||||||
<option value="TPU">TPU</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Finish</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Finiš</label>
|
||||||
<select
|
<select
|
||||||
name="finish"
|
name="finish"
|
||||||
value={formData.finish}
|
value={formData.finish}
|
||||||
@@ -862,8 +841,8 @@ function FilamentForm({
|
|||||||
required
|
required
|
||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Izaberi finish</option>
|
<option value="">Izaberi finiš</option>
|
||||||
{(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
|
{getFinishesForMaterial(formData.tip).map(finish => (
|
||||||
<option key={finish} value={finish}>{finish}</option>
|
<option key={finish} value={finish}>{finish}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -908,7 +887,6 @@ function FilamentForm({
|
|||||||
{color.name}
|
{color.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
<option value="custom">Druga boja...</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -933,7 +911,7 @@ function FilamentForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
<span className="text-green-600 dark:text-green-400">Cena Refill</span>
|
<span className="text-green-600 dark:text-green-400">Cena Refila</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -943,10 +921,10 @@ function FilamentForm({
|
|||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="3499"
|
placeholder="3499"
|
||||||
disabled={isSpoolOnly(formData.finish, formData.tip)}
|
disabled={isSpoolOnly(formData.finish, formData.tip, formData.boja)}
|
||||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
||||||
isSpoolOnly(formData.finish, formData.tip)
|
isSpoolOnly(formData.finish, formData.tip, formData.boja)
|
||||||
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
||||||
: 'bg-white dark:bg-gray-700'
|
: 'bg-white dark:bg-gray-700'
|
||||||
} text-green-600 dark:text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`}
|
} text-green-600 dark:text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`}
|
||||||
/>
|
/>
|
||||||
@@ -954,7 +932,7 @@ function FilamentForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
<span className="text-blue-500 dark:text-blue-400">Cena Spulna</span>
|
<span className="text-blue-500 dark:text-blue-400">Cena Špulne</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -966,8 +944,8 @@ function FilamentForm({
|
|||||||
placeholder="3999"
|
placeholder="3999"
|
||||||
disabled={isRefillOnly(formData.boja, formData.finish)}
|
disabled={isRefillOnly(formData.boja, formData.finish)}
|
||||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
||||||
isRefillOnly(formData.boja, formData.finish)
|
isRefillOnly(formData.boja, formData.finish)
|
||||||
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed text-gray-400'
|
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed text-gray-400'
|
||||||
: 'bg-white dark:bg-gray-700 text-blue-500 dark:text-blue-400 font-bold focus:outline-none focus:ring-2 focus:ring-blue-500'
|
: 'bg-white dark:bg-gray-700 text-blue-500 dark:text-blue-400 font-bold focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -976,9 +954,9 @@ function FilamentForm({
|
|||||||
{/* Quantity inputs for refill, vakuum, and otvoreno */}
|
{/* Quantity inputs for refill, vakuum, and otvoreno */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
Refill
|
Refil
|
||||||
{isSpoolOnly(formData.finish, formData.tip) && (
|
{isSpoolOnly(formData.finish, formData.tip, formData.boja) && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo spulna postoji)</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo špulna postoji)</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -989,10 +967,10 @@ function FilamentForm({
|
|||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
disabled={isSpoolOnly(formData.finish, formData.tip)}
|
disabled={isSpoolOnly(formData.finish, formData.tip, formData.boja)}
|
||||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
||||||
isSpoolOnly(formData.finish, formData.tip)
|
isSpoolOnly(formData.finish, formData.tip, formData.boja)
|
||||||
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
||||||
: 'bg-white dark:bg-gray-700'
|
: 'bg-white dark:bg-gray-700'
|
||||||
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||||
/>
|
/>
|
||||||
@@ -1000,7 +978,7 @@ function FilamentForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
Spulna
|
Špulna
|
||||||
{isRefillOnly(formData.boja, formData.finish, formData.tip) && (
|
{isRefillOnly(formData.boja, formData.finish, formData.tip) && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo refil postoji)</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo refil postoji)</span>
|
||||||
)}
|
)}
|
||||||
@@ -1015,8 +993,8 @@ function FilamentForm({
|
|||||||
placeholder="0"
|
placeholder="0"
|
||||||
disabled={isRefillOnly(formData.boja, formData.finish, formData.tip)}
|
disabled={isRefillOnly(formData.boja, formData.finish, formData.tip)}
|
||||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
||||||
isRefillOnly(formData.boja, formData.finish, formData.tip)
|
isRefillOnly(formData.boja, formData.finish, formData.tip)
|
||||||
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
||||||
: 'bg-white dark:bg-gray-700'
|
: 'bg-white dark:bg-gray-700'
|
||||||
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||||
/>
|
/>
|
||||||
108
app/page.tsx
108
app/page.tsx
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
|
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
|
||||||
import { SaleCountdown } from '../src/components/SaleCountdown';
|
import { SaleCountdown } from '../src/components/SaleCountdown';
|
||||||
|
import ColorRequestModal from '../src/components/ColorRequestModal';
|
||||||
import { Filament } from '../src/types/filament';
|
import { Filament } from '../src/types/filament';
|
||||||
import { filamentService } from '../src/services/api';
|
import { filamentService } from '../src/services/api';
|
||||||
import { trackEvent } from '../src/components/MatomoAnalytics';
|
import { trackEvent } from '../src/components/MatomoAnalytics';
|
||||||
@@ -14,6 +15,7 @@ export default function Home() {
|
|||||||
const [darkMode, setDarkMode] = useState(false);
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [resetKey, setResetKey] = useState(0);
|
const [resetKey, setResetKey] = useState(0);
|
||||||
|
const [showColorRequestModal, setShowColorRequestModal] = useState(false);
|
||||||
// Removed V1/V2 toggle - now only using V2
|
// Removed V1/V2 toggle - now only using V2
|
||||||
|
|
||||||
// Initialize dark mode from localStorage after mounting
|
// Initialize dark mode from localStorage after mounting
|
||||||
@@ -89,6 +91,40 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
|
{/* Full-screen notice overlay - remove after March 8 */}
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 px-6 overflow-hidden">
|
||||||
|
{/* Subtle background glow */}
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-amber-500/5 rounded-full blur-3xl" />
|
||||||
|
|
||||||
|
<div className="relative max-w-xl w-full text-center space-y-10">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Filamenteka"
|
||||||
|
className="h-32 sm:h-44 w-auto mx-auto drop-shadow-2xl"
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full">
|
||||||
|
<span className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" />
|
||||||
|
<span className="text-amber-400 text-sm font-medium tracking-wide uppercase">Obaveštenje</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
|
||||||
|
Privremena pauza
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg sm:text-xl text-gray-400 leading-relaxed max-w-md mx-auto">
|
||||||
|
Trenutno primamo porudžbine samo od postojećih kupaca. Redovna prodaja se vraća nakon <span className="text-amber-400 font-semibold">8. marta</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-white/5">
|
||||||
|
<p className="text-gray-600 text-sm">Hvala na razumevanju</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<header className="bg-gradient-to-r from-blue-50 to-orange-50 dark:from-gray-800 dark:to-gray-900 shadow-lg transition-all duration-300">
|
<header className="bg-gradient-to-r from-blue-50 to-orange-50 dark:from-gray-800 dark:to-gray-900 shadow-lg transition-all duration-300">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-0">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-0">
|
||||||
@@ -164,18 +200,31 @@ export default function Home() {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="tel:+381677102845"
|
href="tel:+381631031048"
|
||||||
onClick={() => trackEvent('Contact', 'Phone Call', 'Homepage')}
|
onClick={() => trackEvent('Contact', 'Phone Call', 'Homepage')}
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
</svg>
|
</svg>
|
||||||
Pozovi +381 67 710 2845
|
Pozovi +381 63 103 1048
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowColorRequestModal(true);
|
||||||
|
trackEvent('Navigation', 'Request Color', 'Homepage');
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||||
|
</svg>
|
||||||
|
Zatraži Novu Boju
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SaleCountdown
|
<SaleCountdown
|
||||||
hasActiveSale={filaments.some(f => f.sale_active === true)}
|
hasActiveSale={filaments.some(f => f.sale_active === true)}
|
||||||
maxSalePercentage={Math.max(...filaments.filter(f => f.sale_active === true).map(f => f.sale_percentage || 0), 0)}
|
maxSalePercentage={Math.max(...filaments.filter(f => f.sale_active === true).map(f => f.sale_percentage || 0), 0)}
|
||||||
saleEndDate={(() => {
|
saleEndDate={(() => {
|
||||||
@@ -189,7 +238,43 @@ export default function Home() {
|
|||||||
return latestSale;
|
return latestSale;
|
||||||
})()}
|
})()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Pricing Information */}
|
||||||
|
<div className="mb-6 space-y-4">
|
||||||
|
{/* Reusable Spool Price Notice */}
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-blue-800 dark:text-blue-200 font-medium">
|
||||||
|
Cena višekratne špulne: 499 RSD
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selling by Grams Notice */}
|
||||||
|
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-green-800 dark:text-green-200 font-semibold">
|
||||||
|
Prodaja filamenta na grame - idealno za testiranje materijala ili manje projekte
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-center gap-4 text-sm">
|
||||||
|
<span className="text-green-700 dark:text-green-300 font-medium">50gr - 299 RSD</span>
|
||||||
|
<span className="text-gray-400 dark:text-gray-600">•</span>
|
||||||
|
<span className="text-green-700 dark:text-green-300 font-medium">100gr - 499 RSD</span>
|
||||||
|
<span className="text-gray-400 dark:text-gray-600">•</span>
|
||||||
|
<span className="text-green-700 dark:text-green-300 font-medium">200gr - 949 RSD</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FilamentTableV2
|
<FilamentTableV2
|
||||||
key={resetKey}
|
key={resetKey}
|
||||||
filaments={filaments}
|
filaments={filaments}
|
||||||
@@ -198,24 +283,29 @@ export default function Home() {
|
|||||||
|
|
||||||
<footer className="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
|
<footer className="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-6">
|
<div className="flex flex-col justify-center items-center gap-6">
|
||||||
<div className="text-center sm:text-left">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Kontakt</h3>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Kontakt</h3>
|
||||||
<a
|
<a
|
||||||
href="tel:+381677102845"
|
href="tel:+381631031048"
|
||||||
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||||
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
|
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
</svg>
|
</svg>
|
||||||
+381 67 710 2845
|
+381 63 103 1048
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{/* Color Request Modal */}
|
||||||
|
<ColorRequestModal
|
||||||
|
isOpen={showColorRequestModal}
|
||||||
|
onClose={() => setShowColorRequestModal(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
427
app/upadaj/analytics/page.tsx
Normal file
427
app/upadaj/analytics/page.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { analyticsService } from '@/src/services/api';
|
||||||
|
import type { AnalyticsOverview, TopSeller, RevenueDataPoint, InventoryAlert, TypeBreakdown } from '@/src/types/sales';
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend } from 'recharts';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
|
|
||||||
|
type Period = '7d' | '30d' | '90d' | '6m' | '1y' | 'all';
|
||||||
|
|
||||||
|
const PERIOD_LABELS: Record<Period, string> = {
|
||||||
|
'7d': '7d',
|
||||||
|
'30d': '30d',
|
||||||
|
'90d': '90d',
|
||||||
|
'6m': '6m',
|
||||||
|
'1y': '1y',
|
||||||
|
'all': 'Sve',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERIOD_GROUP: Record<Period, string> = {
|
||||||
|
'7d': 'day',
|
||||||
|
'30d': 'day',
|
||||||
|
'90d': 'week',
|
||||||
|
'6m': 'month',
|
||||||
|
'1y': 'month',
|
||||||
|
'all': 'month',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRSD(value: number): string {
|
||||||
|
return new Intl.NumberFormat('sr-RS', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RSD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsDashboard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
|
const [period, setPeriod] = useState<Period>('30d');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||||
|
const [topSellers, setTopSellers] = useState<TopSeller[]>([]);
|
||||||
|
const [revenueData, setRevenueData] = useState<RevenueDataPoint[]>([]);
|
||||||
|
const [inventoryAlerts, setInventoryAlerts] = useState<InventoryAlert[]>([]);
|
||||||
|
const [typeBreakdown, setTypeBreakdown] = useState<TypeBreakdown[]>([]);
|
||||||
|
|
||||||
|
// Initialize dark mode
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const saved = localStorage.getItem('darkMode');
|
||||||
|
setDarkMode(saved !== null ? JSON.parse(saved) : true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||||
|
if (darkMode) document.documentElement.classList.add('dark');
|
||||||
|
else document.documentElement.classList.remove('dark');
|
||||||
|
}, [darkMode, mounted]);
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||||
|
window.location.href = '/upadaj';
|
||||||
|
}
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const [overviewData, topSellersData, revenueChartData, alertsData, breakdownData] = await Promise.all([
|
||||||
|
analyticsService.getOverview(period),
|
||||||
|
analyticsService.getTopSellers(period),
|
||||||
|
analyticsService.getRevenueChart(period, PERIOD_GROUP[period]),
|
||||||
|
analyticsService.getInventoryAlerts(),
|
||||||
|
analyticsService.getTypeBreakdown(period),
|
||||||
|
]);
|
||||||
|
setOverview(overviewData);
|
||||||
|
setTopSellers(topSellersData);
|
||||||
|
setRevenueData(revenueChartData);
|
||||||
|
setInventoryAlerts(alertsData);
|
||||||
|
setTypeBreakdown(breakdownData);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Greska pri ucitavanju analitike');
|
||||||
|
console.error('Analytics fetch error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('tokenExpiry');
|
||||||
|
router.push('/upadaj');
|
||||||
|
};
|
||||||
|
|
||||||
|
const PIE_COLORS = ['#22c55e', '#3b82f6'];
|
||||||
|
|
||||||
|
const getStockRowClass = (alert: InventoryAlert): string => {
|
||||||
|
if (alert.days_until_stockout === null) return '';
|
||||||
|
if (alert.days_until_stockout < 7) return 'bg-red-50 dark:bg-red-900/20';
|
||||||
|
if (alert.days_until_stockout < 14) return 'bg-yellow-50 dark:bg-yellow-900/20';
|
||||||
|
return 'bg-green-50 dark:bg-green-900/20';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">Ucitavanje...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Filamenteka"
|
||||||
|
className="h-20 sm:h-32 w-auto drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Analitika</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Nazad na sajt
|
||||||
|
</button>
|
||||||
|
{mounted && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||||
|
>
|
||||||
|
{darkMode ? '\u2600\uFE0F' : '\uD83C\uDF19'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Odjava
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Period Selector */}
|
||||||
|
<div className="mb-6 flex gap-2">
|
||||||
|
{(Object.keys(PERIOD_LABELS) as Period[]).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`px-4 py-2 rounded font-medium transition-colors ${
|
||||||
|
period === p
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 shadow'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{PERIOD_LABELS[p]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">Ucitavanje analitike...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Overview Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prihod</h3>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{overview ? formatRSD(overview.revenue) : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Broj prodaja</h3>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{overview ? overview.sales_count : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prosecna vrednost</h3>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{overview ? formatRSD(overview.avg_order_value) : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Jedinstveni kupci</h3>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{overview ? overview.unique_customers : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revenue Chart */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Prihod po periodu</h2>
|
||||||
|
<div className="h-80">
|
||||||
|
{mounted && revenueData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={revenueData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={darkMode ? '#374151' : '#e5e7eb'} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="period"
|
||||||
|
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
||||||
|
tickFormatter={(value) => formatRSD(value)}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={((value: number) => [formatRSD(value), 'Prihod']) as any}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
||||||
|
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
color: darkMode ? '#f3f4f6' : '#111827',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#3b82f6', r: 4 }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||||
|
Nema podataka za prikaz
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
|
{/* Top Sellers */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Najprodavanije boje</h2>
|
||||||
|
<div className="h-80">
|
||||||
|
{mounted && topSellers.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={topSellers} layout="vertical" margin={{ left: 20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={darkMode ? '#374151' : '#e5e7eb'} />
|
||||||
|
<XAxis type="number" stroke={darkMode ? '#9ca3af' : '#6b7280'} tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="boja"
|
||||||
|
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
width={120}
|
||||||
|
tickFormatter={(value: string, index: number) => {
|
||||||
|
const seller = topSellers[index];
|
||||||
|
return seller ? `${value} (${seller.tip})` : value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={((value: number) => [value, 'Kolicina']) as any}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
||||||
|
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
color: darkMode ? '#f3f4f6' : '#111827',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total_qty" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||||
|
Nema podataka za prikaz
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Breakdown */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Refill vs Spulna</h2>
|
||||||
|
<div className="h-64">
|
||||||
|
{mounted && typeBreakdown.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={typeBreakdown}
|
||||||
|
dataKey="total_qty"
|
||||||
|
nameKey="item_type"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
label={({ name, percent }: any) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||||
|
>
|
||||||
|
{typeBreakdown.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={((value: number, name: string) => [
|
||||||
|
`${value} kom | ${formatRSD(typeBreakdown.find(t => t.item_type === name)?.total_revenue ?? 0)}`,
|
||||||
|
name,
|
||||||
|
]) as any}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
||||||
|
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
color: darkMode ? '#f3f4f6' : '#111827',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||||
|
Nema podataka za prikaz
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inventory Alerts */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upozorenja za zalihe</h2>
|
||||||
|
{inventoryAlerts.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Boja</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tip</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Finish</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Refill</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Spulna</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Kolicina</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pros. dnevna prodaja</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Dana do nestanka</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{inventoryAlerts.map((alert) => (
|
||||||
|
<tr key={alert.id} className={getStockRowClass(alert)}>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.boja}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.tip}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.finish}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">{alert.refill}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">{alert.spulna}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right font-medium">{alert.kolicina}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">
|
||||||
|
{alert.avg_daily_sales > 0 ? alert.avg_daily_sales.toFixed(1) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right font-medium">
|
||||||
|
{alert.days_until_stockout !== null ? (
|
||||||
|
<span className={
|
||||||
|
alert.days_until_stockout < 7
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: alert.days_until_stockout < 14
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
: 'text-green-600 dark:text-green-400'
|
||||||
|
}>
|
||||||
|
{Math.round(alert.days_until_stockout)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">N/A</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Sve zalihe su na zadovoljavajucem nivou.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { colorService } from '@/src/services/api';
|
import { colorService } from '@/src/services/api';
|
||||||
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
|
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
|
||||||
|
import { BulkPriceEditor } from '@/src/components/BulkPriceEditor';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
import '@/src/styles/select.css';
|
import '@/src/styles/select.css';
|
||||||
|
|
||||||
interface Color {
|
interface Color {
|
||||||
@@ -53,13 +55,16 @@ export default function ColorsManagement() {
|
|||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Wait for component to mount to avoid SSR issues with localStorage
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
const token = localStorage.getItem('authToken');
|
const token = localStorage.getItem('authToken');
|
||||||
const expiry = localStorage.getItem('tokenExpiry');
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
|
||||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||||
router.push('/upadaj');
|
window.location.href = '/upadaj';
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [mounted]);
|
||||||
|
|
||||||
// Fetch colors
|
// Fetch colors
|
||||||
const fetchColors = async () => {
|
const fetchColors = async () => {
|
||||||
@@ -184,26 +189,7 @@ export default function ColorsManagement() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{/* Sidebar */}
|
<AdminSidebar />
|
||||||
<div className="w-64 bg-white dark:bg-gray-800 shadow-lg h-screen">
|
|
||||||
<div className="p-6">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
|
|
||||||
<nav className="space-y-2">
|
|
||||||
<a
|
|
||||||
href="/upadaj/dashboard"
|
|
||||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
Filamenti
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/upadaj/colors"
|
|
||||||
className="block px-4 py-2 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded"
|
|
||||||
>
|
|
||||||
Boje
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -218,7 +204,7 @@ export default function ColorsManagement() {
|
|||||||
/>
|
/>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Upravljanje bojama</h1>
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Upravljanje bojama</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4 flex-wrap">
|
||||||
{!showAddForm && !editingColor && (
|
{!showAddForm && !editingColor && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm(true)}
|
onClick={() => setShowAddForm(true)}
|
||||||
@@ -227,6 +213,7 @@ export default function ColorsManagement() {
|
|||||||
Dodaj novu boju
|
Dodaj novu boju
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<BulkPriceEditor colors={colors} onUpdate={fetchColors} />
|
||||||
{selectedColors.size > 0 && (
|
{selectedColors.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleBulkDelete}
|
onClick={handleBulkDelete}
|
||||||
@@ -383,10 +370,24 @@ export default function ColorsManagement() {
|
|||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingColor && (
|
{editingColor && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||||
|
onClick={() => setEditingColor(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 max-w-md w-full max-h-[90vh] overflow-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center p-6 pb-0">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Izmeni boju</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingColor(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Izmeni boju</h3>
|
|
||||||
<ColorForm
|
<ColorForm
|
||||||
color={editingColor}
|
color={editingColor}
|
||||||
onSave={(color) => {
|
onSave={(color) => {
|
||||||
@@ -490,7 +491,7 @@ function ColorForm({
|
|||||||
readOnly={isBambuLabColor}
|
readOnly={isBambuLabColor}
|
||||||
pattern="^#[0-9A-Fa-f]{6}$"
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
placeholder="#000000"
|
placeholder="#000000"
|
||||||
className={`flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' : ''}`}
|
className={`flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isBambuLabColor && (
|
{isBambuLabColor && (
|
||||||
|
|||||||
634
app/upadaj/customers/page.tsx
Normal file
634
app/upadaj/customers/page.tsx
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { customerService } from '@/src/services/api';
|
||||||
|
import { Customer, Sale } from '@/src/types/sales';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
|
|
||||||
|
interface CustomerWithSales extends Customer {
|
||||||
|
sales?: Sale[];
|
||||||
|
total_purchases?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomersManagement() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [customers, setCustomers] = useState<CustomerWithSales[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [expandedCustomerId, setExpandedCustomerId] = useState<string | null>(null);
|
||||||
|
const [expandedSales, setExpandedSales] = useState<Sale[]>([]);
|
||||||
|
const [loadingSales, setLoadingSales] = useState(false);
|
||||||
|
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<Partial<Customer>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [addForm, setAddForm] = useState({ name: '', phone: '', city: '', notes: '' });
|
||||||
|
const [addError, setAddError] = useState('');
|
||||||
|
const [addSaving, setAddSaving] = useState(false);
|
||||||
|
|
||||||
|
// Initialize dark mode
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const saved = localStorage.getItem('darkMode');
|
||||||
|
if (saved !== null) {
|
||||||
|
setDarkMode(JSON.parse(saved));
|
||||||
|
} else {
|
||||||
|
setDarkMode(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||||
|
if (darkMode) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, [darkMode, mounted]);
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||||
|
window.location.href = '/upadaj';
|
||||||
|
}
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
|
// Fetch customers
|
||||||
|
const fetchCustomers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await customerService.getAll();
|
||||||
|
setCustomers(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Greska pri ucitavanju kupaca');
|
||||||
|
console.error('Fetch error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCustomers();
|
||||||
|
}, [fetchCustomers]);
|
||||||
|
|
||||||
|
// Search customers
|
||||||
|
const filteredCustomers = customers.filter((customer) => {
|
||||||
|
if (!searchTerm) return true;
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
customer.name.toLowerCase().includes(term) ||
|
||||||
|
(customer.phone && customer.phone.toLowerCase().includes(term)) ||
|
||||||
|
(customer.city && customer.city.toLowerCase().includes(term))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle expanded row to show purchase history
|
||||||
|
const handleToggleExpand = async (customerId: string) => {
|
||||||
|
if (expandedCustomerId === customerId) {
|
||||||
|
setExpandedCustomerId(null);
|
||||||
|
setExpandedSales([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpandedCustomerId(customerId);
|
||||||
|
setLoadingSales(true);
|
||||||
|
try {
|
||||||
|
const data = await customerService.getById(customerId);
|
||||||
|
setExpandedSales(data.sales || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching customer sales:', err);
|
||||||
|
setExpandedSales([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingSales(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit customer
|
||||||
|
const handleStartEdit = (customer: Customer) => {
|
||||||
|
setEditingCustomer(customer);
|
||||||
|
setEditForm({
|
||||||
|
name: customer.name,
|
||||||
|
phone: customer.phone || '',
|
||||||
|
city: customer.city || '',
|
||||||
|
notes: customer.notes || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingCustomer(null);
|
||||||
|
setEditForm({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!editingCustomer) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await customerService.update(editingCustomer.id, editForm);
|
||||||
|
setEditingCustomer(null);
|
||||||
|
setEditForm({});
|
||||||
|
await fetchCustomers();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Greska pri cuvanju izmena');
|
||||||
|
console.error('Save error:', err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCustomer = async () => {
|
||||||
|
if (!addForm.name.trim()) {
|
||||||
|
setAddError('Ime je obavezno');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddSaving(true);
|
||||||
|
setAddError('');
|
||||||
|
try {
|
||||||
|
await customerService.create({
|
||||||
|
name: addForm.name.trim(),
|
||||||
|
phone: addForm.phone.trim() || undefined,
|
||||||
|
city: addForm.city.trim() || undefined,
|
||||||
|
notes: addForm.notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
setShowAddModal(false);
|
||||||
|
setAddForm({ name: '', phone: '', city: '', notes: '' });
|
||||||
|
await fetchCustomers();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { code?: string } } };
|
||||||
|
if (error.response?.data?.code === '23505') {
|
||||||
|
setAddError('Kupac sa ovim brojem telefona vec postoji');
|
||||||
|
} else {
|
||||||
|
setAddError('Greska pri dodavanju kupca');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setAddSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('tokenExpiry');
|
||||||
|
router.push('/upadaj');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleDateString('sr-Latn-RS', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('sr-Latn-RS', {
|
||||||
|
style: 'decimal',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount) + ' RSD';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">Ucitavanje...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Filamenteka"
|
||||||
|
className="h-20 sm:h-32 w-auto drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Upravljanje kupcima
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Nazad na sajt
|
||||||
|
</button>
|
||||||
|
{mounted && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||||
|
>
|
||||||
|
{darkMode ? '\u2600' : '\u263D'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Odjava
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
||||||
|
{error}
|
||||||
|
<button
|
||||||
|
onClick={() => setError('')}
|
||||||
|
className="ml-4 text-red-600 dark:text-red-300 underline text-sm"
|
||||||
|
>
|
||||||
|
Zatvori
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search bar + Add button */}
|
||||||
|
<div className="mb-6 flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Pretrazi kupce..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full max-w-md px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 whitespace-nowrap font-medium"
|
||||||
|
>
|
||||||
|
+ Dodaj kupca
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer count */}
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Ukupno kupaca: {filteredCustomers.length}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Customer table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Ime
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Telefon
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Grad
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Beleske
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Datum registracije
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Akcije
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{filteredCustomers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="px-6 py-8 text-center text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{searchTerm ? 'Nema rezultata pretrage' : 'Nema registrovanih kupaca'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredCustomers.map((customer) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={customer.id}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
{editingCustomer?.id === customer.id ? (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.name || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, name: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.phone || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, phone: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.city || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, city: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<textarea
|
||||||
|
value={editForm.notes || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, notes: e.target.value })
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
placeholder="npr. stampa figurice, obicno crna..."
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{formatDate(customer.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? '...' : 'Sacuvaj'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="px-3 py-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 text-sm rounded hover:bg-gray-400 dark:hover:bg-gray-500"
|
||||||
|
>
|
||||||
|
Otkazi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{customer.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{customer.phone || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{customer.city || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs whitespace-pre-wrap">
|
||||||
|
{customer.notes || <span className="text-gray-400 dark:text-gray-600 italic">Nema beleski</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{formatDate(customer.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEdit(customer)}
|
||||||
|
className="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Izmeni
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleExpand(customer.id)}
|
||||||
|
className={`px-3 py-1 text-sm rounded ${
|
||||||
|
expandedCustomerId === customer.id
|
||||||
|
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||||
|
: 'bg-indigo-100 dark:bg-indigo-900 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Istorija kupovina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded purchase history */}
|
||||||
|
{expandedCustomerId === customer.id && (
|
||||||
|
<tr key={`${customer.id}-sales`}>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50"
|
||||||
|
>
|
||||||
|
<div className="ml-4">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Istorija kupovina - {customer.name}
|
||||||
|
</h4>
|
||||||
|
{loadingSales ? (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Ucitavanje...
|
||||||
|
</p>
|
||||||
|
) : expandedSales.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Nema evidentiranih kupovina
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded overflow-hidden">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||||
|
ID
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||||
|
Datum
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||||
|
Stavke
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||||
|
Iznos
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||||
|
Napomena
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{expandedSales.map((sale) => (
|
||||||
|
<tr key={sale.id}>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{sale.id.substring(0, 8)}...
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{formatDate(sale.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{sale.item_count ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white font-medium">
|
||||||
|
{formatCurrency(sale.total_amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{sale.notes || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
className="px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-right"
|
||||||
|
>
|
||||||
|
Ukupno ({expandedSales.length} kupovina):
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||||
|
{formatCurrency(
|
||||||
|
expandedSales.reduce(
|
||||||
|
(sum, sale) => sum + sale.total_amount,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Customer Modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Dodaj kupca
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addError && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-sm">
|
||||||
|
{addError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Ime *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addForm.name}
|
||||||
|
onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
|
placeholder="Ime i prezime"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Telefon
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addForm.phone}
|
||||||
|
onChange={(e) => setAddForm({ ...addForm, phone: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
|
placeholder="Broj telefona"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Grad
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addForm.city}
|
||||||
|
onChange={(e) => setAddForm({ ...addForm, city: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
|
placeholder="Grad"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Napomena
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={addForm.notes}
|
||||||
|
onChange={(e) => setAddForm({ ...addForm, notes: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||||
|
placeholder="npr. stampa figurice, obicno crna..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
Otkazi
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAddCustomer}
|
||||||
|
disabled={addSaving}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 font-medium"
|
||||||
|
>
|
||||||
|
{addSaving ? 'Cuvanje...' : 'Dodaj'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,15 @@ export default function AdminLogin() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Redirect to dashboard if already authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
if (token && expiry && Date.now() < parseInt(expiry)) {
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Set dark mode by default
|
// Set dark mode by default
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
@@ -32,8 +41,8 @@ export default function AdminLogin() {
|
|||||||
// Track successful login
|
// Track successful login
|
||||||
trackEvent('Admin', 'Login', 'Success');
|
trackEvent('Admin', 'Login', 'Success');
|
||||||
|
|
||||||
// Redirect to admin dashboard
|
// Redirect to admin dashboard using window.location for static export
|
||||||
router.push('/upadaj/dashboard');
|
window.location.href = '/dashboard';
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError('Neispravno korisničko ime ili lozinka');
|
setError('Neispravno korisničko ime ili lozinka');
|
||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
|
|||||||
350
app/upadaj/requests/page.tsx
Normal file
350
app/upadaj/requests/page.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { colorRequestService } from '@/src/services/api';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
|
|
||||||
|
interface ColorRequest {
|
||||||
|
id: string;
|
||||||
|
color_name: string;
|
||||||
|
material_type: string;
|
||||||
|
finish_type: string;
|
||||||
|
user_email: string;
|
||||||
|
user_phone: string;
|
||||||
|
user_name: string;
|
||||||
|
description: string;
|
||||||
|
reference_url: string;
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | 'completed';
|
||||||
|
admin_notes: string;
|
||||||
|
request_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
processed_at: string;
|
||||||
|
processed_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColorRequestsAdmin() {
|
||||||
|
const [requests, setRequests] = useState<ColorRequest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({ status: '', admin_notes: '' });
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
fetchRequests();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkAuth = () => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
|
||||||
|
if (!token || !expiry || new Date().getTime() > parseInt(expiry)) {
|
||||||
|
window.location.href = '/upadaj';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
try {
|
||||||
|
const data = await colorRequestService.getAll();
|
||||||
|
setRequests(data);
|
||||||
|
} catch (error) {
|
||||||
|
setError('Failed to fetch color requests');
|
||||||
|
console.error('Error:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusUpdate = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await colorRequestService.updateStatus(id, editForm.status, editForm.admin_notes);
|
||||||
|
await fetchRequests();
|
||||||
|
setEditingId(null);
|
||||||
|
setEditForm({ status: '', admin_notes: '' });
|
||||||
|
} catch (error) {
|
||||||
|
setError('Failed to update request');
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this request?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await colorRequestService.delete(id);
|
||||||
|
await fetchRequests();
|
||||||
|
} catch (error) {
|
||||||
|
setError('Failed to delete request');
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const colors = {
|
||||||
|
pending: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
|
||||||
|
approved: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
|
||||||
|
rejected: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
|
||||||
|
completed: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
|
||||||
|
};
|
||||||
|
return colors[status as keyof typeof colors] || 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
const labels = {
|
||||||
|
pending: 'Na čekanju',
|
||||||
|
approved: 'Odobreno',
|
||||||
|
rejected: 'Odbijeno',
|
||||||
|
completed: 'Završeno'
|
||||||
|
};
|
||||||
|
return labels[status as keyof typeof labels] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const month = date.toLocaleDateString('sr-RS', { month: 'short' });
|
||||||
|
const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
|
||||||
|
return `${capitalizedMonth} ${date.getDate()}, ${date.getFullYear()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">Učitavanje zahteva za boje...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
<div className="flex-1 p-8">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Boja
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Materijal/Finiš
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Broj Zahteva
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Korisnik
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Datum
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Akcije
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{requests.map((request) => (
|
||||||
|
<tr key={request.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-gray-100">{request.color_name}</div>
|
||||||
|
{request.description && (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">{request.description}</div>
|
||||||
|
)}
|
||||||
|
{request.reference_url && (
|
||||||
|
<a
|
||||||
|
href={request.reference_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
Pogledaj referencu
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-gray-900 dark:text-gray-100">{request.material_type}</div>
|
||||||
|
{request.finish_type && (
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">{request.finish_type}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
|
||||||
|
{request.request_count || 1} {(request.request_count || 1) === 1 ? 'zahtev' : 'zahteva'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
{request.user_email ? (
|
||||||
|
<a href={`mailto:${request.user_email}`} className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{request.user_email}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">Anonimno</span>
|
||||||
|
)}
|
||||||
|
{request.user_phone && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<a href={`tel:${request.user_phone}`} className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{request.user_phone}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{editingId === request.id ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<select
|
||||||
|
value={editForm.status}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
|
||||||
|
className="text-sm border rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="">Izaberi status</option>
|
||||||
|
<option value="pending">Na čekanju</option>
|
||||||
|
<option value="approved">Odobreno</option>
|
||||||
|
<option value="rejected">Odbijeno</option>
|
||||||
|
<option value="completed">Završeno</option>
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
placeholder="Napomene..."
|
||||||
|
value={editForm.admin_notes}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, admin_notes: e.target.value })}
|
||||||
|
className="text-sm border rounded px-2 py-1 w-full"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadge(request.status)}`}>
|
||||||
|
{getStatusLabel(request.status)}
|
||||||
|
</span>
|
||||||
|
{request.admin_notes && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{request.admin_notes}</div>
|
||||||
|
)}
|
||||||
|
{request.processed_by && (
|
||||||
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
od {request.processed_by}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{formatDate(request.created_at)}
|
||||||
|
</div>
|
||||||
|
{request.processed_at && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
Obrađeno: {formatDate(request.processed_at)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{editingId === request.id ? (
|
||||||
|
<div className="space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusUpdate(request.id)}
|
||||||
|
className="text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 text-sm"
|
||||||
|
>
|
||||||
|
Sačuvaj
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditForm({ status: '', admin_notes: '' });
|
||||||
|
}}
|
||||||
|
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
|
||||||
|
>
|
||||||
|
Otkaži
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(request.id);
|
||||||
|
setEditForm({
|
||||||
|
status: request.status,
|
||||||
|
admin_notes: request.admin_notes || ''
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm"
|
||||||
|
>
|
||||||
|
Izmeni
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(request.id)}
|
||||||
|
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 text-sm"
|
||||||
|
>
|
||||||
|
Obriši
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requests.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
Nema zahteva za boje
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">Ukupno Zahteva</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
|
||||||
|
{requests.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">Na Čekanju</div>
|
||||||
|
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||||
|
{requests.filter(r => r.status === 'pending').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">Odobreno</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{requests.filter(r => r.status === 'approved').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">Završeno</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{requests.filter(r => r.status === 'completed').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
827
app/upadaj/sales/page.tsx
Normal file
827
app/upadaj/sales/page.tsx
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { saleService, customerService, filamentService, colorService } from '@/src/services/api';
|
||||||
|
import { Customer, Sale, SaleItem, CreateSaleRequest } from '@/src/types/sales';
|
||||||
|
import { Filament } from '@/src/types/filament';
|
||||||
|
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||||
|
|
||||||
|
interface LineItem {
|
||||||
|
filament_id: string;
|
||||||
|
item_type: 'refill' | 'spulna';
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesPage() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
|
|
||||||
|
// Sales list state
|
||||||
|
const [sales, setSales] = useState<Sale[]>([]);
|
||||||
|
const [totalSales, setTotalSales] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [showNewSaleModal, setShowNewSaleModal] = useState(false);
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
|
const [selectedSale, setSelectedSale] = useState<Sale | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
// Dark mode init
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const saved = localStorage.getItem('darkMode');
|
||||||
|
setDarkMode(saved !== null ? JSON.parse(saved) : true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||||
|
if (darkMode) document.documentElement.classList.add('dark');
|
||||||
|
else document.documentElement.classList.remove('dark');
|
||||||
|
}, [darkMode, mounted]);
|
||||||
|
|
||||||
|
// Auth check
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const expiry = localStorage.getItem('tokenExpiry');
|
||||||
|
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||||
|
window.location.href = '/upadaj';
|
||||||
|
}
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
|
// Fetch sales
|
||||||
|
const fetchSales = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const data = await saleService.getAll(page, LIMIT);
|
||||||
|
setSales(data.sales);
|
||||||
|
setTotalSales(data.total);
|
||||||
|
} catch {
|
||||||
|
setError('Greska pri ucitavanju prodaja');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
fetchSales();
|
||||||
|
}, [mounted, fetchSales]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalSales / LIMIT);
|
||||||
|
|
||||||
|
const handleViewDetail = async (sale: Sale) => {
|
||||||
|
try {
|
||||||
|
setDetailLoading(true);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
const detail = await saleService.getById(sale.id);
|
||||||
|
setSelectedSale(detail);
|
||||||
|
} catch {
|
||||||
|
setError('Greska pri ucitavanju detalja prodaje');
|
||||||
|
setShowDetailModal(false);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSale = async (id: string) => {
|
||||||
|
if (!window.confirm('Da li ste sigurni da zelite da obrisete ovu prodaju?')) return;
|
||||||
|
try {
|
||||||
|
await saleService.delete(id);
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedSale(null);
|
||||||
|
fetchSales();
|
||||||
|
} catch {
|
||||||
|
setError('Greska pri brisanju prodaje');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('sr-RS', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (amount: number) => {
|
||||||
|
return amount.toLocaleString('sr-RS') + ' RSD';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 p-8">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Prodaja</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
|
className="px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
{darkMode ? 'Svetli mod' : 'Tamni mod'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewSaleModal(true)}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Nova prodaja
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sales Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Ucitavanje...</div>
|
||||||
|
) : sales.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Nema prodaja</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Datum
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Ime kupca
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Stavki
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Cena
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Akcije
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{sales.map((sale) => (
|
||||||
|
<tr key={sale.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatDate(sale.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{sale.customer_name || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{sale.item_count ?? 0}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{formatPrice(sale.total_amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewDetail(sale)}
|
||||||
|
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Detalji
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSale(sale.id)}
|
||||||
|
className="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Obrisi
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex justify-center items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Prethodna
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Strana {page} od {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Sledeca
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Sale Modal */}
|
||||||
|
{showNewSaleModal && (
|
||||||
|
<NewSaleModal
|
||||||
|
onClose={() => setShowNewSaleModal(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setShowNewSaleModal(false);
|
||||||
|
fetchSales();
|
||||||
|
}}
|
||||||
|
formatPrice={formatPrice}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sale Detail Modal */}
|
||||||
|
{showDetailModal && (
|
||||||
|
<SaleDetailModal
|
||||||
|
sale={selectedSale}
|
||||||
|
loading={detailLoading}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedSale(null);
|
||||||
|
}}
|
||||||
|
onDelete={(id) => handleDeleteSale(id)}
|
||||||
|
formatPrice={formatPrice}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NewSaleModal ---
|
||||||
|
|
||||||
|
function NewSaleModal({
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
formatPrice,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
formatPrice: (n: number) => string;
|
||||||
|
}) {
|
||||||
|
const [customerName, setCustomerName] = useState('');
|
||||||
|
const [customerPhone, setCustomerPhone] = useState('');
|
||||||
|
const [customerCity, setCustomerCity] = useState('');
|
||||||
|
const [customerNotes, setCustomerNotes] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [items, setItems] = useState<LineItem[]>([{ filament_id: '', item_type: 'refill', quantity: 1 }]);
|
||||||
|
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||||
|
const [colorPrices, setColorPrices] = useState<Record<string, { cena_refill: number; cena_spulna: number }>>({});
|
||||||
|
const [customerSuggestions, setCustomerSuggestions] = useState<Customer[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [filamentData, colorData] = await Promise.all([
|
||||||
|
filamentService.getAll(),
|
||||||
|
colorService.getAll(),
|
||||||
|
]);
|
||||||
|
setFilaments(filamentData);
|
||||||
|
const priceMap: Record<string, { cena_refill: number; cena_spulna: number }> = {};
|
||||||
|
for (const c of colorData) {
|
||||||
|
priceMap[c.name] = { cena_refill: c.cena_refill || 3499, cena_spulna: c.cena_spulna || 3999 };
|
||||||
|
}
|
||||||
|
setColorPrices(priceMap);
|
||||||
|
} catch {
|
||||||
|
// Data failed to load
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close suggestions on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (suggestionsRef.current && !suggestionsRef.current.contains(e.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCustomerSearch = (value: string) => {
|
||||||
|
setCustomerName(value);
|
||||||
|
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||||
|
if (value.length < 2) {
|
||||||
|
setCustomerSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const results = await customerService.search(value);
|
||||||
|
setCustomerSuggestions(results);
|
||||||
|
setShowSuggestions(results.length > 0);
|
||||||
|
} catch {
|
||||||
|
setCustomerSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectCustomer = (customer: Customer) => {
|
||||||
|
setCustomerName(customer.name);
|
||||||
|
setCustomerPhone(customer.phone || '');
|
||||||
|
setCustomerCity(customer.city || '');
|
||||||
|
setCustomerNotes(customer.notes || '');
|
||||||
|
setShowSuggestions(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilamentById = (id: string): Filament | undefined => {
|
||||||
|
return filaments.find((f) => String(f.id) === String(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilamentPrice = (filament: Filament, type: 'refill' | 'spulna'): number => {
|
||||||
|
const prices = colorPrices[filament.boja];
|
||||||
|
const base = type === 'refill' ? (prices?.cena_refill || 3499) : (prices?.cena_spulna || 3999);
|
||||||
|
if (filament.sale_active && filament.sale_percentage) {
|
||||||
|
return Math.round(base * (1 - filament.sale_percentage / 100));
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemPrice = (item: LineItem): number => {
|
||||||
|
const filament = getFilamentById(item.filament_id);
|
||||||
|
if (!filament) return 0;
|
||||||
|
return getFilamentPrice(filament, item.item_type) * item.quantity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + getItemPrice(item), 0);
|
||||||
|
|
||||||
|
const getAvailableStock = (filament: Filament, type: 'refill' | 'spulna'): number => {
|
||||||
|
return type === 'refill' ? filament.refill : filament.spulna;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (index: number, updates: Partial<LineItem>) => {
|
||||||
|
setItems((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = { ...next[index], ...updates };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
if (items.length <= 1) return;
|
||||||
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setItems((prev) => [...prev, { filament_id: '', item_type: 'refill', quantity: 1 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!customerName.trim()) {
|
||||||
|
setSubmitError('Ime kupca je obavezno');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validItems = items.filter((item) => item.filament_id !== '' && item.quantity > 0);
|
||||||
|
if (validItems.length === 0) {
|
||||||
|
setSubmitError('Dodajte bar jednu stavku');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate stock
|
||||||
|
for (const item of validItems) {
|
||||||
|
const filament = getFilamentById(item.filament_id);
|
||||||
|
if (!filament) {
|
||||||
|
setSubmitError('Nevalidan filament');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const available = getAvailableStock(filament, item.item_type);
|
||||||
|
if (item.quantity > available) {
|
||||||
|
setSubmitError(
|
||||||
|
`Nedovoljno zaliha za ${filament.boja} ${filament.tip} (${item.item_type}): dostupno ${available}, trazeno ${item.quantity}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: CreateSaleRequest = {
|
||||||
|
customer: {
|
||||||
|
name: customerName.trim(),
|
||||||
|
phone: customerPhone.trim() || undefined,
|
||||||
|
city: customerCity.trim() || undefined,
|
||||||
|
notes: customerNotes.trim() || undefined,
|
||||||
|
},
|
||||||
|
items: validItems.map((item) => ({
|
||||||
|
filament_id: item.filament_id,
|
||||||
|
item_type: item.item_type,
|
||||||
|
quantity: item.quantity,
|
||||||
|
})),
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError('');
|
||||||
|
await saleService.create(payload);
|
||||||
|
onCreated();
|
||||||
|
} catch {
|
||||||
|
setSubmitError('Greska pri kreiranju prodaje');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Nova prodaja</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-2xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 rounded text-sm">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Customer Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Kupac</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="relative" ref={suggestionsRef}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Ime kupca *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerName}
|
||||||
|
onChange={(e) => handleCustomerSearch(e.target.value)}
|
||||||
|
onFocus={() => customerSuggestions.length > 0 && setShowSuggestions(true)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Ime i prezime"
|
||||||
|
/>
|
||||||
|
{showSuggestions && customerSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-lg max-h-40 overflow-y-auto">
|
||||||
|
{customerSuggestions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => selectCustomer(c)}
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
{c.city && (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 ml-2">({c.city})</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Telefon
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerPhone}
|
||||||
|
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Broj telefona"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Grad
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerCity}
|
||||||
|
onChange={(e) => setCustomerCity(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Grad"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beleske o kupcu</div>
|
||||||
|
<textarea
|
||||||
|
value={customerNotes}
|
||||||
|
onChange={(e) => setCustomerNotes(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="npr. stampa figurice, obicno koristi crnu..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Stavke</h3>
|
||||||
|
<button
|
||||||
|
onClick={addItem}
|
||||||
|
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
Dodaj stavku
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const filament = getFilamentById(item.filament_id);
|
||||||
|
const available = filament ? getAvailableStock(filament, item.item_type) : 0;
|
||||||
|
const itemPrice = getItemPrice(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-wrap gap-3 items-end p-3 bg-gray-50 dark:bg-gray-700/50 rounded border border-gray-200 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Filament
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={item.filament_id}
|
||||||
|
onChange={(e) => updateItem(index, { filament_id: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value={0}>-- Izaberite filament --</option>
|
||||||
|
{filaments
|
||||||
|
.filter((f) => f.kolicina > 0)
|
||||||
|
.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.boja} - {f.tip} {f.finish} (refill: {f.refill}, spulna: {f.spulna})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Tip
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={item.item_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateItem(index, {
|
||||||
|
item_type: e.target.value as 'refill' | 'spulna',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="refill">Refill</option>
|
||||||
|
<option value="spulna">Spulna</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Kolicina {filament ? `(max ${available})` : ''}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={available || undefined}
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateItem(index, { quantity: Math.max(1, Number(e.target.value)) })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32 text-right">
|
||||||
|
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Cena
|
||||||
|
</label>
|
||||||
|
<div className="px-3 py-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{itemPrice > 0 ? formatPrice(itemPrice) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
disabled={items.length <= 1}
|
||||||
|
className="px-3 py-2 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Ukloni
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-700 rounded flex justify-between items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-900 dark:text-white">Ukupno:</span>
|
||||||
|
<span className="text-xl font-bold text-gray-900 dark:text-white">{formatPrice(totalAmount)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Beleske
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Napomena za prodaju..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
Otkazi
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{submitting ? 'Cuvanje...' : 'Sacuvaj'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SaleDetailModal ---
|
||||||
|
|
||||||
|
function SaleDetailModal({
|
||||||
|
sale,
|
||||||
|
loading,
|
||||||
|
onClose,
|
||||||
|
onDelete,
|
||||||
|
formatPrice,
|
||||||
|
formatDate,
|
||||||
|
}: {
|
||||||
|
sale: Sale | null;
|
||||||
|
loading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
formatPrice: (n: number) => string;
|
||||||
|
formatDate: (d?: string) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Detalji prodaje</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-2xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Ucitavanje...</div>
|
||||||
|
) : !sale ? (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Prodaja nije pronadjena</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Customer Info */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Kupac</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Ime: </span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{sale.customer_name || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Telefon: </span>
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{sale.customer_phone || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Datum: </span>
|
||||||
|
<span className="text-gray-900 dark:text-white">{formatDate(sale.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Stavke</h3>
|
||||||
|
{sale.items && sale.items.length > 0 ? (
|
||||||
|
<div className="border border-gray-200 dark:border-gray-600 rounded overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Filament
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Tip
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Kolicina
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Cena
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-600">
|
||||||
|
{sale.items.map((item: SaleItem) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white">
|
||||||
|
{item.filament_boja} - {item.filament_tip} {item.filament_finish}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 capitalize">
|
||||||
|
{item.item_type}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white text-right">
|
||||||
|
{item.quantity}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white text-right font-medium">
|
||||||
|
{formatPrice(item.unit_price * item.quantity)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Nema stavki</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-700 rounded flex justify-between items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-900 dark:text-white">Ukupno:</span>
|
||||||
|
<span className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{formatPrice(sale.total_amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{sale.notes && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Beleske</h3>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-gray-700/50 p-3 rounded">
|
||||||
|
{sale.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm('Da li ste sigurni da zelite da obrisete ovu prodaju?')) {
|
||||||
|
onDelete(sale.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Obrisi prodaju
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
Zatvori
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
database/migrations/016_add_color_requests.sql
Normal file
35
database/migrations/016_add_color_requests.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- Migration: Add color requests feature
|
||||||
|
-- Allows users to request new colors and admins to view/manage requests
|
||||||
|
|
||||||
|
-- Create color_requests table
|
||||||
|
CREATE TABLE IF NOT EXISTS color_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
color_name VARCHAR(100) NOT NULL,
|
||||||
|
material_type VARCHAR(50) NOT NULL,
|
||||||
|
finish_type VARCHAR(50),
|
||||||
|
user_email VARCHAR(255),
|
||||||
|
user_name VARCHAR(100),
|
||||||
|
description TEXT,
|
||||||
|
reference_url VARCHAR(500),
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'completed')),
|
||||||
|
admin_notes TEXT,
|
||||||
|
request_count INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
processed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
processed_by VARCHAR(100)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better performance
|
||||||
|
CREATE INDEX idx_color_requests_status ON color_requests(status);
|
||||||
|
CREATE INDEX idx_color_requests_created_at ON color_requests(created_at DESC);
|
||||||
|
CREATE INDEX idx_color_requests_color_name ON color_requests(LOWER(color_name));
|
||||||
|
|
||||||
|
-- Apply updated_at trigger to color_requests table
|
||||||
|
CREATE TRIGGER update_color_requests_updated_at BEFORE UPDATE
|
||||||
|
ON color_requests FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Add comment to describe the table
|
||||||
|
COMMENT ON TABLE color_requests IS 'User requests for new filament colors to be added to inventory';
|
||||||
|
COMMENT ON COLUMN color_requests.status IS 'Request status: pending (new), approved (will be ordered), rejected (won''t be added), completed (added to inventory)';
|
||||||
|
COMMENT ON COLUMN color_requests.request_count IS 'Number of users who have requested this same color';
|
||||||
8
database/migrations/017_add_phone_to_color_requests.sql
Normal file
8
database/migrations/017_add_phone_to_color_requests.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Migration: Add phone field to color_requests table
|
||||||
|
-- Allows users to provide phone number for contact
|
||||||
|
|
||||||
|
ALTER TABLE color_requests
|
||||||
|
ADD COLUMN IF NOT EXISTS user_phone VARCHAR(50);
|
||||||
|
|
||||||
|
-- Add comment to describe the new column
|
||||||
|
COMMENT ON COLUMN color_requests.user_phone IS 'User phone number for contact (optional)';
|
||||||
22
database/migrations/018_make_contact_fields_required.sql
Normal file
22
database/migrations/018_make_contact_fields_required.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- Migration: Make email and phone fields required in color_requests table
|
||||||
|
-- These fields are now mandatory for all color requests
|
||||||
|
|
||||||
|
-- First, update any existing NULL values to prevent constraint violation
|
||||||
|
UPDATE color_requests
|
||||||
|
SET user_email = 'unknown@example.com'
|
||||||
|
WHERE user_email IS NULL;
|
||||||
|
|
||||||
|
UPDATE color_requests
|
||||||
|
SET user_phone = 'unknown'
|
||||||
|
WHERE user_phone IS NULL;
|
||||||
|
|
||||||
|
-- Now add NOT NULL constraints
|
||||||
|
ALTER TABLE color_requests
|
||||||
|
ALTER COLUMN user_email SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE color_requests
|
||||||
|
ALTER COLUMN user_phone SET NOT NULL;
|
||||||
|
|
||||||
|
-- Update comments to reflect the requirement
|
||||||
|
COMMENT ON COLUMN color_requests.user_email IS 'User email address for contact (required)';
|
||||||
|
COMMENT ON COLUMN color_requests.user_phone IS 'User phone number for contact (required)';
|
||||||
77
database/migrations/019_add_new_bambu_colors_2025.sql
Normal file
77
database/migrations/019_add_new_bambu_colors_2025.sql
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
-- Migration: Add new Bambu Lab PLA Matte and PLA Wood colors (2025)
|
||||||
|
-- This migration adds the new color offerings from Bambu Lab
|
||||||
|
|
||||||
|
-- Add new PLA Matte colors to the colors table
|
||||||
|
INSERT INTO colors (name, hex) VALUES
|
||||||
|
('Matte Apple Green', '#C6E188'),
|
||||||
|
('Matte Bone White', '#C8C5B6'),
|
||||||
|
('Matte Caramel', '#A4845C'),
|
||||||
|
('Matte Dark Blue', '#042F56'),
|
||||||
|
('Matte Dark Brown', '#7D6556'),
|
||||||
|
('Matte Dark Chocolate', '#4A3729'),
|
||||||
|
('Matte Dark Green', '#68724D'),
|
||||||
|
('Matte Dark Red', '#BB3D43'),
|
||||||
|
('Matte Grass Green', '#7CB342'),
|
||||||
|
('Matte Ice Blue', '#A3D8E1'),
|
||||||
|
('Matte Lemon Yellow', '#F7D959'),
|
||||||
|
('Matte Lilac Purple', '#AE96D4'),
|
||||||
|
('Matte Plum', '#851A52'),
|
||||||
|
('Matte Sakura Pink', '#E8AFCF'),
|
||||||
|
('Matte Sky Blue', '#73B2E5'),
|
||||||
|
('Matte Terracotta', '#A25A37')
|
||||||
|
ON CONFLICT (name)
|
||||||
|
DO UPDATE SET hex = EXCLUDED.hex;
|
||||||
|
|
||||||
|
-- Add new PLA Wood colors to the colors table
|
||||||
|
INSERT INTO colors (name, hex) VALUES
|
||||||
|
('Ochre Yellow', '#BC8B39'),
|
||||||
|
('White Oak', '#D2CCA2'),
|
||||||
|
('Clay Brown', '#8E621A')
|
||||||
|
ON CONFLICT (name)
|
||||||
|
DO UPDATE SET hex = EXCLUDED.hex;
|
||||||
|
|
||||||
|
-- Add PLA Matte filaments (all Refill only - 1 refill each, 0 spool)
|
||||||
|
INSERT INTO filaments (tip, finish, boja, refill, spulna, kolicina, cena)
|
||||||
|
SELECT
|
||||||
|
'PLA' as tip,
|
||||||
|
'Matte' as finish,
|
||||||
|
c.name as boja,
|
||||||
|
1 as refill,
|
||||||
|
0 as spulna,
|
||||||
|
1 as kolicina,
|
||||||
|
'3499' as cena
|
||||||
|
FROM colors c
|
||||||
|
WHERE c.name IN (
|
||||||
|
'Matte Apple Green', 'Matte Bone White', 'Matte Caramel',
|
||||||
|
'Matte Dark Blue', 'Matte Dark Brown', 'Matte Dark Chocolate',
|
||||||
|
'Matte Dark Green', 'Matte Dark Red', 'Matte Grass Green',
|
||||||
|
'Matte Ice Blue', 'Matte Lemon Yellow', 'Matte Lilac Purple',
|
||||||
|
'Matte Plum', 'Matte Sakura Pink', 'Matte Sky Blue', 'Matte Terracotta'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM filaments f
|
||||||
|
WHERE f.tip = 'PLA'
|
||||||
|
AND f.finish = 'Matte'
|
||||||
|
AND f.boja = c.name
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add PLA Wood filaments (all Spool only - 0 refill, 1 spool each)
|
||||||
|
INSERT INTO filaments (tip, finish, boja, refill, spulna, kolicina, cena)
|
||||||
|
SELECT
|
||||||
|
'PLA' as tip,
|
||||||
|
'Wood' as finish,
|
||||||
|
c.name as boja,
|
||||||
|
0 as refill,
|
||||||
|
1 as spulna,
|
||||||
|
1 as kolicina,
|
||||||
|
'3999' as cena
|
||||||
|
FROM colors c
|
||||||
|
WHERE c.name IN (
|
||||||
|
'Ochre Yellow', 'White Oak', 'Clay Brown'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM filaments f
|
||||||
|
WHERE f.tip = 'PLA'
|
||||||
|
AND f.finish = 'Wood'
|
||||||
|
AND f.boja = c.name
|
||||||
|
);
|
||||||
102
database/migrations/020_add_missing_colors_2025.sql
Normal file
102
database/migrations/020_add_missing_colors_2025.sql
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
-- Migration: Add missing Bambu Lab colors (2025)
|
||||||
|
-- Created: 2025-11-13
|
||||||
|
-- Description: Adds missing color definitions from Bambu Lab catalog
|
||||||
|
|
||||||
|
INSERT INTO colors (name, hex, cena_refill, cena_spulna) VALUES
|
||||||
|
-- Basic/Matte Colors
|
||||||
|
('Clear Black', '#1a1a1a', 3499, 3999),
|
||||||
|
('Transparent', '#f0f0f0', 3499, 3999),
|
||||||
|
('Brick Red', '#8b2e2e', 3499, 3999),
|
||||||
|
('Titan Gray', '#5c6670', 3499, 3999),
|
||||||
|
('Indigo Blue', '#4b0082', 3499, 3999),
|
||||||
|
('Malachite Green', '#0bda51', 3499, 3999),
|
||||||
|
('Violet Purple', '#8f00ff', 3499, 3999),
|
||||||
|
('Iris Purple', '#5d3fd3', 3499, 3999),
|
||||||
|
('Royal Blue', '#4169e1', 3499, 3999),
|
||||||
|
('Lava Gray', '#4a4a4a', 3499, 3999),
|
||||||
|
('Burgundy Red', '#800020', 3499, 3999),
|
||||||
|
('Matcha Green', '#8db255', 3499, 3999),
|
||||||
|
('Light Cyan', '#e0ffff', 3499, 3999),
|
||||||
|
|
||||||
|
-- Glow Colors
|
||||||
|
('Blaze', '#ff6347', 3499, 3999),
|
||||||
|
('Glow Blue', '#00d4ff', 3499, 3999),
|
||||||
|
('Glow Pink', '#ff69b4', 3499, 3999),
|
||||||
|
('Glow Yellow', '#ffff00', 3499, 3999),
|
||||||
|
('Glow Orange', '#ff8c00', 3499, 3999),
|
||||||
|
('Nebulane', '#9b59b6', 3499, 3999),
|
||||||
|
|
||||||
|
-- Metallic Colors
|
||||||
|
('IronGray Metallic', '#71797e', 3499, 3999),
|
||||||
|
('Copper Brown Metallic', '#b87333', 3499, 3999),
|
||||||
|
('Iridium Gold Metallic', '#d4af37', 3499, 3999),
|
||||||
|
('Oxide Green Metallic', '#6b8e23', 3499, 3999),
|
||||||
|
('Cobalt Blue Metallic', '#0047ab', 3499, 3999),
|
||||||
|
|
||||||
|
-- Marble Colors
|
||||||
|
('White Marble', '#f5f5f5', 3499, 3999),
|
||||||
|
('Red Granite', '#8b4513', 3499, 3999),
|
||||||
|
|
||||||
|
-- Sparkle Colors
|
||||||
|
('Classic Gold Sparkle', '#ffd700', 3499, 3999),
|
||||||
|
('Onyx Black Sparkle', '#0f0f0f', 3499, 3999),
|
||||||
|
('Crimson Red Sparkle', '#dc143c', 3499, 3999),
|
||||||
|
('Royal Purple Sparkle', '#7851a9', 3499, 3999),
|
||||||
|
('Slate Gray Sparkle', '#708090', 3499, 3999),
|
||||||
|
('Alpine Green Sparkle', '#2e8b57', 3499, 3999),
|
||||||
|
|
||||||
|
-- Gradient Colors
|
||||||
|
('South Beach', '#ff69b4', 3499, 3999),
|
||||||
|
('Dawn Radiance', '#ff7f50', 3499, 3999),
|
||||||
|
('Blue Hawaii', '#1e90ff', 3499, 3999),
|
||||||
|
('Gilded Rose', '#ff1493', 3499, 3999),
|
||||||
|
('Midnight Blaze', '#191970', 3499, 3999),
|
||||||
|
('Neon City', '#00ffff', 3499, 3999),
|
||||||
|
('Velvet Eclipse', '#8b008b', 3499, 3999),
|
||||||
|
('Solar Breeze', '#ffb347', 3499, 3999),
|
||||||
|
('Arctic Whisper', '#b0e0e6', 3499, 3999),
|
||||||
|
('Ocean to Meadow', '#40e0d0', 3499, 3999),
|
||||||
|
('Dusk Glare', '#ff6347', 3499, 3999),
|
||||||
|
('Mint Lime', '#98fb98', 3499, 3999),
|
||||||
|
('Pink Citrus', '#ff69b4', 3499, 3999),
|
||||||
|
('Blueberry Bubblegum', '#7b68ee', 3499, 3999),
|
||||||
|
('Cotton Candy Cloud', '#ffb6c1', 3499, 3999),
|
||||||
|
|
||||||
|
-- Pastel/Light Colors
|
||||||
|
('Lavender', '#e6e6fa', 3499, 3999),
|
||||||
|
('Ice Blue', '#d0e7ff', 3499, 3999),
|
||||||
|
('Mellow Yellow', '#fff44f', 3499, 3999),
|
||||||
|
('Teal', '#008080', 3499, 3999),
|
||||||
|
|
||||||
|
-- ABS Colors
|
||||||
|
('ABS Azure', '#007fff', 3499, 3999),
|
||||||
|
('ABS Olive', '#808000', 3499, 3999),
|
||||||
|
('ABS Blue', '#0000ff', 3499, 3999),
|
||||||
|
('ABS Tangerine Yellow', '#ffcc00', 3499, 3999),
|
||||||
|
('ABS Navy Blue', '#000080', 3499, 3999),
|
||||||
|
('ABS Orange', '#ff8800', 3499, 3999),
|
||||||
|
('ABS Bambu Green', '#00c853', 3499, 3999),
|
||||||
|
('ABS Red', '#ff0000', 3499, 3999),
|
||||||
|
('ABS White', '#ffffff', 3499, 3999),
|
||||||
|
('ABS Black', '#000000', 3499, 3999),
|
||||||
|
('ABS Silver', '#c0c0c0', 3499, 3999),
|
||||||
|
|
||||||
|
-- Translucent Colors
|
||||||
|
('Translucent Gray', '#9e9e9e', 3499, 3999),
|
||||||
|
('Translucent Brown', '#8b4513', 3499, 3999),
|
||||||
|
('Translucent Purple', '#9370db', 3499, 3999),
|
||||||
|
('Translucent Orange', '#ffa500', 3499, 3999),
|
||||||
|
('Translucent Olive', '#6b8e23', 3499, 3999),
|
||||||
|
('Translucent Pink', '#ffb6c1', 3499, 3999),
|
||||||
|
('Translucent Light Blue', '#add8e6', 3499, 3999),
|
||||||
|
('Translucent Tea', '#d2b48c', 3499, 3999),
|
||||||
|
|
||||||
|
-- Additional Colors
|
||||||
|
('Mint', '#98ff98', 3499, 3999),
|
||||||
|
('Champagne', '#f7e7ce', 3499, 3999),
|
||||||
|
('Baby Blue', '#89cff0', 3499, 3999),
|
||||||
|
('Cream', '#fffdd0', 3499, 3999),
|
||||||
|
('Peanut Brown', '#b86f52', 3499, 3999),
|
||||||
|
('Matte Ivory', '#fffff0', 3499, 3999)
|
||||||
|
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
16
database/migrations/021_add_customers_table.sql
Normal file
16
database/migrations/021_add_customers_table.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Add customers table for tracking buyers
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
city VARCHAR(255),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phone is the natural dedup key
|
||||||
|
CREATE UNIQUE INDEX idx_customers_phone ON customers (phone) WHERE phone IS NOT NULL;
|
||||||
|
CREATE INDEX idx_customers_name ON customers (name);
|
||||||
24
database/migrations/022_add_sales_tables.sql
Normal file
24
database/migrations/022_add_sales_tables.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Add sales and sale_items tables for tracking transactions
|
||||||
|
CREATE TABLE sales (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
|
||||||
|
total_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sale_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
sale_id UUID NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
|
||||||
|
filament_id UUID NOT NULL REFERENCES filaments(id) ON DELETE RESTRICT,
|
||||||
|
item_type VARCHAR(10) NOT NULL CHECK (item_type IN ('refill', 'spulna')),
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
unit_price INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sales_customer_id ON sales (customer_id);
|
||||||
|
CREATE INDEX idx_sales_created_at ON sales (created_at);
|
||||||
|
CREATE INDEX idx_sale_items_sale_id ON sale_items (sale_id);
|
||||||
|
CREATE INDEX idx_sale_items_filament_id ON sale_items (filament_id);
|
||||||
213
database/migrations/023_cleanup_fake_colors_add_missing.sql
Normal file
213
database/migrations/023_cleanup_fake_colors_add_missing.sql
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
-- Migration 023: Clean up fake colors and add missing real Bambu Lab colors
|
||||||
|
-- Source of truth: bambu_lab_filaments.numbers spreadsheet
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Rename typo: IronGray Metallic → Iron Gray Metallic (FK cascades to filaments)
|
||||||
|
UPDATE colors SET name = 'Iron Gray Metallic' WHERE name = 'IronGray Metallic';
|
||||||
|
|
||||||
|
-- 2. Delete 67 fake colors not used by any filament
|
||||||
|
DELETE FROM colors WHERE name IN (
|
||||||
|
'Coton Candy Cloud',
|
||||||
|
'Glow in the Dark Blue',
|
||||||
|
'Glow in the Dark Green',
|
||||||
|
'Grey',
|
||||||
|
'Indingo Purple',
|
||||||
|
'Ivory',
|
||||||
|
'Ivory White',
|
||||||
|
'Light Blue',
|
||||||
|
'Light Green',
|
||||||
|
'Matte Black',
|
||||||
|
'Matte Blue',
|
||||||
|
'Matte Brown',
|
||||||
|
'Matte Coral',
|
||||||
|
'Matte Green',
|
||||||
|
'Matte Grey',
|
||||||
|
'Matte Lime',
|
||||||
|
'Matte Mint',
|
||||||
|
'Matte Navy',
|
||||||
|
'Matte Orange',
|
||||||
|
'Matte Pink',
|
||||||
|
'Matte Purple',
|
||||||
|
'Matte White',
|
||||||
|
'Matte Yellow',
|
||||||
|
'Metal Bronze',
|
||||||
|
'Metal Copper',
|
||||||
|
'Metal Gold',
|
||||||
|
'Metal Grey',
|
||||||
|
'Metal Silver',
|
||||||
|
'Mint Green',
|
||||||
|
'Natural',
|
||||||
|
'Navy Blue',
|
||||||
|
'Nebulane',
|
||||||
|
'Silk Black',
|
||||||
|
'Silk Blue',
|
||||||
|
'Silk Bronze',
|
||||||
|
'Silk Copper',
|
||||||
|
'Silk Emerald',
|
||||||
|
'Silk Gold',
|
||||||
|
'Silk Green',
|
||||||
|
'Silk Jade',
|
||||||
|
'Silk Orange',
|
||||||
|
'Silk Pearl',
|
||||||
|
'Silk Pink',
|
||||||
|
'Silk Purple',
|
||||||
|
'Silk Red',
|
||||||
|
'Silk Rose Gold',
|
||||||
|
'Silk Ruby',
|
||||||
|
'Silk Sapphire',
|
||||||
|
'Silk Silver',
|
||||||
|
'Silk White',
|
||||||
|
'Sky Blue',
|
||||||
|
'Sparkle Blue',
|
||||||
|
'Sparkle Gold',
|
||||||
|
'Sparkle Green',
|
||||||
|
'Sparkle Purple',
|
||||||
|
'Sparkle Red',
|
||||||
|
'Sparkle Silver',
|
||||||
|
'Support G',
|
||||||
|
'Support White',
|
||||||
|
'Translucent Tea',
|
||||||
|
'Transparent Blue',
|
||||||
|
'Transparent Green',
|
||||||
|
'Transparent Orange',
|
||||||
|
'Transparent Purple',
|
||||||
|
'Transparent Red',
|
||||||
|
'Transparent Yellow',
|
||||||
|
'Violet'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Add 17 missing real Bambu Lab colors from spreadsheet
|
||||||
|
INSERT INTO colors (name, hex) VALUES
|
||||||
|
('Aurora Purple', '#9C27B0'),
|
||||||
|
('Candy Green', '#66CDAA'),
|
||||||
|
('Candy Red', '#E8505B'),
|
||||||
|
('Crystal Blue', '#68B8E8'),
|
||||||
|
('Flesh', '#FFCCB0'),
|
||||||
|
('Forest Green', '#39541A'),
|
||||||
|
('Grape Jelly', '#6A0DAD'),
|
||||||
|
('Lake Blue', '#1F79E5'),
|
||||||
|
('Lime', '#76FF03'),
|
||||||
|
('Mystic Magenta', '#E040FB'),
|
||||||
|
('Nebulae', '#4A148C'),
|
||||||
|
('Neon Green', '#76FF03'),
|
||||||
|
('Phantom Blue', '#1A237E'),
|
||||||
|
('Quicksilver', '#A6A6A6'),
|
||||||
|
('Rose Gold', '#B76E79'),
|
||||||
|
('Translucent Teal', '#008B8B')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- 4. Fix hex codes for existing colors to match spreadsheet
|
||||||
|
UPDATE colors SET hex = '#3B7DD8' WHERE name = 'ABS Azure';
|
||||||
|
UPDATE colors SET hex = '#00AE42' WHERE name = 'ABS Bambu Green';
|
||||||
|
UPDATE colors SET hex = '#1A1A1A' WHERE name = 'ABS Black';
|
||||||
|
UPDATE colors SET hex = '#1A3C8F' WHERE name = 'ABS Blue';
|
||||||
|
UPDATE colors SET hex = '#1B2A4A' WHERE name = 'ABS Navy Blue';
|
||||||
|
UPDATE colors SET hex = '#6B7339' WHERE name = 'ABS Olive';
|
||||||
|
UPDATE colors SET hex = '#F57C20' WHERE name = 'ABS Orange';
|
||||||
|
UPDATE colors SET hex = '#C0392B' WHERE name = 'ABS Red';
|
||||||
|
UPDATE colors SET hex = '#B0B0B0' WHERE name = 'ABS Silver';
|
||||||
|
UPDATE colors SET hex = '#FFBF00' WHERE name = 'ABS Tangerine Yellow';
|
||||||
|
UPDATE colors SET hex = '#2E7D32' WHERE name = 'Alpine Green Sparkle';
|
||||||
|
UPDATE colors SET hex = '#E0EFF6' WHERE name = 'Arctic Whisper';
|
||||||
|
UPDATE colors SET hex = '#00AE42' WHERE name = 'Bambu Green';
|
||||||
|
UPDATE colors SET hex = '#1A1A1A' WHERE name = 'Black';
|
||||||
|
UPDATE colors SET hex = '#3E2723' WHERE name = 'Black Walnut';
|
||||||
|
UPDATE colors SET hex = '#FF5722' WHERE name = 'Blaze';
|
||||||
|
UPDATE colors SET hex = '#7B8E97' WHERE name = 'Blue Grey';
|
||||||
|
UPDATE colors SET hex = '#00C5CD' WHERE name = 'Blue Hawaii';
|
||||||
|
UPDATE colors SET hex = '#7C4DFF' WHERE name = 'Blueberry Bubblegum';
|
||||||
|
UPDATE colors SET hex = '#9F332A' WHERE name = 'Brick Red';
|
||||||
|
UPDATE colors SET hex = '#5EC323' WHERE name = 'Bright Green';
|
||||||
|
UPDATE colors SET hex = '#DE3163' WHERE name = 'Cherry Pink';
|
||||||
|
UPDATE colors SET hex = '#D7C49E' WHERE name = 'Classic Birch';
|
||||||
|
UPDATE colors SET hex = '#CFB53B' WHERE name = 'Classic Gold Sparkle';
|
||||||
|
UPDATE colors SET hex = '#B08968' WHERE name = 'Clay Brown';
|
||||||
|
UPDATE colors SET hex = '#F0F0F0' WHERE name = 'Clear';
|
||||||
|
UPDATE colors SET hex = '#2C2C2C' WHERE name = 'Clear Black';
|
||||||
|
UPDATE colors SET hex = '#0047AB' WHERE name = 'Cobalt Blue';
|
||||||
|
UPDATE colors SET hex = '#6B4332' WHERE name = 'Cocoa Brown';
|
||||||
|
UPDATE colors SET hex = '#F8BBD0' WHERE name = 'Cotton Candy Cloud';
|
||||||
|
UPDATE colors SET hex = '#F5E6C8' WHERE name = 'Cream';
|
||||||
|
UPDATE colors SET hex = '#B71C1C' WHERE name = 'Crimson Red Sparkle';
|
||||||
|
UPDATE colors SET hex = '#515151' WHERE name = 'Dark Gray';
|
||||||
|
UPDATE colors SET hex = '#FFDAB9' WHERE name = 'Dawn Radiance';
|
||||||
|
UPDATE colors SET hex = '#FF7043' WHERE name = 'Dusk Glare';
|
||||||
|
UPDATE colors SET hex = '#E0F7FA' WHERE name = 'Frozen';
|
||||||
|
UPDATE colors SET hex = '#B76E79' WHERE name = 'Gilded Rose';
|
||||||
|
UPDATE colors SET hex = '#5DADE2' WHERE name = 'Glow Blue';
|
||||||
|
UPDATE colors SET hex = '#82E0AA' WHERE name = 'Glow Green';
|
||||||
|
UPDATE colors SET hex = '#FFB347' WHERE name = 'Glow Orange';
|
||||||
|
UPDATE colors SET hex = '#FFEB3B' WHERE name = 'Glow Yellow';
|
||||||
|
UPDATE colors SET hex = '#00AE42' WHERE name = 'Green';
|
||||||
|
UPDATE colors SET hex = '#FF4F81' WHERE name = 'Hot Pink';
|
||||||
|
UPDATE colors SET hex = '#A5DEE4' WHERE name = 'Ice Blue';
|
||||||
|
UPDATE colors SET hex = '#324585' WHERE name = 'Indigo Blue';
|
||||||
|
UPDATE colors SET hex = '#3B2D6B' WHERE name = 'Indigo Purple';
|
||||||
|
UPDATE colors SET hex = '#C5A04D' WHERE name = 'Iridium Gold Metallic';
|
||||||
|
UPDATE colors SET hex = '#5B3A8C' WHERE name = 'Iris Purple';
|
||||||
|
UPDATE colors SET hex = '#6E6E6E' WHERE name = 'Iron Gray Metallic';
|
||||||
|
UPDATE colors SET hex = '#4A6FA5' WHERE name = 'Jeans Blue';
|
||||||
|
UPDATE colors SET hex = '#5B5B5B' WHERE name = 'Lava Gray';
|
||||||
|
UPDATE colors SET hex = '#B39DDB' WHERE name = 'Lavender';
|
||||||
|
UPDATE colors SET hex = '#B2EBF2' WHERE name = 'Light Cyan';
|
||||||
|
UPDATE colors SET hex = '#C8C8C8' WHERE name = 'Light Gray';
|
||||||
|
UPDATE colors SET hex = '#C8E6C9' WHERE name = 'Light Jade';
|
||||||
|
UPDATE colors SET hex = '#7EC845' WHERE name = 'Lime Green';
|
||||||
|
UPDATE colors SET hex = '#0BDA51' WHERE name = 'Malachite Green';
|
||||||
|
UPDATE colors SET hex = '#7B2D34' WHERE name = 'Maroon Red';
|
||||||
|
UPDATE colors SET hex = '#7B9B5B' WHERE name = 'Matcha Green';
|
||||||
|
UPDATE colors SET hex = '#8DB600' WHERE name = 'Matte Apple Green';
|
||||||
|
UPDATE colors SET hex = '#CBC6B8' WHERE name = 'Matte Bone White';
|
||||||
|
UPDATE colors SET hex = '#AE835B' WHERE name = 'Matte Caramel';
|
||||||
|
UPDATE colors SET hex = '#3C3C3C' WHERE name = 'Matte Charcoal';
|
||||||
|
UPDATE colors SET hex = '#1B3A5C' WHERE name = 'Matte Dark Blue';
|
||||||
|
UPDATE colors SET hex = '#4D3324' WHERE name = 'Matte Dark Chocolate';
|
||||||
|
UPDATE colors SET hex = '#8B1A1A' WHERE name = 'Matte Dark Red';
|
||||||
|
UPDATE colors SET hex = '#61C680' WHERE name = 'Matte Grass Green';
|
||||||
|
UPDATE colors SET hex = '#FFFFFF' WHERE name = 'Matte Ivory White';
|
||||||
|
UPDATE colors SET hex = '#6E6E6E' WHERE name = 'Matte Nardo Gray';
|
||||||
|
UPDATE colors SET hex = '#7D3F6B' WHERE name = 'Matte Plum';
|
||||||
|
UPDATE colors SET hex = '#7EB6D9' WHERE name = 'Matte Sky Blue';
|
||||||
|
UPDATE colors SET hex = '#B15533' WHERE name = 'Matte Terracotta';
|
||||||
|
UPDATE colors SET hex = '#F8E875' WHERE name = 'Mellow Yellow';
|
||||||
|
UPDATE colors SET hex = '#2C1654' WHERE name = 'Midnight Blaze';
|
||||||
|
UPDATE colors SET hex = '#98FFB0' WHERE name = 'Mint';
|
||||||
|
UPDATE colors SET hex = '#A5D6A7' WHERE name = 'Mint Lime';
|
||||||
|
UPDATE colors SET hex = '#39FF14' WHERE name = 'Neon City';
|
||||||
|
UPDATE colors SET hex = '#FF6D00' WHERE name = 'Neon Orange';
|
||||||
|
UPDATE colors SET hex = '#4DB6AC' WHERE name = 'Ocean to Meadow';
|
||||||
|
UPDATE colors SET hex = '#CC7722' WHERE name = 'Ochre Yellow';
|
||||||
|
UPDATE colors SET hex = '#1A1A1A' WHERE name = 'Onyx Black Sparkle';
|
||||||
|
UPDATE colors SET hex = '#4A6741' WHERE name = 'Oxide Green Metallic';
|
||||||
|
UPDATE colors SET hex = '#A0724A' WHERE name = 'Peanut Brown';
|
||||||
|
UPDATE colors SET hex = '#FF8A65' WHERE name = 'Pink Citrus';
|
||||||
|
UPDATE colors SET hex = '#E87530' WHERE name = 'Pumpkin Orange';
|
||||||
|
UPDATE colors SET hex = '#A0522D' WHERE name = 'Red Granite';
|
||||||
|
UPDATE colors SET hex = '#65000B' WHERE name = 'Rosewood';
|
||||||
|
UPDATE colors SET hex = '#002FA7' WHERE name = 'Royal Blue';
|
||||||
|
UPDATE colors SET hex = '#6A1B9A' WHERE name = 'Royal Purple Sparkle';
|
||||||
|
UPDATE colors SET hex = '#708090' WHERE name = 'Slate Gray Sparkle';
|
||||||
|
UPDATE colors SET hex = '#FFD54F' WHERE name = 'Solar Breeze';
|
||||||
|
UPDATE colors SET hex = '#00BCD4' WHERE name = 'South Beach';
|
||||||
|
UPDATE colors SET hex = '#FEC600' WHERE name = 'Sunflower Yellow';
|
||||||
|
UPDATE colors SET hex = '#009FA1' WHERE name = 'Teal';
|
||||||
|
UPDATE colors SET hex = '#8A8D8F' WHERE name = 'Titan Gray';
|
||||||
|
UPDATE colors SET hex = '#A9A9A9' WHERE name = 'Translucent Gray';
|
||||||
|
UPDATE colors SET hex = '#8B4513' WHERE name = 'Translucent Brown';
|
||||||
|
UPDATE colors SET hex = '#ADD8E6' WHERE name = 'Translucent Light Blue';
|
||||||
|
UPDATE colors SET hex = '#808000' WHERE name = 'Translucent Olive';
|
||||||
|
UPDATE colors SET hex = '#FF8C00' WHERE name = 'Translucent Orange';
|
||||||
|
UPDATE colors SET hex = '#FFB6C1' WHERE name = 'Translucent Pink';
|
||||||
|
UPDATE colors SET hex = '#800080' WHERE name = 'Translucent Purple';
|
||||||
|
UPDATE colors SET hex = '#E8E8E8' WHERE name = 'Transparent';
|
||||||
|
UPDATE colors SET hex = '#3B0A45' WHERE name = 'Velvet Eclipse';
|
||||||
|
UPDATE colors SET hex = '#583061' WHERE name = 'Violet Purple';
|
||||||
|
UPDATE colors SET hex = '#E8E8E8' WHERE name = 'White Marble';
|
||||||
|
UPDATE colors SET hex = '#C8B88A' WHERE name = 'White Oak';
|
||||||
|
UPDATE colors SET hex = '#FF69B4' WHERE name = 'Glow Pink';
|
||||||
|
UPDATE colors SET hex = '#F7E7CE' WHERE name = 'Champagne';
|
||||||
|
UPDATE colors SET hex = '#0047AB' WHERE name = 'Cobalt Blue Metallic';
|
||||||
|
UPDATE colors SET hex = '#B87333' WHERE name = 'Copper Brown Metallic';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
205
database/migrations/024_fix_colors_cleanup.sql
Normal file
205
database/migrations/024_fix_colors_cleanup.sql
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
-- Migration 024: Fix colors cleanup (handles partial state from 023)
|
||||||
|
-- Source of truth: bambu_lab_filaments.numbers spreadsheet
|
||||||
|
|
||||||
|
-- Delete the typo duplicate (keep the correct one)
|
||||||
|
DELETE FROM colors WHERE name = 'IronGray Metallic';
|
||||||
|
|
||||||
|
-- Delete fake colors not used by any filament
|
||||||
|
DELETE FROM colors WHERE name IN (
|
||||||
|
'Coton Candy Cloud',
|
||||||
|
'Glow in the Dark Blue',
|
||||||
|
'Glow in the Dark Green',
|
||||||
|
'Grey',
|
||||||
|
'Indingo Purple',
|
||||||
|
'Ivory',
|
||||||
|
'Ivory White',
|
||||||
|
'Light Blue',
|
||||||
|
'Light Green',
|
||||||
|
'Matte Black',
|
||||||
|
'Matte Blue',
|
||||||
|
'Matte Brown',
|
||||||
|
'Matte Coral',
|
||||||
|
'Matte Green',
|
||||||
|
'Matte Grey',
|
||||||
|
'Matte Lime',
|
||||||
|
'Matte Mint',
|
||||||
|
'Matte Navy',
|
||||||
|
'Matte Orange',
|
||||||
|
'Matte Pink',
|
||||||
|
'Matte Purple',
|
||||||
|
'Matte White',
|
||||||
|
'Matte Yellow',
|
||||||
|
'Metal Bronze',
|
||||||
|
'Metal Copper',
|
||||||
|
'Metal Gold',
|
||||||
|
'Metal Grey',
|
||||||
|
'Metal Silver',
|
||||||
|
'Mint Green',
|
||||||
|
'Natural',
|
||||||
|
'Navy Blue',
|
||||||
|
'Nebulane',
|
||||||
|
'Silk Black',
|
||||||
|
'Silk Blue',
|
||||||
|
'Silk Bronze',
|
||||||
|
'Silk Copper',
|
||||||
|
'Silk Emerald',
|
||||||
|
'Silk Gold',
|
||||||
|
'Silk Green',
|
||||||
|
'Silk Jade',
|
||||||
|
'Silk Orange',
|
||||||
|
'Silk Pearl',
|
||||||
|
'Silk Pink',
|
||||||
|
'Silk Purple',
|
||||||
|
'Silk Red',
|
||||||
|
'Silk Rose Gold',
|
||||||
|
'Silk Ruby',
|
||||||
|
'Silk Sapphire',
|
||||||
|
'Silk Silver',
|
||||||
|
'Silk White',
|
||||||
|
'Sky Blue',
|
||||||
|
'Sparkle Blue',
|
||||||
|
'Sparkle Gold',
|
||||||
|
'Sparkle Green',
|
||||||
|
'Sparkle Purple',
|
||||||
|
'Sparkle Red',
|
||||||
|
'Sparkle Silver',
|
||||||
|
'Support G',
|
||||||
|
'Support White',
|
||||||
|
'Translucent Tea',
|
||||||
|
'Transparent Blue',
|
||||||
|
'Transparent Green',
|
||||||
|
'Transparent Orange',
|
||||||
|
'Transparent Purple',
|
||||||
|
'Transparent Red',
|
||||||
|
'Transparent Yellow',
|
||||||
|
'Violet'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fix all hex codes to match spreadsheet
|
||||||
|
UPDATE colors SET hex = '#3B7DD8' WHERE name = 'ABS Azure';
|
||||||
|
UPDATE colors SET hex = '#00AE42' WHERE name = 'ABS Bambu Green';
|
||||||
|
UPDATE colors SET hex = '#1A1A1A' WHERE name = 'ABS Black';
|
||||||
|
UPDATE colors SET hex = '#1A3C8F' WHERE name = 'ABS Blue';
|
||||||
|
UPDATE colors SET hex = '#1B2A4A' WHERE name = 'ABS Navy Blue';
|
||||||
|
UPDATE colors SET hex = '#6B7339' WHERE name = 'ABS Olive';
|
||||||
|
UPDATE colors SET hex = '#F57C20' WHERE name = 'ABS Orange';
|
||||||
|
UPDATE colors SET hex = '#C0392B' WHERE name = 'ABS Red';
|
||||||
|
UPDATE colors SET hex = '#B0B0B0' WHERE name = 'ABS Silver';
|
||||||
|
UPDATE colors SET hex = '#FFBF00' WHERE name = 'ABS Tangerine Yellow';
|
||||||
|
UPDATE colors SET hex = '#2E7D32' WHERE name = 'Alpine Green Sparkle';
|
||||||
|
UPDATE colors SET hex = '#E0EFF6' WHERE name = 'Arctic Whisper';
|
||||||
|
UPDATE colors SET hex = '#9C27B0' WHERE name = 'Aurora Purple';
|
||||||
|
UPDATE colors SET hex = '#00AE42' WHERE name = 'Bambu Green';
|
||||||
|
UPDATE colors SET hex = '#1A1A1A' WHERE name = 'Black';
|
||||||
|
UPDATE colors SET hex = '#3E2723' WHERE name = 'Black Walnut';
|
||||||
|
UPDATE colors SET hex = '#FF5722' WHERE name = 'Blaze';
|
||||||
|
UPDATE colors SET hex = '#7B8E97' WHERE name = 'Blue Grey';
|
||||||
|
UPDATE colors SET hex = '#00C5CD' WHERE name = 'Blue Hawaii';
|
||||||
|
UPDATE colors SET hex = '#7C4DFF' WHERE name = 'Blueberry Bubblegum';
|
||||||
|
UPDATE colors SET hex = '#9F332A' WHERE name = 'Brick Red';
|
||||||
|
UPDATE colors SET hex = '#5EC323' WHERE name = 'Bright Green';
|
||||||
|
UPDATE colors SET hex = '#66CDAA' WHERE name = 'Candy Green';
|
||||||
|
UPDATE colors SET hex = '#E8505B' WHERE name = 'Candy Red';
|
||||||
|
UPDATE colors SET hex = '#F7E7CE' WHERE name = 'Champagne';
|
||||||
|
UPDATE colors SET hex = '#DE3163' WHERE name = 'Cherry Pink';
|
||||||
|
UPDATE colors SET hex = '#D7C49E' WHERE name = 'Classic Birch';
|
||||||
|
UPDATE colors SET hex = '#CFB53B' WHERE name = 'Classic Gold Sparkle';
|
||||||
|
UPDATE colors SET hex = '#B08968' WHERE name = 'Clay Brown';
|
||||||
|
UPDATE colors SET hex = '#F0F0F0' WHERE name = 'Clear';
|
||||||
|
UPDATE colors SET hex = '#2C2C2C' WHERE name = 'Clear Black';
|
||||||
|
UPDATE colors SET hex = '#0047AB' WHERE name = 'Cobalt Blue';
|
||||||
|
UPDATE colors SET hex = '#0047AB' WHERE name = 'Cobalt Blue Metallic';
|
||||||
|
UPDATE colors SET hex = '#6B4332' WHERE name = 'Cocoa Brown';
|
||||||
|
UPDATE colors SET hex = '#B87333' WHERE name = 'Copper Brown Metallic';
|
||||||
|
UPDATE colors SET hex = '#F8BBD0' WHERE name = 'Cotton Candy Cloud';
|
||||||
|
UPDATE colors SET hex = '#F5E6C8' WHERE name = 'Cream';
|
||||||
|
UPDATE colors SET hex = '#B71C1C' WHERE name = 'Crimson Red Sparkle';
|
||||||
|
UPDATE colors SET hex = '#68B8E8' WHERE name = 'Crystal Blue';
|
||||||
|
UPDATE colors SET hex = '#515151' WHERE name = 'Dark Gray';
|
||||||
|
UPDATE colors SET hex = '#FFDAB9' WHERE name = 'Dawn Radiance';
|
||||||
|
UPDATE colors SET hex = '#FF7043' WHERE name = 'Dusk Glare';
|
||||||
|
UPDATE colors SET hex = '#FFCCB0' WHERE name = 'Flesh';
|
||||||
|
UPDATE colors SET hex = '#39541A' WHERE name = 'Forest Green';
|
||||||
|
UPDATE colors SET hex = '#E0F7FA' WHERE name = 'Frozen';
|
||||||
|
UPDATE colors SET hex = '#B76E79' WHERE name = 'Gilded Rose';
|
||||||
|
UPDATE colors SET hex = '#5DADE2' WHERE name = 'Glow Blue';
|
||||||
|
UPDATE colors SET hex = '#82E0AA' WHERE name = 'Glow Green';
|
||||||
|
UPDATE colors SET hex = '#FFB347' WHERE name = 'Glow Orange';
|
||||||
|
UPDATE colors SET hex = '#FF69B4' WHERE name = 'Glow Pink';
|
||||||
|
UPDATE colors SET hex = '#FFEB3B' WHERE name = 'Glow Yellow';
|
||||||
|
UPDATE colors SET hex = '#6A0DAD' WHERE name = 'Grape Jelly';
|
||||||
|
UPDATE colors SET hex = '#00AE42' WHERE name = 'Green';
|
||||||
|
UPDATE colors SET hex = '#FF4F81' WHERE name = 'Hot Pink';
|
||||||
|
UPDATE colors SET hex = '#A5DEE4' WHERE name = 'Ice Blue';
|
||||||
|
UPDATE colors SET hex = '#324585' WHERE name = 'Indigo Blue';
|
||||||
|
UPDATE colors SET hex = '#3B2D6B' WHERE name = 'Indigo Purple';
|
||||||
|
UPDATE colors SET hex = '#C5A04D' WHERE name = 'Iridium Gold Metallic';
|
||||||
|
UPDATE colors SET hex = '#5B3A8C' WHERE name = 'Iris Purple';
|
||||||
|
UPDATE colors SET hex = '#6E6E6E' WHERE name = 'Iron Gray Metallic';
|
||||||
|
UPDATE colors SET hex = '#4A6FA5' WHERE name = 'Jeans Blue';
|
||||||
|
UPDATE colors SET hex = '#1F79E5' WHERE name = 'Lake Blue';
|
||||||
|
UPDATE colors SET hex = '#5B5B5B' WHERE name = 'Lava Gray';
|
||||||
|
UPDATE colors SET hex = '#B39DDB' WHERE name = 'Lavender';
|
||||||
|
UPDATE colors SET hex = '#B2EBF2' WHERE name = 'Light Cyan';
|
||||||
|
UPDATE colors SET hex = '#C8C8C8' WHERE name = 'Light Gray';
|
||||||
|
UPDATE colors SET hex = '#C8E6C9' WHERE name = 'Light Jade';
|
||||||
|
UPDATE colors SET hex = '#76FF03' WHERE name = 'Lime';
|
||||||
|
UPDATE colors SET hex = '#7EC845' WHERE name = 'Lime Green';
|
||||||
|
UPDATE colors SET hex = '#0BDA51' WHERE name = 'Malachite Green';
|
||||||
|
UPDATE colors SET hex = '#7B2D34' WHERE name = 'Maroon Red';
|
||||||
|
UPDATE colors SET hex = '#7B9B5B' WHERE name = 'Matcha Green';
|
||||||
|
UPDATE colors SET hex = '#8DB600' WHERE name = 'Matte Apple Green';
|
||||||
|
UPDATE colors SET hex = '#CBC6B8' WHERE name = 'Matte Bone White';
|
||||||
|
UPDATE colors SET hex = '#AE835B' WHERE name = 'Matte Caramel';
|
||||||
|
UPDATE colors SET hex = '#3C3C3C' WHERE name = 'Matte Charcoal';
|
||||||
|
UPDATE colors SET hex = '#1B3A5C' WHERE name = 'Matte Dark Blue';
|
||||||
|
UPDATE colors SET hex = '#4D3324' WHERE name = 'Matte Dark Chocolate';
|
||||||
|
UPDATE colors SET hex = '#8B1A1A' WHERE name = 'Matte Dark Red';
|
||||||
|
UPDATE colors SET hex = '#61C680' WHERE name = 'Matte Grass Green';
|
||||||
|
UPDATE colors SET hex = '#FFFFFF' WHERE name = 'Matte Ivory White';
|
||||||
|
UPDATE colors SET hex = '#6E6E6E' WHERE name = 'Matte Nardo Gray';
|
||||||
|
UPDATE colors SET hex = '#7D3F6B' WHERE name = 'Matte Plum';
|
||||||
|
UPDATE colors SET hex = '#7EB6D9' WHERE name = 'Matte Sky Blue';
|
||||||
|
UPDATE colors SET hex = '#B15533' WHERE name = 'Matte Terracotta';
|
||||||
|
UPDATE colors SET hex = '#F8E875' WHERE name = 'Mellow Yellow';
|
||||||
|
UPDATE colors SET hex = '#2C1654' WHERE name = 'Midnight Blaze';
|
||||||
|
UPDATE colors SET hex = '#98FFB0' WHERE name = 'Mint';
|
||||||
|
UPDATE colors SET hex = '#A5D6A7' WHERE name = 'Mint Lime';
|
||||||
|
UPDATE colors SET hex = '#E040FB' WHERE name = 'Mystic Magenta';
|
||||||
|
UPDATE colors SET hex = '#4A148C' WHERE name = 'Nebulae';
|
||||||
|
UPDATE colors SET hex = '#39FF14' WHERE name = 'Neon City';
|
||||||
|
UPDATE colors SET hex = '#76FF03' WHERE name = 'Neon Green';
|
||||||
|
UPDATE colors SET hex = '#FF6D00' WHERE name = 'Neon Orange';
|
||||||
|
UPDATE colors SET hex = '#4DB6AC' WHERE name = 'Ocean to Meadow';
|
||||||
|
UPDATE colors SET hex = '#CC7722' WHERE name = 'Ochre Yellow';
|
||||||
|
UPDATE colors SET hex = '#1A1A1A' WHERE name = 'Onyx Black Sparkle';
|
||||||
|
UPDATE colors SET hex = '#4A6741' WHERE name = 'Oxide Green Metallic';
|
||||||
|
UPDATE colors SET hex = '#A0724A' WHERE name = 'Peanut Brown';
|
||||||
|
UPDATE colors SET hex = '#1A237E' WHERE name = 'Phantom Blue';
|
||||||
|
UPDATE colors SET hex = '#FF8A65' WHERE name = 'Pink Citrus';
|
||||||
|
UPDATE colors SET hex = '#E87530' WHERE name = 'Pumpkin Orange';
|
||||||
|
UPDATE colors SET hex = '#A6A6A6' WHERE name = 'Quicksilver';
|
||||||
|
UPDATE colors SET hex = '#A0522D' WHERE name = 'Red Granite';
|
||||||
|
UPDATE colors SET hex = '#B76E79' WHERE name = 'Rose Gold';
|
||||||
|
UPDATE colors SET hex = '#65000B' WHERE name = 'Rosewood';
|
||||||
|
UPDATE colors SET hex = '#002FA7' WHERE name = 'Royal Blue';
|
||||||
|
UPDATE colors SET hex = '#6A1B9A' WHERE name = 'Royal Purple Sparkle';
|
||||||
|
UPDATE colors SET hex = '#708090' WHERE name = 'Slate Gray Sparkle';
|
||||||
|
UPDATE colors SET hex = '#FFD54F' WHERE name = 'Solar Breeze';
|
||||||
|
UPDATE colors SET hex = '#00BCD4' WHERE name = 'South Beach';
|
||||||
|
UPDATE colors SET hex = '#FEC600' WHERE name = 'Sunflower Yellow';
|
||||||
|
UPDATE colors SET hex = '#009FA1' WHERE name = 'Teal';
|
||||||
|
UPDATE colors SET hex = '#8A8D8F' WHERE name = 'Titan Gray';
|
||||||
|
UPDATE colors SET hex = '#A9A9A9' WHERE name = 'Translucent Gray';
|
||||||
|
UPDATE colors SET hex = '#8B4513' WHERE name = 'Translucent Brown';
|
||||||
|
UPDATE colors SET hex = '#ADD8E6' WHERE name = 'Translucent Light Blue';
|
||||||
|
UPDATE colors SET hex = '#808000' WHERE name = 'Translucent Olive';
|
||||||
|
UPDATE colors SET hex = '#FF8C00' WHERE name = 'Translucent Orange';
|
||||||
|
UPDATE colors SET hex = '#FFB6C1' WHERE name = 'Translucent Pink';
|
||||||
|
UPDATE colors SET hex = '#800080' WHERE name = 'Translucent Purple';
|
||||||
|
UPDATE colors SET hex = '#008B8B' WHERE name = 'Translucent Teal';
|
||||||
|
UPDATE colors SET hex = '#E8E8E8' WHERE name = 'Transparent';
|
||||||
|
UPDATE colors SET hex = '#3B0A45' WHERE name = 'Velvet Eclipse';
|
||||||
|
UPDATE colors SET hex = '#583061' WHERE name = 'Violet Purple';
|
||||||
|
UPDATE colors SET hex = '#E8E8E8' WHERE name = 'White Marble';
|
||||||
|
UPDATE colors SET hex = '#C8B88A' WHERE name = 'White Oak';
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
-- Colors table
|
-- Colors table
|
||||||
CREATE TABLE colors (
|
CREATE TABLE IF NOT EXISTS colors (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(100) NOT NULL UNIQUE,
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
hex VARCHAR(7) NOT NULL,
|
hex VARCHAR(7) NOT NULL,
|
||||||
@@ -15,7 +15,7 @@ CREATE TABLE colors (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Filaments table
|
-- Filaments table
|
||||||
CREATE TABLE filaments (
|
CREATE TABLE IF NOT EXISTS filaments (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
tip VARCHAR(50) NOT NULL,
|
tip VARCHAR(50) NOT NULL,
|
||||||
finish VARCHAR(50) NOT NULL,
|
finish VARCHAR(50) NOT NULL,
|
||||||
@@ -32,9 +32,9 @@ CREATE TABLE filaments (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Create indexes for better performance
|
-- Create indexes for better performance
|
||||||
CREATE INDEX idx_filaments_tip ON filaments(tip);
|
CREATE INDEX IF NOT EXISTS idx_filaments_tip ON filaments(tip);
|
||||||
CREATE INDEX idx_filaments_boja ON filaments(boja);
|
CREATE INDEX IF NOT EXISTS idx_filaments_boja ON filaments(boja);
|
||||||
CREATE INDEX idx_filaments_created_at ON filaments(created_at);
|
CREATE INDEX IF NOT EXISTS idx_filaments_created_at ON filaments(created_at);
|
||||||
|
|
||||||
-- Create updated_at trigger function
|
-- Create updated_at trigger function
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
@@ -46,10 +46,12 @@ END;
|
|||||||
$$ language 'plpgsql';
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
-- Apply trigger to filaments table
|
-- Apply trigger to filaments table
|
||||||
|
DROP TRIGGER IF EXISTS update_filaments_updated_at ON filaments;
|
||||||
CREATE TRIGGER update_filaments_updated_at BEFORE UPDATE
|
CREATE TRIGGER update_filaments_updated_at BEFORE UPDATE
|
||||||
ON filaments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
ON filaments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
-- Apply trigger to colors table
|
-- Apply trigger to colors table
|
||||||
|
DROP TRIGGER IF EXISTS update_colors_updated_at ON colors;
|
||||||
CREATE TRIGGER update_colors_updated_at BEFORE UPDATE
|
CREATE TRIGGER update_colors_updated_at BEFORE UPDATE
|
||||||
ON colors FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
ON colors FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
|||||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
4682
package-lock.json
generated
4682
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -3,10 +3,10 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "./scripts/kill-dev.sh && next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint . --ext .ts,.tsx --ignore-path .gitignore",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"security:check": "node scripts/security/security-check.js",
|
"security:check": "node scripts/security/security-check.js",
|
||||||
@@ -20,31 +20,32 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"cheerio": "^1.1.0",
|
"cheerio": "^1.1.0",
|
||||||
"next": "^15.3.4",
|
"next": "^16.1.6",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.2.4",
|
||||||
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.24",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
"jest-environment-jsdom": "^30.0.0",
|
"jest-environment-jsdom": "^30.0.0",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.0.8"
|
"vite": "^5.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
92
scripts/add-missing-colors.js
Normal file
92
scripts/add-missing-colors.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const connectionString = "postgresql://filamenteka_admin:onrBjiAjHKQXBAJSVWU2t2kQ7HDil9re@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
ssl: { rejectUnauthorized: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
const missingColors = [
|
||||||
|
// PLA Matte - New colors with correct hex codes
|
||||||
|
{ name: "Matte Dark Chocolate", hex: "#4A3729", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Dark Brown", hex: "#7D6556", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Terracotta", hex: "#A25A37", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Caramel", hex: "#A4845C", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Dark Blue", hex: "#042F56", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Sky Blue", hex: "#73B2E5", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Ice Blue", hex: "#A3D8E1", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Dark Green", hex: "#68724D", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Grass Green", hex: "#61C680", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Apple Green", hex: "#C6E188", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Dark Red", hex: "#BB3D43", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Plum", hex: "#851A52", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Lilac Purple", hex: "#AE96D4", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Sakura Pink", hex: "#E8AFCF", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Lemon Yellow", hex: "#F7D959", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Matte Bone White", hex: "#C8C5B6", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
|
||||||
|
// PLA Wood colors
|
||||||
|
{ name: "Ochre Yellow", hex: "#BC8B39", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "White Oak", hex: "#D2CCA2", cena_refill: 3499, cena_spulna: 3999 },
|
||||||
|
{ name: "Clay Brown", hex: "#8E621A", cena_refill: 3499, cena_spulna: 3999 }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function addMissingColors() {
|
||||||
|
try {
|
||||||
|
console.log('🎨 Adding missing colors to database...\n');
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const color of missingColors) {
|
||||||
|
try {
|
||||||
|
// Check if color already exists
|
||||||
|
const existingColor = await pool.query(
|
||||||
|
'SELECT * FROM colors WHERE name = $1',
|
||||||
|
[color.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingColor.rows.length > 0) {
|
||||||
|
// Update existing color with correct hex code
|
||||||
|
const existing = existingColor.rows[0];
|
||||||
|
if (existing.hex !== color.hex) {
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE colors SET hex = $1, cena_refill = $2, cena_spulna = $3 WHERE name = $4',
|
||||||
|
[color.hex, color.cena_refill, color.cena_spulna, color.name]
|
||||||
|
);
|
||||||
|
console.log(`✅ Updated: ${color.name} (${existing.hex} → ${color.hex})`);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
console.log(`⏭️ Skipped: ${color.name} (already exists with correct hex)`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Insert new color
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO colors (name, hex, cena_refill, cena_spulna) VALUES ($1, $2, $3, $4)',
|
||||||
|
[color.name, color.hex, color.cena_refill, color.cena_spulna]
|
||||||
|
);
|
||||||
|
console.log(`✅ Added: ${color.name} (${color.hex})`);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error with ${color.name}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n📊 Summary:`);
|
||||||
|
console.log(` Added: ${added}`);
|
||||||
|
console.log(` Updated: ${updated}`);
|
||||||
|
console.log(` Skipped: ${skipped}`);
|
||||||
|
console.log(` Total: ${missingColors.length}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to add colors:', error.message);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMissingColors();
|
||||||
@@ -13,8 +13,8 @@ cd /home/ubuntu/filamenteka-api
|
|||||||
# Backup current server.js
|
# Backup current server.js
|
||||||
cp server.js server.js.backup
|
cp server.js server.js.backup
|
||||||
|
|
||||||
# Download the updated server.js from GitHub
|
# Download the updated server.js from Gitea
|
||||||
curl -o server.js https://raw.githubusercontent.com/daxdax89/Filamenteka/sale/api/server.js
|
curl -o server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js
|
||||||
|
|
||||||
# Restart the service
|
# Restart the service
|
||||||
sudo systemctl restart node-api
|
sudo systemctl restart node-api
|
||||||
@@ -30,7 +30,7 @@ aws ssm send-command \
|
|||||||
--parameters "commands=[
|
--parameters "commands=[
|
||||||
'cd /home/ubuntu/filamenteka-api',
|
'cd /home/ubuntu/filamenteka-api',
|
||||||
'cp server.js server.js.backup',
|
'cp server.js server.js.backup',
|
||||||
'curl -o server.js https://raw.githubusercontent.com/daxdax89/Filamenteka/sale/api/server.js',
|
'curl -o server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js',
|
||||||
'sudo systemctl restart node-api',
|
'sudo systemctl restart node-api',
|
||||||
'sudo systemctl status node-api'
|
'sudo systemctl status node-api'
|
||||||
]" \
|
]" \
|
||||||
|
|||||||
97
scripts/deploy-frontend.sh
Executable file
97
scripts/deploy-frontend.sh
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Frontend deployment script for CloudFront + S3
|
||||||
|
# This script builds the Next.js app and deploys it to S3 + CloudFront
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Starting frontend deployment..."
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
S3_BUCKET="filamenteka-frontend"
|
||||||
|
REGION="eu-central-1"
|
||||||
|
|
||||||
|
# Get CloudFront distribution ID from terraform output
|
||||||
|
echo -e "${BLUE}📋 Getting CloudFront distribution ID...${NC}"
|
||||||
|
DISTRIBUTION_ID=$(terraform -chdir=terraform output -raw cloudfront_distribution_id 2>/dev/null || terraform output -raw cloudfront_distribution_id)
|
||||||
|
|
||||||
|
if [ -z "$DISTRIBUTION_ID" ]; then
|
||||||
|
echo -e "${RED}❌ Could not get CloudFront distribution ID${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Distribution ID: $DISTRIBUTION_ID${NC}"
|
||||||
|
|
||||||
|
# Build the Next.js app
|
||||||
|
echo -e "${BLUE}🔨 Building Next.js app...${NC}"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
if [ ! -d "out" ]; then
|
||||||
|
echo -e "${RED}❌ Build failed - 'out' directory not found${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Build completed${NC}"
|
||||||
|
|
||||||
|
# Upload to S3 with proper cache headers
|
||||||
|
echo -e "${BLUE}📤 Uploading to S3 with optimized cache headers...${NC}"
|
||||||
|
|
||||||
|
# First, delete old files
|
||||||
|
echo -e "${BLUE} 🗑️ Cleaning old files...${NC}"
|
||||||
|
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||||
|
--region $REGION \
|
||||||
|
--delete \
|
||||||
|
--exclude "*"
|
||||||
|
|
||||||
|
# Upload HTML files with no-cache (always revalidate)
|
||||||
|
echo -e "${BLUE} 📄 Uploading HTML files (no-cache)...${NC}"
|
||||||
|
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||||
|
--region $REGION \
|
||||||
|
--exclude "*" \
|
||||||
|
--include "*.html" \
|
||||||
|
--cache-control "public, max-age=0, must-revalidate" \
|
||||||
|
--content-type "text/html"
|
||||||
|
|
||||||
|
# Upload _next static assets with long-term cache (immutable, 1 year)
|
||||||
|
echo -e "${BLUE} 🎨 Uploading Next.js static assets (1 year cache)...${NC}"
|
||||||
|
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
|
||||||
|
--region $REGION \
|
||||||
|
--cache-control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# Upload other static assets with moderate cache (1 day)
|
||||||
|
echo -e "${BLUE} 📦 Uploading other assets (1 day cache)...${NC}"
|
||||||
|
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||||
|
--region $REGION \
|
||||||
|
--exclude "*" \
|
||||||
|
--exclude "*.html" \
|
||||||
|
--exclude "_next/*" \
|
||||||
|
--include "*" \
|
||||||
|
--cache-control "public, max-age=86400"
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ All files uploaded to S3 with optimized cache headers${NC}"
|
||||||
|
|
||||||
|
# Invalidate CloudFront cache
|
||||||
|
echo -e "${BLUE}🔄 Invalidating CloudFront cache...${NC}"
|
||||||
|
INVALIDATION_ID=$(aws cloudfront create-invalidation \
|
||||||
|
--distribution-id $DISTRIBUTION_ID \
|
||||||
|
--paths "/*" \
|
||||||
|
--query 'Invalidation.Id' \
|
||||||
|
--output text)
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ CloudFront invalidation created: $INVALIDATION_ID${NC}"
|
||||||
|
|
||||||
|
# Get CloudFront URL
|
||||||
|
CF_URL=$(terraform -chdir=terraform output -raw cloudfront_domain_name 2>/dev/null || terraform output -raw cloudfront_domain_name)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Deployment complete!${NC}"
|
||||||
|
echo -e "${BLUE}🌐 CloudFront URL: https://$CF_URL${NC}"
|
||||||
|
echo -e "${BLUE}🌐 Custom Domain: https://filamenteka.rs (after DNS update)${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}ℹ️ Note: CloudFront cache invalidation may take 5-10 minutes to propagate globally.${NC}"
|
||||||
26
scripts/kill-dev.sh
Executable file
26
scripts/kill-dev.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Kill any processes running on ports 3000 and 3001
|
||||||
|
echo "🔍 Checking for processes on ports 3000 and 3001..."
|
||||||
|
PIDS=$(lsof -ti:3000,3001 2>/dev/null)
|
||||||
|
if [ -n "$PIDS" ]; then
|
||||||
|
echo "$PIDS" | xargs kill -9 2>/dev/null
|
||||||
|
echo "✅ Killed processes on ports 3000/3001"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No processes found on ports 3000/3001"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill any old Next.js dev server processes (but not the current script)
|
||||||
|
echo "🔍 Checking for Next.js dev processes..."
|
||||||
|
OLD_PIDS=$(ps aux | grep -i "next dev" | grep -v grep | grep -v "kill-dev.sh" | awk '{print $2}')
|
||||||
|
if [ -n "$OLD_PIDS" ]; then
|
||||||
|
echo "$OLD_PIDS" | xargs kill -9 2>/dev/null
|
||||||
|
echo "✅ Killed Next.js dev processes"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No Next.js dev processes found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Give it a moment to clean up
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
echo "✨ Ready to start fresh dev server!"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Script to update API server via GitHub
|
# Script to update API server via Gitea
|
||||||
# Since we can't SSH directly, we'll use the API server to pull latest code
|
# Since we can't SSH directly, we'll use the API server to pull latest code
|
||||||
|
|
||||||
echo "🚀 Updating API server with latest code..."
|
echo "🚀 Updating API server with latest code..."
|
||||||
|
|||||||
77
scripts/verify-color-consistency.js
Normal file
77
scripts/verify-color-consistency.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Verification script to check consistency between frontend colors and migration
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
console.log('Verifying color consistency between frontend and backend...\n');
|
||||||
|
|
||||||
|
// Read frontend color definitions
|
||||||
|
const frontendFile = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../src/data/bambuLabColors.ts'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read migration file
|
||||||
|
const migrationFile = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../database/migrations/019_add_new_bambu_colors_2025.sql'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract colors from frontend (looking for pattern: 'ColorName': { hex: '#HEXCODE' })
|
||||||
|
const frontendColorRegex = /'([^']+)':\s*\{\s*hex:\s*'(#[A-F0-9]{6})'/gi;
|
||||||
|
const frontendColors = {};
|
||||||
|
let match;
|
||||||
|
while ((match = frontendColorRegex.exec(frontendFile)) !== null) {
|
||||||
|
frontendColors[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract new Matte colors from migration
|
||||||
|
const migrationColorRegex = /\('([^']+)',\s*'(#[A-F0-9]{6})'\)/gi;
|
||||||
|
const migrationColors = {};
|
||||||
|
while ((match = migrationColorRegex.exec(migrationFile)) !== null) {
|
||||||
|
migrationColors[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('New colors added in migration 019:');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Check each migration color exists in frontend with same hex code
|
||||||
|
Object.keys(migrationColors).forEach(colorName => {
|
||||||
|
const migrationHex = migrationColors[colorName];
|
||||||
|
const frontendHex = frontendColors[colorName];
|
||||||
|
|
||||||
|
if (!frontendHex) {
|
||||||
|
console.log(`❌ ERROR: '${colorName}' missing in frontend`);
|
||||||
|
hasErrors = true;
|
||||||
|
} else if (frontendHex !== migrationHex) {
|
||||||
|
console.log(`❌ ERROR: '${colorName}' hex mismatch:`);
|
||||||
|
console.log(` Frontend: ${frontendHex}`);
|
||||||
|
console.log(` Migration: ${migrationHex}`);
|
||||||
|
hasErrors = true;
|
||||||
|
} else {
|
||||||
|
console.log(`✓ ${colorName}: ${frontendHex}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if PLA Wood colors exist in frontend
|
||||||
|
console.log('\nPLA Wood colors (should already exist in frontend):');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
const woodColors = ['Ochre Yellow', 'White Oak', 'Clay Brown'];
|
||||||
|
woodColors.forEach(colorName => {
|
||||||
|
if (frontendColors[colorName]) {
|
||||||
|
console.log(`✓ ${colorName}: ${frontendColors[colorName]}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ERROR: '${colorName}' missing in frontend`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
if (hasErrors) {
|
||||||
|
console.log('❌ VERIFICATION FAILED: Inconsistencies detected');
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('✓ VERIFICATION PASSED: All colors are consistent');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
42
src/components/AdminSidebar.tsx
Normal file
42
src/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/dashboard', label: 'Filamenti' },
|
||||||
|
{ href: '/upadaj/colors', label: 'Boje' },
|
||||||
|
{ href: '/upadaj/requests', label: 'Zahtevi za boje' },
|
||||||
|
{ href: '/upadaj/sales', label: 'Prodaja' },
|
||||||
|
{ href: '/upadaj/customers', label: 'Kupci' },
|
||||||
|
{ href: '/upadaj/analytics', label: 'Analitika' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AdminSidebar() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 bg-white dark:bg-gray-800 shadow-lg h-screen sticky top-0 flex-shrink-0">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`block px-4 py-2 rounded ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
363
src/components/BulkFilamentPriceEditor.tsx
Normal file
363
src/components/BulkFilamentPriceEditor.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { filamentService, colorService } from '@/src/services/api';
|
||||||
|
import { Filament } from '@/src/types/filament';
|
||||||
|
|
||||||
|
interface BulkFilamentPriceEditorProps {
|
||||||
|
filaments: Array<Filament & { id: string }>;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkFilamentPriceEditor({ filaments, onUpdate }: BulkFilamentPriceEditorProps) {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [selectedMaterial, setSelectedMaterial] = useState<string>('');
|
||||||
|
const [selectedFinish, setSelectedFinish] = useState<string>('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Price inputs
|
||||||
|
const [newRefillPrice, setNewRefillPrice] = useState<string>('');
|
||||||
|
const [newSpoolPrice, setNewSpoolPrice] = useState<string>('');
|
||||||
|
|
||||||
|
// Get unique materials and finishes
|
||||||
|
const materials = useMemo(() =>
|
||||||
|
[...new Set(filaments.map(f => f.tip))].sort(),
|
||||||
|
[filaments]
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishes = useMemo(() =>
|
||||||
|
[...new Set(filaments.map(f => f.finish))].sort(),
|
||||||
|
[filaments]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter filaments based on selections
|
||||||
|
const filteredFilaments = useMemo(() => {
|
||||||
|
return filaments.filter(f => {
|
||||||
|
const matchesMaterial = !selectedMaterial || f.tip === selectedMaterial;
|
||||||
|
const matchesFinish = !selectedFinish || f.finish === selectedFinish;
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
f.boja.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
f.tip.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
f.finish.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
return matchesMaterial && matchesFinish && matchesSearch;
|
||||||
|
});
|
||||||
|
}, [filaments, selectedMaterial, selectedFinish, searchTerm]);
|
||||||
|
|
||||||
|
// Group filaments by color (since multiple filaments can have same color)
|
||||||
|
const colorGroups = useMemo(() => {
|
||||||
|
const groups = new Map<string, Array<Filament & { id: string }>>();
|
||||||
|
filteredFilaments.forEach(f => {
|
||||||
|
const existing = groups.get(f.boja) || [];
|
||||||
|
existing.push(f);
|
||||||
|
groups.set(f.boja, existing);
|
||||||
|
});
|
||||||
|
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}, [filteredFilaments]);
|
||||||
|
|
||||||
|
const handleApplyPrices = async () => {
|
||||||
|
const refillPrice = parseInt(newRefillPrice);
|
||||||
|
const spoolPrice = parseInt(newSpoolPrice);
|
||||||
|
|
||||||
|
if (isNaN(refillPrice) && isNaN(spoolPrice)) {
|
||||||
|
alert('Molimo unesite bar jednu cenu (refill ili spulna)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((refillPrice && refillPrice < 0) || (spoolPrice && spoolPrice < 0)) {
|
||||||
|
alert('Cena ne može biti negativna');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredFilaments.length === 0) {
|
||||||
|
alert('Nema filamenata koji odgovaraju filterima');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmMsg = `Želite da promenite cene za ${filteredFilaments.length} filament(a)?
|
||||||
|
${selectedMaterial ? `\nMaterijal: ${selectedMaterial}` : ''}
|
||||||
|
${selectedFinish ? `\nFiniš: ${selectedFinish}` : ''}
|
||||||
|
${!isNaN(refillPrice) ? `\nRefill cena: ${refillPrice}` : ''}
|
||||||
|
${!isNaN(spoolPrice) ? `\nSpulna cena: ${spoolPrice}` : ''}`;
|
||||||
|
|
||||||
|
if (!confirm(confirmMsg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Update each filament
|
||||||
|
await Promise.all(
|
||||||
|
filteredFilaments.map(async (filament) => {
|
||||||
|
// Parse current prices
|
||||||
|
const prices = filament.cena.split('/');
|
||||||
|
const currentRefillPrice = parseInt(prices[0]) || 3499;
|
||||||
|
const currentSpoolPrice = prices.length > 1 ? parseInt(prices[1]) || 3999 : 3999;
|
||||||
|
|
||||||
|
// Use new prices if provided, otherwise keep current
|
||||||
|
const finalRefillPrice = !isNaN(refillPrice) ? refillPrice : currentRefillPrice;
|
||||||
|
const finalSpoolPrice = !isNaN(spoolPrice) ? spoolPrice : currentSpoolPrice;
|
||||||
|
|
||||||
|
// Build price string based on quantities
|
||||||
|
let priceString = '';
|
||||||
|
if (filament.refill > 0 && filament.spulna > 0) {
|
||||||
|
priceString = `${finalRefillPrice}/${finalSpoolPrice}`;
|
||||||
|
} else if (filament.refill > 0) {
|
||||||
|
priceString = String(finalRefillPrice);
|
||||||
|
} else if (filament.spulna > 0) {
|
||||||
|
priceString = String(finalSpoolPrice);
|
||||||
|
} else {
|
||||||
|
priceString = `${finalRefillPrice}/${finalSpoolPrice}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filamentService.update(filament.id, {
|
||||||
|
tip: filament.tip,
|
||||||
|
finish: filament.finish,
|
||||||
|
boja: filament.boja,
|
||||||
|
boja_hex: filament.boja_hex,
|
||||||
|
refill: filament.refill,
|
||||||
|
spulna: filament.spulna,
|
||||||
|
cena: priceString
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
alert(`Uspešno ažurirano ${filteredFilaments.length} filament(a)!`);
|
||||||
|
setNewRefillPrice('');
|
||||||
|
setNewSpoolPrice('');
|
||||||
|
onUpdate();
|
||||||
|
setShowModal(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating prices:', error);
|
||||||
|
alert('Greška pri ažuriranju cena');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setSelectedMaterial('');
|
||||||
|
setSelectedFinish('');
|
||||||
|
setSearchTerm('');
|
||||||
|
setNewRefillPrice('');
|
||||||
|
setNewSpoolPrice('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-indigo-500 text-white rounded hover:bg-indigo-600 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Masovno editovanje cena
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Masovno editovanje cena
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-2xl"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-4 p-4 bg-gray-100 dark:bg-gray-700 rounded space-y-3">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">Filteri:</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Materijal
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedMaterial}
|
||||||
|
onChange={(e) => setSelectedMaterial(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Svi materijali</option>
|
||||||
|
{materials.map(m => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Finiš
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedFinish}
|
||||||
|
onChange={(e) => setSelectedFinish(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Svi finiševi</option>
|
||||||
|
{finishes.map(f => (
|
||||||
|
<option key={f} value={f}>{f}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Pretraga
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Pretraži boju, tip, finiš..."
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Prikazano: {filteredFilaments.length} filament(a)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price inputs */}
|
||||||
|
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Nove cene za filtrirane filamente:
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Refill cena
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newRefillPrice}
|
||||||
|
onChange={(e) => setNewRefillPrice(e.target.value)}
|
||||||
|
placeholder="Npr. 3499"
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Spulna cena
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newSpoolPrice}
|
||||||
|
onChange={(e) => setNewSpoolPrice(e.target.value)}
|
||||||
|
placeholder="Npr. 3999"
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
Napomena: Možete promeniti samo refill, samo spulna, ili obe cene. Prazna polja će zadržati postojeće cene.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="flex-1 overflow-y-auto mb-4">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
Pregled filamenata ({colorGroups.length} boja):
|
||||||
|
</h3>
|
||||||
|
{colorGroups.length === 0 ? (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||||
|
Nema filamenata koji odgovaraju filterima
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Boja</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Tip</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Finiš</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Refill</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Spulna</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Trenutna cena</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Nova cena</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{colorGroups.map(([color, filamentGroup]) =>
|
||||||
|
filamentGroup.map((f, idx) => {
|
||||||
|
const prices = f.cena.split('/');
|
||||||
|
const currentRefillPrice = parseInt(prices[0]) || 3499;
|
||||||
|
const currentSpoolPrice = prices.length > 1 ? parseInt(prices[1]) || 3999 : 3999;
|
||||||
|
|
||||||
|
const refillPrice = parseInt(newRefillPrice);
|
||||||
|
const spoolPrice = parseInt(newSpoolPrice);
|
||||||
|
|
||||||
|
const finalRefillPrice = !isNaN(refillPrice) ? refillPrice : currentRefillPrice;
|
||||||
|
const finalSpoolPrice = !isNaN(spoolPrice) ? spoolPrice : currentSpoolPrice;
|
||||||
|
|
||||||
|
let newPriceString = '';
|
||||||
|
if (f.refill > 0 && f.spulna > 0) {
|
||||||
|
newPriceString = `${finalRefillPrice}/${finalSpoolPrice}`;
|
||||||
|
} else if (f.refill > 0) {
|
||||||
|
newPriceString = String(finalRefillPrice);
|
||||||
|
} else if (f.spulna > 0) {
|
||||||
|
newPriceString = String(finalSpoolPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceChanged = newPriceString !== f.cena;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${f.id}-${idx}`}
|
||||||
|
className={`border-b dark:border-gray-700 ${priceChanged ? 'bg-yellow-50 dark:bg-yellow-900/20' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-gray-900 dark:text-white">{f.boja}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.tip}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.finish}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.refill}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.spulna}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.cena}</td>
|
||||||
|
<td className={`px-3 py-2 font-semibold ${priceChanged ? 'text-green-600 dark:text-green-400' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||||
|
{newPriceString || f.cena}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between gap-4 pt-4 border-t dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={resetFilters}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Resetuj filtere
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Zatvori
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleApplyPrices}
|
||||||
|
disabled={loading || filteredFilaments.length === 0}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Čuvanje...' : `Primeni cene (${filteredFilaments.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
src/components/BulkPriceEditor.tsx
Normal file
288
src/components/BulkPriceEditor.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { colorService } from '@/src/services/api';
|
||||||
|
|
||||||
|
interface Color {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hex: string;
|
||||||
|
cena_refill?: number;
|
||||||
|
cena_spulna?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkPriceEditorProps {
|
||||||
|
colors: Color[];
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkPriceEditor({ colors, onUpdate }: BulkPriceEditorProps) {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [priceChanges, setPriceChanges] = useState<Record<string, { cena_refill?: number; cena_spulna?: number }>>({});
|
||||||
|
const [globalRefillPrice, setGlobalRefillPrice] = useState<string>('');
|
||||||
|
const [globalSpulnaPrice, setGlobalSpulnaPrice] = useState<string>('');
|
||||||
|
|
||||||
|
// Filter colors based on search
|
||||||
|
const filteredColors = colors.filter(color =>
|
||||||
|
color.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply global price to all filtered colors
|
||||||
|
const applyGlobalRefillPrice = () => {
|
||||||
|
if (!globalRefillPrice) return;
|
||||||
|
const price = parseInt(globalRefillPrice);
|
||||||
|
if (isNaN(price) || price < 0) return;
|
||||||
|
|
||||||
|
const updates: Record<string, { cena_refill?: number; cena_spulna?: number }> = { ...priceChanges };
|
||||||
|
filteredColors.forEach(color => {
|
||||||
|
updates[color.id] = {
|
||||||
|
...updates[color.id],
|
||||||
|
cena_refill: price
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPriceChanges(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyGlobalSpulnaPrice = () => {
|
||||||
|
if (!globalSpulnaPrice) return;
|
||||||
|
const price = parseInt(globalSpulnaPrice);
|
||||||
|
if (isNaN(price) || price < 0) return;
|
||||||
|
|
||||||
|
const updates: Record<string, { cena_refill?: number; cena_spulna?: number }> = { ...priceChanges };
|
||||||
|
filteredColors.forEach(color => {
|
||||||
|
updates[color.id] = {
|
||||||
|
...updates[color.id],
|
||||||
|
cena_spulna: price
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPriceChanges(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update individual color price
|
||||||
|
const updatePrice = (colorId: string, field: 'cena_refill' | 'cena_spulna', value: string) => {
|
||||||
|
const price = parseInt(value);
|
||||||
|
if (value === '' || (price >= 0 && !isNaN(price))) {
|
||||||
|
setPriceChanges(prev => ({
|
||||||
|
...prev,
|
||||||
|
[colorId]: {
|
||||||
|
...prev[colorId],
|
||||||
|
[field]: value === '' ? undefined : price
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get effective price (with changes or original)
|
||||||
|
const getEffectivePrice = (color: Color, field: 'cena_refill' | 'cena_spulna'): number => {
|
||||||
|
return priceChanges[color.id]?.[field] ?? color[field] ?? (field === 'cena_refill' ? 3499 : 3999);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save all changes
|
||||||
|
const handleSave = async () => {
|
||||||
|
const colorUpdates = Object.entries(priceChanges).filter(([_, changes]) =>
|
||||||
|
changes.cena_refill !== undefined || changes.cena_spulna !== undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (colorUpdates.length === 0) {
|
||||||
|
alert('Nema promena za čuvanje');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Želite da sačuvate promene za ${colorUpdates.length} boja?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Update each color individually
|
||||||
|
await Promise.all(
|
||||||
|
colorUpdates.map(([colorId, changes]) => {
|
||||||
|
const color = colors.find(c => c.id === colorId);
|
||||||
|
if (!color) return Promise.resolve();
|
||||||
|
|
||||||
|
return colorService.update(colorId, {
|
||||||
|
name: color.name,
|
||||||
|
hex: color.hex,
|
||||||
|
cena_refill: changes.cena_refill ?? color.cena_refill,
|
||||||
|
cena_spulna: changes.cena_spulna ?? color.cena_spulna
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
alert(`Uspešno ažurirano ${colorUpdates.length} boja!`);
|
||||||
|
setPriceChanges({});
|
||||||
|
setGlobalRefillPrice('');
|
||||||
|
setGlobalSpulnaPrice('');
|
||||||
|
setSearchTerm('');
|
||||||
|
onUpdate();
|
||||||
|
setShowModal(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating prices:', error);
|
||||||
|
alert('Greška pri ažuriranju cena');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="px-4 py-2 bg-indigo-500 text-white rounded hover:bg-indigo-600"
|
||||||
|
>
|
||||||
|
Masovno editovanje cena
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Masovno editovanje cena
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global price controls */}
|
||||||
|
<div className="mb-4 p-4 bg-gray-100 dark:bg-gray-700 rounded">
|
||||||
|
<h3 className="font-semibold mb-2 text-gray-900 dark:text-white">Primeni na sve prikazane boje:</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={globalRefillPrice}
|
||||||
|
onChange={(e) => setGlobalRefillPrice(e.target.value)}
|
||||||
|
placeholder="Refill cena"
|
||||||
|
className="flex-1 px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={applyGlobalRefillPrice}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Primeni refill
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={globalSpulnaPrice}
|
||||||
|
onChange={(e) => setGlobalSpulnaPrice(e.target.value)}
|
||||||
|
placeholder="Spulna cena"
|
||||||
|
className="flex-1 px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={applyGlobalSpulnaPrice}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Primeni spulna
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Pretraži boje..."
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Prikazano: {filteredColors.length} od {colors.length} boja
|
||||||
|
{Object.keys(priceChanges).length > 0 && ` · ${Object.keys(priceChanges).length} promena`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color list */}
|
||||||
|
<div className="flex-1 overflow-y-auto mb-4">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-gray-900 dark:text-white">Boja</th>
|
||||||
|
<th className="px-4 py-2 text-left text-gray-900 dark:text-white">Refill cena</th>
|
||||||
|
<th className="px-4 py-2 text-left text-gray-900 dark:text-white">Spulna cena</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredColors.map(color => {
|
||||||
|
const hasChanges = priceChanges[color.id] !== undefined;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={color.id}
|
||||||
|
className={`border-b dark:border-gray-700 ${hasChanges ? 'bg-yellow-50 dark:bg-yellow-900/20' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded border border-gray-300"
|
||||||
|
style={{ backgroundColor: color.hex }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-900 dark:text-white">{color.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={getEffectivePrice(color, 'cena_refill')}
|
||||||
|
onChange={(e) => updatePrice(color.id, 'cena_refill', e.target.value)}
|
||||||
|
className="w-full px-2 py-1 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={getEffectivePrice(color, 'cena_spulna')}
|
||||||
|
onChange={(e) => updatePrice(color.id, 'cena_spulna', e.target.value)}
|
||||||
|
className="w-full px-2 py-1 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPriceChanges({});
|
||||||
|
setGlobalRefillPrice('');
|
||||||
|
setGlobalSpulnaPrice('');
|
||||||
|
}}
|
||||||
|
disabled={loading || Object.keys(priceChanges).length === 0}
|
||||||
|
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Poništi promene
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Zatvori
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading || Object.keys(priceChanges).length === 0}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Čuvanje...' : `Sačuvaj promene (${Object.keys(priceChanges).length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/components/ColorRequestForm.tsx
Normal file
190
src/components/ColorRequestForm.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { colorRequestService } from '@/src/services/api';
|
||||||
|
|
||||||
|
interface ColorRequestFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
color_name: '',
|
||||||
|
material_type: 'PLA',
|
||||||
|
finish_type: 'Basic',
|
||||||
|
user_name: '',
|
||||||
|
user_email: '',
|
||||||
|
user_phone: '',
|
||||||
|
description: '',
|
||||||
|
reference_url: ''
|
||||||
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await colorRequestService.submit(formData);
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: 'Vaš zahtev je uspešno poslat!'
|
||||||
|
});
|
||||||
|
setFormData({
|
||||||
|
color_name: '',
|
||||||
|
material_type: 'PLA',
|
||||||
|
finish_type: 'Basic',
|
||||||
|
user_name: '',
|
||||||
|
user_email: '',
|
||||||
|
user_phone: '',
|
||||||
|
description: '',
|
||||||
|
reference_url: ''
|
||||||
|
});
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: 'Greška pri slanju zahteva. Pokušajte ponovo.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-6 text-gray-800 dark:text-gray-100">Zatraži Novu Boju</h2>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`mb-4 p-4 rounded ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||||
|
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="color_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Naziv Boje *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="color_name"
|
||||||
|
name="color_name"
|
||||||
|
required
|
||||||
|
value={formData.color_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
|
||||||
|
placeholder="npr. Sunset Orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="material_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Tip Materijala *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="material_type"
|
||||||
|
name="material_type"
|
||||||
|
required
|
||||||
|
value={formData.material_type}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
|
||||||
|
>
|
||||||
|
<option value="PLA">PLA</option>
|
||||||
|
<option value="PETG">PETG</option>
|
||||||
|
<option value="ABS">ABS</option>
|
||||||
|
<option value="TPU">TPU</option>
|
||||||
|
<option value="PLA-CF">PLA-CF</option>
|
||||||
|
<option value="PETG-CF">PETG-CF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="finish_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Tip Finiša
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="finish_type"
|
||||||
|
name="finish_type"
|
||||||
|
value={formData.finish_type}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
|
||||||
|
>
|
||||||
|
<option value="Basic">Basic</option>
|
||||||
|
<option value="Matte">Matte</option>
|
||||||
|
<option value="Silk">Silk</option>
|
||||||
|
<option value="Metal">Metal</option>
|
||||||
|
<option value="Sparkle">Sparkle</option>
|
||||||
|
<option value="Glow">Glow</option>
|
||||||
|
<option value="Transparent">Transparent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Email *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="user_email"
|
||||||
|
name="user_email"
|
||||||
|
required
|
||||||
|
value={formData.user_email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
|
||||||
|
placeholder="Za obaveštenja o statusu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="user_phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Telefon *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="user_phone"
|
||||||
|
name="user_phone"
|
||||||
|
required
|
||||||
|
value={formData.user_phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
|
||||||
|
placeholder="063 123 4567"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`px-6 py-2 rounded-md text-white font-medium ${
|
||||||
|
isSubmitting
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-purple-600 hover:bg-purple-700'
|
||||||
|
} transition-colors`}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Slanje...' : 'Pošalji Zahtev'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
src/components/ColorRequestModal.tsx
Normal file
232
src/components/ColorRequestModal.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { colorRequestService } from '@/src/services/api';
|
||||||
|
|
||||||
|
interface ColorRequestModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColorRequestModal({ isOpen, onClose }: ColorRequestModalProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
color_name: '',
|
||||||
|
material_type: 'PLA',
|
||||||
|
finish_type: 'Basic',
|
||||||
|
user_email: '',
|
||||||
|
user_phone: ''
|
||||||
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
// Reset form when modal closes
|
||||||
|
setFormData({
|
||||||
|
color_name: '',
|
||||||
|
material_type: 'PLA',
|
||||||
|
finish_type: 'Basic',
|
||||||
|
user_email: '',
|
||||||
|
user_phone: ''
|
||||||
|
});
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await colorRequestService.submit(formData);
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: 'Vaš zahtev je uspešno poslat!'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal after 2 seconds on success
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: 'Greška pri slanju zahteva. Pokušajte ponovo.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">
|
||||||
|
Zatraži Novu Boju
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Ne možete pronaći boju koju tražite? Javite nam!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`mb-4 p-3 rounded text-sm ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||||
|
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="color_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Naziv Boje *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="color_name"
|
||||||
|
name="color_name"
|
||||||
|
required
|
||||||
|
value={formData.color_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
|
||||||
|
placeholder="npr. Sunset Orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="material_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Materijal *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="material_type"
|
||||||
|
name="material_type"
|
||||||
|
required
|
||||||
|
value={formData.material_type}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
|
||||||
|
>
|
||||||
|
<option value="PLA">PLA</option>
|
||||||
|
<option value="PETG">PETG</option>
|
||||||
|
<option value="ABS">ABS</option>
|
||||||
|
<option value="TPU">TPU</option>
|
||||||
|
<option value="PLA-CF">PLA-CF</option>
|
||||||
|
<option value="PETG-CF">PETG-CF</option>
|
||||||
|
<option value="Other">Ostalo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="finish_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Finiš
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="finish_type"
|
||||||
|
name="finish_type"
|
||||||
|
value={formData.finish_type}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
|
||||||
|
>
|
||||||
|
<option value="Basic">Basic</option>
|
||||||
|
<option value="Matte">Matte</option>
|
||||||
|
<option value="Silk">Silk</option>
|
||||||
|
<option value="Metal">Metal</option>
|
||||||
|
<option value="Sparkle">Sparkle</option>
|
||||||
|
<option value="Glow">Glow</option>
|
||||||
|
<option value="Transparent">Transparent</option>
|
||||||
|
<option value="Other">Ostalo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Email *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="user_email"
|
||||||
|
name="user_email"
|
||||||
|
required
|
||||||
|
value={formData.user_email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
|
||||||
|
placeholder="Za obaveštenja o statusu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="user_phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Telefon *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="user_phone"
|
||||||
|
name="user_phone"
|
||||||
|
required
|
||||||
|
value={formData.user_phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
|
||||||
|
placeholder="Za kontakt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
Otkaži
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`px-6 py-2 rounded-md text-white font-medium ${
|
||||||
|
isSubmitting
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-purple-600 hover:bg-purple-700'
|
||||||
|
} transition-colors`}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Slanje...' : 'Pošalji'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import '@/src/styles/select.css';
|
import '@/src/styles/select.css';
|
||||||
import { trackEvent } from './MatomoAnalytics';
|
import { trackEvent } from './MatomoAnalytics';
|
||||||
|
import { getFinishesForMaterial, getAllFinishes, getMaterialOptions } from '@/src/data/bambuLabCatalog';
|
||||||
|
import { Filament } from '@/src/types/filament';
|
||||||
|
|
||||||
interface EnhancedFiltersProps {
|
interface EnhancedFiltersProps {
|
||||||
filters: {
|
filters: {
|
||||||
@@ -8,22 +10,74 @@ interface EnhancedFiltersProps {
|
|||||||
finish: string;
|
finish: string;
|
||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
onFilterChange: (filters: any) => void;
|
onFilterChange: (filters: { material: string; finish: string; color: string }) => void;
|
||||||
uniqueValues: {
|
uniqueValues: {
|
||||||
materials: string[];
|
materials: string[];
|
||||||
finishes: string[];
|
finishes: string[];
|
||||||
colors: string[];
|
colors: string[];
|
||||||
};
|
};
|
||||||
|
inventoryFilaments?: Filament[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||||
filters,
|
filters,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
uniqueValues
|
uniqueValues,
|
||||||
|
inventoryFilaments = []
|
||||||
}) => {
|
}) => {
|
||||||
// Check if any filters are active
|
|
||||||
const hasActiveFilters = filters.material || filters.finish || filters.color;
|
const hasActiveFilters = filters.material || filters.finish || filters.color;
|
||||||
|
|
||||||
|
// Catalog-aware material list (static from catalog)
|
||||||
|
const materialOptions = useMemo(() => getMaterialOptions(), []);
|
||||||
|
|
||||||
|
// Finish options: conditional on selected material
|
||||||
|
const finishOptions = useMemo(() => {
|
||||||
|
const inStock = inventoryFilaments.filter(f => f.kolicina > 0);
|
||||||
|
if (filters.material) {
|
||||||
|
// Show finishes from catalog for this material, but only if they exist in inventory
|
||||||
|
const catalogFinishes = getFinishesForMaterial(filters.material);
|
||||||
|
const inventoryFinishes = new Set(
|
||||||
|
inStock.filter(f => f.tip === filters.material).map(f => f.finish)
|
||||||
|
);
|
||||||
|
return catalogFinishes.filter(f => inventoryFinishes.has(f));
|
||||||
|
}
|
||||||
|
// No material selected: show all finishes from inventory
|
||||||
|
if (inStock.length > 0) {
|
||||||
|
return [...new Set(inStock.map(f => f.finish))].sort();
|
||||||
|
}
|
||||||
|
return getAllFinishes();
|
||||||
|
}, [filters.material, inventoryFilaments]);
|
||||||
|
|
||||||
|
// Color options: conditional on selected material + finish
|
||||||
|
const colorOptions = useMemo(() => {
|
||||||
|
const inStock = inventoryFilaments.filter(f => f.kolicina > 0);
|
||||||
|
let filtered = inStock;
|
||||||
|
if (filters.material) {
|
||||||
|
filtered = filtered.filter(f => f.tip === filters.material);
|
||||||
|
}
|
||||||
|
if (filters.finish) {
|
||||||
|
filtered = filtered.filter(f => f.finish === filters.finish);
|
||||||
|
}
|
||||||
|
return [...new Set(filtered.map(f => f.boja))].sort();
|
||||||
|
}, [filters.material, filters.finish, inventoryFilaments]);
|
||||||
|
|
||||||
|
const handleMaterialChange = (value: string) => {
|
||||||
|
// Reset finish and color when material changes
|
||||||
|
onFilterChange({ material: value, finish: '', color: '' });
|
||||||
|
trackEvent('Filter', 'Material', value || 'All');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinishChange = (value: string) => {
|
||||||
|
// Reset color when finish changes
|
||||||
|
onFilterChange({ ...filters, finish: value, color: '' });
|
||||||
|
trackEvent('Filter', 'Finish', value || 'All');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorChange = (value: string) => {
|
||||||
|
onFilterChange({ ...filters, color: value });
|
||||||
|
trackEvent('Filter', 'Color', value || 'All');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
{/* Filters Grid */}
|
{/* Filters Grid */}
|
||||||
@@ -35,26 +89,15 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.material}
|
value={filters.material}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleMaterialChange(e.target.value)}
|
||||||
onFilterChange({ ...filters, material: e.target.value });
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||||
trackEvent('Filter', 'Material', e.target.value || 'All');
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||||
}}
|
|
||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
||||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Svi materijali</option>
|
<option value="">Svi materijali</option>
|
||||||
<option value="ABS">ABS</option>
|
{materialOptions.map(mat => (
|
||||||
<option value="ASA">ASA</option>
|
<option key={mat} value={mat}>{mat}</option>
|
||||||
<option value="PA6">PA6</option>
|
))}
|
||||||
<option value="PAHT">PAHT</option>
|
|
||||||
<option value="PC">PC</option>
|
|
||||||
<option value="PET">PET</option>
|
|
||||||
<option value="PETG">PETG</option>
|
|
||||||
<option value="PLA">PLA</option>
|
|
||||||
<option value="PPA">PPA</option>
|
|
||||||
<option value="PPS">PPS</option>
|
|
||||||
<option value="TPU">TPU</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,35 +108,15 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.finish}
|
value={filters.finish}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleFinishChange(e.target.value)}
|
||||||
onFilterChange({ ...filters, finish: e.target.value });
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||||
trackEvent('Filter', 'Finish', e.target.value || 'All');
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||||
}}
|
|
||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
||||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Svi finish tipovi</option>
|
<option value="">Svi finish tipovi</option>
|
||||||
<option value="85A">85A</option>
|
{finishOptions.map(finish => (
|
||||||
<option value="90A">90A</option>
|
<option key={finish} value={finish}>{finish}</option>
|
||||||
<option value="95A HF">95A HF</option>
|
))}
|
||||||
<option value="Aero">Aero</option>
|
|
||||||
<option value="Basic">Basic</option>
|
|
||||||
<option value="Basic Gradient">Basic Gradient</option>
|
|
||||||
<option value="CF">CF</option>
|
|
||||||
<option value="FR">FR</option>
|
|
||||||
<option value="Galaxy">Galaxy</option>
|
|
||||||
<option value="GF">GF</option>
|
|
||||||
<option value="Glow">Glow</option>
|
|
||||||
<option value="HF">HF</option>
|
|
||||||
<option value="Marble">Marble</option>
|
|
||||||
<option value="Matte">Matte</option>
|
|
||||||
<option value="Metal">Metal</option>
|
|
||||||
<option value="Silk Multi-Color">Silk Multi-Color</option>
|
|
||||||
<option value="Silk+">Silk+</option>
|
|
||||||
<option value="Sparkle">Sparkle</option>
|
|
||||||
<option value="Translucent">Translucent</option>
|
|
||||||
<option value="Wood">Wood</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,16 +127,13 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.color}
|
value={filters.color}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleColorChange(e.target.value)}
|
||||||
onFilterChange({ ...filters, color: e.target.value });
|
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||||
trackEvent('Filter', 'Color', e.target.value || 'All');
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||||
}}
|
|
||||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
|
||||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
|
||||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Sve boje</option>
|
<option value="">Sve boje</option>
|
||||||
{uniqueValues.colors.map(color => (
|
{colorOptions.map(color => (
|
||||||
<option key={color} value={color}>{color}</option>
|
<option key={color} value={color}>{color}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -141,4 +161,4 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -141,10 +141,11 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enhanced Filters */}
|
{/* Enhanced Filters */}
|
||||||
<EnhancedFilters
|
<EnhancedFilters
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFilterChange={setFilters}
|
onFilterChange={setFilters}
|
||||||
uniqueValues={uniqueValues}
|
uniqueValues={uniqueValues}
|
||||||
|
inventoryFilaments={filaments}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
@@ -157,16 +158,16 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
|
|||||||
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th onClick={() => handleSort('finish')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
<th onClick={() => handleSort('finish')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Finiš {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th onClick={() => handleSort('boja')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
<th onClick={() => handleSort('boja')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th onClick={() => handleSort('refill')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
<th onClick={() => handleSort('refill')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th onClick={() => handleSort('spulna')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
<th onClick={() => handleSort('spulna')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Špulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th onClick={() => handleSort('kolicina')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
<th onClick={() => handleSort('kolicina')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}
|
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
|
|||||||
489
src/data/bambuLabCatalog.ts
Normal file
489
src/data/bambuLabCatalog.ts
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
// Master Bambu Lab product catalog — single source of truth
|
||||||
|
// Material → Finish → Color[] with refill/spool availability
|
||||||
|
|
||||||
|
export interface CatalogColorEntry {
|
||||||
|
name: string;
|
||||||
|
refill: boolean;
|
||||||
|
spool: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogFinish {
|
||||||
|
colors: CatalogColorEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BambuLabCatalog = Record<string, Record<string, CatalogFinish>>;
|
||||||
|
|
||||||
|
export const BAMBU_LAB_CATALOG: BambuLabCatalog = {
|
||||||
|
PLA: {
|
||||||
|
Basic: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Jade White', refill: true, spool: true },
|
||||||
|
{ name: 'Black', refill: true, spool: true },
|
||||||
|
{ name: 'Red', refill: true, spool: true },
|
||||||
|
{ name: 'Bambu Green', refill: true, spool: true },
|
||||||
|
{ name: 'Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Scarlet Red', refill: true, spool: true },
|
||||||
|
{ name: 'Lemon Yellow', refill: true, spool: true },
|
||||||
|
{ name: 'Cyan', refill: true, spool: true },
|
||||||
|
{ name: 'Sakura Pink', refill: true, spool: true },
|
||||||
|
{ name: 'Cobalt Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Mistletoe Green', refill: true, spool: true },
|
||||||
|
{ name: 'Dark Red', refill: true, spool: true },
|
||||||
|
{ name: 'Hot Pink', refill: true, spool: true },
|
||||||
|
{ name: 'Lavender', refill: true, spool: true },
|
||||||
|
{ name: 'Light Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Sky Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Sunflower Yellow', refill: true, spool: true },
|
||||||
|
{ name: 'Pumpkin Orange', refill: true, spool: true },
|
||||||
|
{ name: 'Lime', refill: true, spool: true },
|
||||||
|
{ name: 'Blue Grey', refill: true, spool: false },
|
||||||
|
{ name: 'Beige', refill: true, spool: false },
|
||||||
|
{ name: 'Light Gray', refill: true, spool: false },
|
||||||
|
{ name: 'Yellow', refill: true, spool: false },
|
||||||
|
{ name: 'Orange', refill: true, spool: false },
|
||||||
|
{ name: 'Gold', refill: true, spool: false },
|
||||||
|
{ name: 'Bright Green', refill: true, spool: false },
|
||||||
|
{ name: 'Pink', refill: true, spool: false },
|
||||||
|
{ name: 'Magenta', refill: true, spool: false },
|
||||||
|
{ name: 'Maroon Red', refill: true, spool: false },
|
||||||
|
{ name: 'Purple', refill: true, spool: false },
|
||||||
|
{ name: 'Turquoise', refill: true, spool: false },
|
||||||
|
{ name: 'Brown', refill: true, spool: false },
|
||||||
|
{ name: 'Bronze', refill: true, spool: false },
|
||||||
|
{ name: 'Silver', refill: true, spool: false },
|
||||||
|
{ name: 'Dark Gray', refill: true, spool: false },
|
||||||
|
{ name: 'Indigo Purple', refill: true, spool: false },
|
||||||
|
{ name: 'Cocoa Brown', refill: true, spool: false },
|
||||||
|
{ name: 'Gray', refill: true, spool: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Basic Gradient': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Arctic Whisper', refill: false, spool: true },
|
||||||
|
{ name: 'Cotton Candy Cloud', refill: false, spool: true },
|
||||||
|
{ name: 'Ocean to Meadow', refill: false, spool: true },
|
||||||
|
{ name: 'Solar Breeze', refill: false, spool: true },
|
||||||
|
{ name: 'Dusk Glare', refill: false, spool: true },
|
||||||
|
{ name: 'Blueberry Bubblegum', refill: false, spool: true },
|
||||||
|
{ name: 'Pink Citrus', refill: false, spool: true },
|
||||||
|
{ name: 'Mint Lime', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Matte: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Matte Ivory White', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Charcoal', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Scarlet Red', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Marine Blue', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Mandarin Orange', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Ash Gray', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Desert Tan', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Nardo Gray', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Apple Green', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Bone White', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Caramel', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Dark Blue', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Dark Brown', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Dark Chocolate', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Dark Green', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Dark Red', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Grass Green', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Ice Blue', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Lemon Yellow', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Lilac Purple', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Plum', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Sakura Pink', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Sky Blue', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Latte Brown', refill: true, spool: false },
|
||||||
|
{ name: 'Matte Terracotta', refill: true, spool: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Silk+': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Candy Green', refill: false, spool: true },
|
||||||
|
{ name: 'Candy Red', refill: false, spool: true },
|
||||||
|
{ name: 'Mint', refill: false, spool: true },
|
||||||
|
{ name: 'Titan Gray', refill: false, spool: true },
|
||||||
|
{ name: 'Rose Gold', refill: false, spool: true },
|
||||||
|
{ name: 'Champagne', refill: false, spool: true },
|
||||||
|
{ name: 'Baby Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Gold', refill: false, spool: true },
|
||||||
|
{ name: 'Silver', refill: false, spool: true },
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
{ name: 'Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Purple', refill: false, spool: true },
|
||||||
|
{ name: 'Pink', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Silk Multi-Color': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Aurora Purple', refill: false, spool: true },
|
||||||
|
{ name: 'Phantom Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Mystic Magenta', refill: false, spool: true },
|
||||||
|
{ name: 'Dawn Radiance', refill: false, spool: true },
|
||||||
|
{ name: 'South Beach', refill: false, spool: true },
|
||||||
|
{ name: 'Neon City', refill: false, spool: true },
|
||||||
|
{ name: 'Midnight Blaze', refill: false, spool: true },
|
||||||
|
{ name: 'Gilded Rose', refill: false, spool: true },
|
||||||
|
{ name: 'Blue Hawaii', refill: false, spool: true },
|
||||||
|
{ name: 'Velvet Eclipse', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Metal: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Iron Gray Metallic', refill: false, spool: true },
|
||||||
|
{ name: 'Iridium Gold Metallic', refill: false, spool: true },
|
||||||
|
{ name: 'Cobalt Blue Metallic', refill: false, spool: true },
|
||||||
|
{ name: 'Copper Brown Metallic', refill: false, spool: true },
|
||||||
|
{ name: 'Oxide Green Metallic', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Sparkle: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Onyx Black Sparkle', refill: false, spool: true },
|
||||||
|
{ name: 'Classic Gold Sparkle', refill: false, spool: true },
|
||||||
|
{ name: 'Crimson Red Sparkle', refill: false, spool: true },
|
||||||
|
{ name: 'Royal Purple Sparkle', refill: false, spool: true },
|
||||||
|
{ name: 'Slate Gray Sparkle', refill: false, spool: true },
|
||||||
|
{ name: 'Alpine Green Sparkle', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Galaxy: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Nebulae', refill: true, spool: true },
|
||||||
|
{ name: 'Purple', refill: false, spool: true },
|
||||||
|
{ name: 'Green', refill: false, spool: true },
|
||||||
|
{ name: 'Brown', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Marble: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'White Marble', refill: false, spool: true },
|
||||||
|
{ name: 'Red Granite', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Glow: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Glow Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Glow Green', refill: false, spool: true },
|
||||||
|
{ name: 'Glow Orange', refill: false, spool: true },
|
||||||
|
{ name: 'Glow Pink', refill: false, spool: true },
|
||||||
|
{ name: 'Glow Yellow', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Wood: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Ochre Yellow', refill: false, spool: true },
|
||||||
|
{ name: 'White Oak', refill: false, spool: true },
|
||||||
|
{ name: 'Clay Brown', refill: false, spool: true },
|
||||||
|
{ name: 'Black Walnut', refill: false, spool: true },
|
||||||
|
{ name: 'Rosewood', refill: false, spool: true },
|
||||||
|
{ name: 'Classic Birch', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Tough+': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: true, spool: true },
|
||||||
|
{ name: 'White', refill: true, spool: true },
|
||||||
|
{ name: 'Yellow', refill: true, spool: true },
|
||||||
|
{ name: 'Orange', refill: true, spool: true },
|
||||||
|
{ name: 'Gray', refill: true, spool: true },
|
||||||
|
{ name: 'Silver', refill: true, spool: true },
|
||||||
|
{ name: 'Cyan', refill: true, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Translucent: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Teal', refill: false, spool: true },
|
||||||
|
{ name: 'Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Orange', refill: false, spool: true },
|
||||||
|
{ name: 'Purple', refill: false, spool: true },
|
||||||
|
{ name: 'Red', refill: false, spool: true },
|
||||||
|
{ name: 'Light Jade', refill: false, spool: true },
|
||||||
|
{ name: 'Mellow Yellow', refill: false, spool: true },
|
||||||
|
{ name: 'Cherry Pink', refill: false, spool: true },
|
||||||
|
{ name: 'Ice Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Lavender', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Aero: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
{ name: 'Gray', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'Burgundy Red', refill: false, spool: true },
|
||||||
|
{ name: 'Jeans Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Lava Gray', refill: false, spool: true },
|
||||||
|
{ name: 'Matcha Green', refill: false, spool: true },
|
||||||
|
{ name: 'Royal Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Iris Purple', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PETG: {
|
||||||
|
HF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: true, spool: true },
|
||||||
|
{ name: 'White', refill: true, spool: true },
|
||||||
|
{ name: 'Red', refill: true, spool: true },
|
||||||
|
{ name: 'Green', refill: true, spool: true },
|
||||||
|
{ name: 'Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Gray', refill: true, spool: true },
|
||||||
|
{ name: 'Dark Gray', refill: true, spool: true },
|
||||||
|
{ name: 'Yellow', refill: true, spool: true },
|
||||||
|
{ name: 'Orange', refill: true, spool: true },
|
||||||
|
{ name: 'Cream', refill: true, spool: true },
|
||||||
|
{ name: 'Forest Green', refill: true, spool: true },
|
||||||
|
{ name: 'Lake Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Lime Green', refill: true, spool: true },
|
||||||
|
{ name: 'Peanut Brown', refill: true, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Translucent: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Clear', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Gray', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Brown', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Purple', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Orange', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Olive', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Pink', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Light Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Translucent Teal', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'Brick Red', refill: false, spool: true },
|
||||||
|
{ name: 'Indigo Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Malachite Green', refill: false, spool: true },
|
||||||
|
{ name: 'Titan Gray', refill: false, spool: true },
|
||||||
|
{ name: 'Violet Purple', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ABS: {
|
||||||
|
Basic: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'ABS Azure', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Black', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Blue', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Olive', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Tangerine Yellow', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Navy Blue', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Orange', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Bambu Green', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Red', refill: true, spool: true },
|
||||||
|
{ name: 'ABS White', refill: true, spool: true },
|
||||||
|
{ name: 'ABS Silver', refill: true, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
GF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'ABS GF Yellow', refill: true, spool: false },
|
||||||
|
{ name: 'ABS GF Orange', refill: true, spool: false },
|
||||||
|
{ name: 'Orange', refill: true, spool: false },
|
||||||
|
{ name: 'Red', refill: true, spool: false },
|
||||||
|
{ name: 'Blue', refill: true, spool: false },
|
||||||
|
{ name: 'White', refill: true, spool: false },
|
||||||
|
{ name: 'Gray', refill: true, spool: false },
|
||||||
|
{ name: 'Black', refill: true, spool: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TPU: {
|
||||||
|
'85A': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'Flesh', refill: false, spool: true },
|
||||||
|
{ name: 'Light Cyan', refill: false, spool: true },
|
||||||
|
{ name: 'Neon Orange', refill: false, spool: true },
|
||||||
|
{ name: 'Lime Green', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'90A': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
{ name: 'Red', refill: false, spool: true },
|
||||||
|
{ name: 'Blaze', refill: false, spool: true },
|
||||||
|
{ name: 'Frozen', refill: false, spool: true },
|
||||||
|
{ name: 'Grape Jelly', refill: false, spool: true },
|
||||||
|
{ name: 'Crystal Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Quicksilver', refill: false, spool: true },
|
||||||
|
{ name: 'Cocoa Brown', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'95A HF': {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: true, spool: false },
|
||||||
|
{ name: 'White', refill: true, spool: false },
|
||||||
|
{ name: 'TPU 95A HF Yellow', refill: true, spool: false },
|
||||||
|
{ name: 'Gray', refill: true, spool: false },
|
||||||
|
{ name: 'Yellow', refill: true, spool: false },
|
||||||
|
{ name: 'Blue', refill: true, spool: false },
|
||||||
|
{ name: 'Red', refill: true, spool: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'TPU for AMS': {
|
||||||
|
Basic: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Red', refill: false, spool: true },
|
||||||
|
{ name: 'Yellow', refill: false, spool: true },
|
||||||
|
{ name: 'Neon Green', refill: false, spool: true },
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
{ name: 'Gray', refill: false, spool: true },
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ASA: {
|
||||||
|
Basic: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: true, spool: true },
|
||||||
|
{ name: 'Blue', refill: true, spool: true },
|
||||||
|
{ name: 'Gray', refill: true, spool: true },
|
||||||
|
{ name: 'Green', refill: true, spool: true },
|
||||||
|
{ name: 'Red', refill: true, spool: true },
|
||||||
|
{ name: 'White', refill: true, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Aero: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PA6: {
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
GF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'Blue', refill: false, spool: true },
|
||||||
|
{ name: 'Orange', refill: false, spool: true },
|
||||||
|
{ name: 'Yellow', refill: false, spool: true },
|
||||||
|
{ name: 'Lime', refill: false, spool: true },
|
||||||
|
{ name: 'Brown', refill: false, spool: true },
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
{ name: 'Gray', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PAHT: {
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PC: {
|
||||||
|
Basic: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Clear Black', refill: false, spool: true },
|
||||||
|
{ name: 'Transparent', refill: false, spool: true },
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FR: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
{ name: 'White', refill: false, spool: true },
|
||||||
|
{ name: 'Gray', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PET: {
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PPA: {
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PPS: {
|
||||||
|
CF: {
|
||||||
|
colors: [
|
||||||
|
{ name: 'Black', refill: false, spool: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
export function getMaterialOptions(): string[] {
|
||||||
|
return Object.keys(BAMBU_LAB_CATALOG).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFinishesForMaterial(material: string): string[] {
|
||||||
|
const materialData = BAMBU_LAB_CATALOG[material];
|
||||||
|
if (!materialData) return [];
|
||||||
|
return Object.keys(materialData).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllFinishes(): string[] {
|
||||||
|
const finishes = new Set<string>();
|
||||||
|
for (const material of Object.values(BAMBU_LAB_CATALOG)) {
|
||||||
|
for (const finish of Object.keys(material)) {
|
||||||
|
finishes.add(finish);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...finishes].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorsForMaterialFinish(material: string, finish: string): CatalogColorEntry[] {
|
||||||
|
return BAMBU_LAB_CATALOG[material]?.[finish]?.colors ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorsForMaterial(material: string): CatalogColorEntry[] {
|
||||||
|
const materialData = BAMBU_LAB_CATALOG[material];
|
||||||
|
if (!materialData) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: CatalogColorEntry[] = [];
|
||||||
|
for (const finish of Object.values(materialData)) {
|
||||||
|
for (const color of finish.colors) {
|
||||||
|
if (!seen.has(color.name)) {
|
||||||
|
seen.add(color.name);
|
||||||
|
result.push(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogIsSpoolOnly(material: string, finish: string, color: string): boolean {
|
||||||
|
const entry = BAMBU_LAB_CATALOG[material]?.[finish]?.colors.find(c => c.name === color);
|
||||||
|
return entry ? (entry.spool && !entry.refill) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogIsRefillOnly(material: string, finish: string, color: string): boolean {
|
||||||
|
const entry = BAMBU_LAB_CATALOG[material]?.[finish]?.colors.find(c => c.name === color);
|
||||||
|
return entry ? (entry.refill && !entry.spool) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFinishOptionsForType(type: string): string[] {
|
||||||
|
return getFinishesForMaterial(type);
|
||||||
|
}
|
||||||
@@ -4,145 +4,204 @@ export interface ColorMapping {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const bambuLabColors: Record<string, ColorMapping> = {
|
export const bambuLabColors: Record<string, ColorMapping> = {
|
||||||
'Alpine Green Sparkle': { hex: '#3F5443' },
|
'Alpine Green Sparkle': { hex: '#2E7D32' },
|
||||||
'Apple Green': { hex: '#C6E188' },
|
'Apple Green': { hex: '#C6E188' },
|
||||||
'Arctic Whisper': { hex: '#ECF7F8' },
|
'Arctic Whisper': { hex: '#E0EFF6' },
|
||||||
'Ash Grey': { hex: '#9B9EA0' },
|
'Ash Grey': { hex: '#9B9EA0' },
|
||||||
'Aurora Purple': { hex: '#285BB7' },
|
'Aurora Purple': { hex: ['#7F3696', '#006EC9'], isGradient: true },
|
||||||
'Azure': { hex: '#489FDF' },
|
'Azure': { hex: '#489FDF' },
|
||||||
'Baby Blue': { hex: '#AEC3ED' },
|
'Baby Blue': { hex: '#89CFF0' },
|
||||||
'Bambu Green': { hex: '#00AE42' },
|
'Bambu Green': { hex: '#00AE42' },
|
||||||
'Beige': { hex: '#F7E6DE' },
|
'Beige': { hex: '#F7E6DE' },
|
||||||
'Black': { hex: '#000000' },
|
'Black': { hex: '#1A1A1A' },
|
||||||
'Black Walnut': { hex: '#4D4229' },
|
'Black Walnut': { hex: '#3E2723' },
|
||||||
'Blaze': { hex: '#E78390' },
|
'Blaze': { hex: '#FF5722' },
|
||||||
'Blue': { hex: '#0A2989' },
|
'Blue': { hex: '#0A2989' },
|
||||||
'Blue Grey': { hex: '#5B6579' },
|
'Blue Grey': { hex: '#7B8E97' },
|
||||||
'Blue Hawaii': { hex: '#739FE6' },
|
'Blue Hawaii': { hex: '#00C5CD' },
|
||||||
'Blueberry Bubblegum': { hex: '#BADCF4' },
|
'Blueberry Bubblegum': { hex: '#7C4DFF' },
|
||||||
'Bone White': { hex: '#C8C5B6' },
|
'Bone White': { hex: '#C8C5B6' },
|
||||||
'Bright Green': { hex: '#BDCF00' },
|
'Bright Green': { hex: '#5EC323' },
|
||||||
'Brick Red': { hex: '#9F332A' },
|
'Brick Red': { hex: '#9F332A' },
|
||||||
'Bronze': { hex: '#847D48' },
|
'Bronze': { hex: '#847D48' },
|
||||||
'Brown': { hex: '#9D432C' },
|
'Brown': { hex: '#9D432C' },
|
||||||
'Burgundy Red': { hex: '#951E23' },
|
'Burgundy Red': { hex: '#800020' },
|
||||||
'Candy Green': { hex: '#408619' },
|
'Candy Green': { hex: '#66CDAA' },
|
||||||
'Candy Red': { hex: '#BB3A2E' },
|
'Candy Red': { hex: '#E8505B' },
|
||||||
'Caramel': { hex: '#A4845C' },
|
'Caramel': { hex: '#A4845C' },
|
||||||
'Champagne': { hex: '#EBD0B1' },
|
'Champagne': { hex: '#F7E7CE' },
|
||||||
'Charcoal': { hex: '#000000' },
|
'Charcoal': { hex: '#000000' },
|
||||||
'Cherry Pink': { hex: '#E9B6CC' },
|
'Cherry Pink': { hex: '#DE3163' },
|
||||||
'Chocolate': { hex: '#4A3729' },
|
'Chocolate': { hex: '#4A3729' },
|
||||||
'Clay Brown': { hex: '#8E621A' },
|
'Classic Birch': { hex: '#D7C49E' },
|
||||||
'Clear': { hex: '#FAFAFA' },
|
'Classic Gold Sparkle': { hex: '#CFB53B' },
|
||||||
'Clear Black': { hex: '#5A5161' },
|
'Clay Brown': { hex: '#B08968' },
|
||||||
'Cobalt Blue': { hex: '#0055B8' },
|
'Clear': { hex: '#F0F0F0' },
|
||||||
'Cobalt Blue Metallic': { hex: '#39699E' },
|
'Clear Black': { hex: '#2C2C2C' },
|
||||||
'Cocoa Brown': { hex: '#6F5034' },
|
'Cobalt Blue': { hex: '#0047AB' },
|
||||||
'Copper Brown Metallic': { hex: '#AA6443' },
|
'Cobalt Blue Metallic': { hex: '#0047AB' },
|
||||||
'Cotton Candy Cloud': { hex: '#E9E2EC' },
|
'Cocoa Brown': { hex: '#6B4332' },
|
||||||
'Cream': { hex: '#F3E0B8' },
|
'Copper Brown Metallic': { hex: '#B87333' },
|
||||||
'Crimson Red Sparkle': { hex: '#792B36' },
|
'Cotton Candy Cloud': { hex: '#F8BBD0' },
|
||||||
|
'Cream': { hex: '#F5E6C8' },
|
||||||
|
'Crimson Red Sparkle': { hex: '#B71C1C' },
|
||||||
'Cyan': { hex: '#0086D6' },
|
'Cyan': { hex: '#0086D6' },
|
||||||
'Dark Blue': { hex: '#042F56' },
|
'Dark Blue': { hex: '#042F56' },
|
||||||
'Dark Brown': { hex: '#7D6556' },
|
'Dark Brown': { hex: '#7D6556' },
|
||||||
'Dark Chocolate': { hex: '#4A3729' },
|
'Dark Chocolate': { hex: '#4A3729' },
|
||||||
'Dark Gray': { hex: '#555555' },
|
'Dark Gray': { hex: '#515151' },
|
||||||
'Dark Green': { hex: '#68724D' },
|
'Dark Green': { hex: '#68724D' },
|
||||||
'Dark Red': { hex: '#BB3D43' },
|
'Dark Red': { hex: '#BB3D43' },
|
||||||
'Dawn Radiance': { hex: '#C472A1' },
|
'Dawn Radiance': { hex: '#FFDAB9' },
|
||||||
'Desert Tan': { hex: '#E8DBB7' },
|
'Desert Tan': { hex: '#E8DBB7' },
|
||||||
'Dusk Glare': { hex: '#F6B790' },
|
'Dusk Glare': { hex: '#FF7043' },
|
||||||
'Forest Green': { hex: '#415520' },
|
'Forest Green': { hex: '#39541A' },
|
||||||
'Frozen': { hex: '#A6DEF3' },
|
'Frozen': { hex: '#E0F7FA' },
|
||||||
'Gilded Rose': { hex: '#ED982C' },
|
'Gilded Rose': { hex: '#B76E79' },
|
||||||
'Glow Blue': { hex: '#7AC0E9' },
|
'Glow Blue': { hex: '#5DADE2' },
|
||||||
'Glow Green': { hex: '#A1FFAC' },
|
'Glow Green': { hex: '#82E0AA' },
|
||||||
'Glow Orange': { hex: '#FF9D5B' },
|
'Glow Orange': { hex: '#FFB347' },
|
||||||
'Glow Pink': { hex: '#F17B8F' },
|
'Glow Pink': { hex: '#FF69B4' },
|
||||||
'Glow Yellow': { hex: '#F8FF80' },
|
'Glow Yellow': { hex: '#FFEB3B' },
|
||||||
'Gold': { hex: '#E4BD68' },
|
'Gold': { hex: '#E4BD68' },
|
||||||
'Gray': { hex: '#8E9089' },
|
'Gray': { hex: '#8E9089' },
|
||||||
'Green': { hex: '#3B665E' },
|
'Green': { hex: '#00AE42' },
|
||||||
'Hot Pink': { hex: '#F5547D' },
|
'Hot Pink': { hex: '#FF4F81' },
|
||||||
'Ice Blue': { hex: '#A3D8E1' },
|
'Ice Blue': { hex: '#A5DEE4' },
|
||||||
'Indigo Blue': { hex: '#324585' },
|
'Indigo Blue': { hex: '#324585' },
|
||||||
'Indigo Purple': { hex: '#482A60' },
|
'Indigo Purple': { hex: '#3B2D6B' },
|
||||||
'Iridium Gold Metallic': { hex: '#B39B84' },
|
'Iridium Gold Metallic': { hex: '#C5A04D' },
|
||||||
'Iris Purple': { hex: '#69398E' },
|
'Iris Purple': { hex: '#5B3A8C' },
|
||||||
'Ivory White': { hex: '#FFFFFF' },
|
'Iron Gray Metallic': { hex: '#6E6E6E' },
|
||||||
'Jade White': { hex: '#FFFFFF' },
|
'Jade White': { hex: '#FFFFFF' },
|
||||||
'Jeans Blue': { hex: '#6E88BC' },
|
'Jeans Blue': { hex: '#4A6FA5' },
|
||||||
'Lake Blue': { hex: '#4672E4' },
|
'Lake Blue': { hex: '#1F79E5' },
|
||||||
'Latte Brown': { hex: '#D3B7A7' },
|
'Latte Brown': { hex: '#D3B7A7' },
|
||||||
'Lava Gray': { hex: '#4D5054' },
|
'Lava Gray': { hex: '#5B5B5B' },
|
||||||
'Lavender': { hex: '#B5AAD5' },
|
'Lavender': { hex: '#B39DDB' },
|
||||||
'Lemon Yellow': { hex: '#F7D959' },
|
'Lemon Yellow': { hex: '#F7D959' },
|
||||||
'Light Blue': { hex: '#61B0FF' },
|
'Light Blue': { hex: '#61B0FF' },
|
||||||
'Light Cyan': { hex: '#B9E3DF' },
|
'Light Cyan': { hex: '#B2EBF2' },
|
||||||
'Light Gray': { hex: '#D0D2D4' },
|
'Light Gray': { hex: '#C8C8C8' },
|
||||||
'Light Jade': { hex: '#A4D6AD' },
|
'Light Jade': { hex: '#C8E6C9' },
|
||||||
'Lilac Purple': { hex: '#AE96D4' },
|
'Lilac Purple': { hex: '#AE96D4' },
|
||||||
'Lime': { hex: '#C5ED48' },
|
'Lime': { hex: '#76FF03' },
|
||||||
'Lime Green': { hex: '#8EE43D' },
|
'Lime Green': { hex: '#7EC845' },
|
||||||
'Magenta': { hex: '#EC008C' },
|
'Magenta': { hex: '#EC008C' },
|
||||||
'Malachite Green': { hex: '#16B08E' },
|
'Malachite Green': { hex: '#0BDA51' },
|
||||||
'Mandarin Orange': { hex: '#F99963' },
|
'Mandarin Orange': { hex: '#F99963' },
|
||||||
'Marine Blue': { hex: '#0078BF' },
|
'Marine Blue': { hex: '#0078BF' },
|
||||||
'Maroon Red': { hex: '#0A2989' },
|
'Maroon Red': { hex: '#7B2D34' },
|
||||||
'Matcha Green': { hex: '#5C9748' },
|
'Matcha Green': { hex: '#7B9B5B' },
|
||||||
'Mellow Yellow': { hex: '#EFDCAA' },
|
'Mellow Yellow': { hex: '#F8E875' },
|
||||||
'Midnight Blaze': { hex: '#0047BB' },
|
'Midnight Blaze': { hex: '#2C1654' },
|
||||||
'Mint': { hex: '#A5DAB7' },
|
'Mint': { hex: '#98FFB0' },
|
||||||
'Mint Lime': { hex: '#BAF382' },
|
'Mint Lime': { hex: '#A5D6A7' },
|
||||||
'Mistletoe Green': { hex: '#3F8E43' },
|
'Mistletoe Green': { hex: '#3F8E43' },
|
||||||
'Nardo Gray': { hex: '#747474' },
|
'Nardo Gray': { hex: '#747474' },
|
||||||
'Navy Blue': { hex: '#0C2340' },
|
'Navy Blue': { hex: '#0C2340' },
|
||||||
'Nebulae': { hex: '#424379' },
|
'Nebulae': { hex: '#4A148C' },
|
||||||
'Neon City': { hex: '#0047BB' },
|
'Neon City': { hex: '#39FF14' },
|
||||||
'Neon Green': { hex: '#ABFF1E' },
|
'Neon Green': { hex: '#76FF03' },
|
||||||
'Neon Orange': { hex: '#F68A1B' },
|
'Neon Orange': { hex: '#FF6D00' },
|
||||||
'Ochre Yellow': { hex: '#BC8B39' },
|
'Ochre Yellow': { hex: '#CC7722' },
|
||||||
'Ocean to Meadow': { hex: '#A1E4CA' },
|
'Ocean to Meadow': { hex: '#4DB6AC' },
|
||||||
'Olive': { hex: '#748C45' },
|
'Olive': { hex: '#748C45' },
|
||||||
'Onyx Black Sparkle': { hex: '#2D2B28' },
|
'Onyx Black Sparkle': { hex: '#1A1A1A' },
|
||||||
'Orange': { hex: '#FF6A13' },
|
'Orange': { hex: '#FF6A13' },
|
||||||
'Oxide Green Metallic': { hex: '#1D7C6A' },
|
'Oxide Green Metallic': { hex: '#4A6741' },
|
||||||
'Peanut Brown': { hex: '#7E5A1F' },
|
'Peanut Brown': { hex: '#A0724A' },
|
||||||
'Pink': { hex: '#F55A74' },
|
'Pink': { hex: '#F55A74' },
|
||||||
'Pink Citrus': { hex: '#F8C4BC' },
|
'Pink Citrus': { hex: '#FF8A65' },
|
||||||
'Plum': { hex: '#851A52' },
|
'Plum': { hex: '#851A52' },
|
||||||
'Pumpkin Orange': { hex: '#FF8E16' },
|
'Pumpkin Orange': { hex: '#E87530' },
|
||||||
'Purple': { hex: '#5E43B7' },
|
'Purple': { hex: '#5E43B7' },
|
||||||
'Red': { hex: '#C12E1F' },
|
'Red': { hex: '#C12E1F' },
|
||||||
'Red Granite': { hex: '#AD4E38' },
|
'Red Granite': { hex: '#A0522D' },
|
||||||
'Rose Gold': { hex: '#B29593' },
|
'Rose Gold': { hex: '#B76E79' },
|
||||||
'Rosewood': { hex: '#472A22' },
|
'Rosewood': { hex: '#65000B' },
|
||||||
'Royal Blue': { hex: '#2842AD' },
|
'Royal Blue': { hex: '#002FA7' },
|
||||||
'Royal Purple Sparkle': { hex: '#483D8B' },
|
'Royal Purple Sparkle': { hex: '#6A1B9A' },
|
||||||
'Sakura Pink': { hex: '#E8AFCF' },
|
'Sakura Pink': { hex: '#E8AFCF' },
|
||||||
'Scarlet Red': { hex: '#DE4343' },
|
'Scarlet Red': { hex: '#DE4343' },
|
||||||
'Silver': { hex: '#A6A9AA' },
|
'Silver': { hex: '#A6A9AA' },
|
||||||
'Sky Blue': { hex: '#73B2E5' },
|
'Sky Blue': { hex: '#73B2E5' },
|
||||||
'Slate Gray Sparkle': { hex: '#8E9089' },
|
'Slate Gray Sparkle': { hex: '#708090' },
|
||||||
'Solar Breeze': { hex: '#F3D9D5' },
|
'Solar Breeze': { hex: '#FFD54F' },
|
||||||
'South Beach': { hex: '#468791' },
|
'South Beach': { hex: '#00BCD4' },
|
||||||
'Sunflower Yellow': { hex: '#FEC601' },
|
'Sunflower Yellow': { hex: '#FEC600' },
|
||||||
'Tangerine Yellow': { hex: '#FFC72C' },
|
'Tangerine Yellow': { hex: '#FFC72C' },
|
||||||
'Teal': { hex: '#77EDD7' },
|
'Teal': { hex: '#009FA1' },
|
||||||
'Terracotta': { hex: '#A25A37' },
|
'Terracotta': { hex: '#A25A37' },
|
||||||
'Titan Gray': { hex: '#606367' },
|
'Titan Gray': { hex: '#8A8D8F' },
|
||||||
'Transparent': { hex: '#FFFFFF' },
|
'Transparent': { hex: '#E8E8E8' },
|
||||||
'Turquoise': { hex: '#00B1B7' },
|
'Turquoise': { hex: '#00B1B7' },
|
||||||
'Velvet Eclipse': { hex: '#000000' },
|
'Velvet Eclipse': { hex: '#3B0A45' },
|
||||||
'Violet Purple': { hex: '#583061' },
|
'Violet Purple': { hex: '#583061' },
|
||||||
'White': { hex: '#FFFFFF' },
|
'White': { hex: '#FFFFFF' },
|
||||||
'White Marble': { hex: '#F7F3F0' },
|
'White Marble': { hex: '#E8E8E8' },
|
||||||
'White Oak': { hex: '#D2CCA2' },
|
'White Oak': { hex: '#C8B88A' },
|
||||||
'Yellow': { hex: '#F4EE2A' },
|
'Yellow': { hex: '#F4EE2A' },
|
||||||
|
|
||||||
// Default fallback
|
// ABS Colors
|
||||||
'Unknown': { hex: '#CCCCCC' }
|
'ABS Azure': { hex: '#3B7DD8' },
|
||||||
|
'ABS Olive': { hex: '#6B7339' },
|
||||||
|
'ABS Blue': { hex: '#1A3C8F' },
|
||||||
|
'ABS Tangerine Yellow': { hex: '#FFBF00' },
|
||||||
|
'ABS Navy Blue': { hex: '#1B2A4A' },
|
||||||
|
'ABS Orange': { hex: '#F57C20' },
|
||||||
|
'ABS Bambu Green': { hex: '#00AE42' },
|
||||||
|
'ABS Red': { hex: '#C0392B' },
|
||||||
|
'ABS White': { hex: '#FFFFFF' },
|
||||||
|
'ABS Black': { hex: '#1A1A1A' },
|
||||||
|
'ABS Silver': { hex: '#B0B0B0' },
|
||||||
|
|
||||||
|
// PETG Translucent Colors
|
||||||
|
'Translucent Gray': { hex: '#A9A9A9' },
|
||||||
|
'Translucent Brown': { hex: '#8B4513' },
|
||||||
|
'Translucent Purple': { hex: '#800080' },
|
||||||
|
'Translucent Orange': { hex: '#FF8C00' },
|
||||||
|
'Translucent Olive': { hex: '#808000' },
|
||||||
|
'Translucent Pink': { hex: '#FFB6C1' },
|
||||||
|
'Translucent Light Blue': { hex: '#ADD8E6' },
|
||||||
|
'Translucent Teal': { hex: '#008B8B' },
|
||||||
|
|
||||||
|
// PLA Matte Colors
|
||||||
|
'Matte Ivory White': { hex: '#FFFFFF' },
|
||||||
|
'Matte Charcoal': { hex: '#3C3C3C' },
|
||||||
|
'Matte Scarlet Red': { hex: '#DE4343' },
|
||||||
|
'Matte Marine Blue': { hex: '#0078BF' },
|
||||||
|
'Matte Mandarin Orange': { hex: '#F99963' },
|
||||||
|
'Matte Ash Gray': { hex: '#9B9EA0' },
|
||||||
|
'Matte Desert Tan': { hex: '#E8DBB7' },
|
||||||
|
'Matte Nardo Gray': { hex: '#6E6E6E' },
|
||||||
|
'Matte Apple Green': { hex: '#8DB600' },
|
||||||
|
'Matte Bone White': { hex: '#CBC6B8' },
|
||||||
|
'Matte Caramel': { hex: '#AE835B' },
|
||||||
|
'Matte Dark Blue': { hex: '#1B3A5C' },
|
||||||
|
'Matte Dark Brown': { hex: '#7D6556' },
|
||||||
|
'Matte Dark Chocolate': { hex: '#4D3324' },
|
||||||
|
'Matte Dark Green': { hex: '#68724D' },
|
||||||
|
'Matte Dark Red': { hex: '#8B1A1A' },
|
||||||
|
'Matte Grass Green': { hex: '#61C680' },
|
||||||
|
'Matte Ice Blue': { hex: '#A3D8E1' },
|
||||||
|
'Matte Lemon Yellow': { hex: '#F7D959' },
|
||||||
|
'Matte Lilac Purple': { hex: '#AE96D4' },
|
||||||
|
'Matte Plum': { hex: '#7D3F6B' },
|
||||||
|
'Matte Sakura Pink': { hex: '#E8AFCF' },
|
||||||
|
'Matte Sky Blue': { hex: '#7EB6D9' },
|
||||||
|
'Matte Latte Brown': { hex: '#D3B7A7' },
|
||||||
|
'Matte Terracotta': { hex: '#B15533' },
|
||||||
|
|
||||||
|
// PLA Silk Multi-Color
|
||||||
|
'Phantom Blue': { hex: ['#00629B', '#000000'], isGradient: true },
|
||||||
|
'Mystic Magenta': { hex: ['#720062', '#3A913F'], isGradient: true },
|
||||||
|
|
||||||
|
// TPU Colors
|
||||||
|
'Flesh': { hex: '#FFCCB0' },
|
||||||
|
'Grape Jelly': { hex: '#6A0DAD' },
|
||||||
|
'Crystal Blue': { hex: '#68B8E8' },
|
||||||
|
'Quicksilver': { hex: '#A6A6A6' }
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFilamentColor(colorName: string): ColorMapping {
|
export function getFilamentColor(colorName: string): ColorMapping {
|
||||||
@@ -171,7 +230,7 @@ export function getFilamentColor(colorName: string): ColorMapping {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return default unknown color
|
// Return default unknown color
|
||||||
return bambuLabColors['Unknown'];
|
return { hex: '#CCCCCC' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getColorStyle(colorMapping: ColorMapping): React.CSSProperties {
|
export function getColorStyle(colorMapping: ColorMapping): React.CSSProperties {
|
||||||
|
|||||||
@@ -1,143 +1,246 @@
|
|||||||
// Complete Bambu Lab color database with hex codes
|
// Complete Bambu Lab color database with hex codes
|
||||||
export const bambuLabColors = {
|
// Re-exports catalog-aligned color groupings
|
||||||
// Basic Colors
|
|
||||||
"Black": "#000000",
|
import { BAMBU_LAB_CATALOG } from './bambuLabCatalog';
|
||||||
|
|
||||||
|
// Flat hex lookup (for backwards compatibility)
|
||||||
|
export const bambuLabColors: Record<string, string> = {
|
||||||
|
// PLA Basic
|
||||||
|
"Jade White": "#FFFFFF",
|
||||||
|
"Black": "#1A1A1A",
|
||||||
"White": "#FFFFFF",
|
"White": "#FFFFFF",
|
||||||
"Red": "#E53935",
|
"Red": "#C12E1F",
|
||||||
"Blue": "#1E88E5",
|
"Blue": "#0A2989",
|
||||||
"Green": "#43A047",
|
"Green": "#00AE42",
|
||||||
"Yellow": "#FDD835",
|
"Yellow": "#F4EE2A",
|
||||||
"Orange": "#FB8C00",
|
"Orange": "#FF6A13",
|
||||||
"Purple": "#8E24AA",
|
"Purple": "#5E43B7",
|
||||||
"Pink": "#EC407A",
|
"Pink": "#F55A74",
|
||||||
"Grey": "#757575",
|
"Gray": "#8E9089",
|
||||||
"Brown": "#6D4C41",
|
"Brown": "#9D432C",
|
||||||
"Light Blue": "#64B5F6",
|
"Light Blue": "#61B0FF",
|
||||||
"Light Green": "#81C784",
|
"Light Gray": "#C8C8C8",
|
||||||
"Mint Green": "#4DB6AC",
|
"Sky Blue": "#73B2E5",
|
||||||
"Lime Green": "#C0CA33",
|
"Navy Blue": "#0C2340",
|
||||||
"Sky Blue": "#81D4FA",
|
"Magenta": "#EC008C",
|
||||||
"Navy Blue": "#283593",
|
"Beige": "#F7E6DE",
|
||||||
"Magenta": "#E91E63",
|
"Bambu Green": "#00AE42",
|
||||||
"Violet": "#7B1FA2",
|
"Scarlet Red": "#DE4343",
|
||||||
"Beige": "#F5DEB3",
|
"Lemon Yellow": "#F7D959",
|
||||||
"Ivory": "#FFFFF0",
|
"Cyan": "#0086D6",
|
||||||
|
"Sakura Pink": "#E8AFCF",
|
||||||
// Matte Colors
|
"Cobalt Blue": "#0047AB",
|
||||||
"Matte Black": "#212121",
|
"Mistletoe Green": "#3F8E43",
|
||||||
"Matte White": "#FAFAFA",
|
"Dark Red": "#BB3D43",
|
||||||
"Matte Red": "#C62828",
|
"Hot Pink": "#FF4F81",
|
||||||
"Matte Blue": "#1565C0",
|
"Lavender": "#B39DDB",
|
||||||
"Matte Green": "#2E7D32",
|
"Sunflower Yellow": "#FEC600",
|
||||||
"Matte Yellow": "#F9A825",
|
"Pumpkin Orange": "#E87530",
|
||||||
"Matte Orange": "#EF6C00",
|
"Lime": "#76FF03",
|
||||||
"Matte Purple": "#6A1B9A",
|
"Blue Grey": "#7B8E97",
|
||||||
"Matte Pink": "#D81B60",
|
"Gold": "#E4BD68",
|
||||||
"Matte Grey": "#616161",
|
"Bright Green": "#5EC323",
|
||||||
"Matte Brown": "#4E342E",
|
"Maroon Red": "#7B2D34",
|
||||||
"Matte Mint": "#26A69A",
|
"Turquoise": "#00B1B7",
|
||||||
"Matte Lime": "#9E9D24",
|
"Bronze": "#847D48",
|
||||||
"Matte Navy": "#1A237E",
|
"Silver": "#A6A9AA",
|
||||||
"Matte Coral": "#FF5252",
|
"Dark Gray": "#515151",
|
||||||
|
|
||||||
// Silk Colors
|
// PLA Basic Gradient
|
||||||
"Silk White": "#FEFEFE",
|
"Neon City": "#39FF14",
|
||||||
"Silk Black": "#0A0A0A",
|
"Midnight Blaze": "#2C1654",
|
||||||
"Silk Red": "#F44336",
|
"South Beach": "#00BCD4",
|
||||||
"Silk Blue": "#2196F3",
|
"Arctic Whisper": "#E0EFF6",
|
||||||
"Silk Green": "#4CAF50",
|
"Cotton Candy Cloud": "#F8BBD0",
|
||||||
"Silk Gold": "#FFD54F",
|
"Ocean to Meadow": "#4DB6AC",
|
||||||
"Silk Silver": "#CFD8DC",
|
"Solar Breeze": "#FFD54F",
|
||||||
"Silk Purple": "#9C27B0",
|
"Velvet Eclipse": "#3B0A45",
|
||||||
"Silk Pink": "#F06292",
|
"Dawn Radiance": "#FFDAB9",
|
||||||
"Silk Orange": "#FF9800",
|
"Dusk Glare": "#FF7043",
|
||||||
"Silk Bronze": "#A1887F",
|
"Blueberry Bubblegum": "#7C4DFF",
|
||||||
"Silk Copper": "#BF6F3F",
|
"Blue Hawaii": "#00C5CD",
|
||||||
"Silk Jade": "#00897B",
|
"Gilded Rose": "#B76E79",
|
||||||
"Silk Rose Gold": "#E8A09A",
|
"Pink Citrus": "#FF8A65",
|
||||||
"Silk Pearl": "#F8F8FF",
|
"Mint Lime": "#A5D6A7",
|
||||||
"Silk Ruby": "#E91E63",
|
|
||||||
"Silk Sapphire": "#1976D2",
|
// PLA Matte
|
||||||
"Silk Emerald": "#00695C",
|
"Matte Ivory White": "#FFFFFF",
|
||||||
|
"Matte Charcoal": "#3C3C3C",
|
||||||
// Metal Colors
|
"Matte Scarlet Red": "#DE4343",
|
||||||
"Metal Grey": "#9E9E9E",
|
"Matte Marine Blue": "#0078BF",
|
||||||
"Metal Silver": "#B0BEC5",
|
"Matte Mandarin Orange": "#F99963",
|
||||||
"Metal Gold": "#D4AF37",
|
"Matte Ash Gray": "#9B9EA0",
|
||||||
"Metal Copper": "#B87333",
|
"Matte Desert Tan": "#E8DBB7",
|
||||||
"Metal Bronze": "#CD7F32",
|
"Matte Nardo Gray": "#6E6E6E",
|
||||||
|
"Matte Apple Green": "#8DB600",
|
||||||
// Sparkle Colors
|
"Matte Bone White": "#CBC6B8",
|
||||||
"Sparkle Red": "#EF5350",
|
"Matte Caramel": "#AE835B",
|
||||||
"Sparkle Blue": "#42A5F5",
|
"Matte Dark Blue": "#1B3A5C",
|
||||||
"Sparkle Green": "#66BB6A",
|
"Matte Dark Brown": "#7D6556",
|
||||||
"Sparkle Purple": "#AB47BC",
|
"Matte Dark Chocolate": "#4D3324",
|
||||||
"Sparkle Gold": "#FFCA28",
|
"Matte Dark Green": "#68724D",
|
||||||
"Sparkle Silver": "#E0E0E0",
|
"Matte Dark Red": "#8B1A1A",
|
||||||
|
"Matte Grass Green": "#61C680",
|
||||||
// Glow Colors
|
"Matte Ice Blue": "#A3D8E1",
|
||||||
"Glow in the Dark Green": "#C8E6C9",
|
"Matte Lemon Yellow": "#F7D959",
|
||||||
"Glow in the Dark Blue": "#BBDEFB",
|
"Matte Lilac Purple": "#AE96D4",
|
||||||
|
"Matte Plum": "#7D3F6B",
|
||||||
// Transparent Colors
|
"Matte Sakura Pink": "#E8AFCF",
|
||||||
"Clear": "#FFFFFF",
|
"Matte Sky Blue": "#7EB6D9",
|
||||||
"Transparent Red": "#EF5350",
|
"Matte Latte Brown": "#D3B7A7",
|
||||||
"Transparent Blue": "#42A5F5",
|
"Matte Terracotta": "#B15533",
|
||||||
"Transparent Green": "#66BB6A",
|
|
||||||
"Transparent Yellow": "#FFEE58",
|
// PLA Silk+
|
||||||
"Transparent Orange": "#FFA726",
|
"Candy Green": "#66CDAA",
|
||||||
"Transparent Purple": "#AB47BC",
|
"Candy Red": "#E8505B",
|
||||||
|
"Mint": "#98FFB0",
|
||||||
// Support Materials
|
"Titan Gray": "#8A8D8F",
|
||||||
"Natural": "#F5F5DC",
|
"Rose Gold": "#B76E79",
|
||||||
"Support White": "#F5F5F5",
|
"Champagne": "#F7E7CE",
|
||||||
"Support G": "#90CAF9"
|
"Baby Blue": "#89CFF0",
|
||||||
|
|
||||||
|
// PLA Silk Multi-Color
|
||||||
|
"Aurora Purple": "#9C27B0",
|
||||||
|
"Phantom Blue": "#1A237E",
|
||||||
|
"Mystic Magenta": "#E040FB",
|
||||||
|
|
||||||
|
// PLA Metal
|
||||||
|
"Iron Gray Metallic": "#6E6E6E",
|
||||||
|
"Iridium Gold Metallic": "#C5A04D",
|
||||||
|
"Cobalt Blue Metallic": "#0047AB",
|
||||||
|
"Copper Brown Metallic": "#B87333",
|
||||||
|
"Oxide Green Metallic": "#4A6741",
|
||||||
|
|
||||||
|
// PLA Sparkle
|
||||||
|
"Onyx Black Sparkle": "#1A1A1A",
|
||||||
|
"Classic Gold Sparkle": "#CFB53B",
|
||||||
|
"Crimson Red Sparkle": "#B71C1C",
|
||||||
|
"Royal Purple Sparkle": "#6A1B9A",
|
||||||
|
"Slate Gray Sparkle": "#708090",
|
||||||
|
"Alpine Green Sparkle": "#2E7D32",
|
||||||
|
|
||||||
|
// PLA Translucent
|
||||||
|
"Teal": "#009FA1",
|
||||||
|
"Light Jade": "#C8E6C9",
|
||||||
|
"Mellow Yellow": "#F8E875",
|
||||||
|
"Cherry Pink": "#DE3163",
|
||||||
|
"Ice Blue": "#A5DEE4",
|
||||||
|
|
||||||
|
// PLA Galaxy
|
||||||
|
"Nebulae": "#4A148C",
|
||||||
|
|
||||||
|
// PLA Marble
|
||||||
|
"White Marble": "#E8E8E8",
|
||||||
|
"Red Granite": "#A0522D",
|
||||||
|
|
||||||
|
// PLA Glow
|
||||||
|
"Glow Blue": "#5DADE2",
|
||||||
|
"Glow Green": "#82E0AA",
|
||||||
|
"Glow Orange": "#FFB347",
|
||||||
|
"Glow Pink": "#FF69B4",
|
||||||
|
"Glow Yellow": "#FFEB3B",
|
||||||
|
|
||||||
|
// PLA Wood
|
||||||
|
"Ochre Yellow": "#CC7722",
|
||||||
|
"White Oak": "#C8B88A",
|
||||||
|
"Clay Brown": "#B08968",
|
||||||
|
"Black Walnut": "#3E2723",
|
||||||
|
"Rosewood": "#65000B",
|
||||||
|
"Classic Birch": "#D7C49E",
|
||||||
|
|
||||||
|
// PLA CF
|
||||||
|
"Burgundy Red": "#800020",
|
||||||
|
"Jeans Blue": "#4A6FA5",
|
||||||
|
"Lava Gray": "#5B5B5B",
|
||||||
|
"Matcha Green": "#7B9B5B",
|
||||||
|
"Royal Blue": "#002FA7",
|
||||||
|
"Iris Purple": "#5B3A8C",
|
||||||
|
|
||||||
|
// PETG HF
|
||||||
|
"Cream": "#F5E6C8",
|
||||||
|
"Forest Green": "#39541A",
|
||||||
|
"Lake Blue": "#1F79E5",
|
||||||
|
"Lime Green": "#7EC845",
|
||||||
|
"Peanut Brown": "#A0724A",
|
||||||
|
|
||||||
|
// PETG Translucent
|
||||||
|
"Clear": "#F0F0F0",
|
||||||
|
"Translucent Gray": "#A9A9A9",
|
||||||
|
"Translucent Brown": "#8B4513",
|
||||||
|
"Translucent Purple": "#800080",
|
||||||
|
"Translucent Orange": "#FF8C00",
|
||||||
|
"Translucent Olive": "#808000",
|
||||||
|
"Translucent Pink": "#FFB6C1",
|
||||||
|
"Translucent Light Blue": "#ADD8E6",
|
||||||
|
"Translucent Teal": "#008B8B",
|
||||||
|
|
||||||
|
// PETG CF
|
||||||
|
"Brick Red": "#9F332A",
|
||||||
|
"Indigo Blue": "#324585",
|
||||||
|
"Malachite Green": "#0BDA51",
|
||||||
|
"Violet Purple": "#583061",
|
||||||
|
|
||||||
|
// ABS
|
||||||
|
"ABS Azure": "#3B7DD8",
|
||||||
|
"ABS Black": "#1A1A1A",
|
||||||
|
"ABS Blue": "#1A3C8F",
|
||||||
|
"ABS Olive": "#6B7339",
|
||||||
|
"ABS Tangerine Yellow": "#FFBF00",
|
||||||
|
"ABS Navy Blue": "#1B2A4A",
|
||||||
|
"ABS Orange": "#F57C20",
|
||||||
|
"ABS Bambu Green": "#00AE42",
|
||||||
|
"ABS Red": "#C0392B",
|
||||||
|
"ABS White": "#FFFFFF",
|
||||||
|
"ABS Silver": "#B0B0B0",
|
||||||
|
|
||||||
|
// Additional PLA Basic
|
||||||
|
"Indigo Purple": "#3B2D6B",
|
||||||
|
|
||||||
|
// TPU
|
||||||
|
"Neon Green": "#76FF03",
|
||||||
|
"Flesh": "#FFCCB0",
|
||||||
|
"Light Cyan": "#B2EBF2",
|
||||||
|
"Neon Orange": "#FF6D00",
|
||||||
|
"Blaze": "#FF5722",
|
||||||
|
"Frozen": "#E0F7FA",
|
||||||
|
"Grape Jelly": "#6A0DAD",
|
||||||
|
"Crystal Blue": "#68B8E8",
|
||||||
|
"Quicksilver": "#A6A6A6",
|
||||||
|
"Cocoa Brown": "#6B4332",
|
||||||
|
|
||||||
|
// PC
|
||||||
|
"Clear Black": "#2C2C2C",
|
||||||
|
"Transparent": "#E8E8E8",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Colors grouped by finish type for easier selection
|
// Colors grouped by finish type — derived from catalog
|
||||||
export const colorsByFinish = {
|
export const colorsByFinish: Record<string, string[]> = {};
|
||||||
"Basic": [
|
|
||||||
"Black", "White", "Red", "Blue", "Green", "Yellow", "Orange",
|
// Build colorsByFinish from catalog
|
||||||
"Purple", "Pink", "Grey", "Brown", "Light Blue", "Light Green",
|
for (const [, finishes] of Object.entries(BAMBU_LAB_CATALOG)) {
|
||||||
"Mint Green", "Lime Green", "Sky Blue", "Navy Blue", "Magenta",
|
for (const [finishName, finishData] of Object.entries(finishes)) {
|
||||||
"Violet", "Beige", "Ivory"
|
if (!colorsByFinish[finishName]) {
|
||||||
],
|
colorsByFinish[finishName] = [];
|
||||||
"Matte": [
|
}
|
||||||
"Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green",
|
for (const color of finishData.colors) {
|
||||||
"Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey",
|
if (!colorsByFinish[finishName].includes(color.name)) {
|
||||||
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral"
|
colorsByFinish[finishName].push(color.name);
|
||||||
],
|
}
|
||||||
"Silk": [
|
}
|
||||||
"Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green",
|
}
|
||||||
"Silk Gold", "Silk Silver", "Silk Purple", "Silk Pink", "Silk Orange",
|
}
|
||||||
"Silk Bronze", "Silk Copper", "Silk Jade", "Silk Rose Gold", "Silk Pearl",
|
|
||||||
"Silk Ruby", "Silk Sapphire", "Silk Emerald"
|
// Sort each finish's colors
|
||||||
],
|
for (const finish of Object.keys(colorsByFinish)) {
|
||||||
"Metal": [
|
colorsByFinish[finish].sort();
|
||||||
"Metal Grey", "Metal Silver", "Metal Gold", "Metal Copper", "Metal Bronze"
|
}
|
||||||
],
|
|
||||||
"Sparkle": [
|
|
||||||
"Sparkle Red", "Sparkle Blue", "Sparkle Green", "Sparkle Purple",
|
|
||||||
"Sparkle Gold", "Sparkle Silver"
|
|
||||||
],
|
|
||||||
"Glow": [
|
|
||||||
"Glow in the Dark Green", "Glow in the Dark Blue"
|
|
||||||
],
|
|
||||||
"Transparent": [
|
|
||||||
"Clear", "Transparent Red", "Transparent Blue", "Transparent Green",
|
|
||||||
"Transparent Yellow", "Transparent Orange", "Transparent Purple"
|
|
||||||
],
|
|
||||||
"Support": [
|
|
||||||
"Natural", "Support White", "Support G"
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to get hex code for a color
|
// Function to get hex code for a color
|
||||||
export function getColorHex(colorName: string): string {
|
export function getColorHex(colorName: string): string {
|
||||||
return bambuLabColors[colorName as keyof typeof bambuLabColors] || "#000000";
|
return bambuLabColors[colorName] || "#000000";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to get colors for a specific finish
|
// Function to get colors for a specific finish
|
||||||
export function getColorsForFinish(finish: string): string[] {
|
export function getColorsForFinish(finish: string): string[] {
|
||||||
return colorsByFinish[finish as keyof typeof colorsByFinish] || [];
|
return colorsByFinish[finish] || [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Filament } from '@/src/types/filament';
|
import { Filament } from '@/src/types/filament';
|
||||||
|
import { Customer, Sale, CreateSaleRequest, AnalyticsOverview, TopSeller, RevenueDataPoint, InventoryAlert, TypeBreakdown } from '@/src/types/sales';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||||
|
|
||||||
@@ -101,4 +102,110 @@ export const filamentService = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const colorRequestService = {
|
||||||
|
getAll: async () => {
|
||||||
|
const response = await api.get('/color-requests');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
submit: async (request: {
|
||||||
|
color_name: string;
|
||||||
|
material_type: string;
|
||||||
|
finish_type?: string;
|
||||||
|
user_email: string;
|
||||||
|
user_phone: string;
|
||||||
|
user_name?: string;
|
||||||
|
description?: string;
|
||||||
|
reference_url?: string;
|
||||||
|
}) => {
|
||||||
|
const response = await api.post('/color-requests', request);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStatus: async (id: string, status: string, admin_notes?: string) => {
|
||||||
|
const response = await api.put(`/color-requests/${id}`, { status, admin_notes });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string) => {
|
||||||
|
const response = await api.delete(`/color-requests/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const customerService = {
|
||||||
|
getAll: async (): Promise<Customer[]> => {
|
||||||
|
const response = await api.get('/customers');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
search: async (q: string): Promise<Customer[]> => {
|
||||||
|
const response = await api.get(`/customers/search?q=${encodeURIComponent(q)}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Customer & { sales: Sale[] }> => {
|
||||||
|
const response = await api.get(`/customers/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (customer: Partial<Customer>): Promise<Customer> => {
|
||||||
|
const response = await api.post('/customers', customer);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, customer: Partial<Customer>): Promise<Customer> => {
|
||||||
|
const response = await api.put(`/customers/${id}`, customer);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saleService = {
|
||||||
|
getAll: async (page = 1, limit = 50): Promise<{ sales: Sale[]; total: number }> => {
|
||||||
|
const response = await api.get(`/sales?page=${page}&limit=${limit}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Sale> => {
|
||||||
|
const response = await api.get(`/sales/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateSaleRequest): Promise<Sale> => {
|
||||||
|
const response = await api.post('/sales', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/sales/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const analyticsService = {
|
||||||
|
getOverview: async (period = '30d'): Promise<AnalyticsOverview> => {
|
||||||
|
const response = await api.get(`/analytics/overview?period=${period}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTopSellers: async (period = '30d'): Promise<TopSeller[]> => {
|
||||||
|
const response = await api.get(`/analytics/top-sellers?period=${period}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getInventoryAlerts: async (): Promise<InventoryAlert[]> => {
|
||||||
|
const response = await api.get('/analytics/inventory-alerts');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRevenueChart: async (period = '6m', group = 'month'): Promise<RevenueDataPoint[]> => {
|
||||||
|
const response = await api.get(`/analytics/revenue-chart?period=${period}&group=${group}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTypeBreakdown: async (period = '30d'): Promise<TypeBreakdown[]> => {
|
||||||
|
const response = await api.get(`/analytics/type-breakdown?period=${period}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
@@ -42,4 +42,58 @@
|
|||||||
|
|
||||||
.animate-shimmer {
|
.animate-shimmer {
|
||||||
animation: shimmer 3s ease-in-out infinite;
|
animation: shimmer 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safari form styling fixes */
|
||||||
|
@layer base {
|
||||||
|
/* Remove Safari's default styling for form inputs */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="tel"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="search"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix Safari select arrow */
|
||||||
|
select {
|
||||||
|
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.7rem center;
|
||||||
|
background-size: 1em;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode select arrow */
|
||||||
|
.dark select {
|
||||||
|
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23d1d5db' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure consistent border radius on iOS */
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove iOS zoom on focus */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="tel"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="search"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
91
src/types/sales.ts
Normal file
91
src/types/sales.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
export interface Customer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
city?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaleItem {
|
||||||
|
id: string;
|
||||||
|
sale_id: string;
|
||||||
|
filament_id: string;
|
||||||
|
item_type: 'refill' | 'spulna';
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
created_at?: string;
|
||||||
|
// Joined fields
|
||||||
|
filament_tip?: string;
|
||||||
|
filament_finish?: string;
|
||||||
|
filament_boja?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sale {
|
||||||
|
id: string;
|
||||||
|
customer_id?: string;
|
||||||
|
total_amount: number;
|
||||||
|
notes?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
// Joined fields
|
||||||
|
customer_name?: string;
|
||||||
|
customer_phone?: string;
|
||||||
|
items?: SaleItem[];
|
||||||
|
item_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSaleRequest {
|
||||||
|
customer: {
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
city?: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
items: {
|
||||||
|
filament_id: string;
|
||||||
|
item_type: 'refill' | 'spulna';
|
||||||
|
quantity: number;
|
||||||
|
}[];
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsOverview {
|
||||||
|
revenue: number;
|
||||||
|
sales_count: number;
|
||||||
|
avg_order_value: number;
|
||||||
|
unique_customers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopSeller {
|
||||||
|
boja: string;
|
||||||
|
tip: string;
|
||||||
|
finish: string;
|
||||||
|
total_qty: number;
|
||||||
|
total_revenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueDataPoint {
|
||||||
|
period: string;
|
||||||
|
revenue: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryAlert {
|
||||||
|
id: string;
|
||||||
|
boja: string;
|
||||||
|
tip: string;
|
||||||
|
finish: string;
|
||||||
|
refill: number;
|
||||||
|
spulna: number;
|
||||||
|
kolicina: number;
|
||||||
|
avg_daily_sales: number;
|
||||||
|
days_until_stockout: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypeBreakdown {
|
||||||
|
item_type: string;
|
||||||
|
total_qty: number;
|
||||||
|
total_revenue: number;
|
||||||
|
}
|
||||||
198
terraform/cloudfront-frontend.tf
Normal file
198
terraform/cloudfront-frontend.tf
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# S3 bucket for static website hosting
|
||||||
|
resource "aws_s3_bucket" "frontend" {
|
||||||
|
bucket = "${var.app_name}-frontend"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.app_name}-frontend"
|
||||||
|
Environment = var.environment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# S3 bucket website configuration
|
||||||
|
resource "aws_s3_bucket_website_configuration" "frontend" {
|
||||||
|
bucket = aws_s3_bucket.frontend.id
|
||||||
|
|
||||||
|
index_document {
|
||||||
|
suffix = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
error_document {
|
||||||
|
key = "404.html"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# S3 bucket public access block (we'll use CloudFront OAC instead)
|
||||||
|
resource "aws_s3_bucket_public_access_block" "frontend" {
|
||||||
|
bucket = aws_s3_bucket.frontend.id
|
||||||
|
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# CloudFront Origin Access Control
|
||||||
|
resource "aws_cloudfront_origin_access_control" "frontend" {
|
||||||
|
name = "${var.app_name}-frontend-oac"
|
||||||
|
description = "OAC for ${var.app_name} frontend"
|
||||||
|
origin_access_control_origin_type = "s3"
|
||||||
|
signing_behavior = "always"
|
||||||
|
signing_protocol = "sigv4"
|
||||||
|
}
|
||||||
|
|
||||||
|
# CloudFront Function for directory index rewrites
|
||||||
|
resource "aws_cloudfront_function" "index_rewrite" {
|
||||||
|
name = "${var.app_name}-index-rewrite"
|
||||||
|
runtime = "cloudfront-js-1.0"
|
||||||
|
comment = "Rewrite directory requests to index.html"
|
||||||
|
publish = true
|
||||||
|
code = file("${path.module}/cloudfront-function.js")
|
||||||
|
}
|
||||||
|
|
||||||
|
# CloudFront distribution
|
||||||
|
resource "aws_cloudfront_distribution" "frontend" {
|
||||||
|
enabled = true
|
||||||
|
is_ipv6_enabled = true
|
||||||
|
comment = "${var.app_name} frontend"
|
||||||
|
default_root_object = "index.html"
|
||||||
|
price_class = "PriceClass_100" # US, Canada, Europe only (cheapest)
|
||||||
|
|
||||||
|
# No aliases - Cloudflare will proxy to CloudFront's default domain
|
||||||
|
# aliases = var.domain_name != "" ? [var.domain_name, "www.${var.domain_name}"] : []
|
||||||
|
|
||||||
|
origin {
|
||||||
|
domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
|
||||||
|
origin_id = "S3-${aws_s3_bucket.frontend.id}"
|
||||||
|
origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
|
||||||
|
}
|
||||||
|
|
||||||
|
default_cache_behavior {
|
||||||
|
allowed_methods = ["GET", "HEAD", "OPTIONS"]
|
||||||
|
cached_methods = ["GET", "HEAD"]
|
||||||
|
target_origin_id = "S3-${aws_s3_bucket.frontend.id}"
|
||||||
|
|
||||||
|
forwarded_values {
|
||||||
|
query_string = false
|
||||||
|
cookies {
|
||||||
|
forward = "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_protocol_policy = "redirect-to-https"
|
||||||
|
min_ttl = 0
|
||||||
|
default_ttl = 3600 # 1 hour
|
||||||
|
max_ttl = 86400 # 24 hours
|
||||||
|
compress = true
|
||||||
|
|
||||||
|
function_association {
|
||||||
|
event_type = "viewer-request"
|
||||||
|
function_arn = aws_cloudfront_function.index_rewrite.arn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Custom error responses for SPA routing
|
||||||
|
custom_error_response {
|
||||||
|
error_code = 404
|
||||||
|
response_code = 200
|
||||||
|
response_page_path = "/index.html"
|
||||||
|
error_caching_min_ttl = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
custom_error_response {
|
||||||
|
error_code = 403
|
||||||
|
response_code = 200
|
||||||
|
response_page_path = "/index.html"
|
||||||
|
error_caching_min_ttl = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
restrictions {
|
||||||
|
geo_restriction {
|
||||||
|
restriction_type = "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use default CloudFront certificate (we'll handle SSL via Cloudflare)
|
||||||
|
viewer_certificate {
|
||||||
|
cloudfront_default_certificate = true
|
||||||
|
# If you want CloudFront SSL, add ACM certificate here
|
||||||
|
# acm_certificate_arn = aws_acm_certificate.cert.arn
|
||||||
|
# ssl_support_method = "sni-only"
|
||||||
|
# minimum_protocol_version = "TLSv1.2_2021"
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.app_name}-frontend"
|
||||||
|
Environment = var.environment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# S3 bucket policy to allow CloudFront OAC access
|
||||||
|
resource "aws_s3_bucket_policy" "frontend" {
|
||||||
|
bucket = aws_s3_bucket.frontend.id
|
||||||
|
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Sid = "AllowCloudFrontServicePrincipal"
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = {
|
||||||
|
Service = "cloudfront.amazonaws.com"
|
||||||
|
}
|
||||||
|
Action = "s3:GetObject"
|
||||||
|
Resource = "${aws_s3_bucket.frontend.arn}/*"
|
||||||
|
Condition = {
|
||||||
|
StringEquals = {
|
||||||
|
"AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cloudflare DNS records for frontend
|
||||||
|
resource "cloudflare_record" "frontend_root" {
|
||||||
|
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
|
||||||
|
zone_id = data.cloudflare_zone.domain[0].id
|
||||||
|
name = "@"
|
||||||
|
type = "CNAME"
|
||||||
|
value = aws_cloudfront_distribution.frontend.domain_name
|
||||||
|
ttl = 1
|
||||||
|
proxied = true # Enable Cloudflare proxy for SSL and caching
|
||||||
|
comment = "CloudFront distribution for frontend"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "cloudflare_record" "frontend_www" {
|
||||||
|
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
|
||||||
|
zone_id = data.cloudflare_zone.domain[0].id
|
||||||
|
name = "www"
|
||||||
|
type = "CNAME"
|
||||||
|
value = aws_cloudfront_distribution.frontend.domain_name
|
||||||
|
ttl = 1
|
||||||
|
proxied = true # Enable Cloudflare proxy for SSL and caching
|
||||||
|
comment = "CloudFront distribution for frontend (www)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cloudflare Transform Rule to rewrite Host header for CloudFront
|
||||||
|
resource "cloudflare_ruleset" "frontend_host_header_rewrite" {
|
||||||
|
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
|
||||||
|
zone_id = data.cloudflare_zone.domain[0].id
|
||||||
|
name = "Rewrite Host header for CloudFront"
|
||||||
|
kind = "zone"
|
||||||
|
phase = "http_request_late_transform"
|
||||||
|
|
||||||
|
rules {
|
||||||
|
action = "rewrite"
|
||||||
|
expression = "(http.host eq \"${var.domain_name}\" or http.host eq \"www.${var.domain_name}\")"
|
||||||
|
description = "Rewrite Host header to CloudFront domain"
|
||||||
|
|
||||||
|
action_parameters {
|
||||||
|
headers {
|
||||||
|
name = "Host"
|
||||||
|
operation = "set"
|
||||||
|
value = aws_cloudfront_distribution.frontend.domain_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
terraform/cloudfront-function.js
Normal file
18
terraform/cloudfront-function.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
function handler(event) {
|
||||||
|
var request = event.request;
|
||||||
|
var uri = request.uri;
|
||||||
|
|
||||||
|
// Check whether the URI is missing a file extension
|
||||||
|
if (!uri.includes('.')) {
|
||||||
|
// Check if URI ends with /
|
||||||
|
if (uri.endsWith('/')) {
|
||||||
|
request.uri += 'index.html';
|
||||||
|
} else {
|
||||||
|
// For Next.js static export, try .html first
|
||||||
|
// CloudFront will handle 404s via custom error response
|
||||||
|
request.uri += '.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
@@ -20,117 +20,5 @@ provider "cloudflare" {
|
|||||||
api_token = var.cloudflare_api_token != "" ? var.cloudflare_api_token : "dummy" # Dummy token if not provided
|
api_token = var.cloudflare_api_token != "" ? var.cloudflare_api_token : "dummy" # Dummy token if not provided
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_amplify_app" "filamenteka" {
|
# Frontend hosted on CloudFront + S3 (see cloudfront-frontend.tf)
|
||||||
name = "filamenteka"
|
# CI/CD handled by Gitea Actions (see .gitea/workflows/deploy.yml)
|
||||||
repository = var.github_repository
|
|
||||||
platform = "WEB"
|
|
||||||
|
|
||||||
# GitHub access token for private repos (optional for public repos)
|
|
||||||
# access_token = var.github_token
|
|
||||||
|
|
||||||
# Build settings for Next.js
|
|
||||||
build_spec = <<-EOT
|
|
||||||
version: 1
|
|
||||||
frontend:
|
|
||||||
phases:
|
|
||||||
preBuild:
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- npm run security:check
|
|
||||||
build:
|
|
||||||
commands:
|
|
||||||
- npm run build
|
|
||||||
- npm run test
|
|
||||||
artifacts:
|
|
||||||
baseDirectory: .next
|
|
||||||
files:
|
|
||||||
- '**/*'
|
|
||||||
cache:
|
|
||||||
paths:
|
|
||||||
- node_modules/**/*
|
|
||||||
- .next/cache/**/*
|
|
||||||
EOT
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
environment_variables = {
|
|
||||||
NEXT_PUBLIC_API_URL = "https://api.filamenteka.rs/api" # Using Cloudflare proxied subdomain
|
|
||||||
}
|
|
||||||
|
|
||||||
# Custom rules for single-page app
|
|
||||||
custom_rule {
|
|
||||||
source = "/<*>"
|
|
||||||
status = "404"
|
|
||||||
target = "/index.html"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Enable branch auto build
|
|
||||||
enable_branch_auto_build = true
|
|
||||||
|
|
||||||
tags = {
|
|
||||||
Name = "Filamenteka"
|
|
||||||
Environment = var.environment
|
|
||||||
# Disable GitHub App migration prompt
|
|
||||||
"amplify:github_app_migration" = "opted_out"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main branch
|
|
||||||
resource "aws_amplify_branch" "main" {
|
|
||||||
app_id = aws_amplify_app.filamenteka.id
|
|
||||||
branch_name = "main"
|
|
||||||
|
|
||||||
# Enable auto build
|
|
||||||
enable_auto_build = true
|
|
||||||
|
|
||||||
# Environment variables specific to this branch (optional)
|
|
||||||
environment_variables = {}
|
|
||||||
|
|
||||||
stage = "PRODUCTION"
|
|
||||||
|
|
||||||
tags = {
|
|
||||||
Name = "Filamenteka-main"
|
|
||||||
Environment = var.environment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Development branch (optional)
|
|
||||||
resource "aws_amplify_branch" "dev" {
|
|
||||||
app_id = aws_amplify_app.filamenteka.id
|
|
||||||
branch_name = "dev"
|
|
||||||
|
|
||||||
enable_auto_build = true
|
|
||||||
|
|
||||||
stage = "DEVELOPMENT"
|
|
||||||
|
|
||||||
tags = {
|
|
||||||
Name = "Filamenteka-dev"
|
|
||||||
Environment = "development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Custom domain (optional)
|
|
||||||
resource "aws_amplify_domain_association" "filamenteka" {
|
|
||||||
count = var.domain_name != "" ? 1 : 0
|
|
||||||
|
|
||||||
app_id = aws_amplify_app.filamenteka.id
|
|
||||||
domain_name = var.domain_name
|
|
||||||
wait_for_verification = false
|
|
||||||
|
|
||||||
# Map main branch to root domain
|
|
||||||
sub_domain {
|
|
||||||
branch_name = aws_amplify_branch.main.branch_name
|
|
||||||
prefix = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Map main branch to www subdomain
|
|
||||||
sub_domain {
|
|
||||||
branch_name = aws_amplify_branch.main.branch_name
|
|
||||||
prefix = "www"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Map dev branch to dev subdomain
|
|
||||||
sub_domain {
|
|
||||||
branch_name = aws_amplify_branch.dev.branch_name
|
|
||||||
prefix = "dev"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
output "app_id" {
|
# ===== DEPRECATED: Amplify Outputs (removed after migration to CloudFront) =====
|
||||||
description = "The ID of the Amplify app"
|
# output "app_id" {
|
||||||
value = aws_amplify_app.filamenteka.id
|
# description = "The ID of the Amplify app"
|
||||||
}
|
# value = aws_amplify_app.filamenteka.id
|
||||||
|
# }
|
||||||
output "app_url" {
|
#
|
||||||
description = "The default URL of the Amplify app"
|
# output "app_url" {
|
||||||
value = "https://main.${aws_amplify_app.filamenteka.default_domain}"
|
# description = "The default URL of the Amplify app"
|
||||||
}
|
# value = "https://main.${aws_amplify_app.filamenteka.default_domain}"
|
||||||
|
# }
|
||||||
output "dev_url" {
|
#
|
||||||
description = "The development branch URL"
|
# output "dev_url" {
|
||||||
value = "https://dev.${aws_amplify_app.filamenteka.default_domain}"
|
# description = "The development branch URL"
|
||||||
}
|
# value = "https://dev.${aws_amplify_app.filamenteka.default_domain}"
|
||||||
|
# }
|
||||||
output "custom_domain_url" {
|
#
|
||||||
description = "The custom domain URL (if configured)"
|
# output "custom_domain_url" {
|
||||||
value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
|
# description = "The custom domain URL (if configured)"
|
||||||
}
|
# value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
|
||||||
|
# }
|
||||||
|
|
||||||
output "rds_endpoint" {
|
output "rds_endpoint" {
|
||||||
value = aws_db_instance.filamenteka.endpoint
|
value = aws_db_instance.filamenteka.endpoint
|
||||||
@@ -59,3 +60,24 @@ output "aws_region" {
|
|||||||
description = "AWS Region"
|
description = "AWS Region"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# CloudFront + S3 Frontend Outputs
|
||||||
|
output "s3_bucket_name" {
|
||||||
|
value = aws_s3_bucket.frontend.id
|
||||||
|
description = "S3 bucket name for frontend"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cloudfront_distribution_id" {
|
||||||
|
value = aws_cloudfront_distribution.frontend.id
|
||||||
|
description = "CloudFront distribution ID"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cloudfront_domain_name" {
|
||||||
|
value = aws_cloudfront_distribution.frontend.domain_name
|
||||||
|
description = "CloudFront distribution domain name"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "frontend_url" {
|
||||||
|
value = var.domain_name != "" ? "https://${var.domain_name}" : "https://${aws_cloudfront_distribution.frontend.domain_name}"
|
||||||
|
description = "Frontend URL"
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
# Copy this file to terraform.tfvars and fill in your values
|
# Copy this file to terraform.tfvars and fill in your values
|
||||||
|
|
||||||
# GitHub repository for Amplify
|
|
||||||
github_repository = "https://github.com/yourusername/filamenteka"
|
|
||||||
github_token = "ghp_your_github_token_here"
|
|
||||||
|
|
||||||
# Domain configuration
|
# Domain configuration
|
||||||
domain_name = "filamenteka.yourdomain.com"
|
domain_name = "filamenteka.yourdomain.com"
|
||||||
|
|
||||||
# Cloudflare configuration (optional)
|
# Cloudflare configuration (optional)
|
||||||
cloudflare_api_token = "your-cloudflare-api-token"
|
cloudflare_api_token = "your-cloudflare-api-token"
|
||||||
|
|||||||
@@ -1,14 +1,3 @@
|
|||||||
variable "github_repository" {
|
|
||||||
description = "GitHub repository URL"
|
|
||||||
type = string
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "github_token" {
|
|
||||||
description = "GitHub personal access token for Amplify"
|
|
||||||
type = string
|
|
||||||
sensitive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "domain_name" {
|
variable "domain_name" {
|
||||||
description = "Custom domain name (optional)"
|
description = "Custom domain name (optional)"
|
||||||
type = string
|
type = string
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -12,7 +16,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -20,9 +24,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
}
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user