25 Commits

Author SHA1 Message Date
DaX
d55d0e5e02 Clean up fake colors and add missing Bambu Lab colors
All checks were successful
Deploy / detect (push) Successful in 4s
Deploy / deploy-api (push) Successful in 34s
Deploy / deploy-frontend (push) Successful in 2m4s
Deploy / tag-deploy (push) Successful in 4s
- Add migration 023: delete 67 fake colors, rename IronGray typo, add 16 missing real colors from spreadsheet
- Fix frontend color mappings: remove typo duplicates, rename Silk prefixed colors to match catalog
- Add Translucent Teal, Phantom Blue, Mystic Magenta mappings
- Remove non-existent ABS GF, TPU 95A HF entries
- Update tests for corrected color names
2026-03-05 03:04:19 +01:00
DaX
cff73c1381 Add manual customer creation and fix color edit modal
All checks were successful
Deploy / detect (push) Successful in 4s
Deploy / deploy-frontend (push) Successful in 2m24s
Deploy / tag-deploy (push) Successful in 5s
Deploy / deploy-api (push) Has been skipped
- Add "Dodaj kupca" button and modal form to customers page
- Fix color edit modal dark mode styling and add backdrop blur
2026-03-05 02:35:13 +01:00
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
28 changed files with 4111 additions and 449 deletions

View File

@@ -13,65 +13,100 @@ env:
NEXT_PUBLIC_MATOMO_SITE_ID: "7" NEXT_PUBLIC_MATOMO_SITE_ID: "7"
jobs: jobs:
deploy: # ── Job 1: Change Detection ──────────────────────────────────────────
detect:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
api: ${{ steps.changes.outputs.api }}
migrations: ${{ steps.changes.outputs.migrations }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 0
- name: Detect changes - name: Detect changes since last deploy
id: changes id: changes
run: | run: |
FRONTEND_CHANGED=false # Find the latest deploy tag
API_CHANGED=false LAST_TAG=$(git tag -l 'deploy-*' --sort=-creatordate | head -n 1)
if git diff --name-only HEAD~1 HEAD | grep -qvE '^api/'; then if [ -z "$LAST_TAG" ]; then
FRONTEND_CHANGED=true 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 fi
if git diff --name-only HEAD~1 HEAD | grep -qE '^api/'; then echo "Last deploy tag: $LAST_TAG"
API_CHANGED=true 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 fi
echo "frontend=$FRONTEND_CHANGED" >> $GITHUB_OUTPUT echo "frontend=$FRONTEND" >> $GITHUB_OUTPUT
echo "api=$API_CHANGED" >> $GITHUB_OUTPUT echo "api=$API" >> $GITHUB_OUTPUT
echo "Frontend changed: $FRONTEND_CHANGED" echo "migrations=$MIGRATIONS" >> $GITHUB_OUTPUT
echo "API changed: $API_CHANGED" 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
# ── Frontend Deploy ──────────────────────────────────────────────
- name: Setup Node.js - name: Setup Node.js
if: steps.changes.outputs.frontend == 'true'
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
- name: Install dependencies - name: Install dependencies
if: steps.changes.outputs.frontend == 'true'
run: npm ci run: npm ci
- name: Security check & tests - name: Security check & tests
if: steps.changes.outputs.frontend == 'true'
run: | run: |
npm run security:check npm run security:check
npm test -- --passWithNoTests npm test -- --passWithNoTests
- name: Build Next.js - name: Build Next.js
if: steps.changes.outputs.frontend == 'true'
run: npm run build 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 - name: Setup AWS
if: steps.changes.outputs.frontend == 'true' || steps.changes.outputs.api == 'true'
run: | run: |
pip3 install -q --break-system-packages awscli pip3 install -q --break-system-packages awscli
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}" 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 aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
aws configure set region eu-central-1 aws configure set region ${{ env.AWS_REGION }}
- name: Deploy to S3 & invalidate CloudFront - name: Deploy to S3 & invalidate CloudFront
if: steps.changes.outputs.frontend == 'true'
run: | run: |
# HTML files — no cache
aws s3 sync out/ s3://$S3_BUCKET/ \ aws s3 sync out/ s3://$S3_BUCKET/ \
--delete \ --delete \
--exclude "*" \ --exclude "*" \
@@ -79,9 +114,11 @@ jobs:
--cache-control "public, max-age=0, must-revalidate" \ --cache-control "public, max-age=0, must-revalidate" \
--content-type "text/html" --content-type "text/html"
# Next.js assets — immutable
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \ aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
--cache-control "public, max-age=31536000, immutable" --cache-control "public, max-age=31536000, immutable"
# Everything else — 24h cache
aws s3 sync out/ s3://$S3_BUCKET/ \ aws s3 sync out/ s3://$S3_BUCKET/ \
--exclude "*.html" \ --exclude "*.html" \
--exclude "_next/*" \ --exclude "_next/*" \
@@ -91,20 +128,161 @@ jobs:
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \ --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*" --paths "/*"
# ── API Deploy ─────────────────────────────────────────────────── # ── Job 3: API & Migrations Deploy ──────────────────────────────────
- name: Deploy API via SSM deploy-api:
if: steps.changes.outputs.api == 'true' runs-on: ubuntu-latest
needs: detect
if: needs.detect.outputs.api == 'true' || needs.detect.outputs.migrations == 'true'
steps:
- name: Setup AWS
run: | 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 \ aws ssm send-command \
--region $AWS_REGION \ --region $AWS_REGION \
--instance-ids "$INSTANCE_ID" \ --instance-ids "$INSTANCE_ID" \
--document-name "AWS-RunShellScript" \ --document-name "AWS-RunShellScript" \
--parameters 'commands=[ --parameters file:///tmp/rollback-params.json \
"cd /home/ubuntu/filamenteka-api",
"cp server.js server.js.backup",
"curl -o server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js",
"sudo systemctl restart node-api",
"sudo systemctl status node-api"
]' \
--output json --output json
echo "API deploy command sent via SSM" 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"

View File

@@ -48,14 +48,14 @@ scripts/deploy-frontend.sh # Manual frontend deploy to S3/CloudFront
### Frontend (Next.js App Router) ### Frontend (Next.js App Router)
- `/app/page.tsx` — Public filament inventory table (home page) - `/app/page.tsx` — Public filament inventory table (home page)
- `/app/upadaj/` — Admin panel: login, `/dashboard` (filament CRUD), `/colors` (color management), `/requests` (customer color requests) - `/app/upadaj/` — Admin panel: login, `/dashboard` (filament CRUD), `/colors` (color management), `/customers` (customer management), `/sales` (sales recording), `/requests` (customer color requests), `/analytics` (revenue/inventory dashboards)
- `/src/services/api.ts` — Axios instance with auth interceptors (auto token injection, 401/403 redirect) - `/src/services/api.ts` — Axios instance with auth interceptors (auto token injection, 401/403 redirect)
- `/src/data/bambuLabColors.ts` — Primary color-to-hex mapping with gradient support (used by `ColorCell` component) - `/src/data/bambuLabColors.ts` — Primary color-to-hex mapping with gradient support (used by `ColorCell` component)
- `/src/data/bambuLabColorsComplete.ts` — Extended color database grouped by finish type (used by filters) - `/src/data/bambuLabColorsComplete.ts` — Extended color database grouped by finish type (used by filters)
### API (`/api/server.js`) ### API (`/api/server.js`)
Single-file Express server running on EC2 (port 80). All routes inline. Single-file Express server running on EC2 (port 80). All routes inline.
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests` - Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/customers`, `/api/sales`, `/api/color-requests`, `/api/analytics/*`
- Auth: JWT tokens with 24h expiry, single admin user (password from `ADMIN_PASSWORD` env var) - Auth: JWT tokens with 24h expiry, single admin user (password from `ADMIN_PASSWORD` env var)
### Database (PostgreSQL on RDS) ### Database (PostgreSQL on RDS)
@@ -64,13 +64,15 @@ Key schemas and constraints:
``` ```
filaments: id, tip (material), finish, boja (color), refill, spulna, kolicina, cena, sale_* filaments: id, tip (material), finish, boja (color), refill, spulna, kolicina, cena, sale_*
colors: id, name, hex, cena_refill (default 3499), cena_spulna (default 3999) colors: id, name, hex, cena_refill (default 3499), cena_spulna (default 3999)
customers: id, name, phone (unique), city, notes, created_at
sales: id, customer_id (FK→customers), total_amount, notes, created_at + sale_items join table
color_requests: id, color_name, message, contact_name, contact_phone, status color_requests: id, color_name, message, contact_name, contact_phone, status
``` ```
**Critical constraints:** **Critical constraints:**
- `filaments.boja` → FK to `colors.name` (ON UPDATE CASCADE) — colors must exist before filaments reference them - `filaments.boja` → FK to `colors.name` (ON UPDATE CASCADE) — colors must exist before filaments reference them
- `kolicina = refill + spulna` — enforced by check constraint - `kolicina = refill + spulna` — enforced by check constraint
- Migrations in `/database/migrations/` with sequential numbering (currently up to `020_`) - Migrations in `/database/migrations/` with sequential numbering (currently up to `023_`)
## Key Patterns ## Key Patterns
@@ -86,7 +88,7 @@ color_requests: id, color_name, message, contact_name, contact_phone, status
3. Optionally add to `src/data/bambuLabColorsComplete.ts` finish groups 3. Optionally add to `src/data/bambuLabColorsComplete.ts` finish groups
### API Communication ### API Communication
- Service modules in `src/services/api.ts`: `authService`, `colorService`, `filamentService`, `colorRequestService` - Service modules in `src/services/api.ts`: `authService`, `colorService`, `filamentService`, `colorRequestService`, `customerService`, `saleService`, `analyticsService`
- Auth token stored in localStorage with 24h expiry - Auth token stored in localStorage with 24h expiry
- Cache busting on filament fetches via timestamp query param - Cache busting on filament fetches via timestamp query param

View File

@@ -33,9 +33,9 @@ describe('Bambu Lab Colors Data', () => {
}); });
}); });
it('should have Unknown fallback color', () => { it('should return fallback for unknown colors', () => {
expect(bambuLabColors).toHaveProperty('Unknown'); const result = getFilamentColor('NonExistentColor');
expect(bambuLabColors.Unknown.hex).toBe('#CCCCCC'); expect(result.hex).toBe('#CCCCCC');
}); });
}); });

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

@@ -47,20 +47,17 @@ describe('UI Features Tests', () => {
const adminDashboardPath = join(process.cwd(), 'app', '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', '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

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

@@ -7,70 +7,40 @@ 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';
import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor'; import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor';
// Removed unused imports for Bambu Lab color categorization 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;
}
// Specific type/finish/color combinations that are refill-only
if (type === 'ABS' && finish === 'GF' && (color === 'Yellow' || color === 'Orange')) {
return true;
}
if (type === 'TPU' && finish === '95A HF') {
return true;
}
// All colors starting with "Matte " prefix are refill-only
if (color.startsWith('Matte ')) {
return true;
}
// Galaxy and Basic colors have spools available (not refill-only)
if (finish === 'Galaxy' || finish === 'Basic') {
return false;
}
return REFILL_ONLY_COLORS.includes(color);
}; };
// Helper function to filter colors based on material and finish const getFilteredColors = (
const getFilteredColors = (colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>, type?: string, finish?: string) => { colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>,
// PPA CF only has black color type?: string,
if (type === 'PPA' && finish === 'CF') { finish?: string
return colors.filter(color => color.name.toLowerCase() === 'black'); ) => {
} if (!type || !finish) return colors;
return colors; const catalogColors = getColorsForMaterialFinish(type, finish);
if (catalogColors.length === 0) return colors;
const catalogNames = new Set(catalogColors.map(c => c.name.toLowerCase()));
return colors.filter(c => catalogNames.has(c.name.toLowerCase()));
}; };
interface FilamentWithId extends Filament { interface FilamentWithId extends Filament {
@@ -80,22 +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', 'Tough+', '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'],
'PAHT': ['CF', 'Bez Finisha'],
'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[]>([]);
@@ -360,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">
@@ -401,24 +357,6 @@ export default function AdminDashboard() {
filaments={filaments} filaments={filaments}
onUpdate={fetchFilaments} onUpdate={fetchFilaments}
/> />
<button
onClick={() => router.push('/upadaj/colors')}
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"
>
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)}
@@ -686,6 +624,7 @@ export default function AdminDashboard() {
</table> </table>
</div> </div>
</main> </main>
</div>
</div> </div>
</div> </div>
); );
@@ -710,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
@@ -787,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({
@@ -810,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({
@@ -887,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>
@@ -911,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>
@@ -991,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`}
@@ -1025,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>
@@ -1037,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

@@ -5,6 +5,7 @@ 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 { 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 {
@@ -188,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="/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">
@@ -388,10 +370,24 @@ export default function ColorsManagement() {
{/* Edit Modal */} {/* Edit Modal */}
{editingColor && ( {editingColor && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-auto"> className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={() => setEditingColor(null)}
>
<div
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 max-w-md w-full max-h-[90vh] overflow-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center p-6 pb-0">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Izmeni boju</h3>
<button
onClick={() => setEditingColor(null)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
&times;
</button>
</div>
<div className="p-6"> <div className="p-6">
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Izmeni boju</h3>
<ColorForm <ColorForm
color={editingColor} color={editingColor}
onSave={(color) => { onSave={(color) => {
@@ -495,7 +491,7 @@ function ColorForm({
readOnly={isBambuLabColor} readOnly={isBambuLabColor}
pattern="^#[0-9A-Fa-f]{6}$" pattern="^#[0-9A-Fa-f]{6}$"
placeholder="#000000" placeholder="#000000"
className={`flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' : ''}`} className={`flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}`}
/> />
</div> </div>
{isBambuLabColor && ( {isBambuLabColor && (

View File

@@ -0,0 +1,634 @@
'use client'
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { customerService } from '@/src/services/api';
import { Customer, Sale } from '@/src/types/sales';
import { AdminSidebar } from '@/src/components/AdminSidebar';
interface CustomerWithSales extends Customer {
sales?: Sale[];
total_purchases?: number;
}
export default function CustomersManagement() {
const router = useRouter();
const [customers, setCustomers] = useState<CustomerWithSales[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [expandedCustomerId, setExpandedCustomerId] = useState<string | null>(null);
const [expandedSales, setExpandedSales] = useState<Sale[]>([]);
const [loadingSales, setLoadingSales] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [editForm, setEditForm] = useState<Partial<Customer>>({});
const [saving, setSaving] = useState(false);
const [darkMode, setDarkMode] = useState(false);
const [mounted, setMounted] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [addForm, setAddForm] = useState({ name: '', phone: '', city: '', notes: '' });
const [addError, setAddError] = useState('');
const [addSaving, setAddSaving] = useState(false);
// Initialize dark mode
useEffect(() => {
setMounted(true);
const saved = localStorage.getItem('darkMode');
if (saved !== null) {
setDarkMode(JSON.parse(saved));
} else {
setDarkMode(true);
}
}, []);
useEffect(() => {
if (!mounted) return;
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode, mounted]);
// Check authentication
useEffect(() => {
if (!mounted) return;
const token = localStorage.getItem('authToken');
const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || Date.now() > parseInt(expiry)) {
window.location.href = '/upadaj';
}
}, [mounted]);
// Fetch customers
const fetchCustomers = useCallback(async () => {
try {
setLoading(true);
const data = await customerService.getAll();
setCustomers(data);
} catch (err) {
setError('Greska pri ucitavanju kupaca');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchCustomers();
}, [fetchCustomers]);
// Search customers
const filteredCustomers = customers.filter((customer) => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
return (
customer.name.toLowerCase().includes(term) ||
(customer.phone && customer.phone.toLowerCase().includes(term)) ||
(customer.city && customer.city.toLowerCase().includes(term))
);
});
// Toggle expanded row to show purchase history
const handleToggleExpand = async (customerId: string) => {
if (expandedCustomerId === customerId) {
setExpandedCustomerId(null);
setExpandedSales([]);
return;
}
setExpandedCustomerId(customerId);
setLoadingSales(true);
try {
const data = await customerService.getById(customerId);
setExpandedSales(data.sales || []);
} catch (err) {
console.error('Error fetching customer sales:', err);
setExpandedSales([]);
} finally {
setLoadingSales(false);
}
};
// Edit customer
const handleStartEdit = (customer: Customer) => {
setEditingCustomer(customer);
setEditForm({
name: customer.name,
phone: customer.phone || '',
city: customer.city || '',
notes: customer.notes || '',
});
};
const handleCancelEdit = () => {
setEditingCustomer(null);
setEditForm({});
};
const handleSaveEdit = async () => {
if (!editingCustomer) return;
setSaving(true);
try {
await customerService.update(editingCustomer.id, editForm);
setEditingCustomer(null);
setEditForm({});
await fetchCustomers();
} catch (err) {
setError('Greska pri cuvanju izmena');
console.error('Save error:', err);
} finally {
setSaving(false);
}
};
const handleAddCustomer = async () => {
if (!addForm.name.trim()) {
setAddError('Ime je obavezno');
return;
}
setAddSaving(true);
setAddError('');
try {
await customerService.create({
name: addForm.name.trim(),
phone: addForm.phone.trim() || undefined,
city: addForm.city.trim() || undefined,
notes: addForm.notes.trim() || undefined,
});
setShowAddModal(false);
setAddForm({ name: '', phone: '', city: '', notes: '' });
await fetchCustomers();
} catch (err: unknown) {
const error = err as { response?: { data?: { code?: string } } };
if (error.response?.data?.code === '23505') {
setAddError('Kupac sa ovim brojem telefona vec postoji');
} else {
setAddError('Greska pri dodavanju kupca');
}
} finally {
setAddSaving(false);
}
};
const handleLogout = () => {
localStorage.removeItem('authToken');
localStorage.removeItem('tokenExpiry');
router.push('/upadaj');
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('sr-Latn-RS', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('sr-Latn-RS', {
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount) + ' RSD';
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-gray-600 dark:text-gray-400">Ucitavanje...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
<div className="flex">
<AdminSidebar />
{/* Main Content */}
<div className="flex-1">
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<img
src="/logo.png"
alt="Filamenteka"
className="h-20 sm:h-32 w-auto drop-shadow-lg"
/>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Upravljanje kupcima
</h1>
</div>
<div className="flex gap-4 flex-wrap">
<button
onClick={() => router.push('/')}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Nazad na sajt
</button>
{mounted && (
<button
onClick={() => setDarkMode(!darkMode)}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
>
{darkMode ? '\u2600' : '\u263D'}
</button>
)}
<button
onClick={handleLogout}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Odjava
</button>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{error && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
{error}
<button
onClick={() => setError('')}
className="ml-4 text-red-600 dark:text-red-300 underline text-sm"
>
Zatvori
</button>
</div>
)}
{/* Search bar + Add button */}
<div className="mb-6 flex items-center gap-4">
<input
type="text"
placeholder="Pretrazi kupce..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full max-w-md px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 whitespace-nowrap font-medium"
>
+ Dodaj kupca
</button>
</div>
{/* Customer count */}
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Ukupno kupaca: {filteredCustomers.length}
</p>
{/* Customer table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Ime
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Telefon
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Grad
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Beleske
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Datum registracije
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Akcije
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredCustomers.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-6 py-8 text-center text-gray-500 dark:text-gray-400"
>
{searchTerm ? 'Nema rezultata pretrage' : 'Nema registrovanih kupaca'}
</td>
</tr>
) : (
filteredCustomers.map((customer) => (
<>
<tr
key={customer.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
{editingCustomer?.id === customer.id ? (
<>
<td className="px-6 py-4">
<input
type="text"
value={editForm.name || ''}
onChange={(e) =>
setEditForm({ ...editForm, name: e.target.value })
}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</td>
<td className="px-6 py-4">
<input
type="text"
value={editForm.phone || ''}
onChange={(e) =>
setEditForm({ ...editForm, phone: e.target.value })
}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</td>
<td className="px-6 py-4">
<input
type="text"
value={editForm.city || ''}
onChange={(e) =>
setEditForm({ ...editForm, city: e.target.value })
}
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</td>
<td className="px-6 py-4">
<textarea
value={editForm.notes || ''}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
rows={2}
placeholder="npr. stampa figurice, obicno crna..."
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{formatDate(customer.created_at)}
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<button
onClick={handleSaveEdit}
disabled={saving}
className="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600 disabled:opacity-50"
>
{saving ? '...' : 'Sacuvaj'}
</button>
<button
onClick={handleCancelEdit}
className="px-3 py-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 text-sm rounded hover:bg-gray-400 dark:hover:bg-gray-500"
>
Otkazi
</button>
</div>
</td>
</>
) : (
<>
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">
{customer.name}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{customer.phone || '-'}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{customer.city || '-'}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs whitespace-pre-wrap">
{customer.notes || <span className="text-gray-400 dark:text-gray-600 italic">Nema beleski</span>}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{formatDate(customer.created_at)}
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<button
onClick={() => handleStartEdit(customer)}
className="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600"
>
Izmeni
</button>
<button
onClick={() => handleToggleExpand(customer.id)}
className={`px-3 py-1 text-sm rounded ${
expandedCustomerId === customer.id
? 'bg-indigo-600 text-white hover:bg-indigo-700'
: 'bg-indigo-100 dark:bg-indigo-900 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-800'
}`}
>
Istorija kupovina
</button>
</div>
</td>
</>
)}
</tr>
{/* Expanded purchase history */}
{expandedCustomerId === customer.id && (
<tr key={`${customer.id}-sales`}>
<td
colSpan={6}
className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50"
>
<div className="ml-4">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Istorija kupovina - {customer.name}
</h4>
{loadingSales ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Ucitavanje...
</p>
) : expandedSales.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Nema evidentiranih kupovina
</p>
) : (
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded overflow-hidden">
<thead className="bg-gray-100 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
ID
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Datum
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Stavke
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Iznos
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Napomena
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{expandedSales.map((sale) => (
<tr key={sale.id}>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 font-mono">
{sale.id.substring(0, 8)}...
</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
{formatDate(sale.created_at)}
</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
{sale.item_count ?? '-'}
</td>
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white font-medium">
{formatCurrency(sale.total_amount)}
</td>
<td className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
{sale.notes || '-'}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50 dark:bg-gray-700/50">
<tr>
<td
colSpan={3}
className="px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-right"
>
Ukupno ({expandedSales.length} kupovina):
</td>
<td className="px-4 py-2 text-sm font-bold text-gray-900 dark:text-white">
{formatCurrency(
expandedSales.reduce(
(sum, sale) => sum + sale.total_amount,
0
)
)}
</td>
<td />
</tr>
</tfoot>
</table>
)}
</div>
</td>
</tr>
)}
</>
))
)}
</tbody>
</table>
</div>
</main>
</div>
</div>
{/* Add Customer Modal */}
{showAddModal && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setShowAddModal(false)}
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Dodaj kupca
</h2>
<button
onClick={() => setShowAddModal(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
&times;
</button>
</div>
{addError && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-sm">
{addError}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Ime *
</label>
<input
type="text"
value={addForm.name}
onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Ime i prezime"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Telefon
</label>
<input
type="text"
value={addForm.phone}
onChange={(e) => setAddForm({ ...addForm, phone: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Broj telefona"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Grad
</label>
<input
type="text"
value={addForm.city}
onChange={(e) => setAddForm({ ...addForm, city: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Grad"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Napomena
</label>
<textarea
value={addForm.notes}
onChange={(e) => setAddForm({ ...addForm, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="npr. stampa figurice, obicno crna..."
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowAddModal(false)}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
>
Otkazi
</button>
<button
onClick={handleAddCustomer}
disabled={addSaving}
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 font-medium"
>
{addSaving ? 'Cuvanje...' : 'Dodaj'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

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

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;
@@ -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="/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,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

@@ -0,0 +1,100 @@
-- Migration 023: Clean up fake colors and add missing real Bambu Lab colors
-- Source of truth: bambu_lab_filaments.numbers spreadsheet
BEGIN;
-- 1. Rename typo: IronGray Metallic → Iron Gray Metallic (FK cascades to filaments)
UPDATE colors SET name = 'Iron Gray Metallic' WHERE name = 'IronGray Metallic';
-- 2. Delete 67 fake colors not used by any filament
DELETE FROM colors WHERE name IN (
'Coton Candy Cloud',
'Glow in the Dark Blue',
'Glow in the Dark Green',
'Grey',
'Indingo Purple',
'Ivory',
'Ivory White',
'Light Blue',
'Light Green',
'Matte Black',
'Matte Blue',
'Matte Brown',
'Matte Coral',
'Matte Green',
'Matte Grey',
'Matte Lime',
'Matte Mint',
'Matte Navy',
'Matte Orange',
'Matte Pink',
'Matte Purple',
'Matte White',
'Matte Yellow',
'Metal Bronze',
'Metal Copper',
'Metal Gold',
'Metal Grey',
'Metal Silver',
'Mint Green',
'Natural',
'Navy Blue',
'Nebulane',
'Silk Black',
'Silk Blue',
'Silk Bronze',
'Silk Copper',
'Silk Emerald',
'Silk Gold',
'Silk Green',
'Silk Jade',
'Silk Orange',
'Silk Pearl',
'Silk Pink',
'Silk Purple',
'Silk Red',
'Silk Rose Gold',
'Silk Ruby',
'Silk Sapphire',
'Silk Silver',
'Silk White',
'Sky Blue',
'Sparkle Blue',
'Sparkle Gold',
'Sparkle Green',
'Sparkle Purple',
'Sparkle Red',
'Sparkle Silver',
'Support G',
'Support White',
'Translucent Tea',
'Transparent Blue',
'Transparent Green',
'Transparent Orange',
'Transparent Purple',
'Transparent Red',
'Transparent Yellow',
'Violet'
);
-- 3. Add 17 missing real Bambu Lab colors from spreadsheet
INSERT INTO colors (name, hex) VALUES
('Aurora Purple', '#285BB7'),
('Candy Green', '#408619'),
('Candy Red', '#BB3A2E'),
('Crystal Blue', '#5BC0EB'),
('Flesh', '#E8C4A2'),
('Forest Green', '#415520'),
('Grape Jelly', '#6B2D75'),
('Lake Blue', '#4672E4'),
('Lime', '#C5ED48'),
('Mystic Magenta', '#720062'),
('Nebulae', '#424379'),
('Neon Green', '#ABFF1E'),
('Phantom Blue', '#00629B'),
('Quicksilver', '#A6A9AA'),
('Rose Gold', '#B29593'),
('Translucent Teal', '#008080')
ON CONFLICT (name) DO NOTHING;
COMMIT;

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

407
package-lock.json generated
View File

@@ -15,7 +15,8 @@
"next": "^16.1.6", "next": "^16.1.6",
"pg": "^8.16.2", "pg": "^8.16.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"recharts": "^3.7.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -2672,6 +2673,42 @@
"url": "https://opencollective.com/pkgr" "url": "https://opencollective.com/pkgr"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -3056,6 +3093,18 @@
"@sinonjs/commons": "^3.0.1" "@sinonjs/commons": "^3.0.1"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -3214,6 +3263,69 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3326,7 +3438,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -3363,6 +3475,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -4560,6 +4678,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/co": { "node_modules/co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -4715,9 +4842,130 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": { "node_modules/data-urls": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@@ -4757,6 +5005,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.1", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
@@ -5059,6 +5313,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -5336,6 +5600,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/execa": { "node_modules/execa": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -6012,6 +6282,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -6088,6 +6368,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -8569,10 +8858,32 @@
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -8606,6 +8917,36 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -8620,6 +8961,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -8630,6 +8986,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -9423,6 +9785,12 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -9724,6 +10092,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -9746,6 +10123,28 @@
"node": ">=10.12.0" "node": ">=10.12.0"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@@ -23,7 +23,8 @@
"next": "^16.1.6", "next": "^16.1.6",
"pg": "^8.16.2", "pg": "^8.16.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"recharts": "^3.7.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",

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

@@ -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: 'Aurora Purple', refill: false, spool: true },
{ name: 'Phantom Blue', refill: false, spool: true },
{ name: '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

@@ -8,7 +8,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Apple Green': { hex: '#C6E188' }, 'Apple Green': { hex: '#C6E188' },
'Arctic Whisper': { hex: '#ECF7F8' }, 'Arctic Whisper': { hex: '#ECF7F8' },
'Ash Grey': { hex: '#9B9EA0' }, 'Ash Grey': { hex: '#9B9EA0' },
'Aurora Purple': { hex: '#285BB7' }, 'Aurora Purple': { hex: ['#7F3696', '#006EC9'], isGradient: true },
'Azure': { hex: '#489FDF' }, 'Azure': { hex: '#489FDF' },
'Baby Blue': { hex: '#AEC3ED' }, 'Baby Blue': { hex: '#AEC3ED' },
'Bambu Green': { hex: '#00AE42' }, 'Bambu Green': { hex: '#00AE42' },
@@ -73,8 +73,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Iridium Gold Metallic': { hex: '#B39B84' }, 'Iridium Gold Metallic': { hex: '#B39B84' },
'Iris Purple': { hex: '#69398E' }, 'Iris Purple': { hex: '#69398E' },
'Iron Gray Metallic': { hex: '#6B6C6F' }, 'Iron Gray Metallic': { hex: '#6B6C6F' },
'IronGray Metallic': { hex: '#6B6C6F' },
'Ivory White': { hex: '#FFFFFF' },
'Jade White': { hex: '#FFFFFF' }, 'Jade White': { hex: '#FFFFFF' },
'Jeans Blue': { hex: '#6E88BC' }, 'Jeans Blue': { hex: '#6E88BC' },
'Lake Blue': { hex: '#4672E4' }, 'Lake Blue': { hex: '#4672E4' },
@@ -103,7 +101,6 @@ 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' },
@@ -146,11 +143,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'White Oak': { hex: '#D2CCA2' }, 'White Oak': { hex: '#D2CCA2' },
'Yellow': { hex: '#F4EE2A' }, 'Yellow': { hex: '#F4EE2A' },
// ABS Colors
// ABS GF Colors
'ABS GF Yellow': { hex: '#FDD835' },
'ABS GF Orange': { hex: '#F48438' },
// ABS Colors // ABS Colors
'ABS Azure': { hex: '#489FDF' }, 'ABS Azure': { hex: '#489FDF' },
'ABS Olive': { hex: '#748C45' }, 'ABS Olive': { hex: '#748C45' },
@@ -164,7 +156,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'ABS Black': { hex: '#000000' }, 'ABS Black': { hex: '#000000' },
'ABS Silver': { hex: '#A6A9AA' }, 'ABS Silver': { hex: '#A6A9AA' },
// Translucent Colors // PETG Translucent Colors
'Translucent Gray': { hex: '#B8B8B8' }, 'Translucent Gray': { hex: '#B8B8B8' },
'Translucent Brown': { hex: '#C89A74' }, 'Translucent Brown': { hex: '#C89A74' },
'Translucent Purple': { hex: '#C5A8D8' }, 'Translucent Purple': { hex: '#C5A8D8' },
@@ -172,9 +164,17 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Translucent Olive': { hex: '#A4B885' }, 'Translucent Olive': { hex: '#A4B885' },
'Translucent Pink': { hex: '#F9B8D0' }, 'Translucent Pink': { hex: '#F9B8D0' },
'Translucent Light Blue': { hex: '#A8D8F0' }, 'Translucent Light Blue': { hex: '#A8D8F0' },
'Translucent Tea': { hex: '#D9C7A8' }, 'Translucent Teal': { hex: '#008080' },
// PLA Matte - New Colors (2025) // 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' },
@@ -185,7 +185,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Matte Dark Red': { hex: '#BB3D43' }, 'Matte Dark Red': { hex: '#BB3D43' },
'Matte Grass Green': { hex: '#7CB342' }, 'Matte Grass Green': { hex: '#7CB342' },
'Matte Ice Blue': { hex: '#A3D8E1' }, 'Matte Ice Blue': { hex: '#A3D8E1' },
'Matte Ivory': { hex: '#FFFFF0' },
'Matte Lemon Yellow': { hex: '#F7D959' }, 'Matte Lemon Yellow': { hex: '#F7D959' },
'Matte Lilac Purple': { hex: '#AE96D4' }, 'Matte Lilac Purple': { hex: '#AE96D4' },
'Matte Plum': { hex: '#851A52' }, 'Matte Plum': { hex: '#851A52' },
@@ -195,15 +194,14 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Matte Terracotta': { hex: '#A25A37' }, 'Matte Terracotta': { hex: '#A25A37' },
// PLA Silk Multi-Color // PLA Silk Multi-Color
'Silk Aurora Purple': { hex: ['#7F3696', '#006EC9'], isGradient: true }, 'Phantom Blue': { hex: ['#00629B', '#000000'], isGradient: true },
'Silk Phantom Blue': { hex: ['#00629B', '#000000'], isGradient: true }, 'Mystic Magenta': { hex: ['#720062', '#3A913F'], isGradient: true },
'Silk Mystic Magenta': { hex: ['#720062', '#3A913F'], isGradient: true },
// TPU 95A HF Colors // TPU Colors
'TPU 95A HF Yellow': { hex: '#F3E600' }, 'Flesh': { hex: '#E8C4A2' },
'Grape Jelly': { hex: '#6B2D75' },
// Default fallback 'Crystal Blue': { hex: '#5BC0EB' },
'Unknown': { hex: '#CCCCCC' } 'Quicksilver': { hex: '#A6A9AA' }
}; };
export function getFilamentColor(colorName: string): ColorMapping { export function getFilamentColor(colorName: string): ColorMapping {
@@ -232,7 +230,7 @@ export function getFilamentColor(colorName: string): ColorMapping {
} }
// Return default unknown color // Return default unknown color
return bambuLabColors['Unknown']; return { hex: '#CCCCCC' };
} }
export function getColorStyle(colorMapping: ColorMapping): React.CSSProperties { export function getColorStyle(colorMapping: ColorMapping): React.CSSProperties {

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",
@@ -49,7 +80,7 @@ export const bambuLabColors = {
"Matte Dark Chocolate": "#4A3729", "Matte Dark Chocolate": "#4A3729",
"Matte Dark Green": "#68724D", "Matte Dark Green": "#68724D",
"Matte Dark Red": "#BB3D43", "Matte Dark Red": "#BB3D43",
"Matte Grass Green": "#61C680", "Matte Grass Green": "#7CB342",
"Matte Ice Blue": "#A3D8E1", "Matte Ice Blue": "#A3D8E1",
"Matte Lemon Yellow": "#F7D959", "Matte Lemon Yellow": "#F7D959",
"Matte Lilac Purple": "#AE96D4", "Matte Lilac Purple": "#AE96D4",
@@ -59,131 +90,146 @@ export const bambuLabColors = {
"Matte Latte Brown": "#D3B7A7", "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",
// Silk Multi-Color // PLA Silk Multi-Color
"Silk Aurora Purple": "#7F3696", "Aurora Purple": "#7F3696",
"Silk Phantom Blue": "#00629B", "Phantom Blue": "#00629B",
"Silk Mystic Magenta": "#720062", "Mystic Magenta": "#720062",
// Metal Colors // PLA Metal
"Metal Grey": "#9E9E9E",
"Metal Silver": "#B0BEC5",
"Metal Gold": "#D4AF37",
"Metal Copper": "#B87333",
"Metal Bronze": "#CD7F32",
// Sparkle Colors
"Sparkle Red": "#EF5350",
"Sparkle Blue": "#42A5F5",
"Sparkle Green": "#66BB6A",
"Sparkle Purple": "#AB47BC",
"Sparkle Gold": "#FFCA28",
"Sparkle Silver": "#E0E0E0",
// Glow Colors
"Glow in the Dark Green": "#C8E6C9",
"Glow in the Dark Blue": "#BBDEFB",
// Transparent Colors
"Clear": "#FFFFFF",
"Transparent Red": "#EF5350",
"Transparent Blue": "#42A5F5",
"Transparent Green": "#66BB6A",
"Transparent Yellow": "#FFEE58",
"Transparent Orange": "#FFA726",
"Transparent Purple": "#AB47BC",
// Support Materials
"Natural": "#F5F5DC",
"Support White": "#F5F5F5",
"Support G": "#90CAF9",
// Metal Colors (PLA)
"Iron Gray Metallic": "#6B6C6F", "Iron Gray Metallic": "#6B6C6F",
"Iridium Gold Metallic": "#B39B84",
"Cobalt Blue Metallic": "#39699E",
"Copper Brown Metallic": "#AA6443",
"Oxide Green Metallic": "#1D7C6A",
// ABS GF Colors // PLA Sparkle
"Onyx Black Sparkle": "#2D2B28",
"Classic Gold Sparkle": "#E4BD68",
"Crimson Red Sparkle": "#792B36",
"Royal Purple Sparkle": "#483D8B",
"Slate Gray Sparkle": "#8E9089",
"Alpine Green Sparkle": "#3F5443",
// PLA Galaxy
"Nebulae": "#424379",
// PLA Marble
"White Marble": "#F7F3F0",
"Red Granite": "#AD4E38",
// 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 Teal": "#008080",
// 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 Yellow": "#FDD835",
"ABS GF Orange": "#F48438", "ABS GF Orange": "#F48438",
// TPU 95A HF Colors // 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", "TPU 95A HF Yellow": "#F3E600",
// Wood Colors // PC
"Ochre Yellow": "#BC8B39", "Clear Black": "#5A5161",
"White Oak": "#D2CCA2", "Transparent": "#FFFFFF",
"Clay Brown": "#8E621A"
}; };
// 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 Latte Brown", "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"
],
"Wood": [
"Ochre Yellow", "White Oak", "Clay Brown"
]
};
// 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';
@@ -132,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;
}