43 Commits

Author SHA1 Message Date
DaX
f564f944f7 Fix migration: clean /database/ before docker cp to prevent nesting
All checks were successful
Deploy / deploy-api (push) Successful in 40s
Deploy / deploy-frontend (push) Successful in 3m6s
Deploy / tag-deploy (push) Successful in 5s
Deploy / detect (push) Successful in 6s
2026-03-05 02:12:41 +01:00
DaX
629b9c1756 Make schema.sql idempotent for CI/CD migrations
Some checks failed
Deploy / detect (push) Successful in 4s
Deploy / deploy-api (push) Failing after 22s
Deploy / deploy-frontend (push) Successful in 2m20s
Deploy / tag-deploy (push) Has been skipped
Add IF NOT EXISTS to CREATE TABLE and CREATE INDEX statements.
Drop and recreate triggers to avoid errors on existing databases.
2026-03-05 02:09:45 +01:00
DaX
c5a7666ce6 Fix migration path: copy database/ to /database/ matching migrate.js path resolution
Some checks failed
Deploy / detect (push) Successful in 5s
Deploy / deploy-api (push) Failing after 22s
Deploy / deploy-frontend (push) Successful in 2m5s
Deploy / tag-deploy (push) Has been skipped
2026-03-05 02:07:13 +01:00
DaX
70b7713f2e Fix deploy: API runs in Docker container, use docker cp/exec/restart
Some checks failed
Deploy / detect (push) Successful in 5s
Deploy / deploy-api (push) Failing after 22s
Deploy / tag-deploy (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
EC2 runs the API in a Docker container named filamenteka-api.
Use docker cp for file transfer, docker exec for migrations,
and docker restart to apply changes.
2026-03-05 02:04:55 +01:00
DaX
da79307461 Diagnostic: inspect Docker container and compose setup on EC2
Some checks failed
Deploy / detect (push) Successful in 4s
Deploy / deploy-frontend (push) Has been cancelled
Deploy / deploy-api (push) Failing after 29s
Deploy / tag-deploy (push) Has been cancelled
2026-03-05 02:03:08 +01:00
DaX
3e4f576fd5 Fix all EC2 paths: server.js at /app/, node at /usr/local/bin/node
Some checks failed
Deploy / detect (push) Successful in 7s
Deploy / deploy-api (push) Failing after 24s
Deploy / deploy-frontend (push) Has been cancelled
Deploy / tag-deploy (push) Has been cancelled
EC2 runs node from /app/ directory (not /home/ubuntu/filamenteka-api).
No systemd service exists - restart by killing and re-launching node.
Use absolute path /usr/local/bin/node for all node invocations.
2026-03-05 02:00:59 +01:00
DaX
b24a1fea27 Diagnostic: find node binary path and working directory from running process
Some checks failed
Deploy / tag-deploy (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
Deploy / detect (push) Successful in 4s
Deploy / deploy-api (push) Failing after 28s
2026-03-05 01:59:18 +01:00
DaX
93df263214 Diagnostic: check Docker, running services, and OS on EC2
Some checks failed
Deploy / detect (push) Successful in 6s
Deploy / deploy-api (push) Failing after 30s
Deploy / tag-deploy (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
2026-03-05 01:57:57 +01:00
DaX
e50f361b07 Diagnostic: comprehensive node binary search without set -e
Some checks failed
Deploy / detect (push) Successful in 6s
Deploy / deploy-api (push) Failing after 36s
Deploy / tag-deploy (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
2026-03-05 01:56:30 +01:00
DaX
9594ce56cb Diagnostic: find node binary and systemd service config on EC2
Some checks failed
Deploy / detect (push) Successful in 8s
Deploy / deploy-api (push) Failing after 27s
Deploy / tag-deploy (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
2026-03-05 01:55:05 +01:00
DaX
11dbea3536 Fix migration: use find to locate node binary on EC2
Some checks failed
Deploy / detect (push) Successful in 7s
Deploy / deploy-api (push) Failing after 26s
Deploy / tag-deploy (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
2026-03-05 01:53:17 +01:00
DaX
6d04e16cf6 Fix migration: use login shell to find node binary in SSM context
Some checks failed
Deploy / detect (push) Successful in 4s
Deploy / deploy-api (push) Failing after 21s
Deploy / tag-deploy (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
2026-03-05 01:51:10 +01:00
DaX
85481512c8 Fix SSM parameter passing: use JSON files instead of inline parameters
Some checks failed
Deploy / detect (push) Successful in 4s
Deploy / deploy-api (push) Failing after 22s
Deploy / deploy-frontend (push) Successful in 2m3s
Deploy / tag-deploy (push) Has been skipped
AWS CLI was misinterpreting inline --parameters with embedded paths.
Use file:// JSON parameters for all SSM commands to avoid shell quoting issues.
2026-03-05 01:48:40 +01:00
DaX
1e95455139 Fix migration step: run node as ubuntu user for correct PATH
Some checks failed
Deploy / detect (push) Successful in 4s
Deploy / deploy-api (push) Failing after 14s
Deploy / deploy-frontend (push) Successful in 2m6s
Deploy / tag-deploy (push) Has been skipped
SSM runs as root with minimal PATH. Use sudo -iu ubuntu to get the
ubuntu user's login shell environment where node is available.
2026-03-05 01:46:16 +01:00
DaX
f929c384c1 Fix migration step: source nvm before running node on EC2
Some checks failed
Deploy / deploy-frontend (push) Successful in 2m5s
Deploy / tag-deploy (push) Has been skipped
Deploy / detect (push) Successful in 5s
Deploy / deploy-api (push) Failing after 22s
SSM runs with minimal PATH that doesn't include nvm-managed node.
Source nvm.sh before executing migrate.js.
2026-03-05 01:43:41 +01:00
DaX
291997caa5 Fix migration step: create api/ subdirectory and run migrate from project root
Some checks failed
Deploy / detect (push) Successful in 9s
Deploy / tag-deploy (push) Has been cancelled
Deploy / deploy-api (push) Failing after 26s
Deploy / deploy-frontend (push) Has been cancelled
mkdir -p the api/ and database/ directories before copying files since EC2
only has server.js at the project root. Run migrate.js from project root
so dotenv picks up the .env file correctly.
2026-03-05 01:42:01 +01:00
DaX
0741bd0d0e Fix tag-deploy condition to properly check individual job results
Some checks failed
Deploy / detect (push) Successful in 5s
Deploy / deploy-api (push) Failing after 29s
Deploy / tag-deploy (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
Use explicit job result checks instead of needs.*.result wildcard which
does not work in Gitea Actions. Prevent tagging when any deploy job fails.
2026-03-05 01:40:03 +01:00
DaX
746f0925d0 Fix CI/CD pipeline: use archive download for migrations, inline SSM paths
All checks were successful
Deploy / tag-deploy (push) Successful in 4s
Deploy / detect (push) Successful in 4s
Deploy / deploy-api (push) Has been skipped
Deploy / deploy-frontend (push) Successful in 2m5s
Replace complex Gitea API + python parsing in SSM with simple repo archive
download and extraction. Inline hardcoded paths in SSM commands instead of
shell variable expansion through single quotes to avoid quoting issues.
2026-03-05 01:33:19 +01:00
DaX
2985ea4457 Rewrite CI/CD pipeline with deploy-tag change detection and verified deployments
Some checks failed
Deploy / detect (push) Successful in 4s
Deploy / deploy-api (push) Failing after 22s
Deploy / deploy-frontend (push) Successful in 2m5s
Deploy / tag-deploy (push) Successful in 3s
Replace HEAD~1 diff with git tag-based change detection to catch all changes
since last successful deploy. Split into 4 parallel jobs: detect, frontend,
API (with migrations, health check, and rollback), and deploy tagging.
2026-03-05 01:19:13 +01:00
DaX
f2ae608b01 Trigger API redeploy for customers and sales endpoints
All checks were successful
Deploy / deploy (push) Successful in 15s
2026-03-05 01:09:09 +01:00
DaX
65ae493d54 Align catalog with Bambu Lab product line, add conditional filters and admin sidebar
All checks were successful
Deploy / deploy (push) Successful in 2m24s
- Add master catalog (bambuLabCatalog.ts) as single source of truth for materials, finishes, colors, and refill/spool availability
- Fix incorrect finish-per-material mappings (remove PLA: 85A/90A/95A HF/FR/GF/HF, add ASA: Basic/CF/Aero, fix PETG/PC)
- Implement cascading filters on public site: material restricts finish, finish restricts color
- Add AdminSidebar component across all admin pages
- Redirect /upadaj to /dashboard when already authenticated
- Update color hex mappings and tests to match official Bambu Lab names
2026-03-05 01:04:06 +01:00
DaX
ff6abdeef0 Add sales tracking system with customers, analytics, and inventory management
All checks were successful
Deploy / deploy (push) Successful in 2m26s
- Add customers table (021) and sales/sale_items tables (022) migrations
- Add customer CRUD, sale CRUD (transactional with auto inventory decrement/restore),
  and analytics API endpoints (overview, top sellers, revenue chart, inventory alerts)
- Add sales page with NewSaleModal (customer autocomplete, multi-item form,
  color-based pricing, stock validation) and SaleDetailModal
- Add customers page with search, inline editing, and purchase history
- Add analytics dashboard with recharts (revenue line chart, top sellers bar,
  refill vs spulna pie chart, inventory alerts table with stockout estimates)
- Add customerService, saleService, analyticsService to frontend API layer
- Update sidebar navigation on all admin pages
2026-03-04 23:58:57 +01:00
DaX
e9afe8bc35 Add temporary sales pause overlay until March 8
All checks were successful
Deploy / deploy (push) Successful in 1m58s
2026-02-27 20:09:53 +01:00
DaX
a854fd5524 Upgrade to Next.js 16.1.6, React 19.2.4, and update all dependencies
All checks were successful
Deploy / deploy (push) Successful in 59s
- Next.js 15.5 → 16.1.6 (Turbopack default, faster builds)
- React/React-DOM 19.1 → 19.2.4
- @types/react 18 → 19, @types/react-dom 18 → 19
- TypeScript 5.2 → 5.9
- CI Node.js 18 → 20 (required by Next.js 16)
- Replace removed next lint with standalone ESLint config
- Auto-updated tsconfig.json (jsx: react-jsx, dev types)
2026-02-16 02:39:22 +01:00
DaX
eff60436eb Clean up repo: remove amplify.yml, update gitignore
All checks were successful
Deploy / deploy (push) Successful in 1m29s
Delete deprecated Amplify config leftover from Gitea migration.
Update next-env.d.ts for Next.js 15.5 route types.
Add .mcp.json and *.png to gitignore.
2026-02-16 02:33:35 +01:00
DaX
6dda083ee9 Streamline CLAUDE.md: remove redundancy, fix inaccuracies
All checks were successful
Deploy / deploy (push) Successful in 1m22s
Remove exhaustive component/test/terraform listings that are easily
discoverable. Fix non-existent typecheck script reference. Consolidate
deployment info into single table. Add missing Matomo env vars and
single-test-file command.
2026-02-16 02:16:37 +01:00
DaX
bd27f7a9e4 Fix pip3 install on Ubuntu runner with --break-system-packages flag
All checks were successful
Deploy / deploy (push) Successful in 1m18s
2026-02-16 02:08:08 +01:00
DaX
145c2d4781 Optimize CI/CD pipeline for faster deploys
Some checks failed
Deploy / deploy (push) Failing after 1m12s
- Remove broken npm cache (was timing out ~5min per run)
- Replace AWS CLI v2 installer with pip3 install (~50MB less)
- Inline AWS credential config instead of separate action
- Merge steps to reduce overhead
2026-02-16 02:04:38 +01:00
DaX
28ba314404 Add missing filament colors and fix naming issues
All checks were successful
Deploy / deploy (push) Successful in 10m43s
- Add Silk Multi-Color gradients: Aurora Purple, Phantom Blue, Mystic Magenta
- Add Iron Gray Metallic alias (with space)
- Add Matte Latte Brown
2026-02-16 01:51:42 +01:00
DaX
0cfee1bda7 Update all dependencies to latest stable versions
All checks were successful
Deploy / deploy (push) Successful in 10m52s
Frontend: react 19.2.4, next 15.5.12, axios 1.13.5, jest 30.2.0,
typescript 5.9.3, tailwindcss 3.4.19, autoprefixer 10.4.24
API: cors 2.8.6, dotenv 16.6.1, express 4.22.1, pg 8.18.0,
jsonwebtoken 9.0.3, nodemon 3.1.11
2026-02-16 01:02:59 +01:00
DaX
7cd2058613 Add AWS CLI install step to deploy workflow
All checks were successful
Deploy / deploy (push) Successful in 10m52s
Runner image doesn't include AWS CLI by default.
2026-02-16 00:49:52 +01:00
DaX
c837af6015 Fix workflow: use env block instead of vars context
Some checks failed
Deploy / deploy (push) Failing after 5m59s
Gitea act runner doesn't resolve vars context properly.
Move all non-sensitive config to workflow-level env block.
2026-02-16 00:43:13 +01:00
DaX
58c165749d Migrate from GitHub to Gitea with CI/CD
Some checks failed
Deploy / deploy (push) Failing after 6m11s
- Add Gitea Actions workflow for automated frontend and API deployment
- Update all raw download URLs from GitHub to Gitea
- Remove deprecated Amplify config and GitHub-specific Terraform variables
- Clean up commented-out Amplify resources from Terraform
- Update documentation to reflect new repository and CI/CD setup
2026-02-16 00:35:08 +01:00
DaX
b7f5417e23 Move dashboard to root level route
- Dashboard now accessible at /dashboard instead of /upadaj/dashboard
- Updated all navigation links to use new /dashboard route
- Login page still at /upadaj, redirects to /dashboard on success
- Colors and requests remain under /upadaj prefix
- Updated test files to reference new dashboard path
2025-11-19 19:13:48 +01:00
DaX
17edfc8794 Fix CloudFront routing and TypeScript type safety
- Update CloudFront Function to handle Next.js static export .html files
- Fix TypeScript interface for color request service (add required user_phone field)
- Update ColorRequestForm component to include phone field
2025-11-19 18:56:08 +01:00
DaX
f6f9da9c5b Add bulk price editing features and fix quantity update price preservation
- Fix FilamentForm to preserve prices when updating quantities
  - Add isInitialLoad flag to prevent price override on form load
  - Only update prices when color is actively changed, not on initial render

- Add bulk filament price editor to dashboard
  - Filter by material and finish
  - Set refill and/or spool prices for multiple filaments
  - Preview changes before applying
  - Update all filtered filaments at once

- Add bulk color price editor to colors page
  - Edit prices for all colors in one interface
  - Global price application with search filtering
  - Individual price editing with change tracking

- Add auto-kill script for dev server
  - Kill existing processes on ports 3000/3001 before starting
  - Prevent "port already in use" errors
  - Clean start every time npm run dev is executed
2025-11-18 19:14:01 +01:00
DaX
b1dfa2352a Add 76 missing Bambu Lab colors and expand filament type support
- Added comprehensive color migration with 76 new Bambu Lab colors
- Includes glow, metallic, sparkle, gradient, ABS, and translucent variants
- Added PAHT material type with CF and Bez Finisha finish support
- Added Tough+ finish option for PLA filaments
- Fixed refill/spulna restrictions for specific combinations:
  - ABS GF Yellow/Orange now refill-only
  - TPU 95A HF now refill-only
  - Removed spool restrictions for Galaxy and Basic finishes
- Updated frontend color mappings with all new colors
- All colors now available in admin panel for inventory management
2025-11-13 07:02:06 +01:00
DaX
987039b0f7 Optimize frontend deployment with proper cache headers
Update deployment script to set appropriate cache-control headers:
- HTML files: no-cache to prevent stale content
- Next.js static assets: 1 year cache with immutable flag
- Other assets: 1 day cache for optimal performance

This resolves chronic caching issues with admin panel updates.
2025-10-31 03:39:25 +01:00
DaX
6bc1c8d16d Add CloudFront Function for directory index routing
- Create CloudFront Function to rewrite directory requests to index.html
- Fix admin login page routing issue (/upadaj -> /upadaj/index.html)
- Attach function to CloudFront distribution default cache behavior
- Enables proper routing for all admin pages without .html extension
2025-10-31 02:54:46 +01:00
DaX
d3e001707b Fix admin panel authentication and navigation for static export
- Replace router.push with window.location.href for reliable redirects
- Fix auth check to wait for component mount before accessing localStorage
- Ensure proper redirect after successful login
- Fix redirect behavior on all admin pages (dashboard, colors, requests)
2025-10-31 02:45:47 +01:00
DaX
c0ca1e4bb3 Fix CloudFront domain configuration and add missing color definitions
- Configure CloudFront to accept filamenteka.rs with ACM SSL certificate
- Add Cloudflare Transform Rule for Host header rewriting
- Fix Matte Grass Green hex code (#7CB342 -> #61C680)
- Add PLA Wood colors: Ochre Yellow, White Oak, Clay Brown
- Add script for managing color definitions in database
2025-10-31 02:40:03 +01:00
DaX
1ce127c51c Remove deprecated Amplify resources after CloudFront migration
- Comment out Amplify app, branches, and domain association
- Remove Amplify-related outputs
- Add migration notes for reference
- Amplify app deleted from AWS (dd6qls201bf9n)
2025-10-31 02:07:14 +01:00
DaX
fc95dc4ed2 Migrate frontend from Amplify to CloudFront + S3
- Add CloudFront distribution with S3 origin
- Configure S3 bucket with website hosting
- Add Origin Access Control (OAC) for security
- Configure SPA error handling (404/403 -> index.html)
- Add Cloudflare DNS records (commented out due to invalid token)
- Add deployment script for future updates
- Update outputs to include CloudFront info
2025-10-31 02:04:47 +01:00
49 changed files with 6855 additions and 2078 deletions

15
.eslintrc.json Normal file
View 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
View 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"

6
.gitignore vendored
View File

@@ -58,3 +58,9 @@ 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

240
CLAUDE.md
View File

@@ -5,166 +5,117 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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 # Run Jest in watch mode 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
./scripts/pre-commit.sh # Runs security, build, and test checks (use before commits)
# 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
# Database
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), `/requests` (customer color requests)
- `/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
- `/requests/page.tsx` - Customer color requests
- `/src` - Source files
- `/components` - React components
- `/services/api.ts` - Axios instance with auth interceptors
- `/data` - Color definitions (bambuLabColors.ts, bambuLabColorsComplete.ts)
- `/types` - TypeScript type definitions
### API Structure ### API (`/api/server.js`)
- `/api` - Node.js Express server (runs on EC2, port 80) Single-file Express server running on EC2 (port 80). All routes inline.
- `/server.js` - Main Express server with all routes inline - Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests`
- Database: PostgreSQL on AWS RDS - Auth: JWT tokens with 24h expiry, single admin user (password from `ADMIN_PASSWORD` env var)
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests`
### 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
- `ColorRequestForm` - Customer color request form
- `ColorRequestModal` - Modal for color requests
### 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 color_requests: id, color_name, message, contact_name, contact_phone, status
filaments: {
id: UUID,
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 `020_`)
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)
}
```
#### Color Requests Schema ## Key Patterns
```sql
color_requests: {
id: UUID,
color_name: VARCHAR(255), # Requested color name
message: TEXT, # Customer message
contact_name: VARCHAR(255), # Customer name (required)
contact_phone: VARCHAR(50), # Customer phone (required)
status: VARCHAR(20), # Status: pending, reviewed, fulfilled
created_at: TIMESTAMP
}
```
## Deployment ### Color Management (two-layer system)
1. **Database `colors` table**: Defines valid color names + pricing. Must be populated first due to FK constraint.
2. **Frontend `bambuLabColors.ts`**: Maps color names → hex codes for UI display. Supports solid (`hex: '#FF0000'`) and gradient (`hex: ['#HEX1', '#HEX2'], isGradient: true`).
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.
### Frontend (AWS Amplify) ### Adding New Colors
- Automatic deployment on push to main branch 1. Add to database `colors` table (via admin panel at `/upadaj/colors` or migration)
- Build output: Static files in `/out` directory 2. Add hex mapping to `src/data/bambuLabColors.ts`
- Config: `amplify.yml`, `next.config.js` (output: 'export') 3. Optionally add to `src/data/bambuLabColorsComplete.ts` finish groups
- Security check runs during build (amplify.yml preBuild phase)
### API Server (EC2)
- Manual deployment via `scripts/deploy-api.sh` or `scripts/deploy-api-update.sh`
- Server: `3.71.161.51`
- Domain: `api.filamenteka.rs`
- Service: `node-api` (systemd)
- IMPORTANT: When deploying API, remember to build for AMD64 Linux (not ARM macOS)
### Database (RDS PostgreSQL)
- Host: `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com`
- User: `filamenteka_admin`
- Database: `filamenteka`
- Migrations in `/database/migrations/`
- Schema in `/database/schema.sql`
- Use `scripts/update-db-via-aws.sh` for running migrations on production
## 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`
- 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 |
- Config: `jest.config.js`, `jest.setup.js` |-----------|--------|--------|
- Coverage goal: >80% | Frontend | S3 `filamenteka-frontend` → CloudFront | Static export, OAC-protected |
- Run with `npm test` or `npm run test:watch` | API | EC2 `i-03956ecf32292d7d9` | SSM, systemd `node-api` service |
- Tests include: component tests, API integration tests, data consistency checks | 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 (.env in /api directory) # API Server (.env in /api)
DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka
JWT_SECRET=... JWT_SECRET=...
ADMIN_PASSWORD=... ADMIN_PASSWORD=...
@@ -172,37 +123,10 @@ NODE_ENV=production
PORT=80 PORT=80
``` ```
## Security Considerations ## Pre-commit Hooks (Husky)
- Admin routes protected by JWT authentication (24h expiry) `scripts/pre-commit.sh` runs automatically and blocks commits that fail:
- Password hashing with bcrypt (for future multi-user support) 1. Author mention check (blocks attribution)
- SQL injection prevention via parameterized queries 2. Security check (credential leaks)
- Credential leak detection in pre-commit hooks (`scripts/security/security-check.js`) 3. Build test (ensures compilation)
- CORS configured to allow all origins (update for production hardening) 4. Unit tests (`jest --passWithNoTests`)
- Auth token interceptors handle 401/403 automatically
- Pre-commit hook runs security checks, build tests, and unit tests
## Database Operations
When modifying the database:
1. Create migration file in `/database/migrations/` with sequential numbering
2. Test locally first
3. Run migration on production:
- Use `scripts/update-db-via-aws.sh` for remote execution
- Or use `npm run migrate` for local/scripted execution
4. Update corresponding TypeScript types in `/src/types/`
Important database constraints:
- `filaments.boja` has foreign key to `colors.name` (ON UPDATE CASCADE)
- `filaments.kolicina` has check constraint: `kolicina = refill + spulna`
- Always update `colors` table first before adding filaments with new colors
## 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

View File

@@ -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

View File

@@ -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('Silk Aurora Purple');
expect(bambuLabColors).toHaveProperty('Silk Black'); expect(bambuLabColors).toHaveProperty('Silk Phantom Blue');
expect(bambuLabColors).toHaveProperty('Silk Gold'); expect(bambuLabColors).toHaveProperty('Silk Mystic Magenta');
}); });
it('should have valid hex colors', () => { it('should have valid hex colors', () => {
@@ -40,12 +40,12 @@ 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', () => {

View File

@@ -18,7 +18,7 @@ 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');

View File

@@ -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,7 +22,7 @@ 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
@@ -34,7 +34,7 @@ describe('UI Features Tests', () => {
}); });
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');
}); });

View File

@@ -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
View File

@@ -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"

View File

@@ -385,6 +385,420 @@ app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
} }
}); });
// ==========================================
// 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}`);
}); });

View File

@@ -6,59 +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+' || finish === 'Wood' || (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;
}
// All colors starting with "Matte " prefix are refill-only
if (color.startsWith('Matte ')) {
return true;
}
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 {
@@ -68,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[]>([]);
@@ -123,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 () => {
@@ -344,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">
@@ -381,24 +353,10 @@ export default function AdminDashboard() {
selectedFilaments={selectedFilaments} selectedFilaments={selectedFilaments}
onSaleUpdate={fetchFilaments} onSaleUpdate={fetchFilaments}
/> />
<button <BulkFilamentPriceEditor
onClick={() => router.push('/upadaj/colors')} filaments={filaments}
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 text-sm sm:text-base" onUpdate={fetchFilaments}
> />
Boje
</button>
<button
onClick={() => router.push('/upadaj/requests')}
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"
>
Zahtevi
</button>
<button
onClick={() => router.push('/')}
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"
>
Nazad na sajt
</button>
{mounted && ( {mounted && (
<button <button
onClick={() => setDarkMode(!darkMode)} onClick={() => setDarkMode(!darkMode)}
@@ -666,6 +624,7 @@ export default function AdminDashboard() {
</table> </table>
</div> </div>
</main> </main>
</div>
</div> </div>
</div> </div>
); );
@@ -690,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
@@ -698,6 +657,9 @@ 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")
@@ -727,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) {
@@ -755,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({
@@ -778,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({
@@ -855,17 +826,9 @@ 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>
@@ -879,7 +842,7 @@ 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 finiš</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>
@@ -959,9 +922,9 @@ 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`}
@@ -993,7 +956,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">
Refil 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 špulna postoji)</span> <span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo špulna postoji)</span>
)} )}
</label> </label>
@@ -1005,9 +968,9 @@ 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`}

View File

@@ -91,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">

View 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>
);
}

View File

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

View File

@@ -0,0 +1,493 @@
'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);
// 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 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 */}
<div className="mb-6">
<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"
/>
</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>
</div>
);
}

View File

@@ -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);

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { colorRequestService } from '@/src/services/api'; import { colorRequestService } from '@/src/services/api';
import Link from 'next/link'; import Link from 'next/link';
import { AdminSidebar } from '@/src/components/AdminSidebar';
interface ColorRequest { interface ColorRequest {
id: string; id: string;
@@ -42,7 +43,7 @@ export default function ColorRequestsAdmin() {
const expiry = localStorage.getItem('tokenExpiry'); const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || new Date().getTime() > parseInt(expiry)) { if (!token || !expiry || new Date().getTime() > parseInt(expiry)) {
router.push('/upadaj'); window.location.href = '/upadaj';
} }
}; };
@@ -120,23 +121,11 @@ export default function ColorRequestsAdmin() {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto px-4 py-8"> <div className="flex">
<AdminSidebar />
<div className="flex-1 p-8">
<div className="flex justify-between items-center mb-6"> <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> <h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
<div className="space-x-4">
<Link
href="/upadaj/dashboard"
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Inventar
</Link>
<Link
href="/upadaj/colors"
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Boje
</Link>
</div>
</div> </div>
{error && ( {error && (
@@ -354,6 +343,7 @@ export default function ColorRequestsAdmin() {
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
</div> </div>
); );

827
app/upadaj/sales/page.tsx Normal file
View 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"
>
&times;
</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"
>
&times;
</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>
);
}

View 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;

View 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);

View 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);

View File

@@ -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
View File

@@ -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.

2896
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
} }
} }

View 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();

View File

@@ -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
View 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
View 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!"

View File

@@ -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..."

View 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>
)
}

View 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>
)}
</>
);
}

View 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>
)}
</>
);
}

View File

@@ -14,6 +14,7 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
finish_type: 'Basic', finish_type: 'Basic',
user_name: '', user_name: '',
user_email: '', user_email: '',
user_phone: '',
description: '', description: '',
reference_url: '' reference_url: ''
}); });
@@ -37,6 +38,7 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
finish_type: 'Basic', finish_type: 'Basic',
user_name: '', user_name: '',
user_email: '', user_email: '',
user_phone: '',
description: '', description: '',
reference_url: '' reference_url: ''
}); });
@@ -136,12 +138,13 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
<div> <div>
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email (opciono) Email *
</label> </label>
<input <input
type="email" type="email"
id="user_email" id="user_email"
name="user_email" name="user_email"
required
value={formData.user_email} value={formData.user_email}
onChange={handleChange} 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" 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"
@@ -150,6 +153,23 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
</div> </div>
</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"> <div className="flex justify-end">
<button <button

View File

@@ -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 });
trackEvent('Filter', 'Material', e.target.value || 'All');
}}
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 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 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 });
trackEvent('Filter', 'Finish', e.target.value || 'All');
}}
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 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 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 });
trackEvent('Filter', 'Color', e.target.value || 'All');
}}
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 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 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>

View File

@@ -145,6 +145,7 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
filters={filters} filters={filters}
onFilterChange={setFilters} onFilterChange={setFilters}
uniqueValues={uniqueValues} uniqueValues={uniqueValues}
inventoryFilaments={filaments}
/> />

407
src/data/bambuLabCatalog.ts Normal file
View File

@@ -0,0 +1,407 @@
// 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 },
],
},
'Basic Gradient': {
colors: [
{ name: 'Neon City', refill: false, spool: true },
{ name: 'Midnight Blaze', refill: false, spool: true },
{ name: 'South Beach', refill: false, spool: true },
{ 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: 'Velvet Eclipse', refill: false, spool: true },
{ name: 'Dawn Radiance', refill: false, spool: true },
{ name: 'Dusk Glare', refill: false, spool: true },
{ name: 'Blueberry Bubblegum', refill: false, spool: true },
{ name: 'Blue Hawaii', refill: false, spool: true },
{ name: 'Gilded Rose', 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 },
],
},
'Silk Multi-Color': {
colors: [
{ name: 'Silk Aurora Purple', refill: false, spool: true },
{ name: 'Silk Phantom Blue', refill: false, spool: true },
{ name: 'Silk Mystic Magenta', 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 },
],
},
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 },
],
},
'Tough+': {
colors: [
{ name: 'Black', refill: true, 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: 'Jade White', refill: true, spool: true },
{ name: 'Black', 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: '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 Tea', 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 },
],
},
},
TPU: {
'85A': {
colors: [
{ name: 'Black', refill: false, spool: true },
{ name: 'White', refill: false, spool: true },
{ name: 'Flesh', refill: false, spool: true },
{ name: 'Light Cyan', refill: false, spool: true },
{ name: 'Neon Orange', 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 },
],
},
},
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: {
GF: {
colors: [
{ name: 'Black', 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 },
],
},
FR: {
colors: [
{ name: 'Black', 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);
}

View File

@@ -34,6 +34,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Cherry Pink': { hex: '#E9B6CC' }, 'Cherry Pink': { hex: '#E9B6CC' },
'Chocolate': { hex: '#4A3729' }, 'Chocolate': { hex: '#4A3729' },
'Classic Birch': { hex: '#E8D5B7' }, 'Classic Birch': { hex: '#E8D5B7' },
'Classic Gold Sparkle': { hex: '#E4BD68' },
'Clay Brown': { hex: '#8E621A' }, 'Clay Brown': { hex: '#8E621A' },
'Clear': { hex: '#FAFAFA' }, 'Clear': { hex: '#FAFAFA' },
'Clear Black': { hex: '#5A5161' }, 'Clear Black': { hex: '#5A5161' },
@@ -71,6 +72,8 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Indigo Purple': { hex: '#482A60' }, 'Indigo Purple': { hex: '#482A60' },
'Iridium Gold Metallic': { hex: '#B39B84' }, 'Iridium Gold Metallic': { hex: '#B39B84' },
'Iris Purple': { hex: '#69398E' }, 'Iris Purple': { hex: '#69398E' },
'Iron Gray Metallic': { hex: '#6B6C6F' },
'IronGray Metallic': { hex: '#6B6C6F' },
'Ivory White': { hex: '#FFFFFF' }, 'Ivory White': { hex: '#FFFFFF' },
'Jade White': { hex: '#FFFFFF' }, 'Jade White': { hex: '#FFFFFF' },
'Jeans Blue': { hex: '#6E88BC' }, 'Jeans Blue': { hex: '#6E88BC' },
@@ -100,6 +103,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Nardo Gray': { hex: '#747474' }, 'Nardo Gray': { hex: '#747474' },
'Navy Blue': { hex: '#0C2340' }, 'Navy Blue': { hex: '#0C2340' },
'Nebulae': { hex: '#424379' }, 'Nebulae': { hex: '#424379' },
'Nebulane': { hex: '#424379' },
'Neon City': { hex: '#0047BB' }, 'Neon City': { hex: '#0047BB' },
'Neon Green': { hex: '#ABFF1E' }, 'Neon Green': { hex: '#ABFF1E' },
'Neon Orange': { hex: '#F68A1B' }, 'Neon Orange': { hex: '#F68A1B' },
@@ -142,7 +146,40 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'White Oak': { hex: '#D2CCA2' }, 'White Oak': { hex: '#D2CCA2' },
'Yellow': { hex: '#F4EE2A' }, 'Yellow': { hex: '#F4EE2A' },
// PLA Matte - New Colors (2025) // ABS Colors
'ABS GF Yellow': { hex: '#FDD835' },
'ABS GF Orange': { hex: '#F48438' },
'ABS Azure': { hex: '#489FDF' },
'ABS Olive': { hex: '#748C45' },
'ABS Blue': { hex: '#0A2989' },
'ABS Tangerine Yellow': { hex: '#FFC72C' },
'ABS Navy Blue': { hex: '#0C2340' },
'ABS Orange': { hex: '#FF6A13' },
'ABS Bambu Green': { hex: '#00AE42' },
'ABS Red': { hex: '#C12E1F' },
'ABS White': { hex: '#FFFFFF' },
'ABS Black': { hex: '#000000' },
'ABS Silver': { hex: '#A6A9AA' },
// PETG Translucent Colors
'Translucent Gray': { hex: '#B8B8B8' },
'Translucent Brown': { hex: '#C89A74' },
'Translucent Purple': { hex: '#C5A8D8' },
'Translucent Orange': { hex: '#FFB380' },
'Translucent Olive': { hex: '#A4B885' },
'Translucent Pink': { hex: '#F9B8D0' },
'Translucent Light Blue': { hex: '#A8D8F0' },
'Translucent Tea': { hex: '#D9C7A8' },
// PLA Matte Colors
'Matte Ivory White': { hex: '#FFFFF0' },
'Matte Charcoal': { hex: '#333333' },
'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: '#747474' },
'Matte Apple Green': { hex: '#C6E188' }, 'Matte Apple Green': { hex: '#C6E188' },
'Matte Bone White': { hex: '#C8C5B6' }, 'Matte Bone White': { hex: '#C8C5B6' },
'Matte Caramel': { hex: '#A4845C' }, 'Matte Caramel': { hex: '#A4845C' },
@@ -158,8 +195,21 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Matte Plum': { hex: '#851A52' }, 'Matte Plum': { hex: '#851A52' },
'Matte Sakura Pink': { hex: '#E8AFCF' }, 'Matte Sakura Pink': { hex: '#E8AFCF' },
'Matte Sky Blue': { hex: '#73B2E5' }, 'Matte Sky Blue': { hex: '#73B2E5' },
'Matte Latte Brown': { hex: '#D3B7A7' },
'Matte Terracotta': { hex: '#A25A37' }, 'Matte Terracotta': { hex: '#A25A37' },
// PLA Silk Multi-Color
'Silk Aurora Purple': { hex: ['#7F3696', '#006EC9'], isGradient: true },
'Silk Phantom Blue': { hex: ['#00629B', '#000000'], isGradient: true },
'Silk Mystic Magenta': { hex: ['#720062', '#3A913F'], isGradient: true },
// TPU Colors
'Flesh': { hex: '#E8C4A2' },
'Grape Jelly': { hex: '#6B2D75' },
'Crystal Blue': { hex: '#5BC0EB' },
'Quicksilver': { hex: '#A6A9AA' },
'TPU 95A HF Yellow': { hex: '#F3E600' },
// Default fallback // Default fallback
'Unknown': { hex: '#CCCCCC' } 'Unknown': { hex: '#CCCCCC' }
}; };

View File

@@ -1,6 +1,12 @@
// 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
import { BAMBU_LAB_CATALOG } from './bambuLabCatalog';
// Flat hex lookup (for backwards compatibility)
export const bambuLabColors: Record<string, string> = {
// PLA Basic
"Jade White": "#FFFFFF",
"Black": "#000000", "Black": "#000000",
"White": "#FFFFFF", "White": "#FFFFFF",
"Red": "#E53935", "Red": "#E53935",
@@ -8,39 +14,64 @@ export const bambuLabColors = {
"Green": "#43A047", "Green": "#43A047",
"Yellow": "#FDD835", "Yellow": "#FDD835",
"Orange": "#FB8C00", "Orange": "#FB8C00",
"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": "#D0D2D4",
"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",
"Cobalt Blue": "#0055B8",
"Mistletoe Green": "#3F8E43",
"Dark Red": "#BB3D43",
"Hot Pink": "#F5547D",
"Lavender": "#B5AAD5",
"Sunflower Yellow": "#FEC601",
"Pumpkin Orange": "#FF8E16",
"Lime": "#C5ED48",
"Blue Grey": "#5B6579",
"Gold": "#E4BD68",
"Bright Green": "#BDCF00",
"Maroon Red": "#0A2989",
"Turquoise": "#00B1B7",
"Bronze": "#847D48",
"Silver": "#A6A9AA",
"Dark Gray": "#555555",
// Matte Colors // PLA Basic Gradient
"Matte Black": "#212121", "Neon City": "#0047BB",
"Matte White": "#FAFAFA", "Midnight Blaze": "#0047BB",
"Matte Red": "#C62828", "South Beach": "#468791",
"Matte Blue": "#1565C0", "Arctic Whisper": "#ECF7F8",
"Matte Green": "#2E7D32", "Cotton Candy Cloud": "#E9E2EC",
"Matte Yellow": "#F9A825", "Ocean to Meadow": "#A1E4CA",
"Matte Orange": "#EF6C00", "Solar Breeze": "#F3D9D5",
"Matte Purple": "#6A1B9A", "Velvet Eclipse": "#000000",
"Matte Pink": "#D81B60", "Dawn Radiance": "#C472A1",
"Matte Grey": "#616161", "Dusk Glare": "#F6B790",
"Matte Brown": "#4E342E", "Blueberry Bubblegum": "#BADCF4",
"Matte Mint": "#26A69A", "Blue Hawaii": "#739FE6",
"Matte Lime": "#9E9D24", "Gilded Rose": "#ED982C",
"Matte Navy": "#1A237E", "Pink Citrus": "#F8C4BC",
"Matte Coral": "#FF5252", "Mint Lime": "#BAF382",
// Matte Colors - New 2025 // PLA Matte
"Matte Ivory White": "#FFFFF0",
"Matte Charcoal": "#333333",
"Matte Scarlet Red": "#DE4343",
"Matte Marine Blue": "#0078BF",
"Matte Mandarin Orange": "#F99963",
"Matte Ash Gray": "#9B9EA0",
"Matte Desert Tan": "#E8DBB7",
"Matte Nardo Gray": "#747474",
"Matte Apple Green": "#C6E188", "Matte Apple Green": "#C6E188",
"Matte Bone White": "#C8C5B6", "Matte Bone White": "#C8C5B6",
"Matte Caramel": "#A4845C", "Matte Caramel": "#A4845C",
@@ -56,110 +87,149 @@ export const bambuLabColors = {
"Matte Plum": "#851A52", "Matte Plum": "#851A52",
"Matte Sakura Pink": "#E8AFCF", "Matte Sakura Pink": "#E8AFCF",
"Matte Sky Blue": "#73B2E5", "Matte Sky Blue": "#73B2E5",
"Matte Latte Brown": "#D3B7A7",
"Matte Terracotta": "#A25A37", "Matte Terracotta": "#A25A37",
// Silk Colors // PLA Silk+
"Silk White": "#FEFEFE", "Candy Green": "#408619",
"Silk Black": "#0A0A0A", "Candy Red": "#BB3A2E",
"Silk Red": "#F44336", "Mint": "#A5DAB7",
"Silk Blue": "#2196F3", "Titan Gray": "#606367",
"Silk Green": "#4CAF50", "Rose Gold": "#B29593",
"Silk Gold": "#FFD54F", "Champagne": "#EBD0B1",
"Silk Silver": "#CFD8DC", "Baby Blue": "#AEC3ED",
"Silk Purple": "#9C27B0",
"Silk Pink": "#F06292",
"Silk Orange": "#FF9800",
"Silk Bronze": "#A1887F",
"Silk Copper": "#BF6F3F",
"Silk Jade": "#00897B",
"Silk Rose Gold": "#E8A09A",
"Silk Pearl": "#F8F8FF",
"Silk Ruby": "#E91E63",
"Silk Sapphire": "#1976D2",
"Silk Emerald": "#00695C",
// Metal Colors // PLA Silk Multi-Color
"Metal Grey": "#9E9E9E", "Silk Aurora Purple": "#7F3696",
"Metal Silver": "#B0BEC5", "Silk Phantom Blue": "#00629B",
"Metal Gold": "#D4AF37", "Silk Mystic Magenta": "#720062",
"Metal Copper": "#B87333",
"Metal Bronze": "#CD7F32",
// Sparkle Colors // PLA Metal
"Sparkle Red": "#EF5350", "Iron Gray Metallic": "#6B6C6F",
"Sparkle Blue": "#42A5F5", "Iridium Gold Metallic": "#B39B84",
"Sparkle Green": "#66BB6A", "Cobalt Blue Metallic": "#39699E",
"Sparkle Purple": "#AB47BC", "Copper Brown Metallic": "#AA6443",
"Sparkle Gold": "#FFCA28", "Oxide Green Metallic": "#1D7C6A",
"Sparkle Silver": "#E0E0E0",
// Glow Colors // PLA Sparkle
"Glow in the Dark Green": "#C8E6C9", "Onyx Black Sparkle": "#2D2B28",
"Glow in the Dark Blue": "#BBDEFB", "Classic Gold Sparkle": "#E4BD68",
"Crimson Red Sparkle": "#792B36",
"Royal Purple Sparkle": "#483D8B",
"Slate Gray Sparkle": "#8E9089",
"Alpine Green Sparkle": "#3F5443",
// Transparent Colors // PLA Galaxy
"Clear": "#FFFFFF", "Nebulae": "#424379",
"Transparent Red": "#EF5350",
"Transparent Blue": "#42A5F5",
"Transparent Green": "#66BB6A",
"Transparent Yellow": "#FFEE58",
"Transparent Orange": "#FFA726",
"Transparent Purple": "#AB47BC",
// Support Materials // PLA Marble
"Natural": "#F5F5DC", "White Marble": "#F7F3F0",
"Support White": "#F5F5F5", "Red Granite": "#AD4E38",
"Support G": "#90CAF9"
// PLA Glow
"Glow Blue": "#7AC0E9",
"Glow Green": "#A1FFAC",
"Glow Orange": "#FF9D5B",
"Glow Pink": "#F17B8F",
"Glow Yellow": "#F8FF80",
// PLA Wood
"Ochre Yellow": "#BC8B39",
"White Oak": "#D2CCA2",
"Clay Brown": "#8E621A",
// PLA CF
"Burgundy Red": "#951E23",
"Jeans Blue": "#6E88BC",
"Lava Gray": "#4D5054",
"Matcha Green": "#5C9748",
"Royal Blue": "#2842AD",
"Iris Purple": "#69398E",
// PETG HF
"Cream": "#F3E0B8",
"Forest Green": "#415520",
"Lake Blue": "#4672E4",
"Lime Green": "#8EE43D",
"Peanut Brown": "#7E5A1F",
// PETG Translucent
"Clear": "#FAFAFA",
"Translucent Gray": "#B8B8B8",
"Translucent Brown": "#C89A74",
"Translucent Purple": "#C5A8D8",
"Translucent Orange": "#FFB380",
"Translucent Olive": "#A4B885",
"Translucent Pink": "#F9B8D0",
"Translucent Light Blue": "#A8D8F0",
"Translucent Tea": "#D9C7A8",
// PETG CF
"Brick Red": "#9F332A",
"Indigo Blue": "#324585",
"Malachite Green": "#16B08E",
"Violet Purple": "#583061",
// ABS
"ABS Azure": "#489FDF",
"ABS Black": "#000000",
"ABS Blue": "#0A2989",
"ABS Olive": "#748C45",
"ABS Tangerine Yellow": "#FFC72C",
"ABS Navy Blue": "#0C2340",
"ABS Orange": "#FF6A13",
"ABS Bambu Green": "#00AE42",
"ABS Red": "#C12E1F",
"ABS White": "#FFFFFF",
"ABS Silver": "#A6A9AA",
"ABS GF Yellow": "#FDD835",
"ABS GF Orange": "#F48438",
// TPU
"Flesh": "#E8C4A2",
"Light Cyan": "#B9E3DF",
"Neon Orange": "#F68A1B",
"Blaze": "#E78390",
"Frozen": "#A6DEF3",
"Grape Jelly": "#6B2D75",
"Crystal Blue": "#5BC0EB",
"Quicksilver": "#A6A9AA",
"Cocoa Brown": "#6F5034",
"TPU 95A HF Yellow": "#F3E600",
// PC
"Clear Black": "#5A5161",
"Transparent": "#FFFFFF",
}; };
// 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);
"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" }
],
"Silk": [ // Sort each finish's colors
"Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green", for (const finish of Object.keys(colorsByFinish)) {
"Silk Gold", "Silk Silver", "Silk Purple", "Silk Pink", "Silk Orange", colorsByFinish[finish].sort();
"Silk Bronze", "Silk Copper", "Silk Jade", "Silk Rose Gold", "Silk Pearl", }
"Silk Ruby", "Silk Sapphire", "Silk Emerald"
],
"Metal": [
"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] || [];
} }

View File

@@ -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';
@@ -111,7 +112,8 @@ export const colorRequestService = {
color_name: string; color_name: string;
material_type: string; material_type: string;
finish_type?: string; finish_type?: string;
user_email?: string; user_email: string;
user_phone: string;
user_name?: string; user_name?: string;
description?: string; description?: string;
reference_url?: string; reference_url?: string;
@@ -131,4 +133,79 @@ export const colorRequestService = {
}, },
}; };
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;

91
src/types/sales.ts Normal file
View 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;
}

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

View 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;
}

View File

@@ -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"
}
}

View File

@@ -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"
}

View File

@@ -1,9 +1,5 @@
# 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"

View File

@@ -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

View File

@@ -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"
]
} }