Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3d11afec | ||
|
|
a854fd5524 | ||
|
|
eff60436eb | ||
|
|
6dda083ee9 | ||
|
|
bd27f7a9e4 | ||
|
|
145c2d4781 | ||
|
|
28ba314404 | ||
|
|
0cfee1bda7 | ||
|
|
7cd2058613 | ||
|
|
c837af6015 | ||
|
|
58c165749d | ||
|
|
b7f5417e23 | ||
|
|
17edfc8794 | ||
|
|
f6f9da9c5b | ||
|
|
b1dfa2352a | ||
|
|
987039b0f7 | ||
|
|
6bc1c8d16d | ||
|
|
d3e001707b | ||
|
|
c0ca1e4bb3 | ||
|
|
1ce127c51c | ||
|
|
fc95dc4ed2 | ||
|
|
543e51cc3c | ||
|
|
56a21b27fe | ||
|
|
f99a132e7b | ||
|
|
990221792a | ||
|
|
6f75abf6ee | ||
|
|
01a24e781b | ||
|
|
92b186b6bc | ||
|
|
06b0a20bef | ||
|
|
747d15f1c3 | ||
|
|
6d534352b2 | ||
|
|
9f2dade0e3 | ||
|
|
fd3ba36ae2 | ||
|
|
52f93df34a | ||
|
|
5d1d05574f | ||
|
|
470cf63b83 | ||
|
|
4020bb4ab8 | ||
|
|
9f01158241 | ||
|
|
0648f989ec | ||
|
|
dc18ab9944 | ||
|
|
d45f984769 | ||
|
|
18a4cd1e34 | ||
|
|
34e9885a29 | ||
|
|
04b7ab9b55 | ||
|
|
eb7cb2d94f | ||
|
|
7945d9400f | ||
|
|
b75849e285 | ||
|
|
33e9bf3019 | ||
|
|
7d4e696fcd | ||
|
|
d18e312607 | ||
|
|
f0ea3e963a | ||
|
|
bf2b80f6dc | ||
|
|
0df9d5d294 | ||
|
|
c0682e1969 | ||
|
|
3bd907eaf2 | ||
|
|
7349d1b60d | ||
|
|
a2ec640ecc | ||
|
|
966d253a7e | ||
|
|
181f967bd0 | ||
|
|
12e91d4c3e | ||
|
|
58b3ff2dec | ||
|
|
5babb9e062 | ||
|
|
d5ddb5f3df | ||
|
|
06025623ff | ||
|
|
fa59df4c3d | ||
|
|
57abb80072 | ||
|
|
e8f9a6c6e3 | ||
|
|
808ca077fa | ||
|
|
791195eba1 | ||
|
|
1eec8c8b4a | ||
|
|
b97032bf0c | ||
|
|
33a40072b7 | ||
|
|
99a41f43fb | ||
|
|
82c476430f | ||
|
|
62a4891112 | ||
|
|
18110ab159 |
13
.env.development
Normal file
13
.env.development
Normal file
@@ -0,0 +1,13 @@
|
||||
# Development Environment Configuration
|
||||
NODE_ENV=development
|
||||
|
||||
# API Configuration
|
||||
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
|
||||
|
||||
# AWS Configuration (for reference - no longer used)
|
||||
# AWS_REGION=eu-central-1
|
||||
# DYNAMODB_TABLE_NAME=filamenteka-filaments-dev
|
||||
|
||||
# Admin credentials (development)
|
||||
# Username: admin
|
||||
# Password: admin123
|
||||
@@ -1,3 +1,10 @@
|
||||
# This file is for Amplify to know which env vars to expose to Next.js
|
||||
# The actual values come from Amplify Environment Variables
|
||||
NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
# Production Environment Configuration
|
||||
NODE_ENV=production
|
||||
|
||||
# API Configuration
|
||||
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
|
||||
|
||||
# Matomo Analytics Configuration
|
||||
NEXT_PUBLIC_MATOMO_URL=https://analytics.demirix.dev
|
||||
NEXT_PUBLIC_MATOMO_SITE_ID=7
|
||||
|
||||
|
||||
15
.eslintrc.json
Normal file
15
.eslintrc.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": { "jsx": true }
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
},
|
||||
"ignorePatterns": ["node_modules/", "out/", ".next/", "api/"]
|
||||
}
|
||||
110
.gitea/workflows/deploy.yml
Normal file
110
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,110 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
AWS_REGION: eu-central-1
|
||||
S3_BUCKET: filamenteka-frontend
|
||||
INSTANCE_ID: i-03956ecf32292d7d9
|
||||
NEXT_PUBLIC_API_URL: https://api.filamenteka.rs/api
|
||||
NEXT_PUBLIC_MATOMO_URL: https://analytics.demirix.dev
|
||||
NEXT_PUBLIC_MATOMO_SITE_ID: "7"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect changes
|
||||
id: changes
|
||||
run: |
|
||||
FRONTEND_CHANGED=false
|
||||
API_CHANGED=false
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD | grep -qvE '^api/'; then
|
||||
FRONTEND_CHANGED=true
|
||||
fi
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD | grep -qE '^api/'; then
|
||||
API_CHANGED=true
|
||||
fi
|
||||
|
||||
echo "frontend=$FRONTEND_CHANGED" >> $GITHUB_OUTPUT
|
||||
echo "api=$API_CHANGED" >> $GITHUB_OUTPUT
|
||||
echo "Frontend changed: $FRONTEND_CHANGED"
|
||||
echo "API changed: $API_CHANGED"
|
||||
|
||||
# ── Frontend Deploy ──────────────────────────────────────────────
|
||||
- name: Setup Node.js
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Security check & tests
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
npm run security:check
|
||||
npm test -- --passWithNoTests
|
||||
|
||||
- name: Build Next.js
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: npm run build
|
||||
|
||||
- name: Setup AWS
|
||||
if: steps.changes.outputs.frontend == 'true' || steps.changes.outputs.api == 'true'
|
||||
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 eu-central-1
|
||||
|
||||
- name: Deploy to S3 & invalidate CloudFront
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||
--delete \
|
||||
--exclude "*" \
|
||||
--include "*.html" \
|
||||
--cache-control "public, max-age=0, must-revalidate" \
|
||||
--content-type "text/html"
|
||||
|
||||
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
|
||||
--cache-control "public, max-age=31536000, immutable"
|
||||
|
||||
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||
--exclude "*.html" \
|
||||
--exclude "_next/*" \
|
||||
--cache-control "public, max-age=86400"
|
||||
|
||||
aws cloudfront create-invalidation \
|
||||
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
|
||||
--paths "/*"
|
||||
|
||||
# ── API Deploy ───────────────────────────────────────────────────
|
||||
- name: Deploy API via SSM
|
||||
if: steps.changes.outputs.api == 'true'
|
||||
run: |
|
||||
aws ssm send-command \
|
||||
--region $AWS_REGION \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--document-name "AWS-RunShellScript" \
|
||||
--parameters 'commands=[
|
||||
"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
|
||||
echo "API deploy command sent via SSM"
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -38,8 +38,8 @@ lerna-debug.log*
|
||||
|
||||
# Terraform
|
||||
terraform/.terraform/
|
||||
terraform/*.tfstate
|
||||
terraform/*.tfstate.*
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
terraform/*.tfvars
|
||||
terraform/.terraform.lock.hcl
|
||||
terraform/crash.log
|
||||
@@ -49,3 +49,18 @@ terraform/override.tf
|
||||
terraform/override.tf.json
|
||||
terraform/*_override.tf
|
||||
terraform/*_override.tf.json
|
||||
|
||||
# Temporary scripts
|
||||
force-*.sh
|
||||
quick-fix-*.sh
|
||||
temp-*.sh
|
||||
|
||||
# Lambda packages
|
||||
lambda/*.zip
|
||||
lambda/**/node_modules/
|
||||
|
||||
# MCP config (local dev tooling)
|
||||
.mcp.json
|
||||
|
||||
# Images/screenshots
|
||||
*.png
|
||||
132
CLAUDE.md
Normal file
132
CLAUDE.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments. It consists of:
|
||||
- **Frontend**: Next.js app with React, TypeScript, and Tailwind CSS (static export to `/out`)
|
||||
- **Backend**: Node.js Express API server (`/api/server.js`) with PostgreSQL database
|
||||
- **Infrastructure**: AWS (CloudFront + S3 for frontend, EC2 for API, RDS for database)
|
||||
- **Repository**: `git.demirix.dev/dax/Filamenteka`
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- NEVER mention ANY author in commits. No author tags or attribution of any kind
|
||||
- Build for AMD64 Linux when deploying (development is on ARM macOS)
|
||||
- Always run security checks before commits (Husky pre-commit hook does this automatically)
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start Next.js dev server (port 3000)
|
||||
npm run build # Build static export to /out
|
||||
npm run lint # ESLint
|
||||
npm test # Jest tests
|
||||
npm run test:watch # Jest watch mode
|
||||
npm test -- --testPathPattern=__tests__/components/ColorCell # Run single test file
|
||||
|
||||
# API Development
|
||||
cd api && npm run dev # Start API with nodemon (port 4000)
|
||||
|
||||
# Quality Gates
|
||||
npm run security:check # Credential leak detection
|
||||
npm run test:build # Verify build succeeds without deploying
|
||||
npx tsc --noEmit # TypeScript type checking
|
||||
|
||||
# Database
|
||||
npm run migrate # Run pending migrations locally
|
||||
scripts/update-db-via-aws.sh # Run migrations on production RDS via EC2
|
||||
|
||||
# Deployment (auto via CI/CD on push to main, or manual)
|
||||
scripts/deploy-api-update.sh # Deploy API to EC2 via AWS SSM
|
||||
scripts/deploy-frontend.sh # Manual frontend deploy to S3/CloudFront
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend (Next.js App Router)
|
||||
- `/app/page.tsx` — Public filament inventory table (home page)
|
||||
- `/app/upadaj/` — Admin panel: login, `/dashboard` (filament CRUD), `/colors` (color management), `/requests` (customer color requests)
|
||||
- `/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/bambuLabColorsComplete.ts` — Extended color database grouped by finish type (used by filters)
|
||||
|
||||
### API (`/api/server.js`)
|
||||
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`
|
||||
- Auth: JWT tokens with 24h expiry, single admin user (password from `ADMIN_PASSWORD` env var)
|
||||
|
||||
### Database (PostgreSQL on RDS)
|
||||
Key schemas and constraints:
|
||||
|
||||
```
|
||||
filaments: id, tip (material), finish, boja (color), refill, spulna, kolicina, cena, sale_*
|
||||
colors: id, name, hex, cena_refill (default 3499), cena_spulna (default 3999)
|
||||
color_requests: id, color_name, message, contact_name, contact_phone, status
|
||||
```
|
||||
|
||||
**Critical constraints:**
|
||||
- `filaments.boja` → FK to `colors.name` (ON UPDATE CASCADE) — colors must exist before filaments reference them
|
||||
- `kolicina = refill + spulna` — enforced by check constraint
|
||||
- Migrations in `/database/migrations/` with sequential numbering (currently up to `020_`)
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Color Management (two-layer system)
|
||||
1. **Database `colors` table**: Defines valid color names + pricing. Must be populated first due to FK constraint.
|
||||
2. **Frontend `bambuLabColors.ts`**: Maps color names → hex codes for UI display. Supports solid (`hex: '#FF0000'`) and gradient (`hex: ['#HEX1', '#HEX2'], isGradient: true`).
|
||||
3. Color names must match exactly between `colors.name`, `filaments.boja`, and `bambuLabColors.ts` keys.
|
||||
4. The `ColorCell` component renders table row backgrounds from these mappings.
|
||||
|
||||
### Adding New Colors
|
||||
1. Add to database `colors` table (via admin panel at `/upadaj/colors` or migration)
|
||||
2. Add hex mapping to `src/data/bambuLabColors.ts`
|
||||
3. Optionally add to `src/data/bambuLabColorsComplete.ts` finish groups
|
||||
|
||||
### API Communication
|
||||
- Service modules in `src/services/api.ts`: `authService`, `colorService`, `filamentService`, `colorRequestService`
|
||||
- Auth token stored in localStorage with 24h expiry
|
||||
- Cache busting on filament fetches via timestamp query param
|
||||
|
||||
## CI/CD (Gitea Actions)
|
||||
|
||||
`.gitea/workflows/deploy.yml` triggers on push to `main`. Auto-detects changes via `git diff HEAD~1`:
|
||||
- **Frontend changes** (anything outside `api/`): security check → tests → build → S3 deploy (3-tier cache: HTML no-cache, `_next/` immutable, rest 24h) → CloudFront invalidation
|
||||
- **API changes** (`api/` files): SSM command to EC2 to pull `server.js` from Gitea and restart `node-api` service
|
||||
- Both run if both changed in a single push
|
||||
|
||||
## Deployment Details
|
||||
|
||||
| Component | Target | Method |
|
||||
|-----------|--------|--------|
|
||||
| Frontend | S3 `filamenteka-frontend` → CloudFront | Static export, OAC-protected |
|
||||
| API | EC2 `i-03956ecf32292d7d9` | SSM, systemd `node-api` service |
|
||||
| Database | RDS `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com` | Migrations via `scripts/update-db-via-aws.sh` |
|
||||
| DNS | Cloudflare | `api.filamenteka.rs` → EC2 |
|
||||
| IaC | `/terraform/` | VPC, EC2, RDS, ALB, CloudFront, Cloudflare |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Frontend (.env.local)
|
||||
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
|
||||
NEXT_PUBLIC_MATOMO_URL=https://analytics.demirix.dev
|
||||
NEXT_PUBLIC_MATOMO_SITE_ID=7
|
||||
|
||||
# API Server (.env in /api)
|
||||
DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka
|
||||
JWT_SECRET=...
|
||||
ADMIN_PASSWORD=...
|
||||
NODE_ENV=production
|
||||
PORT=80
|
||||
```
|
||||
|
||||
## Pre-commit Hooks (Husky)
|
||||
|
||||
`scripts/pre-commit.sh` runs automatically and blocks commits that fail:
|
||||
1. Author mention check (blocks attribution)
|
||||
2. Security check (credential leaks)
|
||||
3. Build test (ensures compilation)
|
||||
4. Unit tests (`jest --passWithNoTests`)
|
||||
158
README.md
158
README.md
@@ -1,38 +1,41 @@
|
||||
# Filamenteka
|
||||
|
||||
A web application for tracking Bambu Lab filament inventory with automatic color coding, synced from Confluence documentation.
|
||||
A web application for tracking Bambu Lab filament inventory with automatic color coding.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎨 **Automatic Color Coding** - Table rows are automatically colored based on filament colors
|
||||
- 🔄 **Confluence Sync** - Pulls filament data from Confluence table every 5 minutes
|
||||
- 🔍 **Search & Filter** - Quick search across all filament properties
|
||||
- 📊 **Sortable Columns** - Click headers to sort by any column
|
||||
- 🌈 **Gradient Support** - Special handling for gradient filaments like Cotton Candy Cloud
|
||||
- 📱 **Responsive Design** - Works on desktop and mobile devices
|
||||
- Automatic Color Coding - Table rows are automatically colored based on filament colors
|
||||
- Search & Filter - Quick search across all filament properties
|
||||
- Sortable Columns - Click headers to sort by any column
|
||||
- Gradient Support - Special handling for gradient filaments like Cotton Candy Cloud
|
||||
- Responsive Design - Works on desktop and mobile devices
|
||||
- Sale Management - Bulk sale pricing with countdown timers
|
||||
- Admin Panel - Protected dashboard for inventory management
|
||||
- Spool Types - Support for both regular and refill spools
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: React + TypeScript + Tailwind CSS
|
||||
- **Backend**: API routes for Confluence integration
|
||||
- **Infrastructure**: AWS Amplify (Frankfurt region)
|
||||
- **Frontend**: Next.js + React + TypeScript + Tailwind CSS
|
||||
- **Backend**: Node.js API server (Express)
|
||||
- **Database**: PostgreSQL (AWS RDS)
|
||||
- **Infrastructure**: AWS CloudFront + S3 (Frontend), EC2 (API), RDS (Database)
|
||||
- **CI/CD**: Gitea Actions
|
||||
- **IaC**: Terraform
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- AWS Account
|
||||
- Terraform 1.0+
|
||||
- GitHub account
|
||||
- Confluence account with API access
|
||||
- PostgreSQL (for local development)
|
||||
- AWS Account (for deployment)
|
||||
- Terraform 1.0+ (for infrastructure)
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/filamenteka.git
|
||||
cd filamenteka
|
||||
git clone https://git.demirix.dev/DaX/Filamenteka.git
|
||||
cd Filamenteka
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
@@ -41,12 +44,13 @@ cd filamenteka
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Configure Confluence Access
|
||||
### 3. Environment Setup
|
||||
|
||||
Create a Confluence API token:
|
||||
1. Go to https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
2. Create a new API token
|
||||
3. Note your Confluence domain and the page ID containing your filament table
|
||||
Create a `.env.local` file for local development:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api
|
||||
```
|
||||
|
||||
### 4. Deploy with Terraform
|
||||
|
||||
@@ -60,41 +64,35 @@ terraform plan
|
||||
terraform apply
|
||||
```
|
||||
|
||||
### 5. Environment Variables
|
||||
|
||||
The following environment variables are needed:
|
||||
- `CONFLUENCE_API_URL` - Your Confluence instance URL (e.g., https://your-domain.atlassian.net)
|
||||
- `CONFLUENCE_TOKEN` - Your Confluence API token
|
||||
- `CONFLUENCE_PAGE_ID` - The ID of the Confluence page containing the filament table
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
# Create .env file for local development
|
||||
cat > .env << EOF
|
||||
CONFLUENCE_API_URL=https://your-domain.atlassian.net
|
||||
CONFLUENCE_TOKEN=your_api_token
|
||||
CONFLUENCE_PAGE_ID=your_page_id
|
||||
EOF
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run linting
|
||||
npm run lint
|
||||
|
||||
# Check for security issues
|
||||
npm run security:check
|
||||
```
|
||||
|
||||
Visit http://localhost:5173 to see the app.
|
||||
Visit http://localhost:3000 to see the app.
|
||||
|
||||
## Table Format
|
||||
|
||||
Your Confluence table should have these columns:
|
||||
- **Brand** - Manufacturer (e.g., BambuLab)
|
||||
The filament table displays these columns:
|
||||
- **Tip** - Material type (e.g., PLA, PETG, ABS)
|
||||
- **Finish** - Finish type (e.g., Basic, Matte, Silk)
|
||||
- **Boja** - Color name (e.g., Mistletoe Green, Hot Pink)
|
||||
- **Refill** - Whether it's a refill spool
|
||||
- **Vakum** - Vacuum sealed status
|
||||
- **Otvoreno** - Opened status
|
||||
- **Količina** - Quantity
|
||||
- **Cena** - Price
|
||||
- **Refill** - Number of refill spools
|
||||
- **Spulna** - Number of regular spools
|
||||
- **Kolicina** - Total quantity (refill + spulna)
|
||||
- **Cena** - Price per unit
|
||||
- **Sale** - Active sale percentage and end date
|
||||
|
||||
## Color Mapping
|
||||
|
||||
@@ -108,19 +106,33 @@ Unknown colors default to light gray.
|
||||
|
||||
## Deployment
|
||||
|
||||
Push to the main branch to trigger automatic deployment:
|
||||
Pushing to `main` triggers automatic deployment via Gitea Actions:
|
||||
- Frontend changes are built, tested, and deployed to S3/CloudFront
|
||||
- API changes are deployed to EC2 via AWS SSM
|
||||
|
||||
Manual deployment is also available:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Update filament colors"
|
||||
git push origin main
|
||||
# Frontend
|
||||
./scripts/deploy-frontend.sh
|
||||
|
||||
# API
|
||||
./scripts/deploy-api-update.sh
|
||||
```
|
||||
|
||||
Amplify will automatically build and deploy your changes.
|
||||
## Admin Panel
|
||||
|
||||
Access the admin panel at `/upadaj` for:
|
||||
- Managing filament inventory
|
||||
- Adding/editing/deleting filaments
|
||||
- Managing color definitions and pricing
|
||||
- Bulk sale management
|
||||
|
||||
## Adding New Colors
|
||||
|
||||
To add new color mappings, edit `src/data/bambuLabColors.ts`:
|
||||
Colors can be managed through:
|
||||
1. **Admin Panel**: Navigate to `/upadaj/colors` to add colors via UI
|
||||
2. **Code**: Edit `src/data/bambuLabColors.ts` for frontend color display:
|
||||
|
||||
```typescript
|
||||
export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
@@ -129,27 +141,45 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
};
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Run database migrations:
|
||||
|
||||
```bash
|
||||
# Run all pending migrations
|
||||
npm run migrate
|
||||
|
||||
# Clear migration history (development only)
|
||||
npm run migrate:clear
|
||||
```
|
||||
|
||||
## API Deployment
|
||||
|
||||
Deploy API server updates:
|
||||
|
||||
```bash
|
||||
# Use the deployment script
|
||||
./scripts/deploy-api-update.sh
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Confluence Connection Issues
|
||||
- Verify your API token is valid
|
||||
- Check the page ID is correct
|
||||
- Ensure your Confluence user has read access to the page
|
||||
|
||||
### Color Not Showing
|
||||
- Check if the color name in Confluence matches exactly
|
||||
- Check if the color name matches exactly in the database
|
||||
- Ensure color exists in `colors` table
|
||||
- Add the color mapping to `bambuLabColors.ts`
|
||||
- Colors are case-insensitive but spelling must match
|
||||
|
||||
### Build Errors
|
||||
- Run `npm run test:build` to verify build succeeds
|
||||
- Check TypeScript errors with `npx tsc --noEmit`
|
||||
- Ensure all environment variables are set
|
||||
|
||||
### Database Connection Issues
|
||||
- Verify PostgreSQL is running
|
||||
- Check connection string in environment variables
|
||||
- Ensure database migrations have been run
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Commit your changes
|
||||
4. Push to the branch
|
||||
5. Create a Pull Request
|
||||
|
||||
|
||||
156
__tests__/api-integration.test.ts
Normal file
156
__tests__/api-integration.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = 'https://api.filamenteka.rs/api';
|
||||
const TEST_TIMEOUT = 30000; // 30 seconds
|
||||
|
||||
describe.skip('API Integration Tests - Skipped (requires production API)', () => {
|
||||
let authToken: string;
|
||||
let createdFilamentId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Login to get auth token
|
||||
const loginResponse = await axios.post(`${API_URL}/login`, {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
});
|
||||
authToken = loginResponse.data.token;
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up any test filaments that might have been left behind
|
||||
if (createdFilamentId) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`${API_URL}/filaments/${createdFilamentId}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore errors - filament might already be deleted
|
||||
}
|
||||
}
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
describe('Filament CRUD Operations', () => {
|
||||
it('should create a new filament', async () => {
|
||||
const newFilament = {
|
||||
tip: 'PLA',
|
||||
finish: 'Basic',
|
||||
boja: 'Black',
|
||||
boja_hex: '#000000',
|
||||
refill: 2,
|
||||
spulna: 1,
|
||||
cena: '3999'
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_URL}/filaments`,
|
||||
newFilament,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data).toHaveProperty('id');
|
||||
expect(response.data.tip).toBe('PLA');
|
||||
expect(response.data.boja).toBe('Black');
|
||||
|
||||
// Store ID for cleanup
|
||||
createdFilamentId = response.data.id;
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
it('should retrieve all filaments including the created one', async () => {
|
||||
const response = await axios.get(`${API_URL}/filaments`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.data)).toBe(true);
|
||||
|
||||
// Find our created filament
|
||||
const ourFilament = response.data.find((f: any) => f.id === createdFilamentId);
|
||||
expect(ourFilament).toBeDefined();
|
||||
expect(ourFilament.boja).toBe('Black');
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
it('should update the created filament', async () => {
|
||||
const updateData = {
|
||||
tip: 'PETG',
|
||||
finish: 'Silk',
|
||||
boja: 'Blue',
|
||||
boja_hex: '#1E88E5',
|
||||
refill: 3,
|
||||
spulna: 2,
|
||||
cena: '4500'
|
||||
};
|
||||
|
||||
const response = await axios.put(
|
||||
`${API_URL}/filaments/${createdFilamentId}`,
|
||||
updateData,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.tip).toBe('PETG');
|
||||
expect(response.data.boja).toBe('Blue');
|
||||
expect(response.data.cena).toBe('4500');
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
it('should delete the created filament', async () => {
|
||||
const response = await axios.delete(
|
||||
`${API_URL}/filaments/${createdFilamentId}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify it's deleted
|
||||
const getResponse = await axios.get(`${API_URL}/filaments`);
|
||||
const deletedFilament = getResponse.data.find((f: any) => f.id === createdFilamentId);
|
||||
expect(deletedFilament).toBeUndefined();
|
||||
}, TEST_TIMEOUT);
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should return 401 for unauthorized requests', async () => {
|
||||
await expect(
|
||||
axios.post(`${API_URL}/filaments`, {}, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
response: { status: 401 }
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid data gracefully', async () => {
|
||||
await expect(
|
||||
axios.post(
|
||||
`${API_URL}/filaments`,
|
||||
{ invalid: 'data' },
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
response: { status: 500 }
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
230
__tests__/api.test.ts
Normal file
230
__tests__/api.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import axios from 'axios';
|
||||
import api, { authService, colorService, filamentService } from '../src/services/api';
|
||||
|
||||
// Get the mock axios instance that was created
|
||||
const mockAxiosInstance = (axios.create as jest.Mock).mock.results[0].value;
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
(global as any).localStorage = localStorageMock;
|
||||
|
||||
// Mock window.location
|
||||
const mockLocation = {
|
||||
pathname: '/',
|
||||
href: '',
|
||||
};
|
||||
|
||||
// Only define location if it doesn't exist or is configurable
|
||||
if (!Object.getOwnPropertyDescriptor(window, 'location') ||
|
||||
Object.getOwnPropertyDescriptor(window, 'location')?.configurable) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
} else {
|
||||
// If location exists and is not configurable, we'll work with the existing object
|
||||
Object.assign(window.location, mockLocation);
|
||||
}
|
||||
|
||||
describe('API Service Tests', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Clear localStorage mocks
|
||||
localStorageMock.getItem.mockClear();
|
||||
localStorageMock.removeItem.mockClear();
|
||||
localStorageMock.setItem.mockClear();
|
||||
|
||||
// Reset window location
|
||||
mockLocation.pathname = '/';
|
||||
mockLocation.href = '';
|
||||
});
|
||||
|
||||
describe('Auth Service', () => {
|
||||
it('should login successfully', async () => {
|
||||
const mockResponse = { data: { token: 'test-token', user: 'admin' } };
|
||||
mockAxiosInstance.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.login('admin', 'password123');
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/login', {
|
||||
username: 'admin',
|
||||
password: 'password123'
|
||||
});
|
||||
expect(result).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('should handle login failure', async () => {
|
||||
const error = new Error('Invalid credentials');
|
||||
mockAxiosInstance.post.mockRejectedValue(error);
|
||||
|
||||
await expect(authService.login('admin', 'wrong')).rejects.toThrow('Invalid credentials');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Color Service', () => {
|
||||
it('should get all colors', async () => {
|
||||
const mockColors = [
|
||||
{ id: '1', name: 'Red', hex: '#FF0000' },
|
||||
{ id: '2', name: 'Blue', hex: '#0000FF' }
|
||||
];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: mockColors });
|
||||
|
||||
const result = await colorService.getAll();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/colors');
|
||||
expect(result).toEqual(mockColors);
|
||||
});
|
||||
|
||||
it('should create a color', async () => {
|
||||
const newColor = { name: 'Green', hex: '#00FF00', cena_refill: 100, cena_spulna: 150 };
|
||||
const mockResponse = { id: '3', ...newColor };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await colorService.create(newColor);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/colors', newColor);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should update a color', async () => {
|
||||
const colorId = '1';
|
||||
const updateData = { name: 'Dark Red', hex: '#8B0000' };
|
||||
const mockResponse = { id: colorId, ...updateData };
|
||||
mockAxiosInstance.put.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await colorService.update(colorId, updateData);
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith(`/colors/${colorId}`, updateData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should delete a color', async () => {
|
||||
const colorId = '1';
|
||||
const mockResponse = { success: true };
|
||||
mockAxiosInstance.delete.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await colorService.delete(colorId);
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith(`/colors/${colorId}`);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filament Service', () => {
|
||||
it('should get all filaments with cache buster', async () => {
|
||||
const mockFilaments = [
|
||||
{ id: '1', tip: 'PLA', boja: 'Red' },
|
||||
{ id: '2', tip: 'PETG', boja: 'Blue' }
|
||||
];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: mockFilaments });
|
||||
|
||||
// Mock Date.now()
|
||||
const originalDateNow = Date.now;
|
||||
const mockTimestamp = 1234567890;
|
||||
Date.now = jest.fn(() => mockTimestamp);
|
||||
|
||||
const result = await filamentService.getAll();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith(`/filaments?_t=${mockTimestamp}`);
|
||||
expect(result).toEqual(mockFilaments);
|
||||
|
||||
// Restore Date.now
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
|
||||
it('should create a filament', async () => {
|
||||
const newFilament = {
|
||||
tip: 'ABS',
|
||||
finish: 'Matte',
|
||||
boja: 'Black',
|
||||
boja_hex: '#000000'
|
||||
};
|
||||
const mockResponse = { id: '3', ...newFilament };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await filamentService.create(newFilament);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/filaments', newFilament);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should update a filament', async () => {
|
||||
const filamentId = '1';
|
||||
const updateData = {
|
||||
tip: 'PLA+',
|
||||
finish: 'Silk',
|
||||
cena: '4500'
|
||||
};
|
||||
const mockResponse = { id: filamentId, ...updateData };
|
||||
mockAxiosInstance.put.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await filamentService.update(filamentId, updateData);
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith(`/filaments/${filamentId}`, updateData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should delete a filament', async () => {
|
||||
const filamentId = '1';
|
||||
const mockResponse = { success: true };
|
||||
mockAxiosInstance.delete.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await filamentService.delete(filamentId);
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith(`/filaments/${filamentId}`);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should update bulk sale', async () => {
|
||||
const saleData = {
|
||||
filamentIds: ['1', '2', '3'],
|
||||
salePercentage: 20,
|
||||
saleStartDate: '2024-01-01',
|
||||
saleEndDate: '2024-01-31',
|
||||
enableSale: true
|
||||
};
|
||||
const mockResponse = { updated: 3, success: true };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await filamentService.updateBulkSale(saleData);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/filaments/sale/bulk', saleData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interceptors', () => {
|
||||
it('should have interceptors configured', () => {
|
||||
expect(mockAxiosInstance.interceptors).toBeDefined();
|
||||
expect(mockAxiosInstance.interceptors.request).toBeDefined();
|
||||
expect(mockAxiosInstance.interceptors.response).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have request interceptor set up', () => {
|
||||
const mockRequestUse = mockAxiosInstance.interceptors.request.use;
|
||||
expect(mockRequestUse).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have response interceptor set up', () => {
|
||||
const mockResponseUse = mockAxiosInstance.interceptors.response.use;
|
||||
expect(mockResponseUse).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API configuration', () => {
|
||||
it('should export the configured axios instance', () => {
|
||||
expect(api).toBe(mockAxiosInstance);
|
||||
});
|
||||
|
||||
it('should have axios instance defined', () => {
|
||||
expect(mockAxiosInstance).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
154
__tests__/color-management.test.ts
Normal file
154
__tests__/color-management.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = 'https://api.filamenteka.rs/api';
|
||||
const TEST_TIMEOUT = 30000;
|
||||
|
||||
describe.skip('Color Management Tests - Skipped: API endpoints not deployed', () => {
|
||||
let authToken: string;
|
||||
let createdColorId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Login to get auth token
|
||||
const loginResponse = await axios.post(`${API_URL}/login`, {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
});
|
||||
authToken = loginResponse.data.token;
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up any test colors that might have been left behind
|
||||
if (createdColorId) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`${API_URL}/colors/${createdColorId}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore errors - color might already be deleted
|
||||
}
|
||||
}
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
describe('Color CRUD Operations', () => {
|
||||
it('should create a new color with "Test" prefix', async () => {
|
||||
const testColor = {
|
||||
name: 'Test Color ' + Date.now(), // Unique name to avoid conflicts
|
||||
hex: '#FF00FF'
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_URL}/colors`,
|
||||
testColor,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data).toHaveProperty('id');
|
||||
expect(response.data.name).toBe(testColor.name);
|
||||
expect(response.data.hex).toBe(testColor.hex);
|
||||
|
||||
// Store ID for cleanup
|
||||
createdColorId = response.data.id;
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
it('should retrieve the created test color', async () => {
|
||||
const response = await axios.get(`${API_URL}/colors`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.data)).toBe(true);
|
||||
|
||||
// Find our created color
|
||||
const ourColor = response.data.find((c: any) => c.id === createdColorId);
|
||||
expect(ourColor).toBeDefined();
|
||||
expect(ourColor.name).toContain('Test Color');
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
it('should update the test color', async () => {
|
||||
const updateData = {
|
||||
name: 'Test Color Updated ' + Date.now(),
|
||||
hex: '#00FF00'
|
||||
};
|
||||
|
||||
const response = await axios.put(
|
||||
`${API_URL}/colors/${createdColorId}`,
|
||||
updateData,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.name).toBe(updateData.name);
|
||||
expect(response.data.hex).toBe(updateData.hex);
|
||||
}, TEST_TIMEOUT);
|
||||
|
||||
it('should delete the test color', async () => {
|
||||
const response = await axios.delete(
|
||||
`${API_URL}/colors/${createdColorId}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify it's deleted
|
||||
const getResponse = await axios.get(`${API_URL}/colors`);
|
||||
const deletedColor = getResponse.data.find((c: any) => c.id === createdColorId);
|
||||
expect(deletedColor).toBeUndefined();
|
||||
|
||||
// Clear the ID since it's deleted
|
||||
createdColorId = '';
|
||||
}, TEST_TIMEOUT);
|
||||
});
|
||||
|
||||
describe('Cleanup Test Colors', () => {
|
||||
it('should clean up any colors starting with "Test"', async () => {
|
||||
// Get all colors
|
||||
const response = await axios.get(`${API_URL}/colors`);
|
||||
const testColors = response.data.filter((c: any) =>
|
||||
c.name.startsWith('Test') || c.name.toLowerCase().includes('test')
|
||||
);
|
||||
|
||||
// Delete each test color
|
||||
for (const color of testColors) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`${API_URL}/colors/${color.id}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log(`Cleaned up test color: ${color.name}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to clean up color ${color.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify cleanup
|
||||
const verifyResponse = await axios.get(`${API_URL}/colors`);
|
||||
const remainingTestColors = verifyResponse.data.filter((c: any) =>
|
||||
c.name.startsWith('Test') || c.name.toLowerCase().includes('test')
|
||||
);
|
||||
|
||||
expect(remainingTestColors.length).toBe(0);
|
||||
}, TEST_TIMEOUT);
|
||||
});
|
||||
});
|
||||
148
__tests__/components/BackToTop.test.tsx
Normal file
148
__tests__/components/BackToTop.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent, act } from '@testing-library/react';
|
||||
import { BackToTop } from '@/src/components/BackToTop';
|
||||
|
||||
// Mock window properties
|
||||
global.scrollTo = jest.fn();
|
||||
Object.defineProperty(window, 'pageYOffset', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 0,
|
||||
});
|
||||
|
||||
describe('BackToTop', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.pageYOffset = 0;
|
||||
});
|
||||
|
||||
it('does not render button when at top of page', () => {
|
||||
const { container } = render(<BackToTop />);
|
||||
const button = container.querySelector('button');
|
||||
expect(button).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders button when scrolled down', () => {
|
||||
const { container } = render(<BackToTop />);
|
||||
|
||||
// Simulate scroll down
|
||||
act(() => {
|
||||
window.pageYOffset = 400;
|
||||
fireEvent.scroll(window);
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides button when scrolled back up', () => {
|
||||
const { container } = render(<BackToTop />);
|
||||
|
||||
// Scroll down first
|
||||
act(() => {
|
||||
window.pageYOffset = 400;
|
||||
fireEvent.scroll(window);
|
||||
});
|
||||
|
||||
expect(container.querySelector('button')).toBeInTheDocument();
|
||||
|
||||
// Scroll back up
|
||||
act(() => {
|
||||
window.pageYOffset = 100;
|
||||
fireEvent.scroll(window);
|
||||
});
|
||||
|
||||
expect(container.querySelector('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('scrolls to top when clicked', () => {
|
||||
const { container } = render(<BackToTop />);
|
||||
|
||||
// Make button visible
|
||||
act(() => {
|
||||
window.pageYOffset = 400;
|
||||
fireEvent.scroll(window);
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
fireEvent.click(button!);
|
||||
|
||||
expect(global.scrollTo).toHaveBeenCalledWith({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
|
||||
it('has correct styling when visible', () => {
|
||||
const { container } = render(<BackToTop />);
|
||||
|
||||
// Make button visible
|
||||
act(() => {
|
||||
window.pageYOffset = 400;
|
||||
fireEvent.scroll(window);
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
|
||||
expect(button).toHaveClass('fixed');
|
||||
expect(button).toHaveClass('bottom-8');
|
||||
expect(button).toHaveClass('right-8');
|
||||
expect(button).toHaveClass('bg-blue-600');
|
||||
expect(button).toHaveClass('text-white');
|
||||
expect(button).toHaveClass('rounded-full');
|
||||
expect(button).toHaveClass('shadow-lg');
|
||||
});
|
||||
|
||||
it('has hover effect', () => {
|
||||
const { container } = render(<BackToTop />);
|
||||
|
||||
// Make button visible
|
||||
act(() => {
|
||||
window.pageYOffset = 400;
|
||||
fireEvent.scroll(window);
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
expect(button).toHaveClass('hover:bg-blue-700');
|
||||
expect(button).toHaveClass('hover:scale-110');
|
||||
});
|
||||
|
||||
it('contains arrow icon', () => {
|
||||
const { container } = render(<BackToTop />);
|
||||
|
||||
// Make button visible
|
||||
act(() => {
|
||||
window.pageYOffset = 400;
|
||||
fireEvent.scroll(window);
|
||||
});
|
||||
|
||||
const svg = container.querySelector('svg');
|
||||
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg).toHaveClass('w-6');
|
||||
expect(svg).toHaveClass('h-6');
|
||||
});
|
||||
|
||||
it('has aria-label for accessibility', () => {
|
||||
const { container } = render(<BackToTop />);
|
||||
|
||||
// Make button visible
|
||||
act(() => {
|
||||
window.pageYOffset = 400;
|
||||
fireEvent.scroll(window);
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
expect(button).toHaveAttribute('aria-label', 'Back to top');
|
||||
});
|
||||
|
||||
it('cleans up scroll listener on unmount', () => {
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
const { unmount } = render(<BackToTop />);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
82
__tests__/components/ColorCell.test.tsx
Normal file
82
__tests__/components/ColorCell.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ColorCell } from '@/src/components/ColorCell';
|
||||
|
||||
// Mock the bambuLabColors module
|
||||
jest.mock('@/src/data/bambuLabColors', () => ({
|
||||
getFilamentColor: jest.fn((colorName) => {
|
||||
const colors = {
|
||||
'Black': { hex: '#000000' },
|
||||
'Red': { hex: '#FF0000' },
|
||||
'Blue': { hex: '#0000FF' },
|
||||
'Rainbow': { hex: ['#FF0000', '#00FF00'], isGradient: true }
|
||||
};
|
||||
return colors[colorName] || { hex: '#CCCCCC' };
|
||||
}),
|
||||
getColorStyle: jest.fn((colorMapping) => {
|
||||
if (colorMapping.isGradient && Array.isArray(colorMapping.hex)) {
|
||||
return {
|
||||
background: `linear-gradient(90deg, ${colorMapping.hex[0]} 0%, ${colorMapping.hex[1]} 100%)`
|
||||
};
|
||||
}
|
||||
return {
|
||||
backgroundColor: Array.isArray(colorMapping.hex) ? colorMapping.hex[0] : colorMapping.hex
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
describe('ColorCell', () => {
|
||||
it('renders color name', () => {
|
||||
const { getByText } = render(<ColorCell colorName="Black" />);
|
||||
expect(getByText('Black')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders color swatch with correct style', () => {
|
||||
const { container } = render(<ColorCell colorName="Red" />);
|
||||
const colorDiv = container.querySelector('.w-6.h-6');
|
||||
expect(colorDiv).toHaveStyle({ backgroundColor: '#FF0000' });
|
||||
});
|
||||
|
||||
it('renders with correct dimensions', () => {
|
||||
const { container } = render(<ColorCell colorName="Blue" />);
|
||||
const colorDiv = container.querySelector('.w-6.h-6');
|
||||
expect(colorDiv).toHaveClass('w-6');
|
||||
expect(colorDiv).toHaveClass('h-6');
|
||||
});
|
||||
|
||||
it('has rounded corners and border', () => {
|
||||
const { container } = render(<ColorCell colorName="Black" />);
|
||||
const colorDiv = container.querySelector('.w-6.h-6');
|
||||
expect(colorDiv).toHaveClass('rounded');
|
||||
expect(colorDiv).toHaveClass('border');
|
||||
expect(colorDiv).toHaveClass('border-gray-300');
|
||||
});
|
||||
|
||||
it('renders gradient colors', () => {
|
||||
const { container } = render(<ColorCell colorName="Rainbow" />);
|
||||
const colorDiv = container.querySelector('.w-6.h-6');
|
||||
expect(colorDiv).toHaveStyle({
|
||||
background: 'linear-gradient(90deg, #FF0000 0%, #00FF00 100%)'
|
||||
});
|
||||
});
|
||||
|
||||
it('has title attribute with hex value', () => {
|
||||
const { container } = render(<ColorCell colorName="Black" />);
|
||||
const colorDiv = container.querySelector('.w-6.h-6');
|
||||
expect(colorDiv).toHaveAttribute('title', '#000000');
|
||||
});
|
||||
|
||||
it('has title attribute with gradient hex values', () => {
|
||||
const { container } = render(<ColorCell colorName="Rainbow" />);
|
||||
const colorDiv = container.querySelector('.w-6.h-6');
|
||||
expect(colorDiv).toHaveAttribute('title', '#FF0000 - #00FF00');
|
||||
});
|
||||
|
||||
it('renders with flex layout', () => {
|
||||
const { container } = render(<ColorCell colorName="Black" />);
|
||||
const wrapper = container.firstChild;
|
||||
expect(wrapper).toHaveClass('flex');
|
||||
expect(wrapper).toHaveClass('items-center');
|
||||
expect(wrapper).toHaveClass('gap-2');
|
||||
});
|
||||
});
|
||||
87
__tests__/components/MaterialBadge.test.tsx
Normal file
87
__tests__/components/MaterialBadge.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { MaterialBadge } from '@/src/components/MaterialBadge';
|
||||
|
||||
describe('MaterialBadge', () => {
|
||||
describe('Material type badges', () => {
|
||||
it('renders PLA badge with correct style', () => {
|
||||
const { getByText } = render(<MaterialBadge base="PLA" />);
|
||||
const badge = getByText('PLA');
|
||||
expect(badge).toHaveClass('bg-green-100');
|
||||
expect(badge).toHaveClass('text-green-800');
|
||||
});
|
||||
|
||||
it('renders PETG badge with correct style', () => {
|
||||
const { getByText } = render(<MaterialBadge base="PETG" />);
|
||||
const badge = getByText('PETG');
|
||||
expect(badge).toHaveClass('bg-blue-100');
|
||||
expect(badge).toHaveClass('text-blue-800');
|
||||
});
|
||||
|
||||
it('renders ABS badge with correct style', () => {
|
||||
const { getByText } = render(<MaterialBadge base="ABS" />);
|
||||
const badge = getByText('ABS');
|
||||
expect(badge).toHaveClass('bg-red-100');
|
||||
expect(badge).toHaveClass('text-red-800');
|
||||
});
|
||||
|
||||
it('renders TPU badge with correct style', () => {
|
||||
const { getByText } = render(<MaterialBadge base="TPU" />);
|
||||
const badge = getByText('TPU');
|
||||
expect(badge).toHaveClass('bg-purple-100');
|
||||
expect(badge).toHaveClass('text-purple-800');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown material type', () => {
|
||||
it('renders unknown material with default style', () => {
|
||||
const { getByText } = render(<MaterialBadge base="UNKNOWN" />);
|
||||
const badge = getByText('UNKNOWN');
|
||||
expect(badge).toHaveClass('bg-gray-100');
|
||||
expect(badge).toHaveClass('text-gray-800');
|
||||
});
|
||||
});
|
||||
|
||||
describe('With modifier', () => {
|
||||
it('renders base and modifier', () => {
|
||||
const { getByText } = render(<MaterialBadge base="PLA" modifier="Silk" />);
|
||||
expect(getByText('PLA')).toBeInTheDocument();
|
||||
expect(getByText('Silk')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modifier with correct style', () => {
|
||||
const { getByText } = render(<MaterialBadge base="PLA" modifier="Matte" />);
|
||||
const modifier = getByText('Matte');
|
||||
expect(modifier).toHaveClass('bg-gray-100');
|
||||
expect(modifier).toHaveClass('text-gray-800');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Common styles', () => {
|
||||
it('has correct padding and shape', () => {
|
||||
const { getByText } = render(<MaterialBadge base="PLA" />);
|
||||
const badge = getByText('PLA');
|
||||
expect(badge).toHaveClass('px-2.5');
|
||||
expect(badge).toHaveClass('py-0.5');
|
||||
expect(badge).toHaveClass('rounded-full');
|
||||
});
|
||||
|
||||
it('has correct text size', () => {
|
||||
const { getByText } = render(<MaterialBadge base="PLA" />);
|
||||
const badge = getByText('PLA');
|
||||
expect(badge).toHaveClass('text-xs');
|
||||
});
|
||||
|
||||
it('has correct font weight', () => {
|
||||
const { getByText } = render(<MaterialBadge base="PLA" />);
|
||||
const badge = getByText('PLA');
|
||||
expect(badge).toHaveClass('font-medium');
|
||||
});
|
||||
|
||||
it('accepts custom className', () => {
|
||||
const { container } = render(<MaterialBadge base="PLA" className="custom-class" />);
|
||||
const wrapper = container.firstChild;
|
||||
expect(wrapper).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
229
__tests__/data-consistency.test.ts
Normal file
229
__tests__/data-consistency.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Filament } from '../src/types/filament';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
describe('Data Structure Consistency Tests', () => {
|
||||
const connectionString = "postgresql://filamenteka_admin:onrBjiAjHKQXBAJSVWU2t2kQ7HDil9re@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka";
|
||||
|
||||
// Admin panel expected structure (source of truth)
|
||||
const ADMIN_STRUCTURE = {
|
||||
fields: [
|
||||
'id',
|
||||
'tip',
|
||||
'finish',
|
||||
'boja',
|
||||
'boja_hex', // Now using snake_case consistently
|
||||
'refill',
|
||||
'spulna', // Frontend still uses spulna
|
||||
'kolicina',
|
||||
'cena',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
],
|
||||
requiredFields: ['tip', 'finish', 'boja'],
|
||||
excludedFields: ['brand'], // Should NOT exist
|
||||
fieldTypes: {
|
||||
tip: 'string',
|
||||
finish: 'string',
|
||||
boja: 'string',
|
||||
boja_hex: 'string',
|
||||
refill: 'number',
|
||||
spulna: 'number',
|
||||
kolicina: 'number',
|
||||
cena: 'string'
|
||||
}
|
||||
};
|
||||
|
||||
// Database expected structure
|
||||
const DB_STRUCTURE = {
|
||||
columns: [
|
||||
'id',
|
||||
'tip',
|
||||
'finish',
|
||||
'boja',
|
||||
'boja_hex', // Database uses snake_case
|
||||
'refill',
|
||||
'spulna',
|
||||
'kolicina',
|
||||
'cena',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
],
|
||||
excludedColumns: ['brand'],
|
||||
columnTypes: {
|
||||
id: 'uuid',
|
||||
tip: 'character varying',
|
||||
finish: 'character varying',
|
||||
boja: 'character varying',
|
||||
boja_hex: 'character varying',
|
||||
refill: 'integer',
|
||||
spulna: 'integer',
|
||||
kolicina: 'integer',
|
||||
cena: 'character varying',
|
||||
created_at: 'timestamp with time zone',
|
||||
updated_at: 'timestamp with time zone'
|
||||
}
|
||||
};
|
||||
|
||||
describe('Admin Panel Structure', () => {
|
||||
it('should have correct TypeScript interface', () => {
|
||||
// Check Filament interface matches admin structure
|
||||
const filamentKeys: (keyof Filament)[] = [
|
||||
'id', 'tip', 'finish', 'boja', 'boja_hex',
|
||||
'refill', 'spulna', 'kolicina', 'cena',
|
||||
'status', 'createdAt', 'updatedAt'
|
||||
];
|
||||
|
||||
// Should not have brand
|
||||
expect(filamentKeys).not.toContain('brand');
|
||||
|
||||
// Should have all required fields
|
||||
ADMIN_STRUCTURE.requiredFields.forEach(field => {
|
||||
expect(filamentKeys).toContain(field);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have consistent form fields in admin dashboard', () => {
|
||||
const formFields = [
|
||||
'tip', 'finish', 'boja', 'boja_hex',
|
||||
'refill', 'spulna', 'kolicina', 'cena'
|
||||
];
|
||||
|
||||
// Check all form fields are in admin structure
|
||||
formFields.forEach(field => {
|
||||
expect(ADMIN_STRUCTURE.fields).toContain(field);
|
||||
});
|
||||
|
||||
// Should not have brand in form
|
||||
expect(formFields).not.toContain('brand');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Database Schema Consistency', () => {
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(() => {
|
||||
pool = new Pool({
|
||||
connectionString,
|
||||
ssl: { rejectUnauthorized: false }
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('should have correct columns in database', async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'filaments'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
const columns = result.rows.map(row => row.column_name);
|
||||
const columnTypes = result.rows.reduce((acc, row) => {
|
||||
acc[row.column_name] = row.data_type;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
// Check all expected columns exist
|
||||
DB_STRUCTURE.columns.forEach(col => {
|
||||
expect(columns).toContain(col);
|
||||
});
|
||||
|
||||
// Check no excluded columns exist
|
||||
DB_STRUCTURE.excludedColumns.forEach(col => {
|
||||
expect(columns).not.toContain(col);
|
||||
});
|
||||
|
||||
// Check column types match
|
||||
Object.entries(DB_STRUCTURE.columnTypes).forEach(([col, type]) => {
|
||||
expect(columnTypes[col]).toBe(type);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not have brand column', async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'filaments' AND column_name = 'brand'
|
||||
`);
|
||||
|
||||
expect(parseInt(result.rows[0].count)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Frontend Consistency', () => {
|
||||
it('should transform fields correctly between admin and database', () => {
|
||||
// All fields now use snake_case consistently
|
||||
const snakeCaseFields = {
|
||||
'boja_hex': 'boja_hex',
|
||||
'created_at': 'created_at',
|
||||
'updated_at': 'updated_at'
|
||||
};
|
||||
|
||||
Object.entries(snakeCaseFields).forEach(([adminField, dbField]) => {
|
||||
expect(ADMIN_STRUCTURE.fields).toContain(adminField);
|
||||
expect(DB_STRUCTURE.columns).toContain(dbField);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle quantity fields correctly', () => {
|
||||
// Admin form shows refill, spulna as numbers
|
||||
// Database stores them as strings like "2", "1 spulna"
|
||||
const quantityFields = ['refill', 'spulna'];
|
||||
|
||||
quantityFields.forEach(field => {
|
||||
expect(ADMIN_STRUCTURE.fieldTypes[field]).toBe('number');
|
||||
expect(DB_STRUCTURE.columnTypes[field]).toBe('integer');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Consistency', () => {
|
||||
it('should handle requests without brand field', () => {
|
||||
const validRequest = {
|
||||
tip: 'PLA',
|
||||
finish: 'Basic',
|
||||
boja: 'Black',
|
||||
boja_hex: '#000000',
|
||||
refill: '2',
|
||||
spulna: '1 spulna',
|
||||
kolicina: '3',
|
||||
cena: '3999'
|
||||
};
|
||||
|
||||
// Should not have brand
|
||||
expect(validRequest).not.toHaveProperty('brand');
|
||||
|
||||
// Should have all required fields
|
||||
expect(validRequest).toHaveProperty('tip');
|
||||
expect(validRequest).toHaveProperty('finish');
|
||||
expect(validRequest).toHaveProperty('boja');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Flow Consistency', () => {
|
||||
it('should maintain consistent data flow: Admin → API → Database', () => {
|
||||
// Admin form data
|
||||
const adminData = {
|
||||
tip: 'PLA',
|
||||
finish: 'Basic',
|
||||
boja: 'Black',
|
||||
boja_hex: '#000000',
|
||||
refill: '2',
|
||||
spulna: '1 spulna',
|
||||
kolicina: '3',
|
||||
cena: '3999'
|
||||
};
|
||||
|
||||
// No transformation needed - using boja_hex consistently
|
||||
const apiData = adminData;
|
||||
|
||||
// Database expected data
|
||||
expect(apiData).toHaveProperty('boja_hex');
|
||||
// No longer have bojaHex field
|
||||
expect(apiData).not.toHaveProperty('brand');
|
||||
});
|
||||
});
|
||||
});
|
||||
100
__tests__/data/bambuLabColors.test.ts
Normal file
100
__tests__/data/bambuLabColors.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { bambuLabColors, getFilamentColor, getColorStyle, ColorMapping } from '@/src/data/bambuLabColors';
|
||||
|
||||
describe('Bambu Lab Colors Data', () => {
|
||||
describe('bambuLabColors', () => {
|
||||
it('should have color definitions', () => {
|
||||
expect(Object.keys(bambuLabColors).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have correct structure for each color', () => {
|
||||
Object.entries(bambuLabColors).forEach(([colorName, colorMapping]) => {
|
||||
expect(colorMapping).toHaveProperty('hex');
|
||||
expect(colorMapping.hex).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have known colors', () => {
|
||||
expect(bambuLabColors).toHaveProperty('Black');
|
||||
expect(bambuLabColors).toHaveProperty('White');
|
||||
expect(bambuLabColors).toHaveProperty('Red');
|
||||
expect(bambuLabColors).toHaveProperty('Blue');
|
||||
expect(bambuLabColors).toHaveProperty('Green');
|
||||
});
|
||||
|
||||
it('should have valid hex colors', () => {
|
||||
Object.entries(bambuLabColors).forEach(([colorName, colorMapping]) => {
|
||||
if (typeof colorMapping.hex === 'string') {
|
||||
expect(colorMapping.hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
} else if (Array.isArray(colorMapping.hex)) {
|
||||
colorMapping.hex.forEach(hex => {
|
||||
expect(hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should have Unknown fallback color', () => {
|
||||
expect(bambuLabColors).toHaveProperty('Unknown');
|
||||
expect(bambuLabColors.Unknown.hex).toBe('#CCCCCC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilamentColor', () => {
|
||||
it('should return exact match', () => {
|
||||
const result = getFilamentColor('Black');
|
||||
expect(result).toEqual(bambuLabColors['Black']);
|
||||
});
|
||||
|
||||
it('should return case-insensitive match', () => {
|
||||
const result = getFilamentColor('black');
|
||||
expect(result).toEqual(bambuLabColors['Black']);
|
||||
});
|
||||
|
||||
it('should return partial match', () => {
|
||||
const result = getFilamentColor('PLA Black');
|
||||
expect(result).toEqual(bambuLabColors['Black']);
|
||||
});
|
||||
|
||||
it('should return default color for non-existent color', () => {
|
||||
const result = getFilamentColor('NonExistentColor');
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('hex');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = getFilamentColor('');
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('hex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getColorStyle', () => {
|
||||
it('should return backgroundColor for single hex color', () => {
|
||||
const colorMapping: ColorMapping = { hex: '#FF0000' };
|
||||
const result = getColorStyle(colorMapping);
|
||||
expect(result).toEqual({ backgroundColor: '#FF0000' });
|
||||
});
|
||||
|
||||
it('should return backgroundColor for hex array without gradient', () => {
|
||||
const colorMapping: ColorMapping = { hex: ['#FF0000', '#00FF00'] };
|
||||
const result = getColorStyle(colorMapping);
|
||||
expect(result).toEqual({ backgroundColor: '#FF0000' });
|
||||
});
|
||||
|
||||
it('should return gradient for isGradient true', () => {
|
||||
const colorMapping: ColorMapping = { hex: ['#FF0000', '#00FF00'], isGradient: true };
|
||||
const result = getColorStyle(colorMapping);
|
||||
expect(result).toEqual({
|
||||
background: 'linear-gradient(90deg, #FF0000 0%, #00FF00 100%)'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle gradient with more than 2 colors', () => {
|
||||
const colorMapping: ColorMapping = { hex: ['#FF0000', '#00FF00', '#0000FF'], isGradient: true };
|
||||
const result = getColorStyle(colorMapping);
|
||||
expect(result).toEqual({
|
||||
background: 'linear-gradient(90deg, #FF0000 0%, #00FF00 100%)'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
68
__tests__/data/bambuLabColorsComplete.test.ts
Normal file
68
__tests__/data/bambuLabColorsComplete.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { bambuLabColors, colorsByFinish, getColorHex, getColorsForFinish } from '@/src/data/bambuLabColorsComplete';
|
||||
|
||||
describe('Bambu Lab Colors Complete Data', () => {
|
||||
it('should have color definitions', () => {
|
||||
expect(bambuLabColors).toBeDefined();
|
||||
expect(typeof bambuLabColors).toBe('object');
|
||||
expect(Object.keys(bambuLabColors).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have basic colors', () => {
|
||||
expect(bambuLabColors).toHaveProperty('Black');
|
||||
expect(bambuLabColors).toHaveProperty('White');
|
||||
expect(bambuLabColors).toHaveProperty('Red');
|
||||
expect(bambuLabColors).toHaveProperty('Blue');
|
||||
});
|
||||
|
||||
it('should have matte colors', () => {
|
||||
expect(bambuLabColors).toHaveProperty('Matte Black');
|
||||
expect(bambuLabColors).toHaveProperty('Matte White');
|
||||
expect(bambuLabColors).toHaveProperty('Matte Red');
|
||||
});
|
||||
|
||||
it('should have silk colors', () => {
|
||||
expect(bambuLabColors).toHaveProperty('Silk White');
|
||||
expect(bambuLabColors).toHaveProperty('Silk Black');
|
||||
expect(bambuLabColors).toHaveProperty('Silk Gold');
|
||||
});
|
||||
|
||||
it('should have valid hex colors', () => {
|
||||
Object.entries(bambuLabColors).forEach(([colorName, hex]) => {
|
||||
expect(hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have colorsByFinish defined', () => {
|
||||
expect(colorsByFinish).toBeDefined();
|
||||
expect(typeof colorsByFinish).toBe('object');
|
||||
});
|
||||
|
||||
it('should have finish categories', () => {
|
||||
expect(colorsByFinish).toHaveProperty('Basic');
|
||||
expect(colorsByFinish).toHaveProperty('Matte');
|
||||
expect(colorsByFinish).toHaveProperty('Silk');
|
||||
expect(colorsByFinish).toHaveProperty('Metal');
|
||||
expect(colorsByFinish).toHaveProperty('Sparkle');
|
||||
expect(colorsByFinish).toHaveProperty('Glow');
|
||||
expect(colorsByFinish).toHaveProperty('Transparent');
|
||||
expect(colorsByFinish).toHaveProperty('Support');
|
||||
});
|
||||
|
||||
it('should return valid hex for getColorHex', () => {
|
||||
const hex = getColorHex('Black');
|
||||
expect(hex).toBe('#000000');
|
||||
|
||||
const unknownHex = getColorHex('Unknown Color');
|
||||
expect(unknownHex).toBe('#000000');
|
||||
});
|
||||
|
||||
it('should return colors for finish', () => {
|
||||
const basicColors = getColorsForFinish('Basic');
|
||||
expect(Array.isArray(basicColors)).toBe(true);
|
||||
expect(basicColors.length).toBeGreaterThan(0);
|
||||
|
||||
const unknownFinish = getColorsForFinish('Unknown');
|
||||
expect(Array.isArray(unknownFinish)).toBe(true);
|
||||
expect(unknownFinish.length).toBe(0);
|
||||
});
|
||||
});
|
||||
200
__tests__/filament-crud.test.ts
Normal file
200
__tests__/filament-crud.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
// Mock axios before importing services
|
||||
jest.mock('axios', () => ({
|
||||
create: jest.fn(() => ({
|
||||
interceptors: {
|
||||
request: { use: jest.fn() },
|
||||
response: { use: jest.fn() }
|
||||
},
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
delete: jest.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
import { filamentService, authService } from '../src/services/api';
|
||||
|
||||
describe('Filament CRUD Operations', () => {
|
||||
let authToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Mock successful login
|
||||
jest.spyOn(authService, 'login').mockResolvedValue({ token: 'test-token' });
|
||||
const loginResponse = await authService.login('admin', 'admin123');
|
||||
authToken = loginResponse.token;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Create Filament', () => {
|
||||
it('should create a new filament without brand field', async () => {
|
||||
const newFilament = {
|
||||
tip: 'PLA',
|
||||
finish: 'Basic',
|
||||
boja: 'Black',
|
||||
boja_hex: '#000000',
|
||||
refill: '2',
|
||||
spulna: '1 spulna',
|
||||
kolicina: '3',
|
||||
cena: '3999'
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '123',
|
||||
...newFilament,
|
||||
boja_hex: '#000000',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
jest.spyOn(filamentService, 'create').mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await filamentService.create(newFilament);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(filamentService.create).toHaveBeenCalledWith(newFilament);
|
||||
|
||||
// Verify no brand field was sent
|
||||
const callArg = (filamentService.create as jest.Mock).mock.calls[0][0];
|
||||
expect(callArg).not.toHaveProperty('brand');
|
||||
});
|
||||
|
||||
it('should use boja_hex field directly', async () => {
|
||||
const filamentWithHex = {
|
||||
tip: 'PETG',
|
||||
finish: 'Silk',
|
||||
boja: 'Red',
|
||||
boja_hex: '#FF0000',
|
||||
refill: 'Ne',
|
||||
spulna: 'Ne',
|
||||
kolicina: '1',
|
||||
cena: '4500'
|
||||
};
|
||||
|
||||
// Spy on the actual implementation
|
||||
const createSpy = jest.spyOn(filamentService, 'create');
|
||||
|
||||
// We can't test the actual transformation without a real axios call,
|
||||
// but we can verify the method is called correctly
|
||||
await expect(async () => {
|
||||
await filamentService.create(filamentWithHex);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(createSpy).toHaveBeenCalledWith(filamentWithHex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Filament', () => {
|
||||
it('should update a filament without brand field', async () => {
|
||||
const filamentId = '123';
|
||||
const updateData = {
|
||||
tip: 'ABS',
|
||||
finish: 'Matte',
|
||||
boja: 'Blue',
|
||||
boja_hex: '#0000FF',
|
||||
refill: '1',
|
||||
spulna: '2 spulna',
|
||||
kolicina: '4',
|
||||
cena: '5000'
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: filamentId,
|
||||
...updateData,
|
||||
boja_hex: '#0000FF',
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
jest.spyOn(filamentService, 'update').mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await filamentService.update(filamentId, updateData);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(filamentService.update).toHaveBeenCalledWith(filamentId, updateData);
|
||||
|
||||
// Verify no brand field was sent
|
||||
const callArgs = (filamentService.update as jest.Mock).mock.calls[0];
|
||||
expect(callArgs[1]).not.toHaveProperty('brand');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete Filament', () => {
|
||||
it('should delete a filament by ID', async () => {
|
||||
const filamentId = '123';
|
||||
const mockResponse = { success: true };
|
||||
|
||||
jest.spyOn(filamentService, 'delete').mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await filamentService.delete(filamentId);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(filamentService.delete).toHaveBeenCalledWith(filamentId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get All Filaments', () => {
|
||||
it('should retrieve all filaments with boja_hex field', async () => {
|
||||
const mockFilaments = [
|
||||
{
|
||||
id: '1',
|
||||
tip: 'PLA',
|
||||
finish: 'Basic',
|
||||
boja: 'Black',
|
||||
boja_hex: '#000000',
|
||||
refill: '2',
|
||||
spulna: '1 spulna',
|
||||
kolicina: '3',
|
||||
cena: '3999'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
tip: 'PETG',
|
||||
finish: 'Silk',
|
||||
boja: 'Red',
|
||||
boja_hex: '#FF0000',
|
||||
refill: 'Ne',
|
||||
spulna: 'Ne',
|
||||
kolicina: '1',
|
||||
cena: '4500'
|
||||
}
|
||||
];
|
||||
|
||||
// No transformation needed anymore - we use boja_hex directly
|
||||
const expectedFilaments = mockFilaments;
|
||||
|
||||
jest.spyOn(filamentService, 'getAll').mockResolvedValue(expectedFilaments);
|
||||
|
||||
const result = await filamentService.getAll();
|
||||
|
||||
expect(result).toEqual(expectedFilaments);
|
||||
expect(result[0]).toHaveProperty('boja_hex', '#000000');
|
||||
expect(result[1]).toHaveProperty('boja_hex', '#FF0000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quantity Calculations', () => {
|
||||
it('should correctly calculate total quantity from refill and spulna', () => {
|
||||
const testCases = [
|
||||
{ refill: '2', spulna: '1 spulna', expected: 3 },
|
||||
{ refill: 'Ne', spulna: '3 spulna', expected: 3 },
|
||||
{ refill: '1', spulna: 'Ne', expected: 1 },
|
||||
{ refill: 'Da', spulna: 'spulna', expected: 2 }
|
||||
];
|
||||
|
||||
testCases.forEach(({ refill, spulna, expected }) => {
|
||||
// Parse refill
|
||||
const refillCount = parseInt(refill) || (refill?.toLowerCase() === 'da' ? 1 : 0);
|
||||
|
||||
// Parse spulna
|
||||
const vakuumMatch = spulna?.match(/^(\d+)\s*spulna/);
|
||||
const vakuumCount = vakuumMatch ? parseInt(vakuumMatch[1]) : (spulna?.toLowerCase().includes('spulna') ? 1 : 0);
|
||||
|
||||
const total = refillCount + vakuumCount;
|
||||
|
||||
expect(total).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
29
__tests__/no-mock-data.test.ts
Normal file
29
__tests__/no-mock-data.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('No Mock Data Tests', () => {
|
||||
it('should not have data.json in public folder', () => {
|
||||
const dataJsonPath = join(process.cwd(), 'public', 'data.json');
|
||||
expect(existsSync(dataJsonPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not have fallback to data.json in page.tsx', () => {
|
||||
const pagePath = join(process.cwd(), 'app', 'page.tsx');
|
||||
const pageContent = readFileSync(pagePath, 'utf-8');
|
||||
|
||||
expect(pageContent).not.toContain('data.json');
|
||||
expect(pageContent).not.toContain("'/data.json'");
|
||||
expect(pageContent).toContain('filamentService');
|
||||
});
|
||||
|
||||
it('should use API service in all components', () => {
|
||||
const pagePath = join(process.cwd(), 'app', 'page.tsx');
|
||||
const adminPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
|
||||
const pageContent = readFileSync(pagePath, 'utf-8');
|
||||
const adminContent = readFileSync(adminPath, 'utf-8');
|
||||
|
||||
expect(pageContent).toContain('filamentService');
|
||||
expect(adminContent).toContain('filamentService');
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
// Mock Next.js server components
|
||||
jest.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (data: any, init?: ResponseInit) => ({
|
||||
json: async () => data,
|
||||
...init
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock confluence module
|
||||
jest.mock('../src/server/confluence', () => ({
|
||||
fetchFromConfluence: jest.fn()
|
||||
}));
|
||||
|
||||
import { GET } from '../app/api/filaments/route';
|
||||
import { fetchFromConfluence } from '../src/server/confluence';
|
||||
|
||||
describe('API Security Tests', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should not expose credentials in error responses', async () => {
|
||||
// Simulate missing environment variables
|
||||
delete process.env.CONFLUENCE_TOKEN;
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
// Check that response doesn't contain sensitive information
|
||||
expect(JSON.stringify(data)).not.toContain('ATATT');
|
||||
expect(JSON.stringify(data)).not.toContain('token');
|
||||
expect(JSON.stringify(data)).not.toContain('password');
|
||||
expect(data.error).toBe('Server configuration error');
|
||||
});
|
||||
|
||||
it('should not expose internal error details', async () => {
|
||||
// Set valid environment
|
||||
process.env.CONFLUENCE_API_URL = 'https://test.atlassian.net';
|
||||
process.env.CONFLUENCE_TOKEN = 'test-token';
|
||||
process.env.CONFLUENCE_PAGE_ID = 'test-page';
|
||||
|
||||
// Mock fetchFromConfluence to throw an error
|
||||
const mockFetchFromConfluence = fetchFromConfluence as jest.MockedFunction<typeof fetchFromConfluence>;
|
||||
mockFetchFromConfluence.mockRejectedValueOnce(new Error('Internal database error with sensitive details'));
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
// Should get generic error, not specific details
|
||||
expect(data.error).toBe('Failed to fetch filaments');
|
||||
expect(data).not.toHaveProperty('stack');
|
||||
expect(data).not.toHaveProperty('message');
|
||||
expect(JSON.stringify(data)).not.toContain('Internal database error');
|
||||
expect(JSON.stringify(data)).not.toContain('sensitive details');
|
||||
});
|
||||
});
|
||||
76
__tests__/ui-features.test.ts
Normal file
76
__tests__/ui-features.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('UI Features Tests', () => {
|
||||
it('should have color hex input in admin form', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for color input
|
||||
expect(adminContent).toContain('type="color"');
|
||||
expect(adminContent).toContain('boja_hex');
|
||||
expect(adminContent).toContain('Hex kod boje');
|
||||
});
|
||||
|
||||
it('should display color hex in frontend table', () => {
|
||||
const filamentTablePath = join(process.cwd(), 'src', 'components', 'FilamentTableV2.tsx');
|
||||
const tableContent = readFileSync(filamentTablePath, 'utf-8');
|
||||
|
||||
// Check for color display
|
||||
expect(tableContent).toContain('backgroundColor: filament.boja_hex');
|
||||
expect(tableContent).toContain('boja_hex');
|
||||
});
|
||||
|
||||
it('should have number inputs for quantity fields', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for number inputs for quantities
|
||||
expect(adminContent).toMatch(/type="number"[\s\S]*?name="refill"/);
|
||||
expect(adminContent).toMatch(/type="number"[\s\S]*?name="spulna"/);
|
||||
expect(adminContent).toContain('Refil');
|
||||
expect(adminContent).toContain('Špulna');
|
||||
expect(adminContent).toContain('Ukupna količina');
|
||||
});
|
||||
|
||||
it('should have number input for quantity', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for number input
|
||||
expect(adminContent).toMatch(/type="number"[\s\S]*?name="kolicina"/);
|
||||
expect(adminContent).toContain('min="0"');
|
||||
expect(adminContent).toContain('step="1"');
|
||||
});
|
||||
|
||||
it('should have predefined material options', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for material select dropdown
|
||||
expect(adminContent).toContain('<option value="PLA">PLA</option>');
|
||||
expect(adminContent).toContain('<option value="PETG">PETG</option>');
|
||||
expect(adminContent).toContain('<option value="ABS">ABS</option>');
|
||||
});
|
||||
|
||||
it('should have admin header with navigation', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
|
||||
const dashboardContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for admin header
|
||||
expect(dashboardContent).toContain('Admin');
|
||||
expect(dashboardContent).toContain('Nazad na sajt');
|
||||
expect(dashboardContent).toContain('Odjava');
|
||||
});
|
||||
|
||||
it('should have Safari-specific select styling', () => {
|
||||
const selectCssPath = join(process.cwd(), 'src', 'styles', 'select.css');
|
||||
const selectContent = readFileSync(selectCssPath, 'utf-8');
|
||||
|
||||
// Check for Safari fixes
|
||||
expect(selectContent).toContain('-webkit-appearance: none !important');
|
||||
expect(selectContent).toContain('@supports (-webkit-appearance: none)');
|
||||
expect(selectContent).toContain('-webkit-min-device-pixel-ratio');
|
||||
});
|
||||
});
|
||||
21
amplify.yml
21
amplify.yml
@@ -1,21 +0,0 @@
|
||||
version: 1
|
||||
frontend:
|
||||
phases:
|
||||
preBuild:
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run security:check
|
||||
# Print env vars for debugging (without exposing values)
|
||||
- env | grep CONFLUENCE | sed 's/=.*/=***/'
|
||||
build:
|
||||
commands:
|
||||
- npx tsx scripts/fetch-data.js
|
||||
- npm run build
|
||||
artifacts:
|
||||
baseDirectory: out
|
||||
files:
|
||||
- '**/*'
|
||||
cache:
|
||||
paths:
|
||||
- node_modules/**/*
|
||||
- .next/cache/**/*
|
||||
7
api/.dockerignore
Normal file
7
api/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.env.example
|
||||
README.md
|
||||
.git
|
||||
.gitignore
|
||||
14
api/.env.example
Normal file
14
api/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Database connection
|
||||
DATABASE_URL=postgresql://username:password@localhost:5432/filamenteka
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=your-secret-key-here
|
||||
|
||||
# Admin password
|
||||
ADMIN_PASSWORD=your-admin-password
|
||||
|
||||
# Server port
|
||||
PORT=4000
|
||||
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
18
api/Dockerfile
Normal file
18
api/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
93
api/migrate.js
Normal file
93
api/migrate.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.DATABASE_URL?.includes('amazonaws.com') ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
async function migrate() {
|
||||
try {
|
||||
// Read schema file
|
||||
const schemaPath = path.join(__dirname, '..', 'database', 'schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf8');
|
||||
|
||||
// Execute schema
|
||||
await pool.query(schema);
|
||||
|
||||
console.log('Database migration completed successfully');
|
||||
|
||||
// Run additional migrations
|
||||
const migrationsPath = path.join(__dirname, '..', 'database', 'migrations');
|
||||
if (fs.existsSync(migrationsPath)) {
|
||||
const migrationFiles = fs.readdirSync(migrationsPath)
|
||||
.filter(file => file.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
for (const file of migrationFiles) {
|
||||
console.log(`Running migration: ${file}`);
|
||||
const migrationSQL = fs.readFileSync(path.join(migrationsPath, file), 'utf8');
|
||||
try {
|
||||
await pool.query(migrationSQL);
|
||||
console.log(`✓ Migration ${file} completed`);
|
||||
} catch (err) {
|
||||
console.log(`⚠ Migration ${file} skipped:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import legacy data if available
|
||||
try {
|
||||
const dataPath = path.join(__dirname, '..', 'data.json');
|
||||
if (fs.existsSync(dataPath)) {
|
||||
const legacyData = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
|
||||
|
||||
// Import colors
|
||||
const colors = new Set();
|
||||
legacyData.forEach(item => {
|
||||
if (item.boja) colors.add(item.boja);
|
||||
});
|
||||
|
||||
for (const color of colors) {
|
||||
const hex = legacyData.find(item => item.boja === color)?.bojaHex || '#000000';
|
||||
await pool.query(
|
||||
'INSERT INTO colors (name, hex) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET hex = $2',
|
||||
[color, hex]
|
||||
);
|
||||
}
|
||||
|
||||
// Import filaments
|
||||
for (const item of legacyData) {
|
||||
await pool.query(
|
||||
`INSERT INTO filaments (tip, finish, boja, boja_hex, refill, vakum, otvoreno, kolicina, cena)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
item.tip,
|
||||
item.finish,
|
||||
item.boja,
|
||||
item.bojaHex,
|
||||
item.refill,
|
||||
item.vakum,
|
||||
item.otvoreno,
|
||||
item.kolicina || 1,
|
||||
item.cena
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Legacy data imported successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error importing legacy data:', error);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
1503
api/package-lock.json
generated
Normal file
1503
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
api/package.json
Normal file
22
api/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "filamenteka-api",
|
||||
"version": "1.0.0",
|
||||
"description": "API backend for Filamenteka",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"migrate": "node migrate.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.11.3",
|
||||
"cors": "^2.8.5",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"dotenv": "^16.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
63
api/routes/admin-migrate.js
Normal file
63
api/routes/admin-migrate.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
|
||||
// Temporary migration endpoint - remove after use
|
||||
router.post('/add-basic-refills', authenticate, async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
try {
|
||||
// First, let's insert filaments for all existing colors
|
||||
const result = await pool.query(`
|
||||
INSERT INTO filaments (tip, finish, boja, boja_hex, refill, vakum, otvoreno, kolicina, cena)
|
||||
SELECT
|
||||
'PLA' as tip,
|
||||
'Basic' as finish,
|
||||
c.name as boja,
|
||||
c.hex as boja_hex,
|
||||
'1' as refill,
|
||||
'0 vakuum' as vakum,
|
||||
'0 otvorena' as otvoreno,
|
||||
1 as kolicina,
|
||||
'3999' as cena
|
||||
FROM colors c
|
||||
WHERE NOT EXISTS (
|
||||
-- Only insert if this exact combination doesn't already exist
|
||||
SELECT 1 FROM filaments f
|
||||
WHERE f.tip = 'PLA'
|
||||
AND f.finish = 'Basic'
|
||||
AND f.boja = c.name
|
||||
)
|
||||
`);
|
||||
|
||||
// Update any existing PLA Basic filaments to have at least 1 refill
|
||||
const updateResult = await pool.query(`
|
||||
UPDATE filaments
|
||||
SET refill = '1'
|
||||
WHERE tip = 'PLA'
|
||||
AND finish = 'Basic'
|
||||
AND (refill IS NULL OR refill = '0' OR refill = '')
|
||||
`);
|
||||
|
||||
// Show summary
|
||||
const summary = await pool.query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM filaments
|
||||
WHERE tip = 'PLA'
|
||||
AND finish = 'Basic'
|
||||
AND refill = '1'
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
inserted: result.rowCount,
|
||||
updated: updateResult.rowCount,
|
||||
total: summary.rows[0].count
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
res.status(500).json({ error: 'Migration failed', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
65
api/scripts/add-basic-refills.js
Normal file
65
api/scripts/add-basic-refills.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const { Pool } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.DATABASE_URL?.includes('amazonaws.com') ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
async function addBasicRefills() {
|
||||
try {
|
||||
// First, let's insert filaments for all existing colors
|
||||
const result = await pool.query(`
|
||||
INSERT INTO filaments (tip, finish, boja, boja_hex, refill, vakum, otvoreno, kolicina, cena)
|
||||
SELECT
|
||||
'PLA' as tip,
|
||||
'Basic' as finish,
|
||||
c.name as boja,
|
||||
c.hex as boja_hex,
|
||||
'1' as refill,
|
||||
'0 vakuum' as vakum,
|
||||
'0 otvorena' as otvoreno,
|
||||
1 as kolicina,
|
||||
'3999' as cena
|
||||
FROM colors c
|
||||
WHERE NOT EXISTS (
|
||||
-- Only insert if this exact combination doesn't already exist
|
||||
SELECT 1 FROM filaments f
|
||||
WHERE f.tip = 'PLA'
|
||||
AND f.finish = 'Basic'
|
||||
AND f.boja = c.name
|
||||
)
|
||||
`);
|
||||
|
||||
console.log(`Inserted ${result.rowCount} new PLA Basic filaments with 1 refill each`);
|
||||
|
||||
// Update any existing PLA Basic filaments to have at least 1 refill
|
||||
const updateResult = await pool.query(`
|
||||
UPDATE filaments
|
||||
SET refill = '1'
|
||||
WHERE tip = 'PLA'
|
||||
AND finish = 'Basic'
|
||||
AND (refill IS NULL OR refill = '0' OR refill = '')
|
||||
`);
|
||||
|
||||
console.log(`Updated ${updateResult.rowCount} existing PLA Basic filaments to have 1 refill`);
|
||||
|
||||
// Show summary
|
||||
const summary = await pool.query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM filaments
|
||||
WHERE tip = 'PLA'
|
||||
AND finish = 'Basic'
|
||||
AND refill = '1'
|
||||
`);
|
||||
|
||||
console.log(`\nTotal PLA Basic filaments with 1 refill: ${summary.rows[0].count}`);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error adding basic refills:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
addBasicRefills();
|
||||
73
api/scripts/add-refills.js
Normal file
73
api/scripts/add-refills.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const { Pool } = require('pg');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.DATABASE_URL?.includes('amazonaws.com') ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
async function addRefills() {
|
||||
try {
|
||||
console.log('Adding 1 refill for each color as PLA Basic filaments...\n');
|
||||
|
||||
// First, get all colors
|
||||
const colorsResult = await pool.query('SELECT name, hex FROM colors ORDER BY name');
|
||||
console.log(`Found ${colorsResult.rows.length} colors in database:`);
|
||||
colorsResult.rows.forEach(color => {
|
||||
console.log(` - ${color.name} (${color.hex})`);
|
||||
});
|
||||
console.log('');
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const color of colorsResult.rows) {
|
||||
// Check if PLA Basic already exists for this color
|
||||
const existing = await pool.query(
|
||||
'SELECT id, refill FROM filaments WHERE tip = $1 AND finish = $2 AND boja = $3',
|
||||
['PLA', 'Basic', color.name]
|
||||
);
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
// Insert new filament
|
||||
await pool.query(
|
||||
`INSERT INTO filaments (tip, finish, boja, boja_hex, refill, vakum, otvoreno, kolicina, cena)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
['PLA', 'Basic', color.name, color.hex, '1', '0 vakuum', '0 otvorena', 1, '3999']
|
||||
);
|
||||
console.log(`✓ Added PLA Basic ${color.name}`);
|
||||
inserted++;
|
||||
} else if (!existing.rows[0].refill || existing.rows[0].refill === '0') {
|
||||
// Update existing to have 1 refill
|
||||
await pool.query(
|
||||
'UPDATE filaments SET refill = $1 WHERE id = $2',
|
||||
['1', existing.rows[0].id]
|
||||
);
|
||||
console.log(`✓ Updated PLA Basic ${color.name} to have 1 refill`);
|
||||
updated++;
|
||||
} else {
|
||||
console.log(`- PLA Basic ${color.name} already has ${existing.rows[0].refill} refill(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSummary:`);
|
||||
console.log(`- Inserted ${inserted} new PLA Basic filaments`);
|
||||
console.log(`- Updated ${updated} existing filaments to have 1 refill`);
|
||||
console.log(`- Total colors processed: ${colorsResult.rows.length}`);
|
||||
|
||||
// Show final state
|
||||
const finalCount = await pool.query(
|
||||
"SELECT COUNT(*) as count FROM filaments WHERE tip = 'PLA' AND finish = 'Basic' AND refill = '1'"
|
||||
);
|
||||
console.log(`- Total PLA Basic filaments with 1 refill: ${finalCount.rows[0].count}`);
|
||||
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
addRefills();
|
||||
767
api/server.js
Normal file
767
api/server.js
Normal file
@@ -0,0 +1,767 @@
|
||||
const express = require('express');
|
||||
const { Pool } = require('pg');
|
||||
const cors = require('cors');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 80;
|
||||
|
||||
// PostgreSQL connection
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.DATABASE_URL?.includes('amazonaws.com') ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: true, // Allow all origins in development
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
exposedHeaders: ['Content-Length', 'Content-Type'],
|
||||
maxAge: 86400
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
// Handle preflight requests
|
||||
app.options('*', cors());
|
||||
|
||||
// Health check route
|
||||
app.get('/', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'Filamenteka API' });
|
||||
});
|
||||
|
||||
// JWT middleware
|
||||
const authenticateToken = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key', (err, user) => {
|
||||
if (err) {
|
||||
console.error('JWT verification error:', err);
|
||||
return res.status(403).json({ error: 'Invalid token' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
// Auth endpoints
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// For now, simple hardcoded admin check
|
||||
if (username === 'admin' && password === process.env.ADMIN_PASSWORD) {
|
||||
const token = jwt.sign({ username }, process.env.JWT_SECRET || 'your-secret-key', { expiresIn: '24h' });
|
||||
res.json({ token });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
});
|
||||
|
||||
// Colors endpoints
|
||||
app.get('/api/colors', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM colors ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching colors:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch colors' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/colors', authenticateToken, async (req, res) => {
|
||||
const { name, hex, cena_refill, cena_spulna } = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'INSERT INTO colors (name, hex, cena_refill, cena_spulna) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[name, hex, cena_refill || 3499, cena_spulna || 3999]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating color:', error);
|
||||
res.status(500).json({ error: 'Failed to create color' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/colors/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, hex, cena_refill, cena_spulna } = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'UPDATE colors SET name = $1, hex = $2, cena_refill = $3, cena_spulna = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5 RETURNING *',
|
||||
[name, hex, cena_refill || 3499, cena_spulna || 3999, id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating color:', error);
|
||||
res.status(500).json({ error: 'Failed to update color' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/colors/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
await pool.query('DELETE FROM colors WHERE id = $1', [id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting color:', error);
|
||||
res.status(500).json({ error: 'Failed to delete color' });
|
||||
}
|
||||
});
|
||||
|
||||
// Filaments endpoints (PUBLIC - no auth required)
|
||||
app.get('/api/filaments', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM filaments ORDER BY created_at DESC');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching filaments:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch filaments' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/filaments', authenticateToken, async (req, res) => {
|
||||
const { tip, finish, boja, boja_hex, refill, spulna, cena } = req.body;
|
||||
|
||||
try {
|
||||
// Ensure refill and spulna are numbers
|
||||
const refillNum = parseInt(refill) || 0;
|
||||
const spulnaNum = parseInt(spulna) || 0;
|
||||
const kolicina = refillNum + spulnaNum;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO filaments (tip, finish, boja, boja_hex, refill, spulna, kolicina, cena)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating filament:', error);
|
||||
res.status(500).json({ error: 'Failed to create filament' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { tip, finish, boja, boja_hex, refill, spulna, cena, sale_percentage, sale_active, sale_start_date, sale_end_date } = req.body;
|
||||
|
||||
try {
|
||||
// Ensure refill and spulna are numbers
|
||||
const refillNum = parseInt(refill) || 0;
|
||||
const spulnaNum = parseInt(spulna) || 0;
|
||||
const kolicina = refillNum + spulnaNum;
|
||||
|
||||
// Check if sale fields are provided in the request
|
||||
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
|
||||
'sale_start_date' in req.body || 'sale_end_date' in req.body;
|
||||
|
||||
let result;
|
||||
if (hasSaleFields) {
|
||||
// Update with sale fields if they are provided
|
||||
result = await pool.query(
|
||||
`UPDATE filaments
|
||||
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
|
||||
refill = $5, spulna = $6, kolicina = $7, cena = $8,
|
||||
sale_percentage = $9, sale_active = $10,
|
||||
sale_start_date = $11, sale_end_date = $12,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $13 RETURNING *`,
|
||||
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
|
||||
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
|
||||
);
|
||||
} else {
|
||||
// Update without touching sale fields if they are not provided
|
||||
result = await pool.query(
|
||||
`UPDATE filaments
|
||||
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
|
||||
refill = $5, spulna = $6, kolicina = $7, cena = $8,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $9 RETURNING *`,
|
||||
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena, id]
|
||||
);
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating filament:', error);
|
||||
res.status(500).json({ error: 'Failed to update filament' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/filaments/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
await pool.query('DELETE FROM filaments WHERE id = $1', [id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting filament:', error);
|
||||
res.status(500).json({ error: 'Failed to delete filament' });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk sale update endpoint
|
||||
app.post('/api/filaments/sale/bulk', authenticateToken, async (req, res) => {
|
||||
const { filamentIds, salePercentage, saleStartDate, saleEndDate, enableSale } = req.body;
|
||||
|
||||
try {
|
||||
let query;
|
||||
let params;
|
||||
|
||||
if (filamentIds && filamentIds.length > 0) {
|
||||
// Update specific filaments
|
||||
query = `
|
||||
UPDATE filaments
|
||||
SET sale_percentage = $1,
|
||||
sale_active = $2,
|
||||
sale_start_date = $3,
|
||||
sale_end_date = $4,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ANY($5)
|
||||
RETURNING *`;
|
||||
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate, filamentIds];
|
||||
} else {
|
||||
// Update all filaments
|
||||
query = `
|
||||
UPDATE filaments
|
||||
SET sale_percentage = $1,
|
||||
sale_active = $2,
|
||||
sale_start_date = $3,
|
||||
sale_end_date = $4,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *`;
|
||||
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json({
|
||||
success: true,
|
||||
updatedCount: result.rowCount,
|
||||
updatedFilaments: result.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating sale:', error);
|
||||
res.status(500).json({ error: 'Failed to update sale' });
|
||||
}
|
||||
});
|
||||
|
||||
// Color request endpoints
|
||||
|
||||
// Get all color requests (admin only)
|
||||
app.get('/api/color-requests', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM color_requests ORDER BY created_at DESC'
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching color requests:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch color requests' });
|
||||
}
|
||||
});
|
||||
|
||||
// Submit a new color request (public)
|
||||
app.post('/api/color-requests', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
color_name,
|
||||
material_type,
|
||||
finish_type,
|
||||
user_email,
|
||||
user_phone,
|
||||
user_name,
|
||||
description,
|
||||
reference_url
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!color_name || !material_type || !user_email || !user_phone) {
|
||||
return res.status(400).json({
|
||||
error: 'Color name, material type, email, and phone are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if similar request already exists
|
||||
const existingRequest = await pool.query(
|
||||
`SELECT id, request_count FROM color_requests
|
||||
WHERE LOWER(color_name) = LOWER($1)
|
||||
AND material_type = $2
|
||||
AND (finish_type = $3 OR (finish_type IS NULL AND $3 IS NULL))
|
||||
AND status = 'pending'`,
|
||||
[color_name, material_type, finish_type]
|
||||
);
|
||||
|
||||
if (existingRequest.rows.length > 0) {
|
||||
// Increment request count for existing request
|
||||
const result = await pool.query(
|
||||
`UPDATE color_requests
|
||||
SET request_count = request_count + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[existingRequest.rows[0].id]
|
||||
);
|
||||
res.json({
|
||||
message: 'Your request has been added to an existing request for this color',
|
||||
request: result.rows[0]
|
||||
});
|
||||
} else {
|
||||
// Create new request
|
||||
const result = await pool.query(
|
||||
`INSERT INTO color_requests
|
||||
(color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url]
|
||||
);
|
||||
res.json({
|
||||
message: 'Color request submitted successfully',
|
||||
request: result.rows[0]
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating color request:', error);
|
||||
res.status(500).json({ error: 'Failed to submit color request' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update color request status (admin only)
|
||||
app.put('/api/color-requests/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, admin_notes } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE color_requests
|
||||
SET status = $1,
|
||||
admin_notes = $2,
|
||||
processed_at = CURRENT_TIMESTAMP,
|
||||
processed_by = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4
|
||||
RETURNING *`,
|
||||
[status, admin_notes, req.user.username, id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Color request not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating color request:', error);
|
||||
res.status(500).json({ error: 'Failed to update color request' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete color request (admin only)
|
||||
app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
'DELETE FROM color_requests WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Color request not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Color request deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting color request:', error);
|
||||
res.status(500).json({ error: 'Failed to delete color request' });
|
||||
}
|
||||
});
|
||||
|
||||
// Slug generation helper
|
||||
const generateSlug = (name) => {
|
||||
const base = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const suffix = Math.random().toString(36).substring(2, 8);
|
||||
return `${base}-${suffix}`;
|
||||
};
|
||||
|
||||
// Products endpoints
|
||||
|
||||
// List/filter products (PUBLIC)
|
||||
app.get('/api/products', async (req, res) => {
|
||||
try {
|
||||
const { category, condition, printer_model, in_stock, search } = req.query;
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (category) {
|
||||
conditions.push(`p.category = $${paramIndex++}`);
|
||||
params.push(category);
|
||||
}
|
||||
if (condition) {
|
||||
conditions.push(`p.condition = $${paramIndex++}`);
|
||||
params.push(condition);
|
||||
}
|
||||
if (printer_model) {
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM product_printer_compatibility ppc
|
||||
JOIN printer_models pm ON pm.id = ppc.printer_model_id
|
||||
WHERE ppc.product_id = p.id AND pm.name = $${paramIndex++}
|
||||
)`);
|
||||
params.push(printer_model);
|
||||
}
|
||||
if (in_stock === 'true') {
|
||||
conditions.push(`p.quantity > 0`);
|
||||
} else if (in_stock === 'false') {
|
||||
conditions.push(`p.quantity = 0`);
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(`(p.name ILIKE $${paramIndex} OR p.description ILIKE $${paramIndex})`);
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT p.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series)
|
||||
) FILTER (WHERE pm.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS compatible_printers
|
||||
FROM products p
|
||||
LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id
|
||||
LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id
|
||||
${whereClause}
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch products' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single product (PUBLIC)
|
||||
app.get('/api/products/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT p.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series)
|
||||
) FILTER (WHERE pm.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS compatible_printers
|
||||
FROM products p
|
||||
LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id
|
||||
LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching product:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch product' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create product (auth required)
|
||||
app.post('/api/products', authenticateToken, async (req, res) => {
|
||||
const { name, description, category, condition, price, quantity, image_url, printer_model_ids } = req.body;
|
||||
|
||||
try {
|
||||
const slug = generateSlug(name);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO products (name, slug, description, category, condition, price, quantity, image_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[name, slug, description, category, condition || 'new', price, parseInt(quantity) || 0, image_url]
|
||||
);
|
||||
|
||||
const product = result.rows[0];
|
||||
|
||||
// Insert printer compatibility entries if provided
|
||||
if (printer_model_ids && printer_model_ids.length > 0) {
|
||||
const compatValues = printer_model_ids
|
||||
.map((_, i) => `($1, $${i + 2})`)
|
||||
.join(', ');
|
||||
await pool.query(
|
||||
`INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`,
|
||||
[product.id, ...printer_model_ids]
|
||||
);
|
||||
}
|
||||
|
||||
res.json(product);
|
||||
} catch (error) {
|
||||
console.error('Error creating product:', error);
|
||||
res.status(500).json({ error: 'Failed to create product' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update product (auth required)
|
||||
app.put('/api/products/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, description, category, condition, price, quantity, image_url, sale_percentage, sale_active, sale_start_date, sale_end_date, printer_model_ids } = req.body;
|
||||
|
||||
try {
|
||||
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
|
||||
'sale_start_date' in req.body || 'sale_end_date' in req.body;
|
||||
|
||||
let result;
|
||||
if (hasSaleFields) {
|
||||
result = await pool.query(
|
||||
`UPDATE products
|
||||
SET name = $1, description = $2, category = $3, condition = $4,
|
||||
price = $5, quantity = $6, image_url = $7,
|
||||
sale_percentage = $8, sale_active = $9,
|
||||
sale_start_date = $10, sale_end_date = $11,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $12 RETURNING *`,
|
||||
[name, description, category, condition, price, parseInt(quantity) || 0, image_url,
|
||||
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
|
||||
);
|
||||
} else {
|
||||
result = await pool.query(
|
||||
`UPDATE products
|
||||
SET name = $1, description = $2, category = $3, condition = $4,
|
||||
price = $5, quantity = $6, image_url = $7,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $8 RETURNING *`,
|
||||
[name, description, category, condition, price, parseInt(quantity) || 0, image_url, id]
|
||||
);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
// Update printer compatibility if provided
|
||||
if (printer_model_ids !== undefined) {
|
||||
await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]);
|
||||
|
||||
if (printer_model_ids && printer_model_ids.length > 0) {
|
||||
const compatValues = printer_model_ids
|
||||
.map((_, i) => `($1, $${i + 2})`)
|
||||
.join(', ');
|
||||
await pool.query(
|
||||
`INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`,
|
||||
[id, ...printer_model_ids]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating product:', error);
|
||||
res.status(500).json({ error: 'Failed to update product' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete product (auth required)
|
||||
app.delete('/api/products/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]);
|
||||
const result = await pool.query('DELETE FROM products WHERE id = $1 RETURNING *', [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting product:', error);
|
||||
res.status(500).json({ error: 'Failed to delete product' });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk sale update for products (auth required)
|
||||
app.post('/api/products/sale/bulk', authenticateToken, async (req, res) => {
|
||||
const { productIds, salePercentage, saleStartDate, saleEndDate, enableSale } = req.body;
|
||||
|
||||
try {
|
||||
let query;
|
||||
let params;
|
||||
|
||||
if (productIds && productIds.length > 0) {
|
||||
query = `
|
||||
UPDATE products
|
||||
SET sale_percentage = $1,
|
||||
sale_active = $2,
|
||||
sale_start_date = $3,
|
||||
sale_end_date = $4,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ANY($5)
|
||||
RETURNING *`;
|
||||
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate, productIds];
|
||||
} else {
|
||||
query = `
|
||||
UPDATE products
|
||||
SET sale_percentage = $1,
|
||||
sale_active = $2,
|
||||
sale_start_date = $3,
|
||||
sale_end_date = $4,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *`;
|
||||
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json({
|
||||
success: true,
|
||||
updatedCount: result.rowCount,
|
||||
updatedProducts: result.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating product sale:', error);
|
||||
res.status(500).json({ error: 'Failed to update product sale' });
|
||||
}
|
||||
});
|
||||
|
||||
// Printer models endpoints
|
||||
|
||||
// List all printer models (PUBLIC)
|
||||
app.get('/api/printer-models', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM printer_models ORDER BY series, name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching printer models:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch printer models' });
|
||||
}
|
||||
});
|
||||
|
||||
// Analytics endpoints
|
||||
|
||||
// Aggregated inventory stats (auth required)
|
||||
app.get('/api/analytics/inventory', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const filamentStats = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) AS total_skus,
|
||||
COALESCE(SUM(kolicina), 0) AS total_units,
|
||||
COALESCE(SUM(refill), 0) AS total_refills,
|
||||
COALESCE(SUM(spulna), 0) AS total_spools,
|
||||
COUNT(*) FILTER (WHERE kolicina = 0) AS out_of_stock_skus,
|
||||
COALESCE(SUM(kolicina * cena), 0) AS total_inventory_value
|
||||
FROM filaments
|
||||
`);
|
||||
|
||||
const productStats = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) AS total_skus,
|
||||
COALESCE(SUM(quantity), 0) AS total_units,
|
||||
COUNT(*) FILTER (WHERE quantity = 0) AS out_of_stock_skus,
|
||||
COALESCE(SUM(quantity * price), 0) AS total_inventory_value,
|
||||
COUNT(*) FILTER (WHERE category = 'printer') AS printers,
|
||||
COUNT(*) FILTER (WHERE category = 'build_plate') AS build_plates,
|
||||
COUNT(*) FILTER (WHERE category = 'nozzle') AS nozzles,
|
||||
COUNT(*) FILTER (WHERE category = 'spare_part') AS spare_parts,
|
||||
COUNT(*) FILTER (WHERE category = 'accessory') AS accessories
|
||||
FROM products
|
||||
`);
|
||||
|
||||
const filament = filamentStats.rows[0];
|
||||
const product = productStats.rows[0];
|
||||
|
||||
res.json({
|
||||
filaments: {
|
||||
total_skus: parseInt(filament.total_skus),
|
||||
total_units: parseInt(filament.total_units),
|
||||
total_refills: parseInt(filament.total_refills),
|
||||
total_spools: parseInt(filament.total_spools),
|
||||
out_of_stock_skus: parseInt(filament.out_of_stock_skus),
|
||||
total_inventory_value: parseInt(filament.total_inventory_value)
|
||||
},
|
||||
products: {
|
||||
total_skus: parseInt(product.total_skus),
|
||||
total_units: parseInt(product.total_units),
|
||||
out_of_stock_skus: parseInt(product.out_of_stock_skus),
|
||||
total_inventory_value: parseInt(product.total_inventory_value),
|
||||
by_category: {
|
||||
printers: parseInt(product.printers),
|
||||
build_plates: parseInt(product.build_plates),
|
||||
nozzles: parseInt(product.nozzles),
|
||||
spare_parts: parseInt(product.spare_parts),
|
||||
accessories: parseInt(product.accessories)
|
||||
}
|
||||
},
|
||||
combined: {
|
||||
total_skus: parseInt(filament.total_skus) + parseInt(product.total_skus),
|
||||
total_inventory_value: parseInt(filament.total_inventory_value) + parseInt(product.total_inventory_value),
|
||||
out_of_stock_skus: parseInt(filament.out_of_stock_skus) + parseInt(product.out_of_stock_skus)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching inventory analytics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch inventory analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Active sales overview (auth required)
|
||||
app.get('/api/analytics/sales', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const filamentSales = await pool.query(`
|
||||
SELECT id, tip, finish, boja, cena, sale_percentage, sale_active, sale_start_date, sale_end_date,
|
||||
ROUND(cena * (1 - sale_percentage / 100.0)) AS sale_price
|
||||
FROM filaments
|
||||
WHERE sale_active = true
|
||||
ORDER BY sale_percentage DESC
|
||||
`);
|
||||
|
||||
const productSales = await pool.query(`
|
||||
SELECT id, name, category, price, sale_percentage, sale_active, sale_start_date, sale_end_date,
|
||||
ROUND(price * (1 - sale_percentage / 100.0)) AS sale_price
|
||||
FROM products
|
||||
WHERE sale_active = true
|
||||
ORDER BY sale_percentage DESC
|
||||
`);
|
||||
|
||||
res.json({
|
||||
filaments: {
|
||||
count: filamentSales.rowCount,
|
||||
items: filamentSales.rows
|
||||
},
|
||||
products: {
|
||||
count: productSales.rowCount,
|
||||
items: productSales.rows
|
||||
},
|
||||
total_active_sales: filamentSales.rowCount + productSales.rowCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching sales analytics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch sales analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
18
api/vercel.json
Normal file
18
api/vercel.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{
|
||||
"src": "server.js",
|
||||
"use": "@vercel/node"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "server.js"
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
|
||||
}
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import axios from 'axios';
|
||||
import { Filament } from '../../../src/types/filament';
|
||||
|
||||
interface FilamentWithId extends Filament {
|
||||
id: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const router = useRouter();
|
||||
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingFilament, setEditingFilament] = useState<FilamentWithId | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
// Check authentication
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
|
||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||
router.push('/admin');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Fetch filaments
|
||||
const fetchFilaments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/filaments`);
|
||||
setFilaments(response.data);
|
||||
} catch (err) {
|
||||
setError('Greška pri učitavanju filamenata');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilaments();
|
||||
}, []);
|
||||
|
||||
const getAuthHeaders = () => ({
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('authToken')}`
|
||||
}
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Da li ste sigurni da želite obrisati ovaj filament?')) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`${process.env.NEXT_PUBLIC_API_URL}/filaments/${id}`, getAuthHeaders());
|
||||
await fetchFilaments();
|
||||
} catch (err) {
|
||||
alert('Greška pri brisanju filamenta');
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (filament: Partial<FilamentWithId>) => {
|
||||
try {
|
||||
if (filament.id) {
|
||||
// Update existing
|
||||
await axios.put(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/filaments/${filament.id}`,
|
||||
filament,
|
||||
getAuthHeaders()
|
||||
);
|
||||
} else {
|
||||
// Create new
|
||||
await axios.post(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/filaments`,
|
||||
filament,
|
||||
getAuthHeaders()
|
||||
);
|
||||
}
|
||||
|
||||
setEditingFilament(null);
|
||||
setShowAddForm(false);
|
||||
await fetchFilaments();
|
||||
} catch (err) {
|
||||
alert('Greška pri čuvanju filamenta');
|
||||
console.error('Save error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
router.push('/admin');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-gray-100"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<header className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Dodaj novi filament
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{(showAddForm || editingFilament) && (
|
||||
<FilamentForm
|
||||
filament={editingFilament || {}}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setEditingFilament(null);
|
||||
setShowAddForm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filaments Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-white dark:bg-gray-800 shadow rounded">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-gray-700">
|
||||
<th className="px-4 py-2 text-left">Brand</th>
|
||||
<th className="px-4 py-2 text-left">Tip</th>
|
||||
<th className="px-4 py-2 text-left">Finish</th>
|
||||
<th className="px-4 py-2 text-left">Boja</th>
|
||||
<th className="px-4 py-2 text-left">Refill</th>
|
||||
<th className="px-4 py-2 text-left">Vakum</th>
|
||||
<th className="px-4 py-2 text-left">Otvoreno</th>
|
||||
<th className="px-4 py-2 text-left">Količina</th>
|
||||
<th className="px-4 py-2 text-left">Cena</th>
|
||||
<th className="px-4 py-2 text-left">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filaments.map((filament) => (
|
||||
<tr key={filament.id} className="border-t dark:border-gray-700">
|
||||
<td className="px-4 py-2">{filament.brand}</td>
|
||||
<td className="px-4 py-2">{filament.tip}</td>
|
||||
<td className="px-4 py-2">{filament.finish}</td>
|
||||
<td className="px-4 py-2">{filament.boja}</td>
|
||||
<td className="px-4 py-2">{filament.refill}</td>
|
||||
<td className="px-4 py-2">{filament.vakum}</td>
|
||||
<td className="px-4 py-2">{filament.otvoreno}</td>
|
||||
<td className="px-4 py-2">{filament.kolicina}</td>
|
||||
<td className="px-4 py-2">{filament.cena}</td>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
onClick={() => setEditingFilament(filament)}
|
||||
className="text-blue-600 hover:text-blue-800 mr-2"
|
||||
>
|
||||
Izmeni
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(filament.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
Obriši
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filament Form Component
|
||||
function FilamentForm({
|
||||
filament,
|
||||
onSave,
|
||||
onCancel
|
||||
}: {
|
||||
filament: Partial<FilamentWithId>,
|
||||
onSave: (filament: Partial<FilamentWithId>) => void,
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
brand: filament.brand || '',
|
||||
tip: filament.tip || '',
|
||||
finish: filament.finish || '',
|
||||
boja: filament.boja || '',
|
||||
refill: filament.refill || '',
|
||||
vakum: filament.vakum || '',
|
||||
otvoreno: filament.otvoreno || '',
|
||||
kolicina: filament.kolicina || '',
|
||||
cena: filament.cena || '',
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
...filament,
|
||||
...formData
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8 p-6 bg-white dark:bg-gray-800 rounded shadow">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{filament.id ? 'Izmeni filament' : 'Dodaj novi filament'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Brand</label>
|
||||
<input
|
||||
type="text"
|
||||
name="brand"
|
||||
value={formData.brand}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Tip</label>
|
||||
<select
|
||||
name="tip"
|
||||
value={formData.tip}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">Izaberi tip</option>
|
||||
<option value="PLA">PLA</option>
|
||||
<option value="PETG">PETG</option>
|
||||
<option value="ABS">ABS</option>
|
||||
<option value="TPU">TPU</option>
|
||||
<option value="Silk PLA">Silk PLA</option>
|
||||
<option value="PLA Matte">PLA Matte</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Finish</label>
|
||||
<select
|
||||
name="finish"
|
||||
value={formData.finish}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">Izaberi finish</option>
|
||||
<option value="Basic">Basic</option>
|
||||
<option value="Matte">Matte</option>
|
||||
<option value="Silk">Silk</option>
|
||||
<option value="Metal">Metal</option>
|
||||
<option value="Glow">Glow</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Boja</label>
|
||||
<input
|
||||
type="text"
|
||||
name="boja"
|
||||
value={formData.boja}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Refill</label>
|
||||
<select
|
||||
name="refill"
|
||||
value={formData.refill}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">Ne</option>
|
||||
<option value="Da">Da</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Vakum</label>
|
||||
<input
|
||||
type="text"
|
||||
name="vakum"
|
||||
value={formData.vakum}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Otvoreno</label>
|
||||
<input
|
||||
type="text"
|
||||
name="otvoreno"
|
||||
value={formData.otvoreno}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Količina</label>
|
||||
<input
|
||||
type="text"
|
||||
name="kolicina"
|
||||
value={formData.kolicina}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Cena</label>
|
||||
<input
|
||||
type="text"
|
||||
name="cena"
|
||||
value={formData.cena}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Sačuvaj
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1104
app/dashboard/page.tsx
Normal file
1104
app/dashboard/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
18
app/delovi/layout.tsx
Normal file
18
app/delovi/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Bambu Lab Rezervni Delovi (Spare Parts)',
|
||||
description: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
|
||||
openGraph: {
|
||||
title: 'Bambu Lab Rezervni Delovi (Spare Parts) - Filamenteka',
|
||||
description: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
|
||||
url: 'https://filamenteka.rs/delovi',
|
||||
},
|
||||
alternates: {
|
||||
canonical: 'https://filamenteka.rs/delovi',
|
||||
},
|
||||
};
|
||||
|
||||
export default function DeloviLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
43
app/delovi/page.tsx
Normal file
43
app/delovi/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { SiteHeader } from '@/src/components/layout/SiteHeader';
|
||||
import { SiteFooter } from '@/src/components/layout/SiteFooter';
|
||||
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
|
||||
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
|
||||
import { getCategoryBySlug } from '@/src/config/categories';
|
||||
|
||||
export default function DeloviPage() {
|
||||
const category = getCategoryBySlug('delovi')!;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader currentCategory="delovi" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<article>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Pocetna', href: '/' },
|
||||
{ label: 'Rezervni Delovi' },
|
||||
]} />
|
||||
<div className="flex items-center gap-3 mt-3 mb-6">
|
||||
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
|
||||
<h1
|
||||
className="text-2xl sm:text-3xl font-black tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Bambu Lab Rezervni Delovi
|
||||
</h1>
|
||||
</div>
|
||||
<CatalogPage
|
||||
category={category}
|
||||
seoContent={
|
||||
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
|
||||
Originalni Bambu Lab rezervni delovi i spare parts. AMS moduli, extruder delovi, kablovi, senzori i drugi komponenti za odrzavanje i popravku vaseg 3D stampaca. Kompatibilni sa svim Bambu Lab serijama - A1, P1 i X1.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
app/filamenti/layout.tsx
Normal file
18
app/filamenti/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Bambu Lab Filamenti | PLA, PETG, ABS',
|
||||
description: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU. Privatna prodaja u Srbiji.',
|
||||
openGraph: {
|
||||
title: 'Bambu Lab Filamenti | PLA, PETG, ABS - Filamenteka',
|
||||
description: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU. Privatna prodaja u Srbiji.',
|
||||
url: 'https://filamenteka.rs/filamenti',
|
||||
},
|
||||
alternates: {
|
||||
canonical: 'https://filamenteka.rs/filamenti',
|
||||
},
|
||||
};
|
||||
|
||||
export default function FilamentiLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
43
app/filamenti/page.tsx
Normal file
43
app/filamenti/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { SiteHeader } from '@/src/components/layout/SiteHeader';
|
||||
import { SiteFooter } from '@/src/components/layout/SiteFooter';
|
||||
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
|
||||
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
|
||||
import { getCategoryBySlug } from '@/src/config/categories';
|
||||
|
||||
export default function FilamentiPage() {
|
||||
const category = getCategoryBySlug('filamenti')!;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader currentCategory="filamenti" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<article>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Pocetna', href: '/' },
|
||||
{ label: 'Filamenti' },
|
||||
]} />
|
||||
<div className="flex items-center gap-3 mt-3 mb-6">
|
||||
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
|
||||
<h1
|
||||
className="text-2xl sm:text-3xl font-black tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Bambu Lab Filamenti
|
||||
</h1>
|
||||
</div>
|
||||
<CatalogPage
|
||||
category={category}
|
||||
seoContent={
|
||||
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
|
||||
Originalni Bambu Lab filamenti za 3D stampac. U ponudi imamo PLA, PETG, ABS, TPU i mnoge druge materijale u razlicitim finishima - Basic, Matte, Silk, Sparkle, Translucent, Metal i drugi. Svi filamenti su originalni Bambu Lab proizvodi, neotvoreni i u fabrickom pakovanju. Dostupni kao refill pakovanje ili na spulni.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,30 @@
|
||||
import type { Metadata } from 'next'
|
||||
import '../src/styles/index.css'
|
||||
import { BackToTop } from '../src/components/BackToTop'
|
||||
import { MatomoAnalytics } from '../src/components/MatomoAnalytics'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Filamenteka',
|
||||
description: 'Automatsko praćenje filamenata sa kodiranjem bojama',
|
||||
metadataBase: new URL('https://filamenteka.rs'),
|
||||
title: {
|
||||
default: 'Bambu Lab Oprema Srbija | Filamenteka',
|
||||
template: '%s | Filamenteka',
|
||||
},
|
||||
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji. Filamenti, 3D stampaci, build plate podloge, mlaznice, rezervni delovi i oprema.',
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'sr_RS',
|
||||
siteName: 'Filamenteka',
|
||||
title: 'Bambu Lab Oprema Srbija | Filamenteka',
|
||||
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji.',
|
||||
url: 'https://filamenteka.rs',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
alternates: {
|
||||
canonical: 'https://filamenteka.rs',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -11,9 +32,64 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LocalBusiness',
|
||||
name: 'Filamenteka',
|
||||
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji',
|
||||
url: 'https://filamenteka.rs',
|
||||
telephone: '+381631031048',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressCountry: 'RS',
|
||||
},
|
||||
priceRange: 'RSD',
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="sr">
|
||||
<body>{children}</body>
|
||||
<html lang="sr" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
document.documentElement.classList.add('no-transitions');
|
||||
if (window.location.pathname.startsWith('/upadaj') || window.location.pathname.startsWith('/dashboard')) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
try {
|
||||
var darkMode = localStorage.getItem('darkMode');
|
||||
if (darkMode === 'true') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(function() {
|
||||
document.documentElement.classList.remove('no-transitions');
|
||||
}, 100);
|
||||
});
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
{children}
|
||||
<BackToTop />
|
||||
{process.env.NEXT_PUBLIC_MATOMO_URL && process.env.NEXT_PUBLIC_MATOMO_SITE_ID && (
|
||||
<MatomoAnalytics
|
||||
matomoUrl={process.env.NEXT_PUBLIC_MATOMO_URL}
|
||||
siteId={process.env.NEXT_PUBLIC_MATOMO_SITE_ID}
|
||||
/>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
18
app/mlaznice/layout.tsx
Normal file
18
app/mlaznice/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Bambu Lab Nozzle i Hotend (Mlaznice/Dizne)',
|
||||
description: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
|
||||
openGraph: {
|
||||
title: 'Bambu Lab Nozzle i Hotend (Mlaznice/Dizne) - Filamenteka',
|
||||
description: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
|
||||
url: 'https://filamenteka.rs/mlaznice',
|
||||
},
|
||||
alternates: {
|
||||
canonical: 'https://filamenteka.rs/mlaznice',
|
||||
},
|
||||
};
|
||||
|
||||
export default function MlaznicaLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
43
app/mlaznice/page.tsx
Normal file
43
app/mlaznice/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { SiteHeader } from '@/src/components/layout/SiteHeader';
|
||||
import { SiteFooter } from '@/src/components/layout/SiteFooter';
|
||||
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
|
||||
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
|
||||
import { getCategoryBySlug } from '@/src/config/categories';
|
||||
|
||||
export default function MlaznicePage() {
|
||||
const category = getCategoryBySlug('mlaznice')!;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader currentCategory="mlaznice" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<article>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Pocetna', href: '/' },
|
||||
{ label: 'Mlaznice i Hotend' },
|
||||
]} />
|
||||
<div className="flex items-center gap-3 mt-3 mb-6">
|
||||
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
|
||||
<h1
|
||||
className="text-2xl sm:text-3xl font-black tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Bambu Lab Mlaznice i Hotend
|
||||
</h1>
|
||||
</div>
|
||||
<CatalogPage
|
||||
category={category}
|
||||
seoContent={
|
||||
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
|
||||
Bambu Lab nozzle mlaznice i hotend moduli. Hardened steel, stainless steel i obicne mlaznice u razlicitim precnicima. Complete hotend zamene za A1, P1 i X1 serije. Originalni Bambu Lab delovi u privatnoj prodaji.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
app/oprema/layout.tsx
Normal file
18
app/oprema/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Bambu Lab Oprema i Dodaci (Accessories)',
|
||||
description: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
|
||||
openGraph: {
|
||||
title: 'Bambu Lab Oprema i Dodaci (Accessories) - Filamenteka',
|
||||
description: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
|
||||
url: 'https://filamenteka.rs/oprema',
|
||||
},
|
||||
alternates: {
|
||||
canonical: 'https://filamenteka.rs/oprema',
|
||||
},
|
||||
};
|
||||
|
||||
export default function OpremaLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
43
app/oprema/page.tsx
Normal file
43
app/oprema/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { SiteHeader } from '@/src/components/layout/SiteHeader';
|
||||
import { SiteFooter } from '@/src/components/layout/SiteFooter';
|
||||
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
|
||||
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
|
||||
import { getCategoryBySlug } from '@/src/config/categories';
|
||||
|
||||
export default function OpremaPage() {
|
||||
const category = getCategoryBySlug('oprema')!;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader currentCategory="oprema" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<article>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Pocetna', href: '/' },
|
||||
{ label: 'Oprema i Dodaci' },
|
||||
]} />
|
||||
<div className="flex items-center gap-3 mt-3 mb-6">
|
||||
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
|
||||
<h1
|
||||
className="text-2xl sm:text-3xl font-black tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Bambu Lab Oprema i Dodaci
|
||||
</h1>
|
||||
</div>
|
||||
<CatalogPage
|
||||
category={category}
|
||||
seoContent={
|
||||
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
|
||||
Bambu Lab oprema i dodaci za 3D stampace. AMS (Automatic Material System), spool holderi, alati za odrzavanje, filament drajeri i druga dodatna oprema. Sve sto vam treba za optimalan rad vaseg Bambu Lab 3D stampaca.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
448
app/page.tsx
448
app/page.tsx
@@ -1,112 +1,384 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FilamentTable } from '../src/components/FilamentTable';
|
||||
import { Filament } from '../src/types/filament';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import { SiteHeader } from '@/src/components/layout/SiteHeader';
|
||||
import { SiteFooter } from '@/src/components/layout/SiteFooter';
|
||||
import { SaleCountdown } from '@/src/components/SaleCountdown';
|
||||
import ColorRequestModal from '@/src/components/ColorRequestModal';
|
||||
import { CategoryIcon } from '@/src/components/ui/CategoryIcon';
|
||||
import { getEnabledCategories } from '@/src/config/categories';
|
||||
import { filamentService } from '@/src/services/api';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||
|
||||
const KP_URL = 'https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246';
|
||||
|
||||
export default function Home() {
|
||||
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [showColorRequestModal, setShowColorRequestModal] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Initialize dark mode from localStorage after mounting
|
||||
const categories = getEnabledCategories();
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved) {
|
||||
setDarkMode(JSON.parse(saved));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update dark mode
|
||||
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]);
|
||||
|
||||
const fetchFilaments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Use API if available, fallback to static JSON
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||
const url = apiUrl ? `${apiUrl}/filaments` : '/data.json';
|
||||
console.log('Fetching from:', url);
|
||||
console.log('API URL configured:', apiUrl);
|
||||
const response = await axios.get(url);
|
||||
console.log('Response data:', response.data);
|
||||
setFilaments(response.data);
|
||||
setLastUpdate(new Date());
|
||||
const data = await filamentService.getAll();
|
||||
setFilaments(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Greška pri učitavanju filamenata');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
console.error('Failed to fetch filaments for sale check:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilaments();
|
||||
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(fetchFilaments, 5 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 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-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Filamenteka
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
{lastUpdate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Poslednje ažuriranje: {lastUpdate.toLocaleTimeString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchFilaments}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Ažuriranje...' : 'Osveži'}
|
||||
</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 ? '☀️' : '🌙'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
const activeSaleFilaments = filaments.filter(f => f.sale_active === true);
|
||||
const hasActiveSale = activeSaleFilaments.length > 0;
|
||||
const maxSalePercentage = hasActiveSale
|
||||
? Math.max(...activeSaleFilaments.map(f => f.sale_percentage || 0), 0)
|
||||
: 0;
|
||||
const saleEndDate = (() => {
|
||||
const withEndDate = activeSaleFilaments.filter(f => f.sale_end_date);
|
||||
if (withEndDate.length === 0) return null;
|
||||
return withEndDate.reduce((latest, current) => {
|
||||
if (!latest.sale_end_date) return current;
|
||||
if (!current.sale_end_date) return latest;
|
||||
return new Date(current.sale_end_date) > new Date(latest.sale_end_date) ? current : latest;
|
||||
}).sale_end_date ?? null;
|
||||
})();
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<FilamentTable
|
||||
filaments={filaments}
|
||||
loading={loading}
|
||||
error={error || undefined}
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader />
|
||||
|
||||
{/* ═══ HERO ═══ */}
|
||||
<section className="relative overflow-hidden noise-overlay">
|
||||
{/* Rich multi-layer gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-950 via-purple-950 to-indigo-950" />
|
||||
|
||||
{/* Floating color orbs — big, bright, energetic */}
|
||||
<div className="absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute w-[600px] h-[600px] -top-48 -left-48 rounded-full opacity-40 blur-[100px] animate-float"
|
||||
style={{ background: 'radial-gradient(circle, #3b82f6, transparent)' }} />
|
||||
<div className="absolute w-[500px] h-[500px] top-10 right-0 rounded-full opacity-40 blur-[80px] animate-float-slow"
|
||||
style={{ background: 'radial-gradient(circle, #a855f7, transparent)' }} />
|
||||
<div className="absolute w-[450px] h-[450px] -bottom-24 left-1/3 rounded-full opacity-40 blur-[90px] animate-float-delayed"
|
||||
style={{ background: 'radial-gradient(circle, #f59e0b, transparent)' }} />
|
||||
<div className="absolute w-[400px] h-[400px] bottom-10 right-1/4 rounded-full opacity-40 blur-[80px] animate-float"
|
||||
style={{ background: 'radial-gradient(circle, #22c55e, transparent)' }} />
|
||||
<div className="absolute w-[350px] h-[350px] top-1/2 left-10 rounded-full opacity-35 blur-[70px] animate-float-slow"
|
||||
style={{ background: 'radial-gradient(circle, #ef4444, transparent)' }} />
|
||||
<div className="absolute w-[300px] h-[300px] top-1/4 right-1/3 rounded-full opacity-35 blur-[75px] animate-float-delayed"
|
||||
style={{ background: 'radial-gradient(circle, #06b6d4, transparent)' }} />
|
||||
</div>
|
||||
|
||||
{/* Grid pattern overlay */}
|
||||
<div className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)',
|
||||
backgroundSize: '60px 60px'
|
||||
}} />
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 sm:py-28 lg:py-32">
|
||||
<div className="flex flex-col items-center text-center reveal-up">
|
||||
{/* Logo */}
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Filamenteka"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className="h-24 sm:h-32 md:h-40 w-auto drop-shadow-2xl mb-8"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
|
||||
{/* Tagline */}
|
||||
<h1
|
||||
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-white mb-5 tracking-tight leading-[1.1]"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
Bambu Lab oprema
|
||||
<br />
|
||||
<span className="hero-gradient-text">
|
||||
u Srbiji
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-gray-300 max-w-xl mb-10 leading-relaxed reveal-up reveal-delay-1">
|
||||
Filamenti, stampaci, podloge, mlaznice, delovi i oprema.
|
||||
<br className="hidden sm:block" />
|
||||
Originalni proizvodi, privatna prodaja.
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 reveal-up reveal-delay-2">
|
||||
<a
|
||||
href={KP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Hero CTA')}
|
||||
className="group inline-flex items-center justify-center gap-2.5 px-8 py-4
|
||||
bg-blue-500 hover:bg-blue-600 text-white font-bold text-base rounded-2xl
|
||||
shadow-lg shadow-blue-500/25
|
||||
hover:shadow-2xl hover:shadow-blue-500/35
|
||||
transition-all duration-300 active:scale-[0.97]"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
||||
</svg>
|
||||
Kupi na KupujemProdajem
|
||||
<svg className="w-4 h-4 opacity-60 group-hover:opacity-100 group-hover:translate-x-0.5 transition-all" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="tel:+381631031048"
|
||||
onClick={() => trackEvent('Contact', 'Phone Call', 'Hero CTA')}
|
||||
className="inline-flex items-center justify-center gap-2.5 px-8 py-4
|
||||
text-white/90 hover:text-white font-semibold text-base rounded-2xl
|
||||
border border-white/20 hover:border-white/40
|
||||
hover:bg-white/[0.08]
|
||||
transition-all duration-300 active:scale-[0.97]"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
</svg>
|
||||
+381 63 103 1048
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom fade */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[var(--surface-primary)] to-transparent z-10" />
|
||||
</section>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-8 relative z-20">
|
||||
{/* Sale Banner */}
|
||||
<SaleCountdown
|
||||
hasActiveSale={hasActiveSale}
|
||||
maxSalePercentage={maxSalePercentage}
|
||||
saleEndDate={saleEndDate}
|
||||
/>
|
||||
|
||||
{/* ═══ CATEGORIES ═══ */}
|
||||
<section className="py-12 sm:py-16">
|
||||
<div className="text-center mb-10 sm:mb-14">
|
||||
<h2
|
||||
className="text-4xl sm:text-5xl font-extrabold tracking-tight mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Sta nudimo
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)' }} className="text-base sm:text-lg max-w-md mx-auto">
|
||||
Kompletna ponuda originalne Bambu Lab opreme
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 auto-rows-auto">
|
||||
{categories.map((cat, i) => {
|
||||
// First card (filamenti) is featured — spans 2 cols on lg
|
||||
const isFeatured = i === 0;
|
||||
// Last card spans 2 cols on sm/lg for variety
|
||||
const isWide = i === categories.length - 1;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={cat.slug}
|
||||
href={`/${cat.slug}`}
|
||||
onClick={() => trackEvent('Navigation', 'Category Click', cat.label)}
|
||||
className={`category-card group block rounded-2xl reveal-up reveal-delay-${i + 1} ${
|
||||
isFeatured ? 'lg:col-span-2 lg:row-span-2' : ''
|
||||
} ${isWide ? 'sm:col-span-2 lg:col-span-1' : ''}`}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${cat.colorHex}, ${cat.colorHex}cc)`,
|
||||
boxShadow: `0 8px 32px -8px ${cat.colorHex}35`,
|
||||
}}
|
||||
>
|
||||
<div className={`relative z-10 ${isFeatured ? 'p-8 sm:p-10' : 'p-6 sm:p-7'}`}>
|
||||
{/* SVG Icon */}
|
||||
<div className={`${isFeatured ? 'w-14 h-14' : 'w-11 h-11'} bg-white/20 backdrop-blur-sm rounded-xl flex items-center justify-center mb-4`}>
|
||||
<CategoryIcon slug={cat.slug} className={isFeatured ? 'w-7 h-7 text-white' : 'w-5 h-5 text-white'} />
|
||||
</div>
|
||||
|
||||
<h3 className={`${isFeatured ? 'text-2xl lg:text-3xl' : 'text-lg'} font-bold text-white tracking-tight mb-2`}
|
||||
style={{ fontFamily: 'var(--font-display)' }}>
|
||||
{cat.label}
|
||||
</h3>
|
||||
|
||||
<p className={`text-white/75 ${isFeatured ? 'text-base' : 'text-sm'} leading-relaxed mb-5`}>
|
||||
{cat.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-sm font-bold text-white/90 group-hover:text-white transition-colors">
|
||||
Pogledaj ponudu
|
||||
<svg className="w-4 h-4 ml-1.5 group-hover:translate-x-1.5 transition-transform duration-300" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══ INFO CARDS ═══ */}
|
||||
<section className="pb-12 sm:pb-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-5">
|
||||
{/* Reusable Spool Price */}
|
||||
<div
|
||||
className="rounded-2xl border border-l-4 p-6 sm:p-7 flex items-start gap-4 shadow-sm"
|
||||
style={{ borderColor: 'var(--border-subtle)', borderLeftColor: '#3b82f6', background: 'var(--surface-elevated)' }}
|
||||
>
|
||||
<div className="flex-shrink-0 w-11 h-11 bg-blue-500/10 dark:bg-blue-500/15 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-extrabold text-base mb-1" style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}>
|
||||
Visekratna spulna
|
||||
</h3>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
Cena visekratne spulne: <span className="font-extrabold text-blue-500">499 RSD</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selling by Grams */}
|
||||
<div
|
||||
className="rounded-2xl border border-l-4 p-6 sm:p-7 flex items-start gap-4 shadow-sm"
|
||||
style={{ borderColor: 'var(--border-subtle)', borderLeftColor: '#10b981', background: 'var(--surface-elevated)' }}
|
||||
>
|
||||
<div className="flex-shrink-0 w-11 h-11 bg-emerald-500/10 dark:bg-emerald-500/15 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.988 5.988 0 0 1-2.031.352 5.988 5.988 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L18.75 4.971Zm-16.5.52c.99-.203 1.99-.377 3-.52m0 0 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.989 5.989 0 0 1-2.031.352 5.989 5.989 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L5.25 4.971Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-extrabold text-base mb-1.5" style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}>
|
||||
Prodaja na grame
|
||||
</h3>
|
||||
<p className="text-sm font-medium mb-2.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
Idealno za testiranje materijala ili manje projekte.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ g: '50g', price: '299' },
|
||||
{ g: '100g', price: '499' },
|
||||
{ g: '200g', price: '949' },
|
||||
].map(({ g, price }) => (
|
||||
<span key={g} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-bold bg-emerald-500/10 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||
{g} — {price} RSD
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══ DISCLAIMER ═══ */}
|
||||
<section className="pb-12 sm:pb-16">
|
||||
<div className="rounded-2xl border border-amber-200/60 dark:border-amber-500/20 bg-amber-50/50 dark:bg-amber-500/[0.06] p-5 sm:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-9 h-9 bg-amber-400/20 dark:bg-amber-500/15 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-amber-900 dark:text-amber-200/80">
|
||||
Ovo je privatna prodaja fizickog lica. Filamenteka nije registrovana prodavnica.
|
||||
Sva kupovina se odvija preko KupujemProdajem platforme.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══ CONTACT CTA ═══ */}
|
||||
<section className="pb-16 sm:pb-20">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-slate-100 via-slate-50 to-blue-50 dark:bg-none dark:from-transparent dark:via-transparent dark:to-transparent dark:bg-[#0f172a] dark:noise-overlay border border-slate-200/80 dark:border-transparent">
|
||||
<div className="relative z-10 p-8 sm:p-12 lg:p-14 text-center">
|
||||
<h2
|
||||
className="text-2xl sm:text-3xl lg:text-4xl font-extrabold mb-3 tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Zainteresovani?
|
||||
</h2>
|
||||
<p className="mb-8 max-w-md mx-auto text-base" style={{ color: 'var(--text-muted)' }}>
|
||||
Pogledajte ponudu na KupujemProdajem ili nas kontaktirajte direktno.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-3 sm:gap-4">
|
||||
<a
|
||||
href={KP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Contact CTA')}
|
||||
className="group inline-flex items-center gap-2.5 px-7 py-3.5
|
||||
bg-blue-500 hover:bg-blue-600 text-white
|
||||
font-bold rounded-xl
|
||||
shadow-lg shadow-blue-500/25
|
||||
hover:shadow-xl hover:shadow-blue-500/35
|
||||
transition-all duration-300 active:scale-[0.97]"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
||||
</svg>
|
||||
Kupi na KupujemProdajem
|
||||
<svg className="w-4 h-4 opacity-50 group-hover:opacity-100 group-hover:translate-x-0.5 transition-all" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="tel:+381631031048"
|
||||
onClick={() => trackEvent('Contact', 'Phone Call', 'Contact CTA')}
|
||||
className="inline-flex items-center gap-2.5 px-7 py-3.5
|
||||
font-semibold rounded-xl
|
||||
text-slate-700 dark:text-white/90 hover:text-slate-900 dark:hover:text-white
|
||||
border border-slate-300 dark:border-white/25 hover:border-slate-400 dark:hover:border-white/50
|
||||
hover:bg-slate-50 dark:hover:bg-white/[0.1]
|
||||
transition-all duration-300 active:scale-[0.97]"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
</svg>
|
||||
+381 63 103 1048
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Color Request */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowColorRequestModal(true);
|
||||
trackEvent('Navigation', 'Request Color', 'Contact CTA');
|
||||
}}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium transition-colors duration-200 text-slate-500 hover:text-slate-800 dark:text-white/70 dark:hover:text-white"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
|
||||
</svg>
|
||||
Ne nalazite boju? Zatrazite novu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
<ColorRequestModal
|
||||
isOpen={showColorRequestModal}
|
||||
onClose={() => setShowColorRequestModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
app/ploce/layout.tsx
Normal file
18
app/ploce/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Bambu Lab Build Plate (Podloga)',
|
||||
description: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
|
||||
openGraph: {
|
||||
title: 'Bambu Lab Build Plate (Podloga) - Filamenteka',
|
||||
description: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
|
||||
url: 'https://filamenteka.rs/ploce',
|
||||
},
|
||||
alternates: {
|
||||
canonical: 'https://filamenteka.rs/ploce',
|
||||
},
|
||||
};
|
||||
|
||||
export default function PloceLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
43
app/ploce/page.tsx
Normal file
43
app/ploce/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { SiteHeader } from '@/src/components/layout/SiteHeader';
|
||||
import { SiteFooter } from '@/src/components/layout/SiteFooter';
|
||||
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
|
||||
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
|
||||
import { getCategoryBySlug } from '@/src/config/categories';
|
||||
|
||||
export default function PlocePage() {
|
||||
const category = getCategoryBySlug('ploce')!;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader currentCategory="ploce" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<article>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Pocetna', href: '/' },
|
||||
{ label: 'Build Plate (Podloge)' },
|
||||
]} />
|
||||
<div className="flex items-center gap-3 mt-3 mb-6">
|
||||
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
|
||||
<h1
|
||||
className="text-2xl sm:text-3xl font-black tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Bambu Lab Build Plate Podloge
|
||||
</h1>
|
||||
</div>
|
||||
<CatalogPage
|
||||
category={category}
|
||||
seoContent={
|
||||
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
|
||||
Originalne Bambu Lab build plate podloge za 3D stampanje. Cool Plate, Engineering Plate, High Temp Plate i Textured PEI Plate. Kompatibilne sa A1 Mini, A1, P1S, X1C i drugim modelima. Nove i polovne podloge u privatnoj prodaji.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
app/politika-privatnosti/page.tsx
Normal file
205
app/politika-privatnosti/page.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { SiteHeader } from '@/src/components/layout/SiteHeader';
|
||||
import { SiteFooter } from '@/src/components/layout/SiteFooter';
|
||||
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Politika Privatnosti',
|
||||
description: 'Politika privatnosti sajta Filamenteka — informacije o prikupljanju i obradi podataka, kolacicima, analitici i pravima korisnika.',
|
||||
alternates: {
|
||||
canonical: 'https://filamenteka.rs/politika-privatnosti',
|
||||
},
|
||||
};
|
||||
|
||||
export default function PolitikaPrivatnostiPage() {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader />
|
||||
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Pocetna', href: '/' },
|
||||
{ label: 'Politika Privatnosti' },
|
||||
]} />
|
||||
|
||||
<article className="mt-6">
|
||||
<h1
|
||||
className="text-3xl sm:text-4xl font-black tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Politika Privatnosti
|
||||
</h1>
|
||||
<p className="text-sm mt-2 mb-10" style={{ color: 'var(--text-muted)' }}>
|
||||
Poslednje azuriranje: Februar 2026
|
||||
</p>
|
||||
|
||||
<div className="space-y-8 leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
1. Uvod
|
||||
</h2>
|
||||
<p>
|
||||
Filamenteka (filamenteka.rs) postuje vasu privatnost. Ova politika privatnosti
|
||||
objasnjava koje podatke prikupljamo, kako ih koristimo i koja prava imate u vezi
|
||||
sa vasim podacima.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
2. Podaci koje prikupljamo
|
||||
</h2>
|
||||
|
||||
<h3
|
||||
className="text-lg font-semibold mt-4 mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
2.1 Analitika (Matomo)
|
||||
</h3>
|
||||
<p className="mb-3">
|
||||
Koristimo Matomo, platformu za web analitiku otvorenog koda, koja je hostovana na
|
||||
nasem sopstvenom serveru. Matomo prikuplja sledece anonimizovane podatke:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Anonimizovana IP adresa (poslednja dva okteta su maskirana)</li>
|
||||
<li>Tip uredjaja i operativni sistem</li>
|
||||
<li>Pregledac i rezolucija ekrana</li>
|
||||
<li>Posecene stranice i vreme posete</li>
|
||||
<li>Referalna stranica (odakle ste dosli)</li>
|
||||
</ul>
|
||||
<p className="mt-3">
|
||||
Ovi podaci se koriste iskljucivo za razumevanje kako posetioci koriste sajt i za
|
||||
poboljsanje korisnickog iskustva. Podaci se ne dele sa trecim stranama.
|
||||
</p>
|
||||
|
||||
<h3
|
||||
className="text-lg font-semibold mt-6 mb-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
2.2 Podaci iz kontakt forme (Zahtev za boju)
|
||||
</h3>
|
||||
<p>
|
||||
Kada podnesete zahtev za novu boju filamenta, prikupljamo sledece podatke koje
|
||||
dobrovoljno unosite:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2 mt-2">
|
||||
<li>Vase ime</li>
|
||||
<li>Broj telefona</li>
|
||||
<li>Zeljena boja filamenta i poruka</li>
|
||||
</ul>
|
||||
<p className="mt-3">
|
||||
Ovi podaci se koriste iskljucivo za obradu vaseg zahteva i kontaktiranje u vezi
|
||||
sa dostupnoscu trazene boje. Ne koristimo ih u marketinske svrhe niti ih delimo
|
||||
sa trecim stranama.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
3. Kolacici
|
||||
</h2>
|
||||
<p>
|
||||
Filamenteka ne koristi kolacice za pracenje korisnika niti za marketinske svrhe.
|
||||
Matomo analitika je konfigurisana tako da radi bez kolacica za pracenje. Jedini
|
||||
lokalni podaci koji se cuvaju u vasem pregledacu su podesavanja interfejsa (npr.
|
||||
tamni rezim), koja se cuvaju u localStorage i nikada se ne salju na server.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
4. Korisnicki nalozi
|
||||
</h2>
|
||||
<p>
|
||||
Sajt ne nudi mogucnost registracije niti kreiranja korisnickih naloga za
|
||||
posetioce. Ne prikupljamo niti cuvamo lozinke, email adrese ili druge podatke
|
||||
za autentifikaciju posetilaca.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
5. Odjava od analitike (Matomo Opt-Out)
|
||||
</h2>
|
||||
<p>
|
||||
Ako zelite da budete iskljuceni iz Matomo analitike, mozete aktivirati "Do Not
|
||||
Track" opciju u vasem pregledacu. Matomo je konfigurisan da postuje ovo
|
||||
podesavanje i nece prikupljati podatke o vasim posetama.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
6. GDPR uskladjenost
|
||||
</h2>
|
||||
<p>
|
||||
U skladu sa Opstom uredbom o zastiti podataka (GDPR) i Zakonom o zastiti podataka
|
||||
o licnosti Republike Srbije, imate sledeca prava:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2 mt-2">
|
||||
<li>Pravo na pristup vasim podacima</li>
|
||||
<li>Pravo na ispravku netacnih podataka</li>
|
||||
<li>Pravo na brisanje podataka ("pravo na zaborav")</li>
|
||||
<li>Pravo na ogranicenje obrade</li>
|
||||
<li>Pravo na prigovor na obradu podataka</li>
|
||||
</ul>
|
||||
<p className="mt-3">
|
||||
Za ostvarivanje bilo kog od ovih prava, kontaktirajte nas putem telefona
|
||||
navedenog na sajtu.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
7. Rukovalac podacima
|
||||
</h2>
|
||||
<p>
|
||||
Rukovalac podacima je Filamenteka, privatna prodaja fizickog lica.
|
||||
</p>
|
||||
<ul className="list-none space-y-1 mt-2">
|
||||
<li>Sajt: filamenteka.rs</li>
|
||||
<li>Telefon: +381 63 103 1048</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
8. Izmene politike privatnosti
|
||||
</h2>
|
||||
<p>
|
||||
Zadrzavamo pravo da azuriramo ovu politiku privatnosti u bilo kom trenutku.
|
||||
Sve izmene ce biti objavljene na ovoj stranici sa azuriranim datumom poslednje
|
||||
izmene.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
app/stampaci/layout.tsx
Normal file
18
app/stampaci/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Bambu Lab 3D Stampaci | A1, P1S, X1C',
|
||||
description: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
|
||||
openGraph: {
|
||||
title: 'Bambu Lab 3D Stampaci | A1, P1S, X1C - Filamenteka',
|
||||
description: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
|
||||
url: 'https://filamenteka.rs/stampaci',
|
||||
},
|
||||
alternates: {
|
||||
canonical: 'https://filamenteka.rs/stampaci',
|
||||
},
|
||||
};
|
||||
|
||||
export default function StampaciLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
43
app/stampaci/page.tsx
Normal file
43
app/stampaci/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { SiteHeader } from '@/src/components/layout/SiteHeader';
|
||||
import { SiteFooter } from '@/src/components/layout/SiteFooter';
|
||||
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
|
||||
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
|
||||
import { getCategoryBySlug } from '@/src/config/categories';
|
||||
|
||||
export default function StampaciPage() {
|
||||
const category = getCategoryBySlug('stampaci')!;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader currentCategory="stampaci" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<article>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Pocetna', href: '/' },
|
||||
{ label: '3D Stampaci' },
|
||||
]} />
|
||||
<div className="flex items-center gap-3 mt-3 mb-6">
|
||||
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
|
||||
<h1
|
||||
className="text-2xl sm:text-3xl font-black tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Bambu Lab 3D Stampaci
|
||||
</h1>
|
||||
</div>
|
||||
<CatalogPage
|
||||
category={category}
|
||||
seoContent={
|
||||
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
|
||||
Bambu Lab 3D stampaci u privatnoj prodaji. U ponudi imamo A1 Mini, A1, P1S, X1C i druge modele. Novi i polovni stampaci u razlicitim stanjima. Svi stampaci su originalni Bambu Lab proizvodi sa svom originalnom opremom.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
556
app/upadaj/colors/page.tsx
Normal file
556
app/upadaj/colors/page.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { colorService } from '@/src/services/api';
|
||||
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
|
||||
import { BulkPriceEditor } from '@/src/components/BulkPriceEditor';
|
||||
import '@/src/styles/select.css';
|
||||
|
||||
interface Color {
|
||||
id: string;
|
||||
name: string;
|
||||
hex: string;
|
||||
cena_refill?: number;
|
||||
cena_spulna?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export default function ColorsManagement() {
|
||||
const router = useRouter();
|
||||
const [colors, setColors] = useState<Color[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingColor, setEditingColor] = useState<Color | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedColors, setSelectedColors] = useState<Set<string>>(new Set());
|
||||
|
||||
// Initialize dark mode - default to true for admin
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved !== null) {
|
||||
setDarkMode(JSON.parse(saved));
|
||||
} else {
|
||||
// Default to dark mode for admin
|
||||
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(() => {
|
||||
// Wait for component to mount to avoid SSR issues with localStorage
|
||||
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 colors
|
||||
const fetchColors = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const colors = await colorService.getAll();
|
||||
setColors(colors.sort((a: Color, b: Color) => a.name.localeCompare(b.name)));
|
||||
} catch (err) {
|
||||
setError('Greška pri učitavanju boja');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchColors();
|
||||
}, []);
|
||||
|
||||
|
||||
const handleSave = async (color: Partial<Color>) => {
|
||||
try {
|
||||
if (color.id) {
|
||||
await colorService.update(color.id, {
|
||||
name: color.name!,
|
||||
hex: color.hex!,
|
||||
cena_refill: color.cena_refill,
|
||||
cena_spulna: color.cena_spulna
|
||||
});
|
||||
} else {
|
||||
await colorService.create({
|
||||
name: color.name!,
|
||||
hex: color.hex!,
|
||||
cena_refill: color.cena_refill,
|
||||
cena_spulna: color.cena_spulna
|
||||
});
|
||||
}
|
||||
|
||||
setEditingColor(null);
|
||||
setShowAddForm(false);
|
||||
fetchColors();
|
||||
} catch (err) {
|
||||
setError('Greška pri čuvanju boje');
|
||||
console.error('Save error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Da li ste sigurni da želite obrisati ovu boju?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await colorService.delete(id);
|
||||
fetchColors();
|
||||
} catch (err) {
|
||||
setError('Greška pri brisanju boje');
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedColors.size === 0) {
|
||||
setError('Molimo izaberite boje za brisanje');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Da li ste sigurni da želite obrisati ${selectedColors.size} boja?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete all selected colors
|
||||
await Promise.all(Array.from(selectedColors).map(id => colorService.delete(id)));
|
||||
setSelectedColors(new Set());
|
||||
fetchColors();
|
||||
} catch (err) {
|
||||
setError('Greška pri brisanju boja');
|
||||
console.error('Bulk delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleColorSelection = (colorId: string) => {
|
||||
const newSelection = new Set(selectedColors);
|
||||
if (newSelection.has(colorId)) {
|
||||
newSelection.delete(colorId);
|
||||
} else {
|
||||
newSelection.add(colorId);
|
||||
}
|
||||
setSelectedColors(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const filteredColors = colors.filter(color =>
|
||||
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const allFilteredSelected = filteredColors.every(c => selectedColors.has(c.id));
|
||||
|
||||
if (allFilteredSelected) {
|
||||
// Deselect all
|
||||
setSelectedColors(new Set());
|
||||
} else {
|
||||
// Select all filtered
|
||||
setSelectedColors(new Set(filteredColors.map(c => c.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
router.push('/upadaj');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen bg-gray-50 dark:bg-[#060a14] flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-white/40">Učitavanje...</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-[#060a14] transition-colors">
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 bg-white dark:bg-white/[0.04] 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-white/60 hover:bg-gray-100 dark:hover:bg-white/[0.06] 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 */}
|
||||
<div className="flex-1">
|
||||
<header className="bg-white dark:bg-white/[0.04] 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 bojama</h1>
|
||||
</div>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{!showAddForm && !editingColor && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
|
||||
>
|
||||
Dodaj novu boju
|
||||
</button>
|
||||
)}
|
||||
<BulkPriceEditor colors={colors} onUpdate={fetchColors} />
|
||||
{selectedColors.size > 0 && (
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Obriši izabrane ({selectedColors.size})
|
||||
</button>
|
||||
)}
|
||||
<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-white/[0.06] text-gray-800 dark:text-white/70 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
>
|
||||
{darkMode ? '☀️' : '🌙'}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Add Form (stays at top) */}
|
||||
{showAddForm && (
|
||||
<ColorForm
|
||||
color={{}}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setShowAddForm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pretraži boje..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-white/60 bg-white dark:bg-white/[0.04] border border-gray-300 dark:border-white/[0.08] rounded-lg focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<svg className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors Table */}
|
||||
<div className="overflow-x-auto bg-white dark:bg-white/[0.04] rounded-lg shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-white/[0.06]">
|
||||
<thead className="bg-gray-50 dark:bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const filtered = colors.filter(color =>
|
||||
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
return filtered.length > 0 && filtered.every(c => selectedColors.has(c.id));
|
||||
})()}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Boja</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Naziv</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Hex kod</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Cena Refil</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Cena Spulna</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-white/[0.04] divide-y divide-gray-200 dark:divide-white/[0.06]">
|
||||
{colors
|
||||
.filter(color =>
|
||||
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.map((color) => (
|
||||
<tr key={color.id} className="hover:bg-gray-50 dark:hover:bg-white/[0.06]">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColors.has(color.id)}
|
||||
onChange={() => toggleColorSelection(color.id)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div
|
||||
className="w-10 h-10 rounded border-2 border-gray-300 dark:border-white/[0.08]"
|
||||
style={{ backgroundColor: color.hex }}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{color.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{color.hex}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-green-600 dark:text-green-400">
|
||||
{(color.cena_refill || 3499).toLocaleString('sr-RS')} RSD
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-blue-500 dark:text-blue-400">
|
||||
{(color.cena_spulna || 3999).toLocaleString('sr-RS')} RSD
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => setEditingColor(color)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 mr-3"
|
||||
title="Izmeni"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(color.id)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300"
|
||||
title="Obriši"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingColor && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-white/[0.04] rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Izmeni boju</h3>
|
||||
<ColorForm
|
||||
color={editingColor}
|
||||
onSave={(color) => {
|
||||
handleSave(color);
|
||||
setEditingColor(null);
|
||||
}}
|
||||
onCancel={() => setEditingColor(null)}
|
||||
isModal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Color Form Component
|
||||
function ColorForm({
|
||||
color,
|
||||
onSave,
|
||||
onCancel,
|
||||
isModal = false
|
||||
}: {
|
||||
color: Partial<Color>,
|
||||
onSave: (color: Partial<Color>) => void,
|
||||
onCancel: () => void,
|
||||
isModal?: boolean
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: color.name || '',
|
||||
hex: color.hex || '#000000',
|
||||
cena_refill: color.cena_refill || 3499,
|
||||
cena_spulna: color.cena_spulna || 3999,
|
||||
});
|
||||
|
||||
// Check if this is a Bambu Lab predefined color
|
||||
const isBambuLabColor = !!(formData.name && bambuLabColors.hasOwnProperty(formData.name));
|
||||
const bambuHex = formData.name ? getColorHex(formData.name) : null;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'number' ? parseInt(value) || 0 : value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Use Bambu Lab hex if it's a predefined color
|
||||
const hexToSave = isBambuLabColor && bambuHex ? bambuHex : formData.hex;
|
||||
onSave({
|
||||
...color,
|
||||
name: formData.name,
|
||||
hex: hexToSave,
|
||||
cena_refill: formData.cena_refill,
|
||||
cena_spulna: formData.cena_spulna
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isModal ? "" : "mb-8 p-6 bg-white dark:bg-white/[0.04] rounded-lg shadow transition-colors"}>
|
||||
{!isModal && (
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||
{color.id ? 'Izmeni boju' : 'Dodaj novu boju'}
|
||||
</h2>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Naziv boje</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="npr. Crvena"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Hex kod boje</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
name="hex"
|
||||
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
|
||||
onChange={handleChange}
|
||||
disabled={isBambuLabColor}
|
||||
className={`w-12 h-12 p-1 border-2 border-gray-300 dark:border-white/[0.08] rounded-md ${isBambuLabColor ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
||||
style={{ backgroundColor: isBambuLabColor && bambuHex ? bambuHex : formData.hex }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="hex"
|
||||
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
|
||||
onChange={handleChange}
|
||||
required
|
||||
readOnly={isBambuLabColor}
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
placeholder="#000000"
|
||||
className={`flex-1 px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-gray-100 dark:bg-[#060a14] cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{isBambuLabColor && (
|
||||
<p className="text-xs text-gray-500 dark:text-white/40 mt-1">
|
||||
Bambu Lab predefinisana boja - hex kod se ne može menjati
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Cena Refil</label>
|
||||
<input
|
||||
type="number"
|
||||
name="cena_refill"
|
||||
value={formData.cena_refill}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="3499"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Cena Spulna</label>
|
||||
<input
|
||||
type="number"
|
||||
name="cena_spulna"
|
||||
value={formData.cena_spulna}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="3999"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-white/70 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Sačuvaj
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
app/upadaj/dashboard/[category]/layout.tsx
Normal file
13
app/upadaj/dashboard/[category]/layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export function generateStaticParams() {
|
||||
return [
|
||||
{ category: 'stampaci' },
|
||||
{ category: 'ploce' },
|
||||
{ category: 'mlaznice' },
|
||||
{ category: 'delovi' },
|
||||
{ category: 'oprema' },
|
||||
];
|
||||
}
|
||||
|
||||
export default function CategoryLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
577
app/upadaj/dashboard/[category]/page.tsx
Normal file
577
app/upadaj/dashboard/[category]/page.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, notFound } from 'next/navigation';
|
||||
import { productService, printerModelService } from '@/src/services/api';
|
||||
import { Product, ProductCategory, ProductCondition, PrinterModel } from '@/src/types/product';
|
||||
import '@/src/styles/select.css';
|
||||
|
||||
const CATEGORY_MAP: Record<string, { category: ProductCategory; label: string; plural: string }> = {
|
||||
stampaci: { category: 'printer', label: 'Stampac', plural: 'Stampaci' },
|
||||
ploce: { category: 'build_plate', label: 'Ploca', plural: 'Ploce' },
|
||||
mlaznice: { category: 'nozzle', label: 'Mlaznica', plural: 'Mlaznice' },
|
||||
delovi: { category: 'spare_part', label: 'Deo', plural: 'Delovi' },
|
||||
oprema: { category: 'accessory', label: 'Oprema', plural: 'Oprema' },
|
||||
};
|
||||
|
||||
const CONDITION_LABELS: Record<string, string> = {
|
||||
new: 'Novo',
|
||||
used_like_new: 'Korisceno - kao novo',
|
||||
used_good: 'Korisceno - dobro',
|
||||
used_fair: 'Korisceno - pristojno',
|
||||
};
|
||||
|
||||
// Categories that support printer compatibility
|
||||
const PRINTER_COMPAT_CATEGORIES: ProductCategory[] = ['build_plate', 'nozzle', 'spare_part'];
|
||||
|
||||
export default function CategoryPage() {
|
||||
const params = useParams();
|
||||
const slug = params.category as string;
|
||||
|
||||
const categoryConfig = CATEGORY_MAP[slug];
|
||||
|
||||
// If slug is not recognized, show not found
|
||||
if (!categoryConfig) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { category, label, plural } = categoryConfig;
|
||||
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [printerModels, setPrinterModels] = useState<PrinterModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortField, setSortField] = useState<string>('name');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [selectedProducts, setSelectedProducts] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [data, models] = await Promise.all([
|
||||
productService.getAll({ category }),
|
||||
PRINTER_COMPAT_CATEGORIES.includes(category)
|
||||
? printerModelService.getAll().catch(() => [])
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
setProducts(data);
|
||||
setPrinterModels(models);
|
||||
} catch (err) {
|
||||
setError('Greska pri ucitavanju proizvoda');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [category]);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAndSorted = useMemo(() => {
|
||||
let filtered = products;
|
||||
if (searchTerm) {
|
||||
const search = searchTerm.toLowerCase();
|
||||
filtered = products.filter(p =>
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
let aVal: any = a[sortField as keyof Product] || '';
|
||||
let bVal: any = b[sortField as keyof Product] || '';
|
||||
|
||||
if (sortField === 'price' || sortField === 'stock') {
|
||||
aVal = Number(aVal) || 0;
|
||||
bVal = Number(bVal) || 0;
|
||||
return sortOrder === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
aVal = String(aVal).toLowerCase();
|
||||
bVal = String(bVal).toLowerCase();
|
||||
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [products, searchTerm, sortField, sortOrder]);
|
||||
|
||||
const handleSave = async (product: Partial<Product> & { printer_model_ids?: string[] }) => {
|
||||
try {
|
||||
const dataToSave = {
|
||||
...product,
|
||||
category,
|
||||
};
|
||||
|
||||
if (product.id) {
|
||||
await productService.update(product.id, dataToSave);
|
||||
} else {
|
||||
await productService.create(dataToSave);
|
||||
}
|
||||
|
||||
setEditingProduct(null);
|
||||
setShowAddForm(false);
|
||||
fetchProducts();
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.error || err.message || 'Greska pri cuvanju proizvoda';
|
||||
setError(msg);
|
||||
console.error('Save error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Da li ste sigurni da zelite obrisati ovaj proizvod?')) return;
|
||||
try {
|
||||
await productService.delete(id);
|
||||
fetchProducts();
|
||||
} catch (err) {
|
||||
setError('Greska pri brisanju proizvoda');
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedProducts.size === 0) return;
|
||||
if (!confirm(`Obrisati ${selectedProducts.size} proizvoda?`)) return;
|
||||
|
||||
try {
|
||||
await Promise.all(Array.from(selectedProducts).map(id => productService.delete(id)));
|
||||
setSelectedProducts(new Set());
|
||||
fetchProducts();
|
||||
} catch (err) {
|
||||
setError('Greska pri brisanju proizvoda');
|
||||
console.error('Bulk delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelection = (id: string) => {
|
||||
const next = new Set(selectedProducts);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setSelectedProducts(next);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedProducts.size === filteredAndSorted.length) {
|
||||
setSelectedProducts(new Set());
|
||||
} else {
|
||||
setSelectedProducts(new Set(filteredAndSorted.map(p => p.id)));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-white/40">Ucitavanje...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{plural}</h1>
|
||||
<p className="text-white/40 mt-1">{products.length} proizvoda ukupno</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!showAddForm && !editingProduct && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddForm(true);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm"
|
||||
>
|
||||
Dodaj {label.toLowerCase()}
|
||||
</button>
|
||||
)}
|
||||
{selectedProducts.size > 0 && (
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm"
|
||||
>
|
||||
Obrisi izabrane ({selectedProducts.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-900/20 text-red-400 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pretrazi proizvode..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 pl-10 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{(showAddForm || editingProduct) && (
|
||||
<ProductForm
|
||||
product={editingProduct || undefined}
|
||||
printerModels={printerModels}
|
||||
showPrinterCompat={PRINTER_COMPAT_CATEGORIES.includes(category)}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setEditingProduct(null);
|
||||
setShowAddForm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Products Table */}
|
||||
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
|
||||
<table className="min-w-full divide-y divide-white/[0.06]">
|
||||
<thead className="bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredAndSorted.length > 0 && selectedProducts.size === filteredAndSorted.length}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-4 h-4 text-blue-600 bg-white/[0.06] border-white/[0.08] rounded"
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => handleSort('name')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
|
||||
Naziv {sortField === 'name' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('condition')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
|
||||
Stanje {sortField === 'condition' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('price')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
|
||||
Cena {sortField === 'price' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('stock')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
|
||||
Kolicina {sortField === 'stock' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase">Popust</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.06]">
|
||||
{filteredAndSorted.map(product => (
|
||||
<tr key={product.id} className="hover:bg-white/[0.06]">
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProducts.has(product.id)}
|
||||
onChange={() => toggleSelection(product.id)}
|
||||
className="w-4 h-4 text-blue-600 bg-white/[0.06] border-white/[0.08] rounded"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{product.image_url && (
|
||||
<img src={product.image_url} alt={product.name} className="w-10 h-10 rounded object-cover" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white/90">{product.name}</div>
|
||||
{product.description && (
|
||||
<div className="text-xs text-white/40 truncate max-w-xs">{product.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-white/60">
|
||||
{CONDITION_LABELS[product.condition] || product.condition}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-bold text-white/90">
|
||||
{product.price.toLocaleString('sr-RS')} RSD
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{product.stock > 2 ? (
|
||||
<span className="text-green-400 font-bold">{product.stock}</span>
|
||||
) : product.stock > 0 ? (
|
||||
<span className="text-yellow-400 font-bold">{product.stock}</span>
|
||||
) : (
|
||||
<span className="text-red-400 font-bold">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{product.sale_active && product.sale_percentage ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-900 text-purple-200">
|
||||
-{product.sale_percentage}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-white/30">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingProduct(product);
|
||||
setShowAddForm(false);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className="text-blue-400 hover:text-blue-300 mr-3"
|
||||
title="Izmeni"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(product.id)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
title="Obrisi"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredAndSorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-white/40">
|
||||
Nema proizvoda u ovoj kategoriji
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Product Form Component
|
||||
function ProductForm({
|
||||
product,
|
||||
printerModels,
|
||||
showPrinterCompat,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
product?: Product;
|
||||
printerModels: PrinterModel[];
|
||||
showPrinterCompat: boolean;
|
||||
onSave: (data: Partial<Product> & { printer_model_ids?: string[] }) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: product?.name || '',
|
||||
description: product?.description || '',
|
||||
price: product?.price || 0,
|
||||
condition: (product?.condition || 'new') as ProductCondition,
|
||||
stock: product?.stock || 0,
|
||||
image_url: product?.image_url || '',
|
||||
attributes: JSON.stringify(product?.attributes || {}, null, 2),
|
||||
});
|
||||
const [selectedPrinterIds, setSelectedPrinterIds] = useState<string[]>([]);
|
||||
|
||||
// Load compatible printers on mount
|
||||
useEffect(() => {
|
||||
if (product?.compatible_printers) {
|
||||
// Map names to IDs
|
||||
const ids = printerModels
|
||||
.filter(m => product.compatible_printers?.includes(m.name))
|
||||
.map(m => m.id);
|
||||
setSelectedPrinterIds(ids);
|
||||
}
|
||||
}, [product, printerModels]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'number' ? (parseFloat(value) || 0) : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const togglePrinter = (id: string) => {
|
||||
setSelectedPrinterIds(prev =>
|
||||
prev.includes(id) ? prev.filter(pid => pid !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
alert('Naziv je obavezan');
|
||||
return;
|
||||
}
|
||||
|
||||
let attrs = {};
|
||||
try {
|
||||
attrs = formData.attributes.trim() ? JSON.parse(formData.attributes) : {};
|
||||
} catch {
|
||||
alert('Atributi moraju biti validan JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
id: product?.id,
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
price: formData.price,
|
||||
condition: formData.condition,
|
||||
stock: formData.stock,
|
||||
image_url: formData.image_url || undefined,
|
||||
attributes: attrs,
|
||||
printer_model_ids: showPrinterCompat ? selectedPrinterIds : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white/[0.04] rounded-2xl shadow">
|
||||
<h2 className="text-xl font-bold mb-4 text-white">
|
||||
{product ? 'Izmeni proizvod' : 'Dodaj proizvod'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Naziv</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Opis</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Cena (RSD)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="price"
|
||||
value={formData.price}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Stanje</label>
|
||||
<select
|
||||
name="condition"
|
||||
value={formData.condition}
|
||||
onChange={handleChange}
|
||||
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="new">Novo</option>
|
||||
<option value="used_like_new">Korisceno - kao novo</option>
|
||||
<option value="used_good">Korisceno - dobro</option>
|
||||
<option value="used_fair">Korisceno - pristojno</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Kolicina</label>
|
||||
<input
|
||||
type="number"
|
||||
name="stock"
|
||||
value={formData.stock}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">URL slike</label>
|
||||
<input
|
||||
type="url"
|
||||
name="image_url"
|
||||
value={formData.image_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://..."
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Atributi (JSON)</label>
|
||||
<textarea
|
||||
name="attributes"
|
||||
value={formData.attributes}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
placeholder='{"key": "value"}'
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Printer Compatibility */}
|
||||
{showPrinterCompat && printerModels.length > 0 && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2 text-white/60">Kompatibilni stampaci</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{printerModels.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
onClick={() => togglePrinter(model.id)}
|
||||
className={`px-3 py-1 rounded text-sm transition-colors ${
|
||||
selectedPrinterIds.includes(model.id)
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white/[0.06] text-white/60 hover:bg-white/[0.08]'
|
||||
}`}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
|
||||
>
|
||||
Otkazi
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Sacuvaj
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
460
app/upadaj/dashboard/analitika/page.tsx
Normal file
460
app/upadaj/dashboard/analitika/page.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { analyticsService, filamentService, productService } from '@/src/services/api';
|
||||
import { InventoryStats, SalesStats, Product } from '@/src/types/product';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
|
||||
type Tab = 'inventar' | 'prodaja' | 'posetioci' | 'nabavka';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
printer: 'Stampaci',
|
||||
build_plate: 'Ploce',
|
||||
nozzle: 'Mlaznice',
|
||||
spare_part: 'Delovi',
|
||||
accessory: 'Oprema',
|
||||
};
|
||||
|
||||
export default function AnalitikaPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('inventar');
|
||||
const [inventoryStats, setInventoryStats] = useState<InventoryStats | null>(null);
|
||||
const [salesStats, setSalesStats] = useState<SalesStats | null>(null);
|
||||
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [inv, sales, fils, prods] = await Promise.all([
|
||||
analyticsService.getInventory().catch(() => null),
|
||||
analyticsService.getSales().catch(() => null),
|
||||
filamentService.getAll().catch(() => []),
|
||||
productService.getAll().catch(() => []),
|
||||
]);
|
||||
setInventoryStats(inv);
|
||||
setSalesStats(sales);
|
||||
setFilaments(fils);
|
||||
setProducts(prods);
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'inventar', label: 'Inventar' },
|
||||
{ key: 'prodaja', label: 'Prodaja' },
|
||||
{ key: 'posetioci', label: 'Posetioci' },
|
||||
{ key: 'nabavka', label: 'Nabavka' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-white/40">Ucitavanje analitike...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>Analitika</h1>
|
||||
<p className="text-white/40 mt-1">Pregled stanja inventara i prodaje</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-white/[0.06]">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: 'border-transparent text-white/40 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Inventar Tab */}
|
||||
{activeTab === 'inventar' && (
|
||||
<div className="space-y-6">
|
||||
{inventoryStats ? (
|
||||
<>
|
||||
{/* Filament stats */}
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Filamenti</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-white/40">SKU-ovi</p>
|
||||
<p className="text-2xl font-bold text-white">{inventoryStats.filaments.total_skus}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Ukupno jedinica</p>
|
||||
<p className="text-2xl font-bold text-white">{inventoryStats.filaments.total_units}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Refili</p>
|
||||
<p className="text-2xl font-bold text-green-400">{inventoryStats.filaments.total_refills}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Spulne</p>
|
||||
<p className="text-2xl font-bold text-blue-400">{inventoryStats.filaments.total_spools}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Nema na stanju</p>
|
||||
<p className="text-2xl font-bold text-red-400">{inventoryStats.filaments.out_of_stock}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Vrednost inventara</p>
|
||||
<p className="text-2xl font-bold text-yellow-400">
|
||||
{inventoryStats.filaments.inventory_value.toLocaleString('sr-RS')} RSD
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product stats */}
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Proizvodi</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-white/40">SKU-ovi</p>
|
||||
<p className="text-2xl font-bold text-white">{inventoryStats.products.total_skus}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Ukupno jedinica</p>
|
||||
<p className="text-2xl font-bold text-white">{inventoryStats.products.total_units}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Nema na stanju</p>
|
||||
<p className="text-2xl font-bold text-red-400">{inventoryStats.products.out_of_stock}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Vrednost inventara</p>
|
||||
<p className="text-2xl font-bold text-yellow-400">
|
||||
{inventoryStats.products.inventory_value.toLocaleString('sr-RS')} RSD
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category breakdown */}
|
||||
{inventoryStats.products.by_category && Object.keys(inventoryStats.products.by_category).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-white/40 mb-2">Po kategoriji</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{Object.entries(inventoryStats.products.by_category).map(([cat, count]) => (
|
||||
<div key={cat} className="bg-white/[0.06] rounded p-3">
|
||||
<p className="text-xs text-white/40">{CATEGORY_LABELS[cat] || cat}</p>
|
||||
<p className="text-lg font-bold text-white">{count}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Combined */}
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Ukupno</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Ukupno SKU-ova</p>
|
||||
<p className="text-2xl font-bold text-white">{inventoryStats.combined.total_skus}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Ukupno jedinica</p>
|
||||
<p className="text-2xl font-bold text-white">{inventoryStats.combined.total_units}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Nema na stanju</p>
|
||||
<p className="text-2xl font-bold text-red-400">{inventoryStats.combined.out_of_stock}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<p className="text-white/40">Podaci o inventaru nisu dostupni. Proverite API konekciju.</p>
|
||||
|
||||
{/* Fallback from direct data */}
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Filamenata</p>
|
||||
<p className="text-2xl font-bold text-white">{filaments.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Proizvoda</p>
|
||||
<p className="text-2xl font-bold text-white">{products.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Nisko stanje (fil.)</p>
|
||||
<p className="text-2xl font-bold text-yellow-400">
|
||||
{filaments.filter(f => f.kolicina <= 2 && f.kolicina > 0).length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/40">Nema na stanju (fil.)</p>
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{filaments.filter(f => f.kolicina === 0).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prodaja Tab */}
|
||||
{activeTab === 'prodaja' && (
|
||||
<div className="space-y-6">
|
||||
{salesStats ? (
|
||||
<>
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Aktivni popusti</h3>
|
||||
<p className="text-3xl font-bold text-purple-400">{salesStats.total_active_sales}</p>
|
||||
</div>
|
||||
|
||||
{salesStats.filament_sales.length > 0 && (
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Filamenti na popustu ({salesStats.filament_sales.length})</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-white/60">Naziv</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Popust</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Originalna cena</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Cena sa popustom</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Istice</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.06]">
|
||||
{salesStats.filament_sales.map(sale => (
|
||||
<tr key={sale.id}>
|
||||
<td className="px-3 py-2 text-white/90">{sale.name}</td>
|
||||
<td className="px-3 py-2 text-purple-300">-{sale.sale_percentage}%</td>
|
||||
<td className="px-3 py-2 text-white/40 line-through">{sale.original_price.toLocaleString('sr-RS')} RSD</td>
|
||||
<td className="px-3 py-2 text-green-400 font-bold">{sale.sale_price.toLocaleString('sr-RS')} RSD</td>
|
||||
<td className="px-3 py-2 text-white/40">
|
||||
{sale.sale_end_date ? new Date(sale.sale_end_date).toLocaleDateString('sr-RS') : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{salesStats.product_sales.length > 0 && (
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Proizvodi na popustu ({salesStats.product_sales.length})</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-white/60">Naziv</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Kategorija</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Popust</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Originalna cena</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Cena sa popustom</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Istice</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.06]">
|
||||
{salesStats.product_sales.map(sale => (
|
||||
<tr key={sale.id}>
|
||||
<td className="px-3 py-2 text-white/90">{sale.name}</td>
|
||||
<td className="px-3 py-2 text-white/40">{CATEGORY_LABELS[sale.category] || sale.category}</td>
|
||||
<td className="px-3 py-2 text-orange-300">-{sale.sale_percentage}%</td>
|
||||
<td className="px-3 py-2 text-white/40 line-through">{sale.original_price.toLocaleString('sr-RS')} RSD</td>
|
||||
<td className="px-3 py-2 text-green-400 font-bold">{sale.sale_price.toLocaleString('sr-RS')} RSD</td>
|
||||
<td className="px-3 py-2 text-white/40">
|
||||
{sale.sale_end_date ? new Date(sale.sale_end_date).toLocaleDateString('sr-RS') : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{salesStats.filament_sales.length === 0 && salesStats.product_sales.length === 0 && (
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6 text-center">
|
||||
<p className="text-white/40">Nema aktivnih popusta</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<p className="text-white/40">Podaci o prodaji nisu dostupni. Proverite API konekciju.</p>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-white/30">
|
||||
Filamenti sa popustom: {filaments.filter(f => f.sale_active).length}
|
||||
</p>
|
||||
<p className="text-sm text-white/30">
|
||||
Proizvodi sa popustom: {products.filter(p => p.sale_active).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posetioci Tab */}
|
||||
{activeTab === 'posetioci' && (
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Analitika posetilaca</h3>
|
||||
<p className="text-white/40 mb-4">
|
||||
Matomo analitika je dostupna na eksternom dashboardu.
|
||||
</p>
|
||||
<a
|
||||
href="https://analytics.demirix.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Otvori Matomo analitiku
|
||||
</a>
|
||||
<p className="text-xs text-white/30 mt-3">
|
||||
analytics.demirix.dev - Site ID: 7
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nabavka Tab */}
|
||||
{activeTab === 'nabavka' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Preporuke za nabavku</h3>
|
||||
<p className="text-white/40 text-sm mb-4">Na osnovu trenutnog stanja inventara</p>
|
||||
|
||||
{/* Critical (out of stock) */}
|
||||
{(() => {
|
||||
const critical = filaments.filter(f => f.kolicina === 0);
|
||||
return critical.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-red-400 mb-2 flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-red-500 inline-block" />
|
||||
Kriticno - nema na stanju ({critical.length})
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-white/60">Tip</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Finis</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Boja</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Stanje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.06]">
|
||||
{critical.map(f => (
|
||||
<tr key={f.id}>
|
||||
<td className="px-3 py-2 text-white/90">{f.tip}</td>
|
||||
<td className="px-3 py-2 text-white/60">{f.finish}</td>
|
||||
<td className="px-3 py-2 text-white/90 flex items-center gap-2">
|
||||
{f.boja_hex && <div className="w-4 h-4 rounded border border-white/[0.08]" style={{ backgroundColor: f.boja_hex }} />}
|
||||
{f.boja}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-red-400 font-bold">0</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Warning (low stock) */}
|
||||
{(() => {
|
||||
const warning = filaments.filter(f => f.kolicina > 0 && f.kolicina <= 2);
|
||||
return warning.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-yellow-400 mb-2 flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-yellow-500 inline-block" />
|
||||
Upozorenje - nisko stanje ({warning.length})
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-white/60">Tip</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Finis</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Boja</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Stanje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.06]">
|
||||
{warning.map(f => (
|
||||
<tr key={f.id}>
|
||||
<td className="px-3 py-2 text-white/90">{f.tip}</td>
|
||||
<td className="px-3 py-2 text-white/60">{f.finish}</td>
|
||||
<td className="px-3 py-2 text-white/90 flex items-center gap-2">
|
||||
{f.boja_hex && <div className="w-4 h-4 rounded border border-white/[0.08]" style={{ backgroundColor: f.boja_hex }} />}
|
||||
{f.boja}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-yellow-400 font-bold">{f.kolicina}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* OK */}
|
||||
{(() => {
|
||||
const ok = filaments.filter(f => f.kolicina > 2);
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-green-400 mb-2 flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-green-500 inline-block" />
|
||||
Dobro stanje ({ok.length})
|
||||
</h4>
|
||||
<p className="text-sm text-white/30">{ok.length} filamenata ima dovoljno na stanju (3+)</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Product restock */}
|
||||
{(() => {
|
||||
const lowProducts = products.filter(p => p.stock <= 2);
|
||||
return lowProducts.length > 0 ? (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium text-orange-400 mb-2">Proizvodi za nabavku ({lowProducts.length})</h4>
|
||||
<div className="space-y-1">
|
||||
{lowProducts.map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-white/60">{p.name}</span>
|
||||
<span className={p.stock === 0 ? 'text-red-400 font-bold' : 'text-yellow-400'}>
|
||||
{p.stock}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
448
app/upadaj/dashboard/boje/page.tsx
Normal file
448
app/upadaj/dashboard/boje/page.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { colorService } from '@/src/services/api';
|
||||
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
|
||||
import { BulkPriceEditor } from '@/src/components/BulkPriceEditor';
|
||||
import '@/src/styles/select.css';
|
||||
|
||||
interface Color {
|
||||
id: string;
|
||||
name: string;
|
||||
hex: string;
|
||||
cena_refill?: number;
|
||||
cena_spulna?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export default function BojePage() {
|
||||
const [colors, setColors] = useState<Color[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingColor, setEditingColor] = useState<Color | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedColors, setSelectedColors] = useState<Set<string>>(new Set());
|
||||
|
||||
// Fetch colors
|
||||
const fetchColors = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const colors = await colorService.getAll();
|
||||
setColors(colors.sort((a: Color, b: Color) => a.name.localeCompare(b.name)));
|
||||
} catch (err) {
|
||||
setError('Greska pri ucitavanju boja');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchColors();
|
||||
}, []);
|
||||
|
||||
const handleSave = async (color: Partial<Color>) => {
|
||||
try {
|
||||
if (color.id) {
|
||||
await colorService.update(color.id, {
|
||||
name: color.name!,
|
||||
hex: color.hex!,
|
||||
cena_refill: color.cena_refill,
|
||||
cena_spulna: color.cena_spulna
|
||||
});
|
||||
} else {
|
||||
await colorService.create({
|
||||
name: color.name!,
|
||||
hex: color.hex!,
|
||||
cena_refill: color.cena_refill,
|
||||
cena_spulna: color.cena_spulna
|
||||
});
|
||||
}
|
||||
|
||||
setEditingColor(null);
|
||||
setShowAddForm(false);
|
||||
fetchColors();
|
||||
} catch (err) {
|
||||
setError('Greska pri cuvanju boje');
|
||||
console.error('Save error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Da li ste sigurni da zelite obrisati ovu boju?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await colorService.delete(id);
|
||||
fetchColors();
|
||||
} catch (err) {
|
||||
setError('Greska pri brisanju boje');
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedColors.size === 0) {
|
||||
setError('Molimo izaberite boje za brisanje');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Da li ste sigurni da zelite obrisati ${selectedColors.size} boja?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(Array.from(selectedColors).map(id => colorService.delete(id)));
|
||||
setSelectedColors(new Set());
|
||||
fetchColors();
|
||||
} catch (err) {
|
||||
setError('Greska pri brisanju boja');
|
||||
console.error('Bulk delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleColorSelection = (colorId: string) => {
|
||||
const newSelection = new Set(selectedColors);
|
||||
if (newSelection.has(colorId)) {
|
||||
newSelection.delete(colorId);
|
||||
} else {
|
||||
newSelection.add(colorId);
|
||||
}
|
||||
setSelectedColors(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const filteredColors = colors.filter(color =>
|
||||
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const allFilteredSelected = filteredColors.every(c => selectedColors.has(c.id));
|
||||
|
||||
if (allFilteredSelected) {
|
||||
setSelectedColors(new Set());
|
||||
} else {
|
||||
setSelectedColors(new Set(filteredColors.map(c => c.id)));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-white/40">Ucitavanje...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Upravljanje bojama</h1>
|
||||
<p className="text-white/40 mt-1">{colors.length} boja ukupno</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!showAddForm && !editingColor && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
|
||||
>
|
||||
Dodaj novu boju
|
||||
</button>
|
||||
)}
|
||||
<BulkPriceEditor colors={colors} onUpdate={fetchColors} />
|
||||
{selectedColors.size > 0 && (
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Obrisi izabrane ({selectedColors.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-900/20 text-red-400 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Form */}
|
||||
{showAddForm && (
|
||||
<ColorForm
|
||||
color={{}}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setShowAddForm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pretrazi boje..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 pl-10 pr-4 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Colors Table */}
|
||||
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
|
||||
<table className="min-w-full divide-y divide-white/[0.06]">
|
||||
<thead className="bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
const filtered = colors.filter(color =>
|
||||
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
return filtered.length > 0 && filtered.every(c => selectedColors.has(c.id));
|
||||
})()}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Boja</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Naziv</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Hex kod</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Cena Refil</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Cena Spulna</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
|
||||
{colors
|
||||
.filter(color =>
|
||||
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.map((color) => (
|
||||
<tr key={color.id} className="hover:bg-white/[0.06]">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColors.has(color.id)}
|
||||
onChange={() => toggleColorSelection(color.id)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div
|
||||
className="w-10 h-10 rounded border-2 border-white/[0.08]"
|
||||
style={{ backgroundColor: color.hex }}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{color.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{color.hex}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-green-400">
|
||||
{(color.cena_refill || 3499).toLocaleString('sr-RS')} RSD
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-blue-400">
|
||||
{(color.cena_spulna || 3999).toLocaleString('sr-RS')} RSD
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => setEditingColor(color)}
|
||||
className="text-blue-400 hover:text-blue-300 mr-3"
|
||||
title="Izmeni"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(color.id)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
title="Obrisi"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingColor && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white/[0.04] rounded-2xl shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold mb-4 text-white">Izmeni boju</h3>
|
||||
<ColorForm
|
||||
color={editingColor}
|
||||
onSave={(color) => {
|
||||
handleSave(color);
|
||||
setEditingColor(null);
|
||||
}}
|
||||
onCancel={() => setEditingColor(null)}
|
||||
isModal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Color Form Component
|
||||
function ColorForm({
|
||||
color,
|
||||
onSave,
|
||||
onCancel,
|
||||
isModal = false
|
||||
}: {
|
||||
color: Partial<Color>,
|
||||
onSave: (color: Partial<Color>) => void,
|
||||
onCancel: () => void,
|
||||
isModal?: boolean
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: color.name || '',
|
||||
hex: color.hex || '#000000',
|
||||
cena_refill: color.cena_refill || 3499,
|
||||
cena_spulna: color.cena_spulna || 3999,
|
||||
});
|
||||
|
||||
const isBambuLabColor = !!(formData.name && Object.prototype.hasOwnProperty.call(bambuLabColors, formData.name));
|
||||
const bambuHex = formData.name ? getColorHex(formData.name) : null;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'number' ? parseInt(value) || 0 : value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const hexToSave = isBambuLabColor && bambuHex ? bambuHex : formData.hex;
|
||||
onSave({
|
||||
...color,
|
||||
name: formData.name,
|
||||
hex: hexToSave,
|
||||
cena_refill: formData.cena_refill,
|
||||
cena_spulna: formData.cena_spulna
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isModal ? "" : "p-6 bg-white/[0.04] rounded-2xl shadow"}>
|
||||
{!isModal && (
|
||||
<h2 className="text-xl font-bold mb-4 text-white">
|
||||
{color.id ? 'Izmeni boju' : 'Dodaj novu boju'}
|
||||
</h2>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Naziv boje</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="npr. Crvena"
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Hex kod boje</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
name="hex"
|
||||
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
|
||||
onChange={handleChange}
|
||||
disabled={isBambuLabColor}
|
||||
className={`w-12 h-12 p-1 border-2 border-white/[0.08] rounded-md ${isBambuLabColor ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
||||
style={{ backgroundColor: isBambuLabColor && bambuHex ? bambuHex : formData.hex }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="hex"
|
||||
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
|
||||
onChange={handleChange}
|
||||
required
|
||||
readOnly={isBambuLabColor}
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
placeholder="#000000"
|
||||
className={`flex-1 px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-[#060a14] cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{isBambuLabColor && (
|
||||
<p className="text-xs text-white/40 mt-1">
|
||||
Bambu Lab predefinisana boja - hex kod se ne moze menjati
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Cena Refil</label>
|
||||
<input
|
||||
type="number"
|
||||
name="cena_refill"
|
||||
value={formData.cena_refill}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="3499"
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Cena Spulna</label>
|
||||
<input
|
||||
type="number"
|
||||
name="cena_spulna"
|
||||
value={formData.cena_spulna}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="3999"
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
|
||||
>
|
||||
Otkazi
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Sacuvaj
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
990
app/upadaj/dashboard/filamenti/page.tsx
Normal file
990
app/upadaj/dashboard/filamenti/page.tsx
Normal file
@@ -0,0 +1,990 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { filamentService, colorService } from '@/src/services/api';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||
import { SaleManager } from '@/src/components/SaleManager';
|
||||
import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor';
|
||||
import '@/src/styles/select.css';
|
||||
|
||||
// Colors that only come as refills (no spools)
|
||||
const REFILL_ONLY_COLORS = [
|
||||
'Beige',
|
||||
'Light Gray',
|
||||
'Yellow',
|
||||
'Orange',
|
||||
'Gold',
|
||||
'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 => {
|
||||
// If the finish/type combination is spool-only, then it's not refill-only
|
||||
if (isSpoolOnly(finish, type)) {
|
||||
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 = (colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>, type?: string, finish?: string) => {
|
||||
// PPA CF only has black color
|
||||
if (type === 'PPA' && finish === 'CF') {
|
||||
return colors.filter(color => color.name.toLowerCase() === 'black');
|
||||
}
|
||||
return colors;
|
||||
};
|
||||
|
||||
interface FilamentWithId extends Filament {
|
||||
id: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: 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 FilamentiPage() {
|
||||
const router = useRouter();
|
||||
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingFilament, setEditingFilament] = useState<FilamentWithId | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [sortField, setSortField] = useState<string>('boja');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [selectedFilaments, setSelectedFilaments] = useState<Set<string>>(new Set());
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [availableColors, setAvailableColors] = useState<Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>>([]);
|
||||
|
||||
// Fetch filaments
|
||||
const fetchFilaments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const filaments = await filamentService.getAll();
|
||||
setFilaments(filaments);
|
||||
} catch (err) {
|
||||
setError('Greska pri ucitavanju filamenata');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllData = async () => {
|
||||
// Fetch both filaments and colors
|
||||
await fetchFilaments();
|
||||
try {
|
||||
const colors = await colorService.getAll();
|
||||
setAvailableColors(colors.sort((a: any, b: any) => a.name.localeCompare(b.name)));
|
||||
} catch (error) {
|
||||
console.error('Error loading colors:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllData();
|
||||
}, []);
|
||||
|
||||
// Sorting logic
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and sort filaments
|
||||
const filteredAndSortedFilaments = useMemo(() => {
|
||||
// First, filter by search term
|
||||
let filtered = filaments;
|
||||
if (searchTerm) {
|
||||
const search = searchTerm.toLowerCase();
|
||||
filtered = filaments.filter(f =>
|
||||
f.tip?.toLowerCase().includes(search) ||
|
||||
f.finish?.toLowerCase().includes(search) ||
|
||||
f.boja?.toLowerCase().includes(search) ||
|
||||
f.cena?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Then sort if needed
|
||||
if (!sortField) return filtered;
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
let aVal = a[sortField as keyof FilamentWithId];
|
||||
let bVal = b[sortField as keyof FilamentWithId];
|
||||
|
||||
// Handle null/undefined values
|
||||
if (aVal === null || aVal === undefined) aVal = '';
|
||||
if (bVal === null || bVal === undefined) bVal = '';
|
||||
|
||||
// Handle date fields
|
||||
if (sortField === 'created_at' || sortField === 'updated_at') {
|
||||
const aDate = new Date(String(aVal));
|
||||
const bDate = new Date(String(bVal));
|
||||
return sortOrder === 'asc' ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime();
|
||||
}
|
||||
|
||||
// Handle numeric fields
|
||||
if (sortField === 'kolicina' || sortField === 'refill' || sortField === 'spulna') {
|
||||
const aNum = Number(aVal) || 0;
|
||||
const bNum = Number(bVal) || 0;
|
||||
return sortOrder === 'asc' ? aNum - bNum : bNum - aNum;
|
||||
}
|
||||
|
||||
// String comparison for other fields
|
||||
aVal = String(aVal).toLowerCase();
|
||||
bVal = String(bVal).toLowerCase();
|
||||
|
||||
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [filaments, sortField, sortOrder, searchTerm]);
|
||||
|
||||
const handleSave = async (filament: Partial<FilamentWithId>) => {
|
||||
try {
|
||||
// Extract only the fields the API expects
|
||||
const { id, ...dataForApi } = filament;
|
||||
|
||||
// Ensure numeric fields are numbers
|
||||
const cleanData = {
|
||||
tip: dataForApi.tip || 'PLA',
|
||||
finish: dataForApi.finish || 'Basic',
|
||||
boja: dataForApi.boja || '',
|
||||
boja_hex: dataForApi.boja_hex || '#000000',
|
||||
refill: Number(dataForApi.refill) || 0,
|
||||
spulna: Number(dataForApi.spulna) || 0,
|
||||
cena: dataForApi.cena || '3499'
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!cleanData.tip || !cleanData.finish || !cleanData.boja) {
|
||||
setError('Tip, Finish, and Boja are required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await filamentService.update(id, cleanData);
|
||||
trackEvent('Admin', 'Update Filament', `${cleanData.tip} ${cleanData.finish} ${cleanData.boja}`);
|
||||
} else {
|
||||
await filamentService.create(cleanData);
|
||||
trackEvent('Admin', 'Create Filament', `${cleanData.tip} ${cleanData.finish} ${cleanData.boja}`);
|
||||
}
|
||||
|
||||
setEditingFilament(null);
|
||||
setShowAddForm(false);
|
||||
fetchAllData();
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401 || err.response?.status === 403) {
|
||||
setError('Sesija je istekla. Molimo prijavite se ponovo.');
|
||||
setTimeout(() => {
|
||||
router.push('/upadaj');
|
||||
}, 2000);
|
||||
} else {
|
||||
// Extract error message properly
|
||||
let errorMessage = 'Greska pri cuvanju filamenata';
|
||||
if (err.response?.data?.error) {
|
||||
errorMessage = err.response.data.error;
|
||||
} else if (err.response?.data?.message) {
|
||||
errorMessage = err.response.data.message;
|
||||
} else if (typeof err.response?.data === 'string') {
|
||||
errorMessage = err.response.data;
|
||||
} else if (err.message) {
|
||||
errorMessage = err.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
console.error('Save error:', err.response?.data || err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Da li ste sigurni da zelite obrisati ovaj filament?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await filamentService.delete(id);
|
||||
fetchAllData();
|
||||
} catch (err) {
|
||||
setError('Greska pri brisanju filamenata');
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedFilaments.size === 0) {
|
||||
setError('Molimo izaberite filamente za brisanje');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Da li ste sigurni da zelite obrisati ${selectedFilaments.size} filamenata?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete all selected filaments
|
||||
await Promise.all(Array.from(selectedFilaments).map(id => filamentService.delete(id)));
|
||||
setSelectedFilaments(new Set());
|
||||
fetchAllData();
|
||||
} catch (err) {
|
||||
setError('Greska pri brisanju filamenata');
|
||||
console.error('Bulk delete error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFilamentSelection = (filamentId: string) => {
|
||||
const newSelection = new Set(selectedFilaments);
|
||||
if (newSelection.has(filamentId)) {
|
||||
newSelection.delete(filamentId);
|
||||
} else {
|
||||
newSelection.add(filamentId);
|
||||
}
|
||||
setSelectedFilaments(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedFilaments.size === filteredAndSortedFilaments.length) {
|
||||
setSelectedFilaments(new Set());
|
||||
} else {
|
||||
setSelectedFilaments(new Set(filteredAndSortedFilaments.map(f => f.id)));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-white/40">Ucitavanje...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Filamenti</h1>
|
||||
<p className="text-white/40 mt-1">{filaments.length} filamenata ukupno</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!showAddForm && !editingFilament && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddForm(true);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className="px-3 sm:px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm sm:text-base"
|
||||
>
|
||||
Dodaj novi
|
||||
</button>
|
||||
)}
|
||||
{selectedFilaments.size > 0 && (
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
className="px-3 sm:px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm sm:text-base"
|
||||
>
|
||||
Obrisi izabrane ({selectedFilaments.size})
|
||||
</button>
|
||||
)}
|
||||
<SaleManager
|
||||
filaments={filaments}
|
||||
selectedFilaments={selectedFilaments}
|
||||
onSaleUpdate={fetchFilaments}
|
||||
/>
|
||||
<BulkFilamentPriceEditor
|
||||
filaments={filaments}
|
||||
onUpdate={fetchFilaments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-900/20 text-red-400 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Bar and Sorting */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pretrazi po tipu, finishu, boji ili ceni..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 pl-10 pr-4 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div>
|
||||
<select
|
||||
value={`${sortField || 'boja'}-${sortOrder || 'asc'}`}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const [field, order] = e.target.value.split('-');
|
||||
if (field && order) {
|
||||
setSortField(field);
|
||||
setSortOrder(order as 'asc' | 'desc');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sort change error:', error);
|
||||
}
|
||||
}}
|
||||
className="custom-select w-full px-3 py-2 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-md focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="boja-asc">Sortiraj po: Boja (A-Z)</option>
|
||||
<option value="boja-desc">Sortiraj po: Boja (Z-A)</option>
|
||||
<option value="tip-asc">Sortiraj po: Tip (A-Z)</option>
|
||||
<option value="tip-desc">Sortiraj po: Tip (Z-A)</option>
|
||||
<option value="finish-asc">Sortiraj po: Finis (A-Z)</option>
|
||||
<option value="finish-desc">Sortiraj po: Finis (Z-A)</option>
|
||||
<option value="created_at-desc">Sortiraj po: Poslednje dodano</option>
|
||||
<option value="created_at-asc">Sortiraj po: Prvo dodano</option>
|
||||
<option value="updated_at-desc">Sortiraj po: Poslednje azurirano</option>
|
||||
<option value="updated_at-asc">Sortiraj po: Prvo azurirano</option>
|
||||
<option value="kolicina-desc">Sortiraj po: Kolicina (visoka-niska)</option>
|
||||
<option value="kolicina-asc">Sortiraj po: Kolicina (niska-visoka)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{(showAddForm || editingFilament) && (
|
||||
<div>
|
||||
<FilamentForm
|
||||
key={editingFilament?.id || 'new'}
|
||||
filament={editingFilament || {}}
|
||||
filaments={filaments}
|
||||
availableColors={availableColors}
|
||||
onSave={handleSave}
|
||||
onCancel={() => {
|
||||
setEditingFilament(null);
|
||||
setShowAddForm(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filaments Table */}
|
||||
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
|
||||
<table className="min-w-full divide-y divide-white/[0.06]">
|
||||
<thead className="bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredAndSortedFilaments.length > 0 && selectedFilaments.size === filteredAndSortedFilaments.length}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => handleSort('tip')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
|
||||
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('finish')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
|
||||
Finis {sortField === 'finish' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('boja')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
|
||||
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('refill')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
|
||||
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('spulna')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
|
||||
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('kolicina')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
|
||||
Kolicina {sortField === 'kolicina' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('cena')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
|
||||
Cena {sortField === 'cena' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Popust</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
|
||||
{filteredAndSortedFilaments.map((filament) => (
|
||||
<tr key={filament.id} className="hover:bg-white/[0.06]">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFilaments.has(filament.id)}
|
||||
onChange={() => toggleFilamentSelection(filament.id)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.tip}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.finish}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
|
||||
<div className="flex items-center gap-2">
|
||||
{filament.boja_hex && (
|
||||
<div
|
||||
className="w-7 h-7 rounded border border-white/[0.08]"
|
||||
style={{ backgroundColor: filament.boja_hex }}
|
||||
title={filament.boja_hex}
|
||||
/>
|
||||
)}
|
||||
<span>{filament.boja}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
|
||||
{filament.refill > 0 ? (
|
||||
<span className="text-green-400 font-bold">{filament.refill}</span>
|
||||
) : (
|
||||
<span className="text-white/30">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
|
||||
{filament.spulna > 0 ? (
|
||||
<span className="text-blue-400 font-bold">{filament.spulna}</span>
|
||||
) : (
|
||||
<span className="text-white/30">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.kolicina}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-white/90">
|
||||
{(() => {
|
||||
const hasRefill = filament.refill > 0;
|
||||
const hasSpool = filament.spulna > 0;
|
||||
|
||||
if (!hasRefill && !hasSpool) return '-';
|
||||
|
||||
let refillPrice = 3499;
|
||||
let spoolPrice = 3999;
|
||||
|
||||
if (filament.cena) {
|
||||
const prices = filament.cena.split('/');
|
||||
if (prices.length === 1) {
|
||||
refillPrice = parseInt(prices[0]) || 3499;
|
||||
spoolPrice = parseInt(prices[0]) || 3999;
|
||||
} else if (prices.length === 2) {
|
||||
refillPrice = parseInt(prices[0]) || 3499;
|
||||
spoolPrice = parseInt(prices[1]) || 3999;
|
||||
}
|
||||
} else {
|
||||
const colorData = availableColors.find(c => c.name === filament.boja);
|
||||
refillPrice = colorData?.cena_refill || 3499;
|
||||
spoolPrice = colorData?.cena_spulna || 3999;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasRefill && (
|
||||
<span className="text-green-400">
|
||||
{refillPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
{hasRefill && hasSpool && <span className="mx-1">/</span>}
|
||||
{hasSpool && (
|
||||
<span className="text-blue-400">
|
||||
{spoolPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 text-white/40">RSD</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{filament.sale_active && filament.sale_percentage ? (
|
||||
<div>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-900 text-purple-200">
|
||||
-{filament.sale_percentage}%
|
||||
</span>
|
||||
{filament.sale_end_date && (
|
||||
<div className="text-xs text-white/40 mt-1">
|
||||
do {new Date(filament.sale_end_date).toLocaleDateString('sr-RS')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-white/30">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingFilament(filament);
|
||||
setShowAddForm(false);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className="text-blue-400 hover:text-blue-300 mr-3"
|
||||
title="Izmeni"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(filament.id)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
title="Obrisi"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filament Form Component
|
||||
function FilamentForm({
|
||||
filament,
|
||||
filaments,
|
||||
availableColors,
|
||||
onSave,
|
||||
onCancel
|
||||
}: {
|
||||
filament: Partial<FilamentWithId>,
|
||||
filaments: FilamentWithId[],
|
||||
availableColors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>,
|
||||
onSave: (filament: Partial<FilamentWithId>) => void,
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
tip: filament.tip || (filament.id ? '' : 'PLA'),
|
||||
finish: filament.finish || (filament.id ? '' : 'Basic'),
|
||||
boja: filament.boja || '',
|
||||
boja_hex: filament.boja_hex || '',
|
||||
refill: isSpoolOnly(filament.finish, filament.tip) ? 0 : (filament.refill || 0),
|
||||
spulna: isRefillOnly(filament.boja || '', filament.finish, filament.tip) ? 0 : (filament.spulna || 0),
|
||||
kolicina: filament.kolicina || 0,
|
||||
cena: '',
|
||||
cena_refill: 0,
|
||||
cena_spulna: 0,
|
||||
});
|
||||
|
||||
// Track if this is the initial load to prevent price override
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
// Update form when filament prop changes
|
||||
useEffect(() => {
|
||||
let refillPrice = 0;
|
||||
let spulnaPrice = 0;
|
||||
|
||||
if (filament.cena) {
|
||||
const prices = filament.cena.split('/');
|
||||
refillPrice = parseInt(prices[0]) || 0;
|
||||
spulnaPrice = prices.length > 1 ? parseInt(prices[1]) || 0 : parseInt(prices[0]) || 0;
|
||||
}
|
||||
|
||||
const colorData = availableColors.find(c => c.name === filament.boja);
|
||||
if (!refillPrice && colorData?.cena_refill) refillPrice = colorData.cena_refill;
|
||||
if (!spulnaPrice && colorData?.cena_spulna) spulnaPrice = colorData.cena_spulna;
|
||||
|
||||
setFormData({
|
||||
tip: filament.tip || (filament.id ? '' : 'PLA'),
|
||||
finish: filament.finish || (filament.id ? '' : 'Basic'),
|
||||
boja: filament.boja || '',
|
||||
boja_hex: filament.boja_hex || '',
|
||||
refill: filament.refill || 0,
|
||||
spulna: filament.spulna || 0,
|
||||
kolicina: filament.kolicina || 0,
|
||||
cena: filament.cena || '',
|
||||
cena_refill: refillPrice || 3499,
|
||||
cena_spulna: spulnaPrice || 3999,
|
||||
});
|
||||
|
||||
setIsInitialLoad(true);
|
||||
}, [filament]);
|
||||
|
||||
// Update prices when color selection changes (but not on initial load)
|
||||
useEffect(() => {
|
||||
if (isInitialLoad) {
|
||||
setIsInitialLoad(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.boja && availableColors.length > 0) {
|
||||
const colorData = availableColors.find(c => c.name === formData.boja);
|
||||
if (colorData) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
cena_refill: colorData.cena_refill || prev.cena_refill,
|
||||
cena_spulna: colorData.cena_spulna || prev.cena_spulna,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [formData.boja, availableColors.length]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'refill' || name === 'spulna' || name === 'cena_refill' || name === 'cena_spulna') {
|
||||
const numValue = parseInt(value) || 0;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: numValue
|
||||
});
|
||||
} else if (name === 'tip') {
|
||||
const newTypeFinishes = FINISH_OPTIONS_BY_TYPE[value] || [];
|
||||
const resetFinish = !newTypeFinishes.includes(formData.finish);
|
||||
const spoolOnly = isSpoolOnly(formData.finish, value);
|
||||
const needsColorReset = value === 'PPA' && formData.finish === 'CF' && formData.boja.toLowerCase() !== 'black';
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
...(resetFinish ? { finish: '' } : {}),
|
||||
...(spoolOnly ? { refill: 0 } : {}),
|
||||
...(needsColorReset ? { boja: '' } : {})
|
||||
});
|
||||
} else if (name === 'boja') {
|
||||
const refillOnly = isRefillOnly(value, formData.finish, formData.tip);
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
...(refillOnly ? { spulna: 0 } : {})
|
||||
});
|
||||
} else if (name === 'finish') {
|
||||
const refillOnly = isRefillOnly(formData.boja, value, formData.tip);
|
||||
const spoolOnly = isSpoolOnly(value, formData.tip);
|
||||
const needsColorReset = formData.tip === 'PPA' && value === 'CF' && formData.boja.toLowerCase() !== 'black';
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
...(refillOnly ? { spulna: 0 } : {}),
|
||||
...(spoolOnly ? { refill: 0 } : {}),
|
||||
...(needsColorReset ? { boja: '' } : {})
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const totalQuantity = formData.refill + formData.spulna;
|
||||
|
||||
if (totalQuantity === 0) {
|
||||
alert('Kolicina mora biti veca od 0. Dodajte refill ili spulna.');
|
||||
return;
|
||||
}
|
||||
|
||||
const refillPrice = formData.cena_refill;
|
||||
const spoolPrice = formData.cena_spulna;
|
||||
|
||||
let priceString = '';
|
||||
if (formData.refill > 0 && formData.spulna > 0) {
|
||||
priceString = `${refillPrice}/${spoolPrice}`;
|
||||
} else if (formData.refill > 0) {
|
||||
priceString = String(refillPrice);
|
||||
} else if (formData.spulna > 0) {
|
||||
priceString = String(spoolPrice);
|
||||
} else {
|
||||
priceString = '3499/3999';
|
||||
}
|
||||
|
||||
const dataToSave = {
|
||||
id: filament.id,
|
||||
tip: formData.tip,
|
||||
finish: formData.finish,
|
||||
boja: formData.boja,
|
||||
boja_hex: formData.boja_hex,
|
||||
refill: formData.refill,
|
||||
spulna: formData.spulna,
|
||||
cena: priceString
|
||||
};
|
||||
|
||||
onSave(dataToSave);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white/[0.04] rounded-2xl shadow">
|
||||
<h2 className="text-xl font-bold mb-4 text-white">
|
||||
{filament.id ? 'Izmeni filament' : 'Dodaj novi filament'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Tip</label>
|
||||
<select
|
||||
name="tip"
|
||||
value={formData.tip}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Izaberi tip</option>
|
||||
<option value="ABS">ABS</option>
|
||||
<option value="ASA">ASA</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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Finis</label>
|
||||
<select
|
||||
name="finish"
|
||||
value={formData.finish}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Izaberi finis</option>
|
||||
{(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
|
||||
<option key={finish} value={finish}>{finish}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Boja</label>
|
||||
<select
|
||||
name="boja"
|
||||
value={formData.boja}
|
||||
onChange={(e) => {
|
||||
const selectedColorName = e.target.value;
|
||||
let hexValue = formData.boja_hex;
|
||||
|
||||
const dbColor = availableColors.find(c => c.name === selectedColorName);
|
||||
if (dbColor) {
|
||||
hexValue = dbColor.hex;
|
||||
}
|
||||
|
||||
handleChange({
|
||||
target: {
|
||||
name: 'boja',
|
||||
value: selectedColorName
|
||||
}
|
||||
} as any);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
boja_hex: hexValue,
|
||||
cena_refill: dbColor?.cena_refill || prev.cena_refill || 3499,
|
||||
cena_spulna: dbColor?.cena_spulna || prev.cena_spulna || 3999
|
||||
}));
|
||||
}}
|
||||
required
|
||||
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Izaberite boju</option>
|
||||
{getFilteredColors(availableColors, formData.tip, formData.finish).map(color => (
|
||||
<option key={color.id} value={color.name}>
|
||||
{color.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Druga boja...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">
|
||||
{formData.boja && formData.boja !== 'custom' ? `Hex kod za ${formData.boja}` : 'Hex kod boje'}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name="boja_hex"
|
||||
value={formData.boja_hex || '#000000'}
|
||||
onChange={handleChange}
|
||||
disabled={false}
|
||||
className="w-full h-10 px-1 py-1 border border-white/[0.08] rounded-md bg-white/[0.06] cursor-pointer"
|
||||
/>
|
||||
{formData.boja_hex && (
|
||||
<span className="text-sm text-white/40">{formData.boja_hex}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">
|
||||
<span className="text-green-400">Cena Refila</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="cena_refill"
|
||||
value={formData.cena_refill || availableColors.find(c => c.name === formData.boja)?.cena_refill || 3499}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="3499"
|
||||
disabled={isSpoolOnly(formData.finish, formData.tip)}
|
||||
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
|
||||
isSpoolOnly(formData.finish, formData.tip)
|
||||
? 'bg-white/[0.08] cursor-not-allowed'
|
||||
: 'bg-white/[0.06]'
|
||||
} text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">
|
||||
<span className="text-blue-400">Cena Spulne</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="cena_spulna"
|
||||
value={formData.cena_spulna || availableColors.find(c => c.name === formData.boja)?.cena_spulna || 3999}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="3999"
|
||||
disabled={isRefillOnly(formData.boja, formData.finish)}
|
||||
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
|
||||
isRefillOnly(formData.boja, formData.finish)
|
||||
? 'bg-white/[0.08] cursor-not-allowed text-white/40'
|
||||
: 'bg-white/[0.06] text-blue-400 font-bold focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">
|
||||
Refil
|
||||
{isSpoolOnly(formData.finish, formData.tip) && (
|
||||
<span className="text-xs text-white/40 ml-2">(samo spulna postoji)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="refill"
|
||||
value={formData.refill}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
disabled={isSpoolOnly(formData.finish, formData.tip)}
|
||||
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
|
||||
isSpoolOnly(formData.finish, formData.tip)
|
||||
? 'bg-white/[0.08] cursor-not-allowed'
|
||||
: 'bg-white/[0.06]'
|
||||
} text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">
|
||||
Spulna
|
||||
{isRefillOnly(formData.boja, formData.finish, formData.tip) && (
|
||||
<span className="text-xs text-white/40 ml-2">(samo refil postoji)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="spulna"
|
||||
value={formData.spulna}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
disabled={isRefillOnly(formData.boja, formData.finish, formData.tip)}
|
||||
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
|
||||
isRefillOnly(formData.boja, formData.finish, formData.tip)
|
||||
? 'bg-white/[0.08] cursor-not-allowed'
|
||||
: 'bg-white/[0.06]'
|
||||
} text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-white/60">Ukupna kolicina</label>
|
||||
<input
|
||||
type="number"
|
||||
name="kolicina"
|
||||
value={formData.refill + formData.spulna}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.08] text-white/90 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
|
||||
>
|
||||
Otkazi
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Sacuvaj
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
app/upadaj/dashboard/layout.tsx
Normal file
8
app/upadaj/dashboard/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
import { AdminLayout } from '@/src/components/layout/AdminLayout';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return <AdminLayout currentPath={pathname}>{children}</AdminLayout>;
|
||||
}
|
||||
249
app/upadaj/dashboard/page.tsx
Normal file
249
app/upadaj/dashboard/page.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { filamentService, productService } from '@/src/services/api';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { Product } from '@/src/types/product';
|
||||
|
||||
interface StatsCard {
|
||||
label: string;
|
||||
value: number | string;
|
||||
colorHex: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export default function AdminOverview() {
|
||||
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [filamentData, productData] = await Promise.all([
|
||||
filamentService.getAll(),
|
||||
productService.getAll().catch(() => []),
|
||||
]);
|
||||
setFilaments(filamentData);
|
||||
setProducts(productData);
|
||||
} catch (error) {
|
||||
console.error('Error loading overview data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const lowStockFilaments = filaments.filter(f => f.kolicina <= 2 && f.kolicina > 0);
|
||||
const outOfStockFilaments = filaments.filter(f => f.kolicina === 0);
|
||||
const lowStockProducts = products.filter(p => p.stock <= 2 && p.stock > 0);
|
||||
const outOfStockProducts = products.filter(p => p.stock === 0);
|
||||
const activeSales = filaments.filter(f => f.sale_active).length + products.filter(p => p.sale_active).length;
|
||||
|
||||
const statsCards: StatsCard[] = [
|
||||
{ label: 'Ukupno filamenata', value: filaments.length, colorHex: '#3b82f6', href: '/upadaj/dashboard/filamenti' },
|
||||
{ label: 'Ukupno proizvoda', value: products.length, colorHex: '#22c55e', href: '/upadaj/dashboard/stampaci' },
|
||||
{ label: 'Nisko stanje', value: lowStockFilaments.length + lowStockProducts.length, colorHex: '#f59e0b' },
|
||||
{ label: 'Aktivni popusti', value: activeSales, colorHex: '#a855f7', href: '/upadaj/dashboard/prodaja' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-white/40 text-sm">Ucitavanje...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page title */}
|
||||
<div>
|
||||
<h1
|
||||
className="text-2xl font-black text-white tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
Pregled
|
||||
</h1>
|
||||
<p className="text-white/40 mt-1 text-sm">Brzi pregled stanja inventara</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{statsCards.map((card) => {
|
||||
const content = (
|
||||
<div
|
||||
key={card.label}
|
||||
className="rounded-2xl p-5 text-white"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${card.colorHex}, ${card.colorHex}cc)`,
|
||||
boxShadow: `0 4px 20px ${card.colorHex}30`,
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-semibold opacity-80">{card.label}</p>
|
||||
<p
|
||||
className="text-3xl font-black mt-1"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
{card.value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
return card.href ? (
|
||||
<Link key={card.label} href={card.href} className="hover:scale-[1.02] transition-transform">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={card.label}>{content}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Low Stock Alerts */}
|
||||
{(lowStockFilaments.length > 0 || outOfStockFilaments.length > 0 || lowStockProducts.length > 0 || outOfStockProducts.length > 0) && (
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
|
||||
<h2
|
||||
className="text-lg font-bold text-white mb-4"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
Upozorenja o stanju
|
||||
</h2>
|
||||
|
||||
{/* Out of stock */}
|
||||
{(outOfStockFilaments.length > 0 || outOfStockProducts.length > 0) && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-red-400 mb-2">
|
||||
Nema na stanju ({outOfStockFilaments.length + outOfStockProducts.length})
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{outOfStockFilaments.slice(0, 5).map(f => (
|
||||
<div key={f.id} className="text-sm text-white/60 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
|
||||
{f.tip} {f.finish} - {f.boja}
|
||||
</div>
|
||||
))}
|
||||
{outOfStockProducts.slice(0, 5).map(p => (
|
||||
<div key={p.id} className="text-sm text-white/60 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
|
||||
{p.name}
|
||||
</div>
|
||||
))}
|
||||
{(outOfStockFilaments.length + outOfStockProducts.length) > 10 && (
|
||||
<p className="text-xs text-white/30">
|
||||
...i jos {outOfStockFilaments.length + outOfStockProducts.length - 10}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Low stock */}
|
||||
{(lowStockFilaments.length > 0 || lowStockProducts.length > 0) && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-amber-400 mb-2">
|
||||
Nisko stanje ({lowStockFilaments.length + lowStockProducts.length})
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{lowStockFilaments.slice(0, 5).map(f => (
|
||||
<div key={f.id} className="text-sm text-white/60 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500 shrink-0" />
|
||||
{f.tip} {f.finish} - {f.boja} (kolicina: {f.kolicina})
|
||||
</div>
|
||||
))}
|
||||
{lowStockProducts.slice(0, 5).map(p => (
|
||||
<div key={p.id} className="text-sm text-white/60 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500 shrink-0" />
|
||||
{p.name} (stanje: {p.stock})
|
||||
</div>
|
||||
))}
|
||||
{(lowStockFilaments.length + lowStockProducts.length) > 10 && (
|
||||
<p className="text-xs text-white/30">
|
||||
...i jos {lowStockFilaments.length + lowStockProducts.length - 10}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
|
||||
<h2
|
||||
className="text-lg font-bold text-white mb-4"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
Poslednje dodano
|
||||
</h2>
|
||||
<div className="space-y-2.5">
|
||||
{[...filaments]
|
||||
.sort((a, b) => {
|
||||
const dateA = a.updated_at || a.created_at || '';
|
||||
const dateB = b.updated_at || b.created_at || '';
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
})
|
||||
.slice(0, 5)
|
||||
.map(f => (
|
||||
<div key={f.id} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{f.boja_hex && (
|
||||
<div
|
||||
className="w-4 h-4 rounded-md border border-white/10"
|
||||
style={{ backgroundColor: f.boja_hex }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-white/70">{f.tip} {f.finish} - {f.boja}</span>
|
||||
</div>
|
||||
<span className="text-white/30 text-xs">
|
||||
{f.updated_at
|
||||
? new Date(f.updated_at).toLocaleDateString('sr-RS')
|
||||
: f.created_at
|
||||
? new Date(f.created_at).toLocaleDateString('sr-RS')
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{filaments.length === 0 && (
|
||||
<p className="text-white/30 text-sm">Nema filamenata</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
|
||||
<h2
|
||||
className="text-lg font-bold text-white mb-4"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
Brze akcije
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{[
|
||||
{ href: '/upadaj/dashboard/filamenti', label: 'Dodaj filament', color: '#3b82f6' },
|
||||
{ href: '/upadaj/dashboard/stampaci', label: 'Dodaj proizvod', color: '#22c55e' },
|
||||
{ href: '/upadaj/dashboard/prodaja', label: 'Upravljaj popustima', color: '#a855f7' },
|
||||
{ href: '/upadaj/dashboard/boje', label: 'Upravljaj bojama', color: '#ec4899' },
|
||||
{ href: '/upadaj/dashboard/analitika', label: 'Analitika', color: '#14b8a6' },
|
||||
].map(action => (
|
||||
<Link
|
||||
key={action.href}
|
||||
href={action.href}
|
||||
className="px-4 py-2.5 text-white rounded-xl text-sm font-semibold hover:scale-[1.03] transition-transform"
|
||||
style={{
|
||||
background: action.color,
|
||||
boxShadow: `0 2px 10px ${action.color}30`,
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
360
app/upadaj/dashboard/prodaja/page.tsx
Normal file
360
app/upadaj/dashboard/prodaja/page.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { filamentService, productService, analyticsService } from '@/src/services/api';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { Product, SalesStats } from '@/src/types/product';
|
||||
|
||||
export default function ProdajaPage() {
|
||||
const [salesStats, setSalesStats] = useState<SalesStats | null>(null);
|
||||
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Filament sale form
|
||||
const [filamentSalePercentage, setFilamentSalePercentage] = useState(10);
|
||||
const [filamentSaleEndDate, setFilamentSaleEndDate] = useState('');
|
||||
const [filamentSaleEnabled, setFilamentSaleEnabled] = useState(true);
|
||||
|
||||
// Product sale form
|
||||
const [productSalePercentage, setProductSalePercentage] = useState(10);
|
||||
const [productSaleEndDate, setProductSaleEndDate] = useState('');
|
||||
const [productSaleEnabled, setProductSaleEnabled] = useState(true);
|
||||
|
||||
const [savingFilamentSale, setSavingFilamentSale] = useState(false);
|
||||
const [savingProductSale, setSavingProductSale] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [filamentData, productData, salesData] = await Promise.all([
|
||||
filamentService.getAll(),
|
||||
productService.getAll().catch(() => []),
|
||||
analyticsService.getSales().catch(() => null),
|
||||
]);
|
||||
setFilaments(filamentData);
|
||||
setProducts(productData);
|
||||
setSalesStats(salesData);
|
||||
} catch (err) {
|
||||
setError('Greska pri ucitavanju podataka');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const getCurrentDateTime = () => {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
return now.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const handleFilamentBulkSale = async () => {
|
||||
if (!confirm('Primeniti popust na SVE filamente?')) return;
|
||||
setSavingFilamentSale(true);
|
||||
try {
|
||||
await filamentService.updateBulkSale({
|
||||
salePercentage: filamentSalePercentage,
|
||||
saleEndDate: filamentSaleEndDate || undefined,
|
||||
enableSale: filamentSaleEnabled,
|
||||
});
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError('Greska pri azuriranju popusta za filamente');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSavingFilamentSale(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearFilamentSales = async () => {
|
||||
if (!confirm('Ukloniti SVE popuste sa filamenata?')) return;
|
||||
setSavingFilamentSale(true);
|
||||
try {
|
||||
await filamentService.updateBulkSale({
|
||||
salePercentage: 0,
|
||||
enableSale: false,
|
||||
});
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError('Greska pri brisanju popusta');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSavingFilamentSale(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProductBulkSale = async () => {
|
||||
if (!confirm('Primeniti popust na SVE proizvode?')) return;
|
||||
setSavingProductSale(true);
|
||||
try {
|
||||
await productService.updateBulkSale({
|
||||
salePercentage: productSalePercentage,
|
||||
saleEndDate: productSaleEndDate || undefined,
|
||||
enableSale: productSaleEnabled,
|
||||
});
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError('Greska pri azuriranju popusta za proizvode');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSavingProductSale(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearProductSales = async () => {
|
||||
if (!confirm('Ukloniti SVE popuste sa proizvoda?')) return;
|
||||
setSavingProductSale(true);
|
||||
try {
|
||||
await productService.updateBulkSale({
|
||||
salePercentage: 0,
|
||||
enableSale: false,
|
||||
});
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError('Greska pri brisanju popusta');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSavingProductSale(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeFilamentSales = filaments.filter(f => f.sale_active);
|
||||
const activeProductSales = products.filter(p => p.sale_active);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-white/40">Ucitavanje...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>Upravljanje popustima</h1>
|
||||
<p className="text-white/40 mt-1">
|
||||
{activeFilamentSales.length + activeProductSales.length} aktivnih popusta
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-900/20 text-red-400 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white/[0.04] p-5 rounded-2xl">
|
||||
<p className="text-sm text-white/40">Filamenti sa popustom</p>
|
||||
<p className="text-3xl font-bold text-purple-400 mt-1">{activeFilamentSales.length}</p>
|
||||
<p className="text-xs text-white/30 mt-1">od {filaments.length} ukupno</p>
|
||||
</div>
|
||||
<div className="bg-white/[0.04] p-5 rounded-2xl">
|
||||
<p className="text-sm text-white/40">Proizvodi sa popustom</p>
|
||||
<p className="text-3xl font-bold text-orange-400 mt-1">{activeProductSales.length}</p>
|
||||
<p className="text-xs text-white/30 mt-1">od {products.length} ukupno</p>
|
||||
</div>
|
||||
<div className="bg-white/[0.04] p-5 rounded-2xl">
|
||||
<p className="text-sm text-white/40">Ukupno aktivnih</p>
|
||||
<p className="text-3xl font-bold text-blue-400 mt-1">
|
||||
{activeFilamentSales.length + activeProductSales.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filament Sale Controls */}
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Popusti na filamente</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/60 mb-1">Procenat popusta (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={filamentSalePercentage}
|
||||
onChange={(e) => setFilamentSalePercentage(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/60 mb-1">Kraj popusta (opciono)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filamentSaleEndDate}
|
||||
onChange={(e) => setFilamentSaleEndDate(e.target.value)}
|
||||
min={getCurrentDateTime()}
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filamentSaleEnabled}
|
||||
onChange={(e) => setFilamentSaleEnabled(e.target.checked)}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm text-white/60">
|
||||
Aktivan: <span className={filamentSaleEnabled ? 'text-green-400' : 'text-white/30'}>{filamentSaleEnabled ? 'Da' : 'Ne'}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleFilamentBulkSale}
|
||||
disabled={savingFilamentSale}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-xl hover:bg-purple-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{savingFilamentSale ? 'Cuvanje...' : 'Primeni na sve filamente'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearFilamentSales}
|
||||
disabled={savingFilamentSale}
|
||||
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.1] disabled:opacity-50 text-sm"
|
||||
>
|
||||
Ukloni sve popuste
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Sale Controls */}
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Popusti na proizvode</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/60 mb-1">Procenat popusta (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={productSalePercentage}
|
||||
onChange={(e) => setProductSalePercentage(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/60 mb-1">Kraj popusta (opciono)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={productSaleEndDate}
|
||||
onChange={(e) => setProductSaleEndDate(e.target.value)}
|
||||
min={getCurrentDateTime()}
|
||||
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={productSaleEnabled}
|
||||
onChange={(e) => setProductSaleEnabled(e.target.checked)}
|
||||
className="w-4 h-4 text-orange-600"
|
||||
/>
|
||||
<span className="text-sm text-white/60">
|
||||
Aktivan: <span className={productSaleEnabled ? 'text-green-400' : 'text-white/30'}>{productSaleEnabled ? 'Da' : 'Ne'}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleProductBulkSale}
|
||||
disabled={savingProductSale}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-xl hover:bg-orange-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{savingProductSale ? 'Cuvanje...' : 'Primeni na sve proizvode'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearProductSales}
|
||||
disabled={savingProductSale}
|
||||
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.1] disabled:opacity-50 text-sm"
|
||||
>
|
||||
Ukloni sve popuste
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Sales List */}
|
||||
{(activeFilamentSales.length > 0 || activeProductSales.length > 0) && (
|
||||
<div className="bg-white/[0.04] rounded-2xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Aktivni popusti</h2>
|
||||
|
||||
{activeFilamentSales.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-purple-400 mb-2">Filamenti ({activeFilamentSales.length})</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-white/60">Filament</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Popust</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Istice</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.06]">
|
||||
{activeFilamentSales.map(f => (
|
||||
<tr key={f.id} className="hover:bg-white/[0.06]">
|
||||
<td className="px-3 py-2 text-white/90">{f.tip} {f.finish} - {f.boja}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="text-purple-300 font-medium">-{f.sale_percentage}%</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white/40">
|
||||
{f.sale_end_date
|
||||
? new Date(f.sale_end_date).toLocaleDateString('sr-RS')
|
||||
: 'Neograniceno'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeProductSales.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-orange-400 mb-2">Proizvodi ({activeProductSales.length})</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-white/60">Proizvod</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Popust</th>
|
||||
<th className="px-3 py-2 text-left text-white/60">Istice</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.06]">
|
||||
{activeProductSales.map(p => (
|
||||
<tr key={p.id} className="hover:bg-white/[0.06]">
|
||||
<td className="px-3 py-2 text-white/90">{p.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="text-orange-300 font-medium">-{p.sale_percentage}%</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white/40">
|
||||
{p.sale_end_date
|
||||
? new Date(p.sale_end_date).toLocaleDateString('sr-RS')
|
||||
: 'Neograniceno'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
app/upadaj/dashboard/zahtevi/page.tsx
Normal file
334
app/upadaj/dashboard/zahtevi/page.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { colorRequestService } from '@/src/services/api';
|
||||
|
||||
interface ColorRequest {
|
||||
id: string;
|
||||
color_name: string;
|
||||
material_type: string;
|
||||
finish_type: string;
|
||||
user_email: string;
|
||||
user_phone: string;
|
||||
user_name: string;
|
||||
description: string;
|
||||
reference_url: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'completed';
|
||||
admin_notes: string;
|
||||
request_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
processed_at: string;
|
||||
processed_by: string;
|
||||
}
|
||||
|
||||
export default function ZahteviPage() {
|
||||
const [requests, setRequests] = useState<ColorRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState({ status: '', admin_notes: '' });
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, []);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
try {
|
||||
const data = await colorRequestService.getAll();
|
||||
setRequests(data);
|
||||
} catch (error) {
|
||||
setError('Failed to fetch color requests');
|
||||
console.error('Error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusUpdate = async (id: string) => {
|
||||
try {
|
||||
await colorRequestService.updateStatus(id, editForm.status, editForm.admin_notes);
|
||||
await fetchRequests();
|
||||
setEditingId(null);
|
||||
setEditForm({ status: '', admin_notes: '' });
|
||||
} catch (error) {
|
||||
setError('Failed to update request');
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this request?')) return;
|
||||
|
||||
try {
|
||||
await colorRequestService.delete(id);
|
||||
await fetchRequests();
|
||||
} catch (error) {
|
||||
setError('Failed to delete request');
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const colors = {
|
||||
pending: 'bg-yellow-900/30 text-yellow-300',
|
||||
approved: 'bg-green-900/30 text-green-300',
|
||||
rejected: 'bg-red-900/30 text-red-300',
|
||||
completed: 'bg-blue-900/30 text-blue-300'
|
||||
};
|
||||
return colors[status as keyof typeof colors] || 'bg-white/[0.06] text-white/60';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels = {
|
||||
pending: 'Na cekanju',
|
||||
approved: 'Odobreno',
|
||||
rejected: 'Odbijeno',
|
||||
completed: 'Zavrseno'
|
||||
};
|
||||
return labels[status as keyof typeof labels] || status;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
const month = date.toLocaleDateString('sr-RS', { month: 'short' });
|
||||
const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
|
||||
return `${capitalizedMonth} ${date.getDate()}, ${date.getFullYear()}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-white/40">Ucitavanje zahteva za boje...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Zahtevi za Boje</h1>
|
||||
<p className="text-white/40 mt-1">{requests.length} zahteva ukupno</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-900/20 text-red-400 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white/[0.04] rounded-2xl shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
|
||||
Boja
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
|
||||
Materijal/Finis
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
|
||||
Broj Zahteva
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
|
||||
Korisnik
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
|
||||
Datum
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
|
||||
Akcije
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
|
||||
{requests.map((request) => (
|
||||
<tr key={request.id} className="hover:bg-white/[0.06]">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-white/90">{request.color_name}</div>
|
||||
{request.description && (
|
||||
<div className="text-sm text-white/40 mt-1">{request.description}</div>
|
||||
)}
|
||||
{request.reference_url && (
|
||||
<a
|
||||
href={request.reference_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline"
|
||||
>
|
||||
Pogledaj referencu
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm">
|
||||
<div className="text-white/90">{request.material_type}</div>
|
||||
{request.finish_type && (
|
||||
<div className="text-white/40">{request.finish_type}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-900/30 text-purple-300">
|
||||
{request.request_count || 1} {(request.request_count || 1) === 1 ? 'zahtev' : 'zahteva'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm">
|
||||
{request.user_email ? (
|
||||
<a href={`mailto:${request.user_email}`} className="text-blue-400 hover:underline">
|
||||
{request.user_email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-white/30">Anonimno</span>
|
||||
)}
|
||||
{request.user_phone && (
|
||||
<div className="mt-1">
|
||||
<a href={`tel:${request.user_phone}`} className="text-blue-400 hover:underline">
|
||||
{request.user_phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{editingId === request.id ? (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={editForm.status}
|
||||
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
|
||||
className="text-sm border rounded px-2 py-1 bg-white/[0.06] border-white/[0.08] text-white/90"
|
||||
>
|
||||
<option value="">Izaberi status</option>
|
||||
<option value="pending">Na cekanju</option>
|
||||
<option value="approved">Odobreno</option>
|
||||
<option value="rejected">Odbijeno</option>
|
||||
<option value="completed">Zavrseno</option>
|
||||
</select>
|
||||
<textarea
|
||||
placeholder="Napomene..."
|
||||
value={editForm.admin_notes}
|
||||
onChange={(e) => setEditForm({ ...editForm, admin_notes: e.target.value })}
|
||||
className="text-sm border rounded px-2 py-1 w-full bg-white/[0.06] border-white/[0.08] text-white/90"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadge(request.status)}`}>
|
||||
{getStatusLabel(request.status)}
|
||||
</span>
|
||||
{request.admin_notes && (
|
||||
<div className="text-xs text-white/30 mt-1">{request.admin_notes}</div>
|
||||
)}
|
||||
{request.processed_by && (
|
||||
<div className="text-xs text-white/30 mt-1">
|
||||
od {request.processed_by}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm text-white/40">
|
||||
{formatDate(request.created_at)}
|
||||
</div>
|
||||
{request.processed_at && (
|
||||
<div className="text-xs text-white/30">
|
||||
Obradjeno: {formatDate(request.processed_at)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{editingId === request.id ? (
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(request.id)}
|
||||
className="text-green-400 hover:text-green-300 text-sm"
|
||||
>
|
||||
Sacuvaj
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setEditForm({ status: '', admin_notes: '' });
|
||||
}}
|
||||
className="text-white/40 hover:text-white/60 text-sm"
|
||||
>
|
||||
Otkazi
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(request.id);
|
||||
setEditForm({
|
||||
status: request.status,
|
||||
admin_notes: request.admin_notes || ''
|
||||
});
|
||||
}}
|
||||
className="text-blue-400 hover:text-blue-300 text-sm"
|
||||
>
|
||||
Izmeni
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(request.id)}
|
||||
className="text-red-400 hover:text-red-300 text-sm"
|
||||
>
|
||||
Obrisi
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{requests.length === 0 && (
|
||||
<div className="text-center py-8 text-white/40">
|
||||
Nema zahteva za boje
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
|
||||
<div className="text-sm text-white/40">Ukupno Zahteva</div>
|
||||
<div className="text-2xl font-bold text-white/90">
|
||||
{requests.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
|
||||
<div className="text-sm text-white/40">Na Cekanju</div>
|
||||
<div className="text-2xl font-bold text-yellow-400">
|
||||
{requests.filter(r => r.status === 'pending').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
|
||||
<div className="text-sm text-white/40">Odobreno</div>
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{requests.filter(r => r.status === 'approved').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
|
||||
<div className="text-sm text-white/40">Zavrseno</div>
|
||||
<div className="text-2xl font-bold text-blue-400">
|
||||
{requests.filter(r => r.status === 'completed').length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import axios from 'axios';
|
||||
import { authService } from '@/src/services/api';
|
||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||
|
||||
export default function AdminLogin() {
|
||||
const router = useRouter();
|
||||
@@ -11,49 +12,63 @@ export default function AdminLogin() {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Set dark mode by default
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
|
||||
username,
|
||||
password
|
||||
});
|
||||
const response = await authService.login(username, password);
|
||||
|
||||
// Store token in localStorage
|
||||
localStorage.setItem('authToken', response.data.token);
|
||||
localStorage.setItem('tokenExpiry', String(Date.now() + response.data.expiresIn * 1000));
|
||||
localStorage.setItem('authToken', response.token);
|
||||
localStorage.setItem('tokenExpiry', String(Date.now() + 24 * 60 * 60 * 1000)); // 24 hours
|
||||
|
||||
// Redirect to admin dashboard
|
||||
router.push('/admin/dashboard');
|
||||
} catch (err) {
|
||||
// Track successful login
|
||||
trackEvent('Admin', 'Login', 'Success');
|
||||
|
||||
// Redirect to admin dashboard using window.location for static export
|
||||
window.location.href = '/upadaj/dashboard';
|
||||
} catch (err: any) {
|
||||
setError('Neispravno korisničko ime ili lozinka');
|
||||
console.error('Login error:', err);
|
||||
trackEvent('Admin', 'Login', 'Failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#060a14] py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
<div className="flex flex-col items-center">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Filamenteka"
|
||||
className="h-40 w-auto mb-6 drop-shadow-lg"
|
||||
/>
|
||||
<h2
|
||||
className="text-center text-3xl font-black text-white tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
Admin Prijava
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="mt-2 text-center text-sm text-white/40">
|
||||
Prijavite se za upravljanje filamentima
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
|
||||
<div className="rounded-xl bg-red-500/10 border border-red-500/20 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Korisničko ime
|
||||
@@ -64,7 +79,7 @@ export default function AdminLogin() {
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
|
||||
className="appearance-none relative block w-full px-4 py-3 border border-white/[0.08] placeholder-white/30 text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 sm:text-sm bg-white/[0.04]"
|
||||
placeholder="Korisničko ime"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -80,7 +95,7 @@ export default function AdminLogin() {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
|
||||
className="appearance-none relative block w-full px-4 py-3 border border-white/[0.08] placeholder-white/30 text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 sm:text-sm bg-white/[0.04]"
|
||||
placeholder="Lozinka"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -92,7 +107,8 @@ export default function AdminLogin() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style={{ boxShadow: '0 4px 14px rgba(59, 130, 246, 0.3)' }}
|
||||
>
|
||||
{loading ? 'Prijavljivanje...' : 'Prijavite se'}
|
||||
</button>
|
||||
360
app/upadaj/requests/page.tsx
Normal file
360
app/upadaj/requests/page.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { colorRequestService } from '@/src/services/api';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ColorRequest {
|
||||
id: string;
|
||||
color_name: string;
|
||||
material_type: string;
|
||||
finish_type: string;
|
||||
user_email: string;
|
||||
user_phone: string;
|
||||
user_name: string;
|
||||
description: string;
|
||||
reference_url: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'completed';
|
||||
admin_notes: string;
|
||||
request_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
processed_at: string;
|
||||
processed_by: string;
|
||||
}
|
||||
|
||||
export default function ColorRequestsAdmin() {
|
||||
const [requests, setRequests] = useState<ColorRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState({ status: '', admin_notes: '' });
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
fetchRequests();
|
||||
}, []);
|
||||
|
||||
const checkAuth = () => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
|
||||
if (!token || !expiry || new Date().getTime() > parseInt(expiry)) {
|
||||
window.location.href = '/upadaj';
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRequests = async () => {
|
||||
try {
|
||||
const data = await colorRequestService.getAll();
|
||||
setRequests(data);
|
||||
} catch (error) {
|
||||
setError('Failed to fetch color requests');
|
||||
console.error('Error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusUpdate = async (id: string) => {
|
||||
try {
|
||||
await colorRequestService.updateStatus(id, editForm.status, editForm.admin_notes);
|
||||
await fetchRequests();
|
||||
setEditingId(null);
|
||||
setEditForm({ status: '', admin_notes: '' });
|
||||
} catch (error) {
|
||||
setError('Failed to update request');
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this request?')) return;
|
||||
|
||||
try {
|
||||
await colorRequestService.delete(id);
|
||||
await fetchRequests();
|
||||
} catch (error) {
|
||||
setError('Failed to delete request');
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const colors = {
|
||||
pending: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
|
||||
approved: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
|
||||
rejected: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
|
||||
completed: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
|
||||
};
|
||||
return colors[status as keyof typeof colors] || 'bg-gray-100 dark:bg-white/[0.06] text-gray-800 dark:text-white/60';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels = {
|
||||
pending: 'Na čekanju',
|
||||
approved: 'Odobreno',
|
||||
rejected: 'Odbijeno',
|
||||
completed: 'Završeno'
|
||||
};
|
||||
return labels[status as keyof typeof labels] || status;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
const month = date.toLocaleDateString('sr-RS', { month: 'short' });
|
||||
const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
|
||||
return `${capitalizedMonth} ${date.getDate()}, ${date.getFullYear()}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-[#060a14] flex items-center justify-center">
|
||||
<div className="text-gray-500 dark:text-white/40">Učitavanje zahteva za boje...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-[#060a14]">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
|
||||
<div 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>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-white/[0.04] rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-100 dark:bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
Boja
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
Materijal/Finiš
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
Broj Zahteva
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
Korisnik
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
Datum
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
Akcije
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-white/[0.04] divide-y divide-gray-200 dark:divide-white/[0.06]">
|
||||
{requests.map((request) => (
|
||||
<tr key={request.id} className="hover:bg-gray-50 dark:hover:bg-white/[0.06]">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{request.color_name}</div>
|
||||
{request.description && (
|
||||
<div className="text-sm text-gray-500 dark:text-white/40 mt-1">{request.description}</div>
|
||||
)}
|
||||
{request.reference_url && (
|
||||
<a
|
||||
href={request.reference_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Pogledaj referencu
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-900 dark:text-gray-100">{request.material_type}</div>
|
||||
{request.finish_type && (
|
||||
<div className="text-gray-500 dark:text-white/40">{request.finish_type}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
|
||||
{request.request_count || 1} {(request.request_count || 1) === 1 ? 'zahtev' : 'zahteva'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm">
|
||||
{request.user_email ? (
|
||||
<a href={`mailto:${request.user_email}`} className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{request.user_email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">Anonimno</span>
|
||||
)}
|
||||
{request.user_phone && (
|
||||
<div className="mt-1">
|
||||
<a href={`tel:${request.user_phone}`} className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{request.user_phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{editingId === request.id ? (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={editForm.status}
|
||||
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="">Izaberi status</option>
|
||||
<option value="pending">Na čekanju</option>
|
||||
<option value="approved">Odobreno</option>
|
||||
<option value="rejected">Odbijeno</option>
|
||||
<option value="completed">Završeno</option>
|
||||
</select>
|
||||
<textarea
|
||||
placeholder="Napomene..."
|
||||
value={editForm.admin_notes}
|
||||
onChange={(e) => setEditForm({ ...editForm, admin_notes: e.target.value })}
|
||||
className="text-sm border rounded px-2 py-1 w-full"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadge(request.status)}`}>
|
||||
{getStatusLabel(request.status)}
|
||||
</span>
|
||||
{request.admin_notes && (
|
||||
<div className="text-xs text-gray-500 mt-1">{request.admin_notes}</div>
|
||||
)}
|
||||
{request.processed_by && (
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
od {request.processed_by}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm text-gray-600 dark:text-white/40">
|
||||
{formatDate(request.created_at)}
|
||||
</div>
|
||||
{request.processed_at && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500">
|
||||
Obrađeno: {formatDate(request.processed_at)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{editingId === request.id ? (
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(request.id)}
|
||||
className="text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 text-sm"
|
||||
>
|
||||
Sačuvaj
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setEditForm({ status: '', admin_notes: '' });
|
||||
}}
|
||||
className="text-gray-600 dark:text-white/40 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(request.id);
|
||||
setEditForm({
|
||||
status: request.status,
|
||||
admin_notes: request.admin_notes || ''
|
||||
});
|
||||
}}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm"
|
||||
>
|
||||
Izmeni
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(request.id)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 text-sm"
|
||||
>
|
||||
Obriši
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{requests.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-white/40">
|
||||
Nema zahteva za boje
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-white/40">Ukupno Zahteva</div>
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
|
||||
{requests.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-white/40">Na Čekanju</div>
|
||||
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{requests.filter(r => r.status === 'pending').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-white/40">Odobreno</div>
|
||||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{requests.filter(r => r.status === 'approved').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-white/40">Završeno</div>
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{requests.filter(r => r.status === 'completed').length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
app/uslovi-koriscenja/page.tsx
Normal file
208
app/uslovi-koriscenja/page.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { SiteHeader } from '@/src/components/layout/SiteHeader';
|
||||
import { SiteFooter } from '@/src/components/layout/SiteFooter';
|
||||
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Uslovi Koriscenja',
|
||||
description: 'Uslovi koriscenja sajta Filamenteka — informacije o privatnoj prodaji, proizvodima, cenama i pravima kupaca.',
|
||||
alternates: {
|
||||
canonical: 'https://filamenteka.rs/uslovi-koriscenja',
|
||||
},
|
||||
};
|
||||
|
||||
export default function UsloviKoriscenjaPage() {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader />
|
||||
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Pocetna', href: '/' },
|
||||
{ label: 'Uslovi Koriscenja' },
|
||||
]} />
|
||||
|
||||
<article className="mt-6">
|
||||
<h1
|
||||
className="text-3xl sm:text-4xl font-black tracking-tight"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Uslovi Koriscenja
|
||||
</h1>
|
||||
<p className="text-sm mt-2 mb-10" style={{ color: 'var(--text-muted)' }}>
|
||||
Poslednje azuriranje: Februar 2026
|
||||
</p>
|
||||
|
||||
<div className="space-y-8 leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
1. O sajtu
|
||||
</h2>
|
||||
<p>
|
||||
Filamenteka (filamenteka.rs) je informativni katalog koji prikazuje ponudu
|
||||
Bambu Lab opreme dostupne za privatnu prodaju. Sajt sluzi iskljucivo kao
|
||||
prezentacija proizvoda i nije e-commerce prodavnica — kupovina se ne vrsi
|
||||
direktno preko ovog sajta.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
2. Privatna prodaja fizickog lica
|
||||
</h2>
|
||||
<p>
|
||||
Filamenteka je privatna prodaja fizickog lica i nije registrovana prodavnica,
|
||||
preduzetnik niti pravno lice. Ovo je kljucna pravna distinkcija koja znaci da
|
||||
se na transakcije primenjuju pravila koja vaze za privatnu prodaju izmedju
|
||||
fizickih lica, a ne pravila koja se odnose na potrosacku zastitu u kontekstu
|
||||
registrovanih trgovaca.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
3. Proizvodi i stanje
|
||||
</h2>
|
||||
<p className="mb-3">
|
||||
Proizvodi navedeni na sajtu su originalni Bambu Lab proizvodi. Stanje proizvoda
|
||||
je oznaceno na sledecu nacin:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-2">
|
||||
<li>
|
||||
<strong style={{ color: 'var(--text-primary)' }}>Novi proizvodi</strong> —
|
||||
neotvoreno, originalno fabricko pakovanje. Proizvod nije koriscen niti otvaran.
|
||||
</li>
|
||||
<li>
|
||||
<strong style={{ color: 'var(--text-primary)' }}>Korisceni proizvodi</strong> —
|
||||
stanje je opisano za svaki proizvod pojedinacno u opisu artikla.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
4. Cene
|
||||
</h2>
|
||||
<p>
|
||||
Sve cene prikazane na sajtu su informativnog karaktera i izrazene su u srpskim
|
||||
dinarima (RSD). Cene se mogu promeniti bez prethodne najave. Aktuelna cena u
|
||||
trenutku kupovine je ona koja je navedena u oglasu na KupujemProdajem platformi.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
5. Garancija
|
||||
</h2>
|
||||
<p>
|
||||
S obzirom da se radi o privatnoj prodaji fizickog lica, na proizvode se ne daje
|
||||
komercijalna garancija osim onoga sto je zakonski propisano za privatnu prodaju.
|
||||
Svi proizvodi oznaceni kao novi su u neotvorenom fabrickom pakovanju, sto
|
||||
potvrdjuje njihov originalni kvalitet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
6. Nacin kupovine
|
||||
</h2>
|
||||
<p>
|
||||
Sve transakcije se obavljaju iskljucivo putem platforme{' '}
|
||||
<a
|
||||
href="https://www.kupujemprodajem.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline underline-offset-4"
|
||||
>
|
||||
KupujemProdajem
|
||||
</a>
|
||||
. Na sve transakcije obavljene putem te platforme primenjuju se uslovi
|
||||
koriscenja KupujemProdajem platforme, ukljucujuci njihov sistem zastite kupaca.
|
||||
Filamenteka ne odgovara za uslove i pravila KupujemProdajem platforme.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
7. Intelektualna svojina
|
||||
</h2>
|
||||
<p>
|
||||
Bambu Lab je zastiteni znak kompanije Bambu Lab. Filamenteka nije u vlasnistvu,
|
||||
niti je ovlascena od strane, niti je na bilo koji nacin povezana sa kompanijom
|
||||
Bambu Lab. Svi nazivi proizvoda, logotipi i zastitni znakovi su vlasnistvo
|
||||
njihovih respektivnih vlasnika.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
8. Ogranicenje odgovornosti
|
||||
</h2>
|
||||
<p>
|
||||
Informacije na ovom sajtu su predstavljene "takve kakve jesu". Ulazemo
|
||||
razumne napore da informacije budu tacne i azurne, ali ne garantujemo potpunu
|
||||
tacnost, kompletnost ili aktuelnost sadrzaja. Filamenteka ne snosi odgovornost
|
||||
za bilo kakvu stetu nastalu koriscenjem informacija sa ovog sajta.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
9. Izmene uslova koriscenja
|
||||
</h2>
|
||||
<p>
|
||||
Zadrzavamo pravo da azuriramo ove uslove koriscenja u bilo kom trenutku.
|
||||
Sve izmene ce biti objavljene na ovoj stranici sa azuriranim datumom poslednje
|
||||
izmene. Nastavak koriscenja sajta nakon objave izmena smatra se prihvatanjem
|
||||
novih uslova.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
className="text-xl font-bold mb-3"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
10. Kontakt
|
||||
</h2>
|
||||
<p>
|
||||
Za sva pitanja u vezi sa ovim uslovima koriscenja, mozete nas kontaktirati:
|
||||
</p>
|
||||
<ul className="list-none space-y-1 mt-2">
|
||||
<li>Sajt: filamenteka.rs</li>
|
||||
<li>Telefon: +381 63 103 1048</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
config/environments.js
Normal file
19
config/environments.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Environment configuration
|
||||
const environments = {
|
||||
development: {
|
||||
name: 'development',
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL || 'https://api.filamenteka.rs/api'
|
||||
},
|
||||
production: {
|
||||
name: 'production',
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL
|
||||
}
|
||||
};
|
||||
|
||||
const currentEnv = process.env.NODE_ENV || 'development';
|
||||
|
||||
module.exports = {
|
||||
env: environments[currentEnv] || environments.development,
|
||||
isDev: currentEnv === 'development',
|
||||
isProd: currentEnv === 'production'
|
||||
};
|
||||
71
database/migrations/002_add_new_finishes.sql
Normal file
71
database/migrations/002_add_new_finishes.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- Migration: Add documentation for new finish types
|
||||
-- Date: 2025-06-20
|
||||
-- Description: Document all available finish types for filaments
|
||||
|
||||
-- The finish column already supports VARCHAR(50) which is sufficient
|
||||
-- This migration serves as documentation for available finish values
|
||||
|
||||
-- Standard finishes:
|
||||
-- 'Basic' - Standard finish
|
||||
-- 'Matte' - Matte/non-glossy finish
|
||||
-- 'Silk' - Silk/shiny finish
|
||||
-- 'Silk+' - Enhanced silk finish
|
||||
-- 'Translucent' - Semi-transparent finish
|
||||
-- 'Silk Multi-Color' - Multi-color silk finish
|
||||
-- 'Basic Gradient' - Gradient color transition
|
||||
-- 'Sparkle' - Sparkle/glitter finish
|
||||
-- 'Metal' - Metallic finish
|
||||
-- 'Marble' - Marble-like texture
|
||||
-- 'Galaxy' - Galaxy/space-like finish
|
||||
-- 'Glow' - Glow-in-the-dark
|
||||
-- 'Wood' - Wood-filled composite
|
||||
|
||||
-- Technical materials:
|
||||
-- 'CF' - Carbon Fiber reinforced
|
||||
-- 'GF' - Glass Fiber reinforced
|
||||
-- 'GF Aero' - Glass Fiber Aerospace grade
|
||||
-- 'FR' - Flame Retardant
|
||||
-- 'HF' - High Flow
|
||||
|
||||
-- TPU/Flexible grades:
|
||||
-- '95A HF' - Shore 95A High Flow
|
||||
-- '90A' - Shore 90A hardness
|
||||
-- '85A' - Shore 85A hardness
|
||||
|
||||
-- Optional: Create a finish_types table for validation (not enforced by FK to allow flexibility)
|
||||
CREATE TABLE IF NOT EXISTS finish_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
category VARCHAR(50),
|
||||
description TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert all finish types for reference
|
||||
INSERT INTO finish_types (code, name, category, description) VALUES
|
||||
('Basic', 'Basic', 'Standard', 'Standard filament finish'),
|
||||
('Matte', 'Matte', 'Standard', 'Non-glossy matte finish'),
|
||||
('Silk', 'Silk', 'Standard', 'Shiny silk-like finish'),
|
||||
('Silk+', 'Silk Plus', 'Standard', 'Enhanced silk finish with extra shine'),
|
||||
('Translucent', 'Translucent', 'Standard', 'Semi-transparent finish'),
|
||||
('Silk Multi-Color', 'Silk Multi-Color', 'Special', 'Multi-color silk finish'),
|
||||
('Basic Gradient', 'Basic Gradient', 'Special', 'Gradient color transition'),
|
||||
('Sparkle', 'Sparkle', 'Special', 'Contains glitter particles'),
|
||||
('Metal', 'Metal', 'Special', 'Metallic appearance'),
|
||||
('Marble', 'Marble', 'Special', 'Marble-like texture and appearance'),
|
||||
('Galaxy', 'Galaxy', 'Special', 'Space/galaxy-like appearance'),
|
||||
('Glow', 'Glow', 'Special', 'Glow-in-the-dark properties'),
|
||||
('Wood', 'Wood', 'Composite', 'Wood-filled composite'),
|
||||
('CF', 'Carbon Fiber', 'Technical', 'Carbon fiber reinforced'),
|
||||
('GF', 'Glass Fiber', 'Technical', 'Glass fiber reinforced'),
|
||||
('GF Aero', 'Glass Fiber Aero', 'Technical', 'Aerospace grade glass fiber'),
|
||||
('FR', 'Flame Retardant', 'Technical', 'Flame retardant properties'),
|
||||
('HF', 'High Flow', 'Technical', 'High flow for detailed prints'),
|
||||
('95A HF', '95A High Flow', 'Flexible', 'Shore 95A hardness with high flow'),
|
||||
('90A', '90A', 'Flexible', 'Shore 90A hardness TPU'),
|
||||
('85A', '85A', 'Flexible', 'Shore 85A hardness TPU')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- Add index for finish column if not exists
|
||||
CREATE INDEX IF NOT EXISTS idx_filaments_finish ON filaments(finish);
|
||||
99
database/migrations/003_add_bambu_lab_colors.sql
Normal file
99
database/migrations/003_add_bambu_lab_colors.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
-- Migration: Add all Bambu Lab predefined colors
|
||||
-- Date: 2025-06-20
|
||||
-- Description: Import comprehensive Bambu Lab color palette
|
||||
|
||||
-- Insert all Bambu Lab colors
|
||||
INSERT INTO colors (name, hex) VALUES
|
||||
-- Basic Colors
|
||||
('Black', '#1A1A1A'),
|
||||
('White', '#FFFFFF'),
|
||||
('Red', '#E53935'),
|
||||
('Blue', '#1E88E5'),
|
||||
('Green', '#43A047'),
|
||||
('Yellow', '#FDD835'),
|
||||
('Orange', '#FB8C00'),
|
||||
('Purple', '#8E24AA'),
|
||||
('Pink', '#EC407A'),
|
||||
('Grey', '#757575'),
|
||||
('Brown', '#6D4C41'),
|
||||
('Light Blue', '#64B5F6'),
|
||||
('Light Green', '#81C784'),
|
||||
('Mint Green', '#4DB6AC'),
|
||||
('Lime Green', '#C0CA33'),
|
||||
('Sky Blue', '#81D4FA'),
|
||||
('Navy Blue', '#283593'),
|
||||
('Magenta', '#E91E63'),
|
||||
('Violet', '#7B1FA2'),
|
||||
('Beige', '#F5DEB3'),
|
||||
('Ivory', '#FFFFF0'),
|
||||
|
||||
-- Matte Colors
|
||||
('Matte Black', '#212121'),
|
||||
('Matte White', '#FAFAFA'),
|
||||
('Matte Red', '#C62828'),
|
||||
('Matte Blue', '#1565C0'),
|
||||
('Matte Green', '#2E7D32'),
|
||||
('Matte Yellow', '#F9A825'),
|
||||
('Matte Orange', '#EF6C00'),
|
||||
('Matte Purple', '#6A1B9A'),
|
||||
('Matte Pink', '#D81B60'),
|
||||
('Matte Grey', '#616161'),
|
||||
('Matte Brown', '#4E342E'),
|
||||
('Matte Mint', '#26A69A'),
|
||||
('Matte Lime', '#9E9D24'),
|
||||
('Matte Navy', '#1A237E'),
|
||||
('Matte Coral', '#FF5252'),
|
||||
|
||||
-- Silk Colors
|
||||
('Silk White', '#FEFEFE'),
|
||||
('Silk Black', '#0A0A0A'),
|
||||
('Silk Red', '#F44336'),
|
||||
('Silk Blue', '#2196F3'),
|
||||
('Silk Green', '#4CAF50'),
|
||||
('Silk Gold', '#FFD54F'),
|
||||
('Silk Silver', '#CFD8DC'),
|
||||
('Silk Purple', '#9C27B0'),
|
||||
('Silk Pink', '#F06292'),
|
||||
('Silk Orange', '#FF9800'),
|
||||
('Silk Bronze', '#A1887F'),
|
||||
('Silk Copper', '#BF6F3F'),
|
||||
('Silk Jade', '#00897B'),
|
||||
('Silk Rose Gold', '#E8A09A'),
|
||||
('Silk Pearl', '#F8F8FF'),
|
||||
('Silk Ruby', '#E91E63'),
|
||||
('Silk Sapphire', '#1976D2'),
|
||||
('Silk Emerald', '#00695C'),
|
||||
|
||||
-- Metal Colors
|
||||
('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')
|
||||
ON CONFLICT (name) DO UPDATE SET hex = EXCLUDED.hex;
|
||||
2
database/migrations/004_remove_brand_column.sql
Normal file
2
database/migrations/004_remove_brand_column.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Migration to remove brand column from filaments table
|
||||
ALTER TABLE filaments DROP COLUMN IF EXISTS brand CASCADE;
|
||||
2
database/migrations/005_remove_serbian_colors.sql
Normal file
2
database/migrations/005_remove_serbian_colors.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Migration to remove Serbian colors from boje table
|
||||
DELETE FROM boje WHERE name IN ('Bela', 'Crna', 'Crvena', 'Plava', 'Zelena', 'Žuta', 'Narandžasta', 'Ljubičasta', 'Siva', 'Braon');
|
||||
29
database/migrations/006_add_basic_refill_filaments.sql
Normal file
29
database/migrations/006_add_basic_refill_filaments.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Add 1 refill filament for each color as user owns all basic colors
|
||||
-- This creates PLA Basic filaments with 1 refill for each color in the database
|
||||
|
||||
-- First, let's insert filaments for all existing colors
|
||||
INSERT INTO filaments (tip, finish, boja, boja_hex, refill, spulna, kolicina, cena)
|
||||
SELECT
|
||||
'PLA' as tip,
|
||||
'Basic' as finish,
|
||||
c.name as boja,
|
||||
c.hex as boja_hex,
|
||||
1 as refill,
|
||||
0 as spulna,
|
||||
1 as kolicina,
|
||||
'3999' as cena
|
||||
FROM colors c
|
||||
WHERE NOT EXISTS (
|
||||
-- Only insert if this exact combination doesn't already exist
|
||||
SELECT 1 FROM filaments f
|
||||
WHERE f.tip = 'PLA'
|
||||
AND f.finish = 'Basic'
|
||||
AND f.boja = c.name
|
||||
);
|
||||
|
||||
-- Update any existing PLA Basic filaments to have at least 1 refill
|
||||
UPDATE filaments
|
||||
SET refill = '1'
|
||||
WHERE tip = 'PLA'
|
||||
AND finish = 'Basic'
|
||||
AND (refill IS NULL OR refill = '0' OR refill = '');
|
||||
2
database/migrations/007_remove_otvoreno_column.sql
Normal file
2
database/migrations/007_remove_otvoreno_column.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Remove otvoreno column from filaments table
|
||||
ALTER TABLE filaments DROP COLUMN IF EXISTS otvoreno;
|
||||
5
database/migrations/008_update_vakum_to_spulna.sql
Normal file
5
database/migrations/008_update_vakum_to_spulna.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Rename vakum column to spulna
|
||||
ALTER TABLE filaments RENAME COLUMN vakum TO spulna;
|
||||
|
||||
-- Update existing data to use 'spulna' instead of 'vakuum'
|
||||
UPDATE filaments SET spulna = REPLACE(spulna, 'vakuum', 'spulna') WHERE spulna LIKE '%vakuum%';
|
||||
48
database/migrations/009_update_refill_only_colors.sql
Normal file
48
database/migrations/009_update_refill_only_colors.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- Update spulna to '0' for colors that only come as refills
|
||||
UPDATE filaments
|
||||
SET spulna = '0'
|
||||
WHERE boja IN (
|
||||
'Beige',
|
||||
'Light Gray',
|
||||
'Yellow',
|
||||
'Orange',
|
||||
'Gold',
|
||||
'Bright Green',
|
||||
'Pink',
|
||||
'Magenta',
|
||||
'Maroon Red',
|
||||
'Purple',
|
||||
'Turquoise',
|
||||
'Cobalt Blue',
|
||||
'Brown',
|
||||
'Bronze',
|
||||
'Silver',
|
||||
'Blue Grey',
|
||||
'Dark Gray'
|
||||
);
|
||||
|
||||
-- Also update their quantity to be based only on refill count
|
||||
UPDATE filaments
|
||||
SET kolicina = CASE
|
||||
WHEN refill ~ '^\d+$' THEN CAST(refill AS INTEGER)
|
||||
ELSE 0
|
||||
END
|
||||
WHERE boja IN (
|
||||
'Beige',
|
||||
'Light Gray',
|
||||
'Yellow',
|
||||
'Orange',
|
||||
'Gold',
|
||||
'Bright Green',
|
||||
'Pink',
|
||||
'Magenta',
|
||||
'Maroon Red',
|
||||
'Purple',
|
||||
'Turquoise',
|
||||
'Cobalt Blue',
|
||||
'Brown',
|
||||
'Bronze',
|
||||
'Silver',
|
||||
'Blue Grey',
|
||||
'Dark Gray'
|
||||
);
|
||||
43
database/migrations/010_fix_quantity_calculations.sql
Normal file
43
database/migrations/010_fix_quantity_calculations.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- Fix quantity calculations to be the sum of refill + spulna counts
|
||||
UPDATE filaments
|
||||
SET kolicina =
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN refill ~ '^\d+$' THEN CAST(refill AS INTEGER)
|
||||
ELSE 0
|
||||
END, 0
|
||||
) +
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN spulna ~ '^(\d+)\s*spuln' THEN
|
||||
CAST(SUBSTRING(spulna FROM '^(\d+)\s*spuln') AS INTEGER)
|
||||
ELSE 0
|
||||
END, 0
|
||||
);
|
||||
|
||||
-- Specifically fix refill-only colors to ensure quantity matches refill count
|
||||
UPDATE filaments
|
||||
SET kolicina =
|
||||
CASE
|
||||
WHEN refill ~ '^\d+$' THEN CAST(refill AS INTEGER)
|
||||
ELSE 0
|
||||
END
|
||||
WHERE boja IN (
|
||||
'Beige',
|
||||
'Light Gray',
|
||||
'Yellow',
|
||||
'Orange',
|
||||
'Gold',
|
||||
'Bright Green',
|
||||
'Pink',
|
||||
'Magenta',
|
||||
'Maroon Red',
|
||||
'Purple',
|
||||
'Turquoise',
|
||||
'Cobalt Blue',
|
||||
'Brown',
|
||||
'Bronze',
|
||||
'Silver',
|
||||
'Blue Grey',
|
||||
'Dark Gray'
|
||||
);
|
||||
36
database/migrations/011_standardize_data_types.sql
Normal file
36
database/migrations/011_standardize_data_types.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- Standardize data types for refill and spulna columns
|
||||
-- First, convert existing string values to integers
|
||||
|
||||
-- Create temporary columns
|
||||
ALTER TABLE filaments ADD COLUMN refill_new INTEGER DEFAULT 0;
|
||||
ALTER TABLE filaments ADD COLUMN spulna_new INTEGER DEFAULT 0;
|
||||
|
||||
-- Convert refill values
|
||||
UPDATE filaments
|
||||
SET refill_new = CASE
|
||||
WHEN refill ~ '^\d+$' THEN CAST(refill AS INTEGER)
|
||||
WHEN LOWER(refill) = 'da' THEN 1
|
||||
ELSE 0
|
||||
END;
|
||||
|
||||
-- Convert spulna values (extract number from "X spulna" format)
|
||||
UPDATE filaments
|
||||
SET spulna_new = CASE
|
||||
WHEN spulna ~ '^(\d+)\s*spuln' THEN
|
||||
CAST(SUBSTRING(spulna FROM '^(\d+)\s*spuln') AS INTEGER)
|
||||
WHEN spulna ~ '^\d+$' THEN CAST(spulna AS INTEGER)
|
||||
ELSE 0
|
||||
END;
|
||||
|
||||
-- Drop old columns and rename new ones
|
||||
ALTER TABLE filaments DROP COLUMN refill;
|
||||
ALTER TABLE filaments DROP COLUMN spulna;
|
||||
ALTER TABLE filaments RENAME COLUMN refill_new TO refill;
|
||||
ALTER TABLE filaments RENAME COLUMN spulna_new TO spulna;
|
||||
|
||||
-- Update kolicina to ensure it matches refill + spulna
|
||||
UPDATE filaments SET kolicina = refill + spulna;
|
||||
|
||||
-- Add check constraint to ensure kolicina is always the sum of refill and spulna
|
||||
ALTER TABLE filaments ADD CONSTRAINT check_kolicina
|
||||
CHECK (kolicina = refill + spulna);
|
||||
7
database/migrations/012_add_color_prices.sql
Normal file
7
database/migrations/012_add_color_prices.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Add price fields to colors table
|
||||
ALTER TABLE colors
|
||||
ADD COLUMN cena_refill INTEGER DEFAULT 3499,
|
||||
ADD COLUMN cena_spulna INTEGER DEFAULT 3999;
|
||||
|
||||
-- Update existing colors with default prices
|
||||
UPDATE colors SET cena_refill = 3499, cena_spulna = 3999;
|
||||
96
database/migrations/013_add_pla_basic_colors.sql
Normal file
96
database/migrations/013_add_pla_basic_colors.sql
Normal file
@@ -0,0 +1,96 @@
|
||||
-- Migration: Add specific PLA Basic colors
|
||||
-- This migration adds the specific set of colors for PLA Basic filaments
|
||||
|
||||
-- First, let's add any missing colors to the colors table
|
||||
INSERT INTO colors (name, hex) VALUES
|
||||
('Jade White', '#FFFFFF'),
|
||||
('Beige', '#F7E6DE'),
|
||||
('Light Gray', '#D0D2D4'),
|
||||
('Yellow', '#F4EE2A'),
|
||||
('Sunflower Yellow', '#FEC601'),
|
||||
('Pumpkin Orange', '#FF8E16'),
|
||||
('Orange', '#FF6A13'),
|
||||
('Gold', '#E4BD68'),
|
||||
('Bright Green', '#BDCF00'),
|
||||
('Bambu Green', '#16C344'),
|
||||
('Mistletoe Green', '#3F8E43'),
|
||||
('Pink', '#F55A74'),
|
||||
('Hot Pink', '#F5547D'),
|
||||
('Magenta', '#EC008C'),
|
||||
('Red', '#C12E1F'),
|
||||
('Maroon Red', '#832140'),
|
||||
('Purple', '#5E43B7'),
|
||||
('Indigo Purple', '#482A60'),
|
||||
('Turquoise', '#00B1B7'),
|
||||
('Cyan', '#0086D6'),
|
||||
('Cobalt Blue', '#0055B8'),
|
||||
('Blue', '#0A2989'),
|
||||
('Brown', '#9D432C'),
|
||||
('Cocoa Brown', '#6F5034'),
|
||||
('Bronze', '#847D48'),
|
||||
('Gray', '#8E9089'),
|
||||
('Silver', '#A6A9AA'),
|
||||
('Blue Grey', '#5B6579'),
|
||||
('Dark Gray', '#555555'),
|
||||
('Black', '#000000'),
|
||||
('Latte Brown', '#D3B7A7')
|
||||
ON CONFLICT (name)
|
||||
DO UPDATE SET hex = EXCLUDED.hex;
|
||||
|
||||
-- Now add PLA Basic filaments for each of these colors
|
||||
-- We'll add them with 1 refill and 1 spool each
|
||||
INSERT INTO filaments (tip, finish, boja, boja_hex, refill, spulna, kolicina, cena)
|
||||
SELECT
|
||||
'PLA' as tip,
|
||||
'Basic' as finish,
|
||||
c.name as boja,
|
||||
c.hex as boja_hex,
|
||||
1 as refill,
|
||||
1 as spulna,
|
||||
2 as kolicina,
|
||||
'3499/3999' as cena
|
||||
FROM colors c
|
||||
WHERE c.name IN (
|
||||
'Jade White', 'Beige', 'Light Gray', 'Yellow', 'Sunflower Yellow',
|
||||
'Pumpkin Orange', 'Orange', 'Gold', 'Bright Green', 'Bambu Green',
|
||||
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
|
||||
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
|
||||
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
|
||||
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black', 'Latte Brown'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM filaments f
|
||||
WHERE f.tip = 'PLA'
|
||||
AND f.finish = 'Basic'
|
||||
AND f.boja = c.name
|
||||
);
|
||||
|
||||
-- Update any existing PLA Basic filaments to ensure they have correct inventory
|
||||
UPDATE filaments
|
||||
SET refill = 1, spulna = 1, kolicina = 2, cena = '3499/3999'
|
||||
WHERE tip = 'PLA'
|
||||
AND finish = 'Basic'
|
||||
AND boja IN (
|
||||
'Jade White', 'Beige', 'Light Gray', 'Yellow', 'Sunflower Yellow',
|
||||
'Pumpkin Orange', 'Orange', 'Gold', 'Bright Green', 'Bambu Green',
|
||||
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
|
||||
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
|
||||
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
|
||||
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black', 'Latte Brown'
|
||||
);
|
||||
|
||||
-- Zero out ALL other filaments (not PLA Basic with these specific colors)
|
||||
UPDATE filaments
|
||||
SET refill = 0, spulna = 0, kolicina = 0
|
||||
WHERE NOT (
|
||||
tip = 'PLA'
|
||||
AND finish = 'Basic'
|
||||
AND boja IN (
|
||||
'Jade White', 'Beige', 'Light Gray', 'Yellow', 'Sunflower Yellow',
|
||||
'Pumpkin Orange', 'Orange', 'Gold', 'Bright Green', 'Bambu Green',
|
||||
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
|
||||
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
|
||||
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
|
||||
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black', 'Latte Brown'
|
||||
)
|
||||
);
|
||||
10
database/migrations/014_add_sale_fields.sql
Normal file
10
database/migrations/014_add_sale_fields.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Add sale fields to filaments table
|
||||
ALTER TABLE filaments
|
||||
ADD COLUMN IF NOT EXISTS sale_percentage INTEGER DEFAULT 0 CHECK (sale_percentage >= 0 AND sale_percentage <= 100),
|
||||
ADD COLUMN IF NOT EXISTS sale_active BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS sale_start_date TIMESTAMP WITH TIME ZONE,
|
||||
ADD COLUMN IF NOT EXISTS sale_end_date TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
-- Add indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_filaments_sale_active ON filaments(sale_active) WHERE sale_active = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_filaments_sale_dates ON filaments(sale_start_date, sale_end_date) WHERE sale_active = TRUE;
|
||||
83
database/migrations/015_fix_refill_only_colors.sql
Normal file
83
database/migrations/015_fix_refill_only_colors.sql
Normal file
@@ -0,0 +1,83 @@
|
||||
-- Migration: Fix refill-only colors to have 0 spools instead of 1
|
||||
-- These colors should only be available as refills, not as regular spools
|
||||
|
||||
-- First, add Nardo Gray to colors table if it doesn't exist
|
||||
INSERT INTO colors (name, hex) VALUES
|
||||
('Nardo Gray', '#747474')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Update all refill-only colors to have spulna = 0
|
||||
-- This ensures they can only be purchased as refills
|
||||
UPDATE filaments
|
||||
SET
|
||||
spulna = 0,
|
||||
kolicina = refill -- Total quantity should equal refill count only
|
||||
WHERE tip = 'PLA'
|
||||
AND finish = 'Basic'
|
||||
AND boja IN (
|
||||
-- Colors specified by user as refill-only
|
||||
'Nardo Gray',
|
||||
'Blue Grey', -- Note: Database uses "Blue Grey" not "Blue Gray"
|
||||
'Light Gray',
|
||||
'Brown',
|
||||
'Beige',
|
||||
'Bronze',
|
||||
'Purple',
|
||||
'Cobalt Blue',
|
||||
'Turquoise',
|
||||
'Bright Green',
|
||||
'Yellow',
|
||||
'Gold',
|
||||
'Orange',
|
||||
'Maroon Red'
|
||||
);
|
||||
|
||||
-- Also handle any existing entries with alternate spellings
|
||||
UPDATE filaments
|
||||
SET
|
||||
spulna = 0,
|
||||
kolicina = refill
|
||||
WHERE tip = 'PLA'
|
||||
AND finish = 'Basic'
|
||||
AND (
|
||||
boja = 'Blue Gray' -- Handle alternate spelling if it exists
|
||||
);
|
||||
|
||||
-- Ensure these colors maintain their refill-only status
|
||||
-- even if they don't exist yet (for future inserts)
|
||||
INSERT INTO filaments (tip, finish, boja, boja_hex, refill, spulna, kolicina, cena)
|
||||
SELECT
|
||||
'PLA' as tip,
|
||||
'Basic' as finish,
|
||||
c.name as boja,
|
||||
c.hex as boja_hex,
|
||||
0 as refill, -- Start with 0 refills
|
||||
0 as spulna, -- Always 0 spools for refill-only
|
||||
0 as kolicina, -- Total is 0
|
||||
'3499' as cena -- Refill price only
|
||||
FROM colors c
|
||||
WHERE c.name IN (
|
||||
'Nardo Gray',
|
||||
'Blue Grey',
|
||||
'Light Gray',
|
||||
'Brown',
|
||||
'Beige',
|
||||
'Bronze',
|
||||
'Purple',
|
||||
'Cobalt Blue',
|
||||
'Turquoise',
|
||||
'Bright Green',
|
||||
'Yellow',
|
||||
'Gold',
|
||||
'Orange',
|
||||
'Maroon Red'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM filaments f
|
||||
WHERE f.tip = 'PLA'
|
||||
AND f.finish = 'Basic'
|
||||
AND f.boja = c.name
|
||||
);
|
||||
|
||||
-- Add a comment to track which colors are refill-only
|
||||
COMMENT ON TABLE filaments IS 'Filament inventory. Note: The following PLA Basic colors are refill-only (spulna must always be 0): Nardo Gray, Blue Grey, Light Gray, Brown, Beige, Bronze, Purple, Cobalt Blue, Turquoise, Bright Green, Yellow, Gold, Orange, Maroon Red';
|
||||
35
database/migrations/016_add_color_requests.sql
Normal file
35
database/migrations/016_add_color_requests.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Migration: Add color requests feature
|
||||
-- Allows users to request new colors and admins to view/manage requests
|
||||
|
||||
-- Create color_requests table
|
||||
CREATE TABLE IF NOT EXISTS color_requests (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
color_name VARCHAR(100) NOT NULL,
|
||||
material_type VARCHAR(50) NOT NULL,
|
||||
finish_type VARCHAR(50),
|
||||
user_email VARCHAR(255),
|
||||
user_name VARCHAR(100),
|
||||
description TEXT,
|
||||
reference_url VARCHAR(500),
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'completed')),
|
||||
admin_notes TEXT,
|
||||
request_count INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at TIMESTAMP WITH TIME ZONE,
|
||||
processed_by VARCHAR(100)
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX idx_color_requests_status ON color_requests(status);
|
||||
CREATE INDEX idx_color_requests_created_at ON color_requests(created_at DESC);
|
||||
CREATE INDEX idx_color_requests_color_name ON color_requests(LOWER(color_name));
|
||||
|
||||
-- Apply updated_at trigger to color_requests table
|
||||
CREATE TRIGGER update_color_requests_updated_at BEFORE UPDATE
|
||||
ON color_requests FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Add comment to describe the table
|
||||
COMMENT ON TABLE color_requests IS 'User requests for new filament colors to be added to inventory';
|
||||
COMMENT ON COLUMN color_requests.status IS 'Request status: pending (new), approved (will be ordered), rejected (won''t be added), completed (added to inventory)';
|
||||
COMMENT ON COLUMN color_requests.request_count IS 'Number of users who have requested this same color';
|
||||
8
database/migrations/017_add_phone_to_color_requests.sql
Normal file
8
database/migrations/017_add_phone_to_color_requests.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Migration: Add phone field to color_requests table
|
||||
-- Allows users to provide phone number for contact
|
||||
|
||||
ALTER TABLE color_requests
|
||||
ADD COLUMN IF NOT EXISTS user_phone VARCHAR(50);
|
||||
|
||||
-- Add comment to describe the new column
|
||||
COMMENT ON COLUMN color_requests.user_phone IS 'User phone number for contact (optional)';
|
||||
22
database/migrations/018_make_contact_fields_required.sql
Normal file
22
database/migrations/018_make_contact_fields_required.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Migration: Make email and phone fields required in color_requests table
|
||||
-- These fields are now mandatory for all color requests
|
||||
|
||||
-- First, update any existing NULL values to prevent constraint violation
|
||||
UPDATE color_requests
|
||||
SET user_email = 'unknown@example.com'
|
||||
WHERE user_email IS NULL;
|
||||
|
||||
UPDATE color_requests
|
||||
SET user_phone = 'unknown'
|
||||
WHERE user_phone IS NULL;
|
||||
|
||||
-- Now add NOT NULL constraints
|
||||
ALTER TABLE color_requests
|
||||
ALTER COLUMN user_email SET NOT NULL;
|
||||
|
||||
ALTER TABLE color_requests
|
||||
ALTER COLUMN user_phone SET NOT NULL;
|
||||
|
||||
-- Update comments to reflect the requirement
|
||||
COMMENT ON COLUMN color_requests.user_email IS 'User email address for contact (required)';
|
||||
COMMENT ON COLUMN color_requests.user_phone IS 'User phone number for contact (required)';
|
||||
77
database/migrations/019_add_new_bambu_colors_2025.sql
Normal file
77
database/migrations/019_add_new_bambu_colors_2025.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- Migration: Add new Bambu Lab PLA Matte and PLA Wood colors (2025)
|
||||
-- This migration adds the new color offerings from Bambu Lab
|
||||
|
||||
-- Add new PLA Matte colors to the colors table
|
||||
INSERT INTO colors (name, hex) VALUES
|
||||
('Matte Apple Green', '#C6E188'),
|
||||
('Matte Bone White', '#C8C5B6'),
|
||||
('Matte Caramel', '#A4845C'),
|
||||
('Matte Dark Blue', '#042F56'),
|
||||
('Matte Dark Brown', '#7D6556'),
|
||||
('Matte Dark Chocolate', '#4A3729'),
|
||||
('Matte Dark Green', '#68724D'),
|
||||
('Matte Dark Red', '#BB3D43'),
|
||||
('Matte Grass Green', '#7CB342'),
|
||||
('Matte Ice Blue', '#A3D8E1'),
|
||||
('Matte Lemon Yellow', '#F7D959'),
|
||||
('Matte Lilac Purple', '#AE96D4'),
|
||||
('Matte Plum', '#851A52'),
|
||||
('Matte Sakura Pink', '#E8AFCF'),
|
||||
('Matte Sky Blue', '#73B2E5'),
|
||||
('Matte Terracotta', '#A25A37')
|
||||
ON CONFLICT (name)
|
||||
DO UPDATE SET hex = EXCLUDED.hex;
|
||||
|
||||
-- Add new PLA Wood colors to the colors table
|
||||
INSERT INTO colors (name, hex) VALUES
|
||||
('Ochre Yellow', '#BC8B39'),
|
||||
('White Oak', '#D2CCA2'),
|
||||
('Clay Brown', '#8E621A')
|
||||
ON CONFLICT (name)
|
||||
DO UPDATE SET hex = EXCLUDED.hex;
|
||||
|
||||
-- Add PLA Matte filaments (all Refill only - 1 refill each, 0 spool)
|
||||
INSERT INTO filaments (tip, finish, boja, refill, spulna, kolicina, cena)
|
||||
SELECT
|
||||
'PLA' as tip,
|
||||
'Matte' as finish,
|
||||
c.name as boja,
|
||||
1 as refill,
|
||||
0 as spulna,
|
||||
1 as kolicina,
|
||||
'3499' as cena
|
||||
FROM colors c
|
||||
WHERE c.name IN (
|
||||
'Matte Apple Green', 'Matte Bone White', 'Matte Caramel',
|
||||
'Matte Dark Blue', 'Matte Dark Brown', 'Matte Dark Chocolate',
|
||||
'Matte Dark Green', 'Matte Dark Red', 'Matte Grass Green',
|
||||
'Matte Ice Blue', 'Matte Lemon Yellow', 'Matte Lilac Purple',
|
||||
'Matte Plum', 'Matte Sakura Pink', 'Matte Sky Blue', 'Matte Terracotta'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM filaments f
|
||||
WHERE f.tip = 'PLA'
|
||||
AND f.finish = 'Matte'
|
||||
AND f.boja = c.name
|
||||
);
|
||||
|
||||
-- Add PLA Wood filaments (all Spool only - 0 refill, 1 spool each)
|
||||
INSERT INTO filaments (tip, finish, boja, refill, spulna, kolicina, cena)
|
||||
SELECT
|
||||
'PLA' as tip,
|
||||
'Wood' as finish,
|
||||
c.name as boja,
|
||||
0 as refill,
|
||||
1 as spulna,
|
||||
1 as kolicina,
|
||||
'3999' as cena
|
||||
FROM colors c
|
||||
WHERE c.name IN (
|
||||
'Ochre Yellow', 'White Oak', 'Clay Brown'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM filaments f
|
||||
WHERE f.tip = 'PLA'
|
||||
AND f.finish = 'Wood'
|
||||
AND f.boja = c.name
|
||||
);
|
||||
102
database/migrations/020_add_missing_colors_2025.sql
Normal file
102
database/migrations/020_add_missing_colors_2025.sql
Normal file
@@ -0,0 +1,102 @@
|
||||
-- Migration: Add missing Bambu Lab colors (2025)
|
||||
-- Created: 2025-11-13
|
||||
-- Description: Adds missing color definitions from Bambu Lab catalog
|
||||
|
||||
INSERT INTO colors (name, hex, cena_refill, cena_spulna) VALUES
|
||||
-- Basic/Matte Colors
|
||||
('Clear Black', '#1a1a1a', 3499, 3999),
|
||||
('Transparent', '#f0f0f0', 3499, 3999),
|
||||
('Brick Red', '#8b2e2e', 3499, 3999),
|
||||
('Titan Gray', '#5c6670', 3499, 3999),
|
||||
('Indigo Blue', '#4b0082', 3499, 3999),
|
||||
('Malachite Green', '#0bda51', 3499, 3999),
|
||||
('Violet Purple', '#8f00ff', 3499, 3999),
|
||||
('Iris Purple', '#5d3fd3', 3499, 3999),
|
||||
('Royal Blue', '#4169e1', 3499, 3999),
|
||||
('Lava Gray', '#4a4a4a', 3499, 3999),
|
||||
('Burgundy Red', '#800020', 3499, 3999),
|
||||
('Matcha Green', '#8db255', 3499, 3999),
|
||||
('Light Cyan', '#e0ffff', 3499, 3999),
|
||||
|
||||
-- Glow Colors
|
||||
('Blaze', '#ff6347', 3499, 3999),
|
||||
('Glow Blue', '#00d4ff', 3499, 3999),
|
||||
('Glow Pink', '#ff69b4', 3499, 3999),
|
||||
('Glow Yellow', '#ffff00', 3499, 3999),
|
||||
('Glow Orange', '#ff8c00', 3499, 3999),
|
||||
('Nebulane', '#9b59b6', 3499, 3999),
|
||||
|
||||
-- Metallic Colors
|
||||
('IronGray Metallic', '#71797e', 3499, 3999),
|
||||
('Copper Brown Metallic', '#b87333', 3499, 3999),
|
||||
('Iridium Gold Metallic', '#d4af37', 3499, 3999),
|
||||
('Oxide Green Metallic', '#6b8e23', 3499, 3999),
|
||||
('Cobalt Blue Metallic', '#0047ab', 3499, 3999),
|
||||
|
||||
-- Marble Colors
|
||||
('White Marble', '#f5f5f5', 3499, 3999),
|
||||
('Red Granite', '#8b4513', 3499, 3999),
|
||||
|
||||
-- Sparkle Colors
|
||||
('Classic Gold Sparkle', '#ffd700', 3499, 3999),
|
||||
('Onyx Black Sparkle', '#0f0f0f', 3499, 3999),
|
||||
('Crimson Red Sparkle', '#dc143c', 3499, 3999),
|
||||
('Royal Purple Sparkle', '#7851a9', 3499, 3999),
|
||||
('Slate Gray Sparkle', '#708090', 3499, 3999),
|
||||
('Alpine Green Sparkle', '#2e8b57', 3499, 3999),
|
||||
|
||||
-- Gradient Colors
|
||||
('South Beach', '#ff69b4', 3499, 3999),
|
||||
('Dawn Radiance', '#ff7f50', 3499, 3999),
|
||||
('Blue Hawaii', '#1e90ff', 3499, 3999),
|
||||
('Gilded Rose', '#ff1493', 3499, 3999),
|
||||
('Midnight Blaze', '#191970', 3499, 3999),
|
||||
('Neon City', '#00ffff', 3499, 3999),
|
||||
('Velvet Eclipse', '#8b008b', 3499, 3999),
|
||||
('Solar Breeze', '#ffb347', 3499, 3999),
|
||||
('Arctic Whisper', '#b0e0e6', 3499, 3999),
|
||||
('Ocean to Meadow', '#40e0d0', 3499, 3999),
|
||||
('Dusk Glare', '#ff6347', 3499, 3999),
|
||||
('Mint Lime', '#98fb98', 3499, 3999),
|
||||
('Pink Citrus', '#ff69b4', 3499, 3999),
|
||||
('Blueberry Bubblegum', '#7b68ee', 3499, 3999),
|
||||
('Cotton Candy Cloud', '#ffb6c1', 3499, 3999),
|
||||
|
||||
-- Pastel/Light Colors
|
||||
('Lavender', '#e6e6fa', 3499, 3999),
|
||||
('Ice Blue', '#d0e7ff', 3499, 3999),
|
||||
('Mellow Yellow', '#fff44f', 3499, 3999),
|
||||
('Teal', '#008080', 3499, 3999),
|
||||
|
||||
-- ABS Colors
|
||||
('ABS Azure', '#007fff', 3499, 3999),
|
||||
('ABS Olive', '#808000', 3499, 3999),
|
||||
('ABS Blue', '#0000ff', 3499, 3999),
|
||||
('ABS Tangerine Yellow', '#ffcc00', 3499, 3999),
|
||||
('ABS Navy Blue', '#000080', 3499, 3999),
|
||||
('ABS Orange', '#ff8800', 3499, 3999),
|
||||
('ABS Bambu Green', '#00c853', 3499, 3999),
|
||||
('ABS Red', '#ff0000', 3499, 3999),
|
||||
('ABS White', '#ffffff', 3499, 3999),
|
||||
('ABS Black', '#000000', 3499, 3999),
|
||||
('ABS Silver', '#c0c0c0', 3499, 3999),
|
||||
|
||||
-- Translucent Colors
|
||||
('Translucent Gray', '#9e9e9e', 3499, 3999),
|
||||
('Translucent Brown', '#8b4513', 3499, 3999),
|
||||
('Translucent Purple', '#9370db', 3499, 3999),
|
||||
('Translucent Orange', '#ffa500', 3499, 3999),
|
||||
('Translucent Olive', '#6b8e23', 3499, 3999),
|
||||
('Translucent Pink', '#ffb6c1', 3499, 3999),
|
||||
('Translucent Light Blue', '#add8e6', 3499, 3999),
|
||||
('Translucent Tea', '#d2b48c', 3499, 3999),
|
||||
|
||||
-- Additional Colors
|
||||
('Mint', '#98ff98', 3499, 3999),
|
||||
('Champagne', '#f7e7ce', 3499, 3999),
|
||||
('Baby Blue', '#89cff0', 3499, 3999),
|
||||
('Cream', '#fffdd0', 3499, 3999),
|
||||
('Peanut Brown', '#b86f52', 3499, 3999),
|
||||
('Matte Ivory', '#fffff0', 3499, 3999)
|
||||
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
32
database/migrations/021_create_products_table.sql
Normal file
32
database/migrations/021_create_products_table.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- Migration: Create products table for non-filament product categories
|
||||
-- This is purely additive - does not modify existing tables
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TYPE product_category AS ENUM ('printer', 'build_plate', 'nozzle', 'spare_part', 'accessory');
|
||||
CREATE TYPE product_condition AS ENUM ('new', 'used_like_new', 'used_good', 'used_fair');
|
||||
|
||||
CREATE TABLE products (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
category product_category NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
price INTEGER,
|
||||
condition product_condition DEFAULT 'new',
|
||||
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
|
||||
sale_percentage INTEGER DEFAULT 0,
|
||||
sale_active BOOLEAN DEFAULT FALSE,
|
||||
sale_start_date TIMESTAMP WITH TIME ZONE,
|
||||
sale_end_date TIMESTAMP WITH TIME ZONE,
|
||||
attributes JSONB NOT NULL DEFAULT '{}',
|
||||
image_url VARCHAR(500),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_products_category ON products(category);
|
||||
CREATE INDEX idx_products_slug ON products(slug);
|
||||
CREATE INDEX idx_products_is_active ON products(is_active);
|
||||
CREATE INDEX idx_products_category_active ON products(category, is_active);
|
||||
25
database/migrations/022_create_printer_compatibility.sql
Normal file
25
database/migrations/022_create_printer_compatibility.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Migration: Create printer models and product compatibility junction table
|
||||
|
||||
CREATE TABLE printer_models (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
series VARCHAR(50) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE product_printer_compatibility (
|
||||
product_id UUID REFERENCES products(id) ON DELETE CASCADE,
|
||||
printer_model_id UUID REFERENCES printer_models(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (product_id, printer_model_id)
|
||||
);
|
||||
|
||||
-- Seed printer models
|
||||
INSERT INTO printer_models (name, series) VALUES
|
||||
('A1 Mini', 'A'),
|
||||
('A1', 'A'),
|
||||
('P1P', 'P'),
|
||||
('P1S', 'P'),
|
||||
('X1C', 'X'),
|
||||
('X1E', 'X'),
|
||||
('H2D', 'H'),
|
||||
('H2S', 'H'),
|
||||
('P2S', 'P');
|
||||
12
database/migrations/023_create_product_images.sql
Normal file
12
database/migrations/023_create_product_images.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Migration: Multi-image support for products
|
||||
|
||||
CREATE TABLE product_images (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
alt_text VARCHAR(255),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_product_images_product_id ON product_images(product_id);
|
||||
56
database/schema.sql
Normal file
56
database/schema.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- Filamenteka PostgreSQL Schema
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Colors table
|
||||
CREATE TABLE colors (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
hex VARCHAR(7) NOT NULL,
|
||||
cena_refill INTEGER DEFAULT 3499,
|
||||
cena_spulna INTEGER DEFAULT 3999,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Filaments table
|
||||
CREATE TABLE filaments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tip VARCHAR(50) NOT NULL,
|
||||
finish VARCHAR(50) NOT NULL,
|
||||
boja VARCHAR(100) NOT NULL,
|
||||
boja_hex VARCHAR(7),
|
||||
refill INTEGER DEFAULT 0,
|
||||
spulna INTEGER DEFAULT 0,
|
||||
kolicina INTEGER DEFAULT 0,
|
||||
cena VARCHAR(50),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_color FOREIGN KEY (boja) REFERENCES colors(name) ON UPDATE CASCADE,
|
||||
CONSTRAINT check_kolicina CHECK (kolicina = refill + spulna)
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX idx_filaments_tip ON filaments(tip);
|
||||
CREATE INDEX idx_filaments_boja ON filaments(boja);
|
||||
CREATE INDEX idx_filaments_created_at ON filaments(created_at);
|
||||
|
||||
-- Create updated_at trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply trigger to filaments table
|
||||
CREATE TRIGGER update_filaments_updated_at BEFORE UPDATE
|
||||
ON filaments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Apply trigger to colors table
|
||||
CREATE TRIGGER update_colors_updated_at BEFORE UPDATE
|
||||
ON colors FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Default colors are now inserted by Bambu Lab colors migration
|
||||
@@ -1 +1,24 @@
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Add TextEncoder/TextDecoder globals for Node.js environment
|
||||
const { TextEncoder, TextDecoder } = require('util');
|
||||
global.TextEncoder = TextEncoder;
|
||||
global.TextDecoder = TextDecoder;
|
||||
|
||||
// Mock axios globally
|
||||
jest.mock('axios', () => ({
|
||||
create: jest.fn(() => ({
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: {
|
||||
request: {
|
||||
use: jest.fn()
|
||||
},
|
||||
response: {
|
||||
use: jest.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}))
|
||||
BIN
lambda/auth.zip
BIN
lambda/auth.zip
Binary file not shown.
@@ -1,110 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME;
|
||||
const ADMIN_PASSWORD_HASH = process.env.ADMIN_PASSWORD_HASH;
|
||||
|
||||
// CORS headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
|
||||
'Access-Control-Allow-Methods': 'POST,OPTIONS'
|
||||
};
|
||||
|
||||
// Helper function to create response
|
||||
const createResponse = (statusCode, body) => ({
|
||||
statusCode,
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// Login handler
|
||||
const login = async (event) => {
|
||||
try {
|
||||
const { username, password } = JSON.parse(event.body);
|
||||
|
||||
// Validate credentials
|
||||
if (username !== ADMIN_USERNAME) {
|
||||
return createResponse(401, { error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Compare password with hash
|
||||
const isValid = await bcrypt.compare(password, ADMIN_PASSWORD_HASH);
|
||||
if (!isValid) {
|
||||
return createResponse(401, { error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
{ username, role: 'admin' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
return createResponse(200, {
|
||||
token,
|
||||
expiresIn: 86400 // 24 hours in seconds
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return createResponse(500, { error: 'Authentication failed' });
|
||||
}
|
||||
};
|
||||
|
||||
// Verify token (for Lambda authorizer)
|
||||
const verifyToken = async (event) => {
|
||||
try {
|
||||
const token = event.authorizationToken?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
return {
|
||||
principalId: decoded.username,
|
||||
policyDocument: {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Action: 'execute-api:Invoke',
|
||||
Effect: 'Allow',
|
||||
Resource: event.methodArn
|
||||
}
|
||||
]
|
||||
},
|
||||
context: {
|
||||
username: decoded.username,
|
||||
role: decoded.role
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
};
|
||||
|
||||
// Main handler
|
||||
exports.handler = async (event) => {
|
||||
const { httpMethod, resource } = event;
|
||||
|
||||
// Handle CORS preflight
|
||||
if (httpMethod === 'OPTIONS') {
|
||||
return createResponse(200, {});
|
||||
}
|
||||
|
||||
// Handle login
|
||||
if (resource === '/auth/login' && httpMethod === 'POST') {
|
||||
return login(event);
|
||||
}
|
||||
|
||||
// Handle token verification (for Lambda authorizer)
|
||||
if (event.type === 'TOKEN') {
|
||||
return verifyToken(event);
|
||||
}
|
||||
|
||||
return createResponse(404, { error: 'Not found' });
|
||||
};
|
||||
161
lambda/auth/package-lock.json
generated
161
lambda/auth/package-lock.json
generated
@@ -1,161 +0,0 @@
|
||||
{
|
||||
"name": "auth-api",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "auth-api",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "auth-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Lambda function for authentication",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs"
|
||||
}
|
||||
Binary file not shown.
@@ -1,232 +0,0 @@
|
||||
const AWS = require('aws-sdk');
|
||||
const dynamodb = new AWS.DynamoDB.DocumentClient();
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const TABLE_NAME = process.env.TABLE_NAME;
|
||||
|
||||
// CORS headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
|
||||
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS'
|
||||
};
|
||||
|
||||
// Helper function to create response
|
||||
const createResponse = (statusCode, body) => ({
|
||||
statusCode,
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// GET all filaments or filter by query params
|
||||
const getFilaments = async (event) => {
|
||||
try {
|
||||
const queryParams = event.queryStringParameters || {};
|
||||
|
||||
let params = {
|
||||
TableName: TABLE_NAME
|
||||
};
|
||||
|
||||
// If filtering by brand, type, or status, use the appropriate index
|
||||
if (queryParams.brand) {
|
||||
params = {
|
||||
...params,
|
||||
IndexName: 'brand-index',
|
||||
KeyConditionExpression: 'brand = :brand',
|
||||
ExpressionAttributeValues: {
|
||||
':brand': queryParams.brand
|
||||
}
|
||||
};
|
||||
const result = await dynamodb.query(params).promise();
|
||||
return createResponse(200, result.Items);
|
||||
} else if (queryParams.tip) {
|
||||
params = {
|
||||
...params,
|
||||
IndexName: 'tip-index',
|
||||
KeyConditionExpression: 'tip = :tip',
|
||||
ExpressionAttributeValues: {
|
||||
':tip': queryParams.tip
|
||||
}
|
||||
};
|
||||
const result = await dynamodb.query(params).promise();
|
||||
return createResponse(200, result.Items);
|
||||
} else if (queryParams.status) {
|
||||
params = {
|
||||
...params,
|
||||
IndexName: 'status-index',
|
||||
KeyConditionExpression: 'status = :status',
|
||||
ExpressionAttributeValues: {
|
||||
':status': queryParams.status
|
||||
}
|
||||
};
|
||||
const result = await dynamodb.query(params).promise();
|
||||
return createResponse(200, result.Items);
|
||||
}
|
||||
|
||||
// Get all items
|
||||
const result = await dynamodb.scan(params).promise();
|
||||
return createResponse(200, result.Items);
|
||||
} catch (error) {
|
||||
console.error('Error getting filaments:', error);
|
||||
return createResponse(500, { error: 'Failed to fetch filaments' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET single filament by ID
|
||||
const getFilament = async (event) => {
|
||||
try {
|
||||
const { id } = event.pathParameters;
|
||||
|
||||
const params = {
|
||||
TableName: TABLE_NAME,
|
||||
Key: { id }
|
||||
};
|
||||
|
||||
const result = await dynamodb.get(params).promise();
|
||||
|
||||
if (!result.Item) {
|
||||
return createResponse(404, { error: 'Filament not found' });
|
||||
}
|
||||
|
||||
return createResponse(200, result.Item);
|
||||
} catch (error) {
|
||||
console.error('Error getting filament:', error);
|
||||
return createResponse(500, { error: 'Failed to fetch filament' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST - Create new filament
|
||||
const createFilament = async (event) => {
|
||||
try {
|
||||
const body = JSON.parse(event.body);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Determine status based on vakum and otvoreno fields
|
||||
let status = 'new';
|
||||
if (body.otvoreno && body.otvoreno.toLowerCase().includes('otvorena')) {
|
||||
status = 'opened';
|
||||
} else if (body.refill && body.refill.toLowerCase() === 'da') {
|
||||
status = 'refill';
|
||||
}
|
||||
|
||||
const item = {
|
||||
id: uuidv4(),
|
||||
...body,
|
||||
status,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
|
||||
const params = {
|
||||
TableName: TABLE_NAME,
|
||||
Item: item
|
||||
};
|
||||
|
||||
await dynamodb.put(params).promise();
|
||||
return createResponse(201, item);
|
||||
} catch (error) {
|
||||
console.error('Error creating filament:', error);
|
||||
return createResponse(500, { error: 'Failed to create filament' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT - Update filament
|
||||
const updateFilament = async (event) => {
|
||||
try {
|
||||
const { id } = event.pathParameters;
|
||||
const body = JSON.parse(event.body);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Determine status based on vakum and otvoreno fields
|
||||
let status = 'new';
|
||||
if (body.otvoreno && body.otvoreno.toLowerCase().includes('otvorena')) {
|
||||
status = 'opened';
|
||||
} else if (body.refill && body.refill.toLowerCase() === 'da') {
|
||||
status = 'refill';
|
||||
}
|
||||
|
||||
const params = {
|
||||
TableName: TABLE_NAME,
|
||||
Key: { id },
|
||||
UpdateExpression: `SET
|
||||
brand = :brand,
|
||||
tip = :tip,
|
||||
finish = :finish,
|
||||
boja = :boja,
|
||||
refill = :refill,
|
||||
vakum = :vakum,
|
||||
otvoreno = :otvoreno,
|
||||
kolicina = :kolicina,
|
||||
cena = :cena,
|
||||
#status = :status,
|
||||
updatedAt = :updatedAt`,
|
||||
ExpressionAttributeNames: {
|
||||
'#status': 'status'
|
||||
},
|
||||
ExpressionAttributeValues: {
|
||||
':brand': body.brand,
|
||||
':tip': body.tip,
|
||||
':finish': body.finish,
|
||||
':boja': body.boja,
|
||||
':refill': body.refill,
|
||||
':vakum': body.vakum,
|
||||
':otvoreno': body.otvoreno,
|
||||
':kolicina': body.kolicina,
|
||||
':cena': body.cena,
|
||||
':status': status,
|
||||
':updatedAt': timestamp
|
||||
},
|
||||
ReturnValues: 'ALL_NEW'
|
||||
};
|
||||
|
||||
const result = await dynamodb.update(params).promise();
|
||||
return createResponse(200, result.Attributes);
|
||||
} catch (error) {
|
||||
console.error('Error updating filament:', error);
|
||||
return createResponse(500, { error: 'Failed to update filament' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE filament
|
||||
const deleteFilament = async (event) => {
|
||||
try {
|
||||
const { id } = event.pathParameters;
|
||||
|
||||
const params = {
|
||||
TableName: TABLE_NAME,
|
||||
Key: { id }
|
||||
};
|
||||
|
||||
await dynamodb.delete(params).promise();
|
||||
return createResponse(200, { message: 'Filament deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting filament:', error);
|
||||
return createResponse(500, { error: 'Failed to delete filament' });
|
||||
}
|
||||
};
|
||||
|
||||
// Main handler
|
||||
exports.handler = async (event) => {
|
||||
const { httpMethod, resource } = event;
|
||||
|
||||
// Handle CORS preflight
|
||||
if (httpMethod === 'OPTIONS') {
|
||||
return createResponse(200, {});
|
||||
}
|
||||
|
||||
// Route requests
|
||||
if (resource === '/filaments' && httpMethod === 'GET') {
|
||||
return getFilaments(event);
|
||||
} else if (resource === '/filaments' && httpMethod === 'POST') {
|
||||
return createFilament(event);
|
||||
} else if (resource === '/filaments/{id}' && httpMethod === 'GET') {
|
||||
return getFilament(event);
|
||||
} else if (resource === '/filaments/{id}' && httpMethod === 'PUT') {
|
||||
return updateFilament(event);
|
||||
} else if (resource === '/filaments/{id}' && httpMethod === 'DELETE') {
|
||||
return deleteFilament(event);
|
||||
}
|
||||
|
||||
return createResponse(404, { error: 'Not found' });
|
||||
};
|
||||
593
lambda/filaments/package-lock.json
generated
593
lambda/filaments/package-lock.json
generated
@@ -1,593 +0,0 @@
|
||||
{
|
||||
"name": "filaments-api",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "filaments-api",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"possible-typed-array-names": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-sdk": {
|
||||
"version": "2.1692.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1692.0.tgz",
|
||||
"integrity": "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"buffer": "4.9.2",
|
||||
"events": "1.1.1",
|
||||
"ieee754": "1.1.13",
|
||||
"jmespath": "0.16.0",
|
||||
"querystring": "0.2.0",
|
||||
"sax": "1.2.1",
|
||||
"url": "0.10.3",
|
||||
"util": "^0.12.4",
|
||||
"uuid": "8.0.0",
|
||||
"xml2js": "0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-sdk/node_modules/uuid": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz",
|
||||
"integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "4.9.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
|
||||
"integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.0.2",
|
||||
"ieee754": "^1.1.4",
|
||||
"isarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.0",
|
||||
"es-define-property": "^1.0.0",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz",
|
||||
"integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-callable": "^1.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
|
||||
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
|
||||
"integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.3",
|
||||
"get-proto": "^1.0.0",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"safe-regex-test": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-typed-array": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
||||
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"which-typed-array": "^1.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jmespath": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
|
||||
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
|
||||
"integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/querystring": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
|
||||
"integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
|
||||
"deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-regex-test": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"is-regex": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz",
|
||||
"integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/url": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz",
|
||||
"integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "1.3.2",
|
||||
"querystring": "0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
||||
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"is-arguments": "^1.0.4",
|
||||
"is-generator-function": "^1.0.7",
|
||||
"is-typed-array": "^1.1.3",
|
||||
"which-typed-array": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||
"integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"available-typed-arrays": "^1.0.7",
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"for-each": "^0.3.5",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
|
||||
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "filaments-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Lambda function for filaments CRUD operations",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs"
|
||||
}
|
||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'export',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
4380
package-lock.json
generated
4380
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@@ -3,13 +3,13 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "./scripts/kill-dev.sh && next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint . --ext .ts,.tsx --ignore-path .gitignore",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"security:check": "node scripts/security-check.js",
|
||||
"security:check": "node scripts/security/security-check.js",
|
||||
"test:build": "node scripts/test-build.js",
|
||||
"prepare": "husky",
|
||||
"migrate": "cd scripts && npm install && npm run migrate",
|
||||
@@ -20,30 +20,31 @@
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cheerio": "^1.1.0",
|
||||
"next": "^15.3.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"next": "^16.1.6",
|
||||
"pg": "^8.16.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^30.0.0",
|
||||
"jest-environment-jsdom": "^30.0.0",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
167
public/data.json
167
public/data.json
@@ -1,167 +0,0 @@
|
||||
[
|
||||
{
|
||||
"brand": "Bambu Lab",
|
||||
"tip": "PLA",
|
||||
"finish": "Basic",
|
||||
"boja": "Lavender Purple",
|
||||
"refill": "",
|
||||
"vakum": "u vakuumu",
|
||||
"otvoreno": "",
|
||||
"kolicina": "1kg",
|
||||
"cena": "2500"
|
||||
},
|
||||
{
|
||||
"brand": "Bambu Lab",
|
||||
"tip": "PLA",
|
||||
"finish": "Matte",
|
||||
"boja": "Charcoal Black",
|
||||
"refill": "",
|
||||
"vakum": "",
|
||||
"otvoreno": "otvorena",
|
||||
"kolicina": "0.8kg",
|
||||
"cena": "2800"
|
||||
},
|
||||
{
|
||||
"brand": "Bambu Lab",
|
||||
"tip": "PETG",
|
||||
"finish": "Basic",
|
||||
"boja": "Transparent",
|
||||
"refill": "Da",
|
||||
"vakum": "u vakuumu",
|
||||
"otvoreno": "",
|
||||
"kolicina": "1kg",
|
||||
"cena": "3200"
|
||||
},
|
||||
{
|
||||
"brand": "Azure Film",
|
||||
"tip": "PLA",
|
||||
"finish": "Basic",
|
||||
"boja": "White",
|
||||
"refill": "",
|
||||
"vakum": "u vakuumu",
|
||||
"otvoreno": "",
|
||||
"kolicina": "1kg",
|
||||
"cena": "2200"
|
||||
},
|
||||
{
|
||||
"brand": "Azure Film",
|
||||
"tip": "PETG",
|
||||
"finish": "Basic",
|
||||
"boja": "Orange",
|
||||
"refill": "",
|
||||
"vakum": "",
|
||||
"otvoreno": "otvorena",
|
||||
"kolicina": "0.5kg",
|
||||
"cena": "2600"
|
||||
},
|
||||
{
|
||||
"brand": "Bambu Lab",
|
||||
"tip": "Silk PLA",
|
||||
"finish": "Silk",
|
||||
"boja": "Gold",
|
||||
"refill": "Da",
|
||||
"vakum": "",
|
||||
"otvoreno": "",
|
||||
"kolicina": "0.5kg",
|
||||
"cena": "3500"
|
||||
},
|
||||
{
|
||||
"brand": "Bambu Lab",
|
||||
"tip": "PLA Matte",
|
||||
"finish": "Matte",
|
||||
"boja": "Forest Green",
|
||||
"refill": "",
|
||||
"vakum": "u vakuumu",
|
||||
"otvoreno": "",
|
||||
"kolicina": "1kg",
|
||||
"cena": "2800"
|
||||
},
|
||||
{
|
||||
"brand": "PanaChroma",
|
||||
"tip": "PLA",
|
||||
"finish": "Basic",
|
||||
"boja": "Red",
|
||||
"refill": "",
|
||||
"vakum": "u vakuumu",
|
||||
"otvoreno": "",
|
||||
"kolicina": "1kg",
|
||||
"cena": "2300"
|
||||
},
|
||||
{
|
||||
"brand": "PanaChroma",
|
||||
"tip": "PETG",
|
||||
"finish": "Basic",
|
||||
"boja": "Blue",
|
||||
"refill": "",
|
||||
"vakum": "",
|
||||
"otvoreno": "otvorena",
|
||||
"kolicina": "0.75kg",
|
||||
"cena": "2700"
|
||||
},
|
||||
{
|
||||
"brand": "Fiberlogy",
|
||||
"tip": "PLA",
|
||||
"finish": "Basic",
|
||||
"boja": "Gray",
|
||||
"refill": "",
|
||||
"vakum": "u vakuumu",
|
||||
"otvoreno": "",
|
||||
"kolicina": "0.85kg",
|
||||
"cena": "2400"
|
||||
},
|
||||
{
|
||||
"brand": "Fiberlogy",
|
||||
"tip": "ABS",
|
||||
"finish": "Basic",
|
||||
"boja": "Black",
|
||||
"refill": "",
|
||||
"vakum": "",
|
||||
"otvoreno": "",
|
||||
"kolicina": "1kg",
|
||||
"cena": "2900"
|
||||
},
|
||||
{
|
||||
"brand": "Fiberlogy",
|
||||
"tip": "TPU",
|
||||
"finish": "Basic",
|
||||
"boja": "Lime Green",
|
||||
"refill": "",
|
||||
"vakum": "",
|
||||
"otvoreno": "otvorena",
|
||||
"kolicina": "0.3kg",
|
||||
"cena": "4500"
|
||||
},
|
||||
{
|
||||
"brand": "Azure Film",
|
||||
"tip": "PLA",
|
||||
"finish": "Silk",
|
||||
"boja": "Silver",
|
||||
"refill": "Da",
|
||||
"vakum": "u vakuumu",
|
||||
"otvoreno": "",
|
||||
"kolicina": "1kg",
|
||||
"cena": "2800"
|
||||
},
|
||||
{
|
||||
"brand": "Bambu Lab",
|
||||
"tip": "PLA",
|
||||
"finish": "Basic",
|
||||
"boja": "Jade White",
|
||||
"refill": "",
|
||||
"vakum": "",
|
||||
"otvoreno": "otvorena",
|
||||
"kolicina": "0.5kg",
|
||||
"cena": "2500"
|
||||
},
|
||||
{
|
||||
"brand": "PanaChroma",
|
||||
"tip": "Silk PLA",
|
||||
"finish": "Silk",
|
||||
"boja": "Copper",
|
||||
"refill": "",
|
||||
"vakum": "u vakuumu",
|
||||
"otvoreno": "",
|
||||
"kolicina": "1kg",
|
||||
"cena": "3200"
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user