Compare commits
25 Commits
refactor
...
deploy-202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d55d0e5e02 | ||
|
|
cff73c1381 | ||
|
|
f564f944f7 | ||
|
|
629b9c1756 | ||
|
|
c5a7666ce6 | ||
|
|
70b7713f2e | ||
|
|
da79307461 | ||
|
|
3e4f576fd5 | ||
|
|
b24a1fea27 | ||
|
|
93df263214 | ||
|
|
e50f361b07 | ||
|
|
9594ce56cb | ||
|
|
11dbea3536 | ||
|
|
6d04e16cf6 | ||
|
|
85481512c8 | ||
|
|
1e95455139 | ||
|
|
f929c384c1 | ||
|
|
291997caa5 | ||
|
|
0741bd0d0e | ||
|
|
746f0925d0 | ||
|
|
2985ea4457 | ||
|
|
f2ae608b01 | ||
|
|
65ae493d54 | ||
|
|
ff6abdeef0 | ||
|
|
e9afe8bc35 |
@@ -13,65 +13,100 @@ env:
|
||||
NEXT_PUBLIC_MATOMO_SITE_ID: "7"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# ── Job 1: Change Detection ──────────────────────────────────────────
|
||||
detect:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
api: ${{ steps.changes.outputs.api }}
|
||||
migrations: ${{ steps.changes.outputs.migrations }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect changes
|
||||
- name: Detect changes since last deploy
|
||||
id: changes
|
||||
run: |
|
||||
FRONTEND_CHANGED=false
|
||||
API_CHANGED=false
|
||||
# Find the latest deploy tag
|
||||
LAST_TAG=$(git tag -l 'deploy-*' --sort=-creatordate | head -n 1)
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD | grep -qvE '^api/'; then
|
||||
FRONTEND_CHANGED=true
|
||||
if [ -z "$LAST_TAG" ]; then
|
||||
echo "No deploy tag found — deploying everything"
|
||||
echo "frontend=true" >> $GITHUB_OUTPUT
|
||||
echo "api=true" >> $GITHUB_OUTPUT
|
||||
echo "migrations=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD | grep -qE '^api/'; then
|
||||
API_CHANGED=true
|
||||
echo "Last deploy tag: $LAST_TAG"
|
||||
DIFF_FILES=$(git diff --name-only "$LAST_TAG"..HEAD)
|
||||
echo "Changed files since $LAST_TAG:"
|
||||
echo "$DIFF_FILES"
|
||||
|
||||
FRONTEND=false
|
||||
API=false
|
||||
MIGRATIONS=false
|
||||
|
||||
if echo "$DIFF_FILES" | grep -qvE '^(api/|database/)'; then
|
||||
FRONTEND=true
|
||||
fi
|
||||
if echo "$DIFF_FILES" | grep -qE '^api/'; then
|
||||
API=true
|
||||
fi
|
||||
if echo "$DIFF_FILES" | grep -qE '^database/migrations/'; then
|
||||
MIGRATIONS=true
|
||||
fi
|
||||
|
||||
echo "frontend=$FRONTEND_CHANGED" >> $GITHUB_OUTPUT
|
||||
echo "api=$API_CHANGED" >> $GITHUB_OUTPUT
|
||||
echo "Frontend changed: $FRONTEND_CHANGED"
|
||||
echo "API changed: $API_CHANGED"
|
||||
echo "frontend=$FRONTEND" >> $GITHUB_OUTPUT
|
||||
echo "api=$API" >> $GITHUB_OUTPUT
|
||||
echo "migrations=$MIGRATIONS" >> $GITHUB_OUTPUT
|
||||
echo "Frontend: $FRONTEND | API: $API | Migrations: $MIGRATIONS"
|
||||
|
||||
# ── Job 2: Frontend Deploy ───────────────────────────────────────────
|
||||
deploy-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect
|
||||
if: needs.detect.outputs.frontend == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# ── 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: Verify build output
|
||||
run: |
|
||||
if [ ! -d "out" ]; then
|
||||
echo "ERROR: out/ directory not found after build"
|
||||
exit 1
|
||||
fi
|
||||
echo "Build output verified: $(find out -type f | wc -l) files"
|
||||
|
||||
- name: Setup AWS
|
||||
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
|
||||
aws configure set region ${{ env.AWS_REGION }}
|
||||
|
||||
- name: Deploy to S3 & invalidate CloudFront
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
# HTML files — no cache
|
||||
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||
--delete \
|
||||
--exclude "*" \
|
||||
@@ -79,9 +114,11 @@ jobs:
|
||||
--cache-control "public, max-age=0, must-revalidate" \
|
||||
--content-type "text/html"
|
||||
|
||||
# Next.js assets — immutable
|
||||
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
|
||||
--cache-control "public, max-age=31536000, immutable"
|
||||
|
||||
# Everything else — 24h cache
|
||||
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||
--exclude "*.html" \
|
||||
--exclude "_next/*" \
|
||||
@@ -91,20 +128,161 @@ jobs:
|
||||
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
|
||||
--paths "/*"
|
||||
|
||||
# ── API Deploy ───────────────────────────────────────────────────
|
||||
- name: Deploy API via SSM
|
||||
if: steps.changes.outputs.api == 'true'
|
||||
# ── Job 3: API & Migrations Deploy ──────────────────────────────────
|
||||
deploy-api:
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect
|
||||
if: needs.detect.outputs.api == 'true' || needs.detect.outputs.migrations == 'true'
|
||||
steps:
|
||||
- name: Setup AWS
|
||||
run: |
|
||||
pip3 install -q --break-system-packages awscli
|
||||
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
|
||||
aws configure set region ${{ env.AWS_REGION }}
|
||||
|
||||
- name: Run database migrations
|
||||
if: needs.detect.outputs.migrations == 'true'
|
||||
run: |
|
||||
cat > /tmp/migrate-params.json << 'PARAMS'
|
||||
{"commands":["set -e","cd /tmp","rm -rf repo.tar.gz filamenteka","curl -sf -o repo.tar.gz https://git.demirix.dev/dax/Filamenteka/archive/main.tar.gz","tar xzf repo.tar.gz","docker exec filamenteka-api rm -rf /database","docker cp filamenteka/database filamenteka-api:/database","docker cp filamenteka/api/migrate.js filamenteka-api:/app/migrate.js","rm -rf repo.tar.gz filamenteka","docker exec filamenteka-api ls /database/schema.sql /database/migrations/","docker exec -w /app filamenteka-api node migrate.js"]}
|
||||
PARAMS
|
||||
CMD_ID=$(aws ssm send-command \
|
||||
--region $AWS_REGION \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--document-name "AWS-RunShellScript" \
|
||||
--parameters file:///tmp/migrate-params.json \
|
||||
--query "Command.CommandId" --output text)
|
||||
|
||||
echo "Migration SSM command: $CMD_ID"
|
||||
|
||||
# Poll for completion (max 2 minutes)
|
||||
for i in $(seq 1 24); do
|
||||
sleep 5
|
||||
STATUS=$(aws ssm get-command-invocation \
|
||||
--command-id "$CMD_ID" \
|
||||
--instance-id "$INSTANCE_ID" \
|
||||
--query "Status" --output text 2>/dev/null || echo "Pending")
|
||||
|
||||
echo "Attempt $i: $STATUS"
|
||||
|
||||
if [ "$STATUS" = "Success" ]; then
|
||||
echo "Migrations completed successfully"
|
||||
aws ssm get-command-invocation \
|
||||
--command-id "$CMD_ID" \
|
||||
--instance-id "$INSTANCE_ID" \
|
||||
--query "StandardOutputContent" --output text
|
||||
break
|
||||
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Cancelled" ] || [ "$STATUS" = "TimedOut" ]; then
|
||||
echo "Migration failed with status: $STATUS"
|
||||
aws ssm get-command-invocation \
|
||||
--command-id "$CMD_ID" \
|
||||
--instance-id "$INSTANCE_ID" \
|
||||
--query "StandardErrorContent" --output text
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$STATUS" != "Success" ]; then
|
||||
echo "Migration timed out after 2 minutes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy server.js
|
||||
id: deploy
|
||||
run: |
|
||||
cat > /tmp/deploy-params.json << 'PARAMS'
|
||||
{"commands":["set -e","docker exec filamenteka-api cp /app/server.js /app/server.js.backup","curl -sf -o /tmp/server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js","docker cp /tmp/server.js filamenteka-api:/app/server.js","rm -f /tmp/server.js","docker restart filamenteka-api","echo API deployed and restarted"]}
|
||||
PARAMS
|
||||
CMD_ID=$(aws ssm send-command \
|
||||
--region $AWS_REGION \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--document-name "AWS-RunShellScript" \
|
||||
--parameters file:///tmp/deploy-params.json \
|
||||
--query "Command.CommandId" --output text)
|
||||
|
||||
echo "Deploy SSM command: $CMD_ID"
|
||||
|
||||
for i in $(seq 1 24); do
|
||||
sleep 5
|
||||
STATUS=$(aws ssm get-command-invocation \
|
||||
--command-id "$CMD_ID" \
|
||||
--instance-id "$INSTANCE_ID" \
|
||||
--query "Status" --output text 2>/dev/null || echo "Pending")
|
||||
|
||||
echo "Attempt $i: $STATUS"
|
||||
|
||||
if [ "$STATUS" = "Success" ]; then
|
||||
echo "API deploy completed"
|
||||
break
|
||||
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Cancelled" ] || [ "$STATUS" = "TimedOut" ]; then
|
||||
echo "API deploy failed with status: $STATUS"
|
||||
aws ssm get-command-invocation \
|
||||
--command-id "$CMD_ID" \
|
||||
--instance-id "$INSTANCE_ID" \
|
||||
--query "StandardErrorContent" --output text
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$STATUS" != "Success" ]; then
|
||||
echo "API deploy timed out"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Health check
|
||||
id: health
|
||||
run: |
|
||||
echo "Waiting for API to be ready..."
|
||||
for i in $(seq 1 6); do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://api.filamenteka.rs/api/filaments || echo "000")
|
||||
echo "Health check attempt $i: HTTP $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "API is healthy"
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "Health check failed after 6 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Rollback on failure
|
||||
if: failure() && steps.deploy.outcome == 'success'
|
||||
run: |
|
||||
echo "Rolling back to server.js.backup..."
|
||||
cat > /tmp/rollback-params.json << 'PARAMS'
|
||||
{"commands":["docker exec filamenteka-api sh -c 'if [ -f /app/server.js.backup ]; then cp /app/server.js.backup /app/server.js; fi'","docker restart filamenteka-api","echo Rollback complete"]}
|
||||
PARAMS
|
||||
aws ssm send-command \
|
||||
--region $AWS_REGION \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--document-name "AWS-RunShellScript" \
|
||||
--parameters '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"
|
||||
]' \
|
||||
--parameters file:///tmp/rollback-params.json \
|
||||
--output json
|
||||
echo "API deploy command sent via SSM"
|
||||
echo "Rollback command sent"
|
||||
|
||||
# ── Job 4: Tag Successful Deploy ────────────────────────────────────
|
||||
tag-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect, deploy-frontend, deploy-api]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.detect.result == 'success' &&
|
||||
(needs.deploy-frontend.result == 'success' || needs.deploy-frontend.result == 'skipped') &&
|
||||
(needs.deploy-api.result == 'success' || needs.deploy-api.result == 'skipped') &&
|
||||
!(needs.deploy-frontend.result == 'skipped' && needs.deploy-api.result == 'skipped')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Create deploy tag
|
||||
run: |
|
||||
TAG="deploy-$(date -u +%Y%m%d-%H%M%S)"
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
echo "Tagged successful deploy: $TAG"
|
||||
|
||||
10
CLAUDE.md
10
CLAUDE.md
@@ -48,14 +48,14 @@ scripts/deploy-frontend.sh # Manual frontend deploy to S3/CloudFront
|
||||
|
||||
### 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)
|
||||
- `/app/upadaj/` — Admin panel: login, `/dashboard` (filament CRUD), `/colors` (color management), `/customers` (customer management), `/sales` (sales recording), `/requests` (customer color requests), `/analytics` (revenue/inventory dashboards)
|
||||
- `/src/services/api.ts` — Axios instance with auth interceptors (auto token injection, 401/403 redirect)
|
||||
- `/src/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`
|
||||
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/customers`, `/api/sales`, `/api/color-requests`, `/api/analytics/*`
|
||||
- Auth: JWT tokens with 24h expiry, single admin user (password from `ADMIN_PASSWORD` env var)
|
||||
|
||||
### Database (PostgreSQL on RDS)
|
||||
@@ -64,13 +64,15 @@ 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)
|
||||
customers: id, name, phone (unique), city, notes, created_at
|
||||
sales: id, customer_id (FK→customers), total_amount, notes, created_at + sale_items join table
|
||||
color_requests: id, color_name, message, contact_name, contact_phone, status
|
||||
```
|
||||
|
||||
**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_`)
|
||||
- Migrations in `/database/migrations/` with sequential numbering (currently up to `023_`)
|
||||
|
||||
## Key Patterns
|
||||
|
||||
@@ -86,7 +88,7 @@ color_requests: id, color_name, message, contact_name, contact_phone, status
|
||||
3. Optionally add to `src/data/bambuLabColorsComplete.ts` finish groups
|
||||
|
||||
### API Communication
|
||||
- Service modules in `src/services/api.ts`: `authService`, `colorService`, `filamentService`, `colorRequestService`
|
||||
- Service modules in `src/services/api.ts`: `authService`, `colorService`, `filamentService`, `colorRequestService`, `customerService`, `saleService`, `analyticsService`
|
||||
- Auth token stored in localStorage with 24h expiry
|
||||
- Cache busting on filament fetches via timestamp query param
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ describe('Bambu Lab Colors Data', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should have Unknown fallback color', () => {
|
||||
expect(bambuLabColors).toHaveProperty('Unknown');
|
||||
expect(bambuLabColors.Unknown.hex).toBe('#CCCCCC');
|
||||
it('should return fallback for unknown colors', () => {
|
||||
const result = getFilamentColor('NonExistentColor');
|
||||
expect(result.hex).toBe('#CCCCCC');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,15 +15,15 @@ describe('Bambu Lab Colors Complete Data', () => {
|
||||
});
|
||||
|
||||
it('should have matte colors', () => {
|
||||
expect(bambuLabColors).toHaveProperty('Matte Black');
|
||||
expect(bambuLabColors).toHaveProperty('Matte White');
|
||||
expect(bambuLabColors).toHaveProperty('Matte Red');
|
||||
expect(bambuLabColors).toHaveProperty('Matte Ivory White');
|
||||
expect(bambuLabColors).toHaveProperty('Matte Charcoal');
|
||||
expect(bambuLabColors).toHaveProperty('Matte Scarlet Red');
|
||||
});
|
||||
|
||||
it('should have silk colors', () => {
|
||||
expect(bambuLabColors).toHaveProperty('Silk White');
|
||||
expect(bambuLabColors).toHaveProperty('Silk Black');
|
||||
expect(bambuLabColors).toHaveProperty('Silk Gold');
|
||||
expect(bambuLabColors).toHaveProperty('Aurora Purple');
|
||||
expect(bambuLabColors).toHaveProperty('Phantom Blue');
|
||||
expect(bambuLabColors).toHaveProperty('Mystic Magenta');
|
||||
});
|
||||
|
||||
it('should have valid hex colors', () => {
|
||||
@@ -40,12 +40,12 @@ describe('Bambu Lab Colors Complete Data', () => {
|
||||
it('should have finish categories', () => {
|
||||
expect(colorsByFinish).toHaveProperty('Basic');
|
||||
expect(colorsByFinish).toHaveProperty('Matte');
|
||||
expect(colorsByFinish).toHaveProperty('Silk');
|
||||
expect(colorsByFinish).toHaveProperty('Silk+');
|
||||
expect(colorsByFinish).toHaveProperty('Metal');
|
||||
expect(colorsByFinish).toHaveProperty('Sparkle');
|
||||
expect(colorsByFinish).toHaveProperty('Glow');
|
||||
expect(colorsByFinish).toHaveProperty('Transparent');
|
||||
expect(colorsByFinish).toHaveProperty('Support');
|
||||
expect(colorsByFinish).toHaveProperty('Translucent');
|
||||
expect(colorsByFinish).toHaveProperty('CF');
|
||||
});
|
||||
|
||||
it('should return valid hex for getColorHex', () => {
|
||||
|
||||
@@ -47,20 +47,17 @@ describe('UI Features Tests', () => {
|
||||
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>');
|
||||
// Check for material select dropdown (now generated from catalog)
|
||||
expect(adminContent).toContain('getMaterialOptions()');
|
||||
expect(adminContent).toContain('Izaberi tip');
|
||||
});
|
||||
|
||||
it('should have admin header with navigation', () => {
|
||||
it('should have admin sidebar and header', () => {
|
||||
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');
|
||||
// Check for admin sidebar and header
|
||||
expect(dashboardContent).toContain('AdminSidebar');
|
||||
expect(dashboardContent).toContain('Odjava');
|
||||
});
|
||||
|
||||
|
||||
667
api/server.js
667
api/server.js
@@ -385,380 +385,417 @@ app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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}`;
|
||||
};
|
||||
// ==========================================
|
||||
// Customer endpoints (admin-only)
|
||||
// ==========================================
|
||||
|
||||
// Products endpoints
|
||||
|
||||
// List/filter products (PUBLIC)
|
||||
app.get('/api/products', async (req, res) => {
|
||||
app.get('/api/customers', authenticateToken, 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
|
||||
);
|
||||
|
||||
const result = await pool.query('SELECT * FROM customers ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch products' });
|
||||
console.error('Error fetching customers:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch customers' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single product (PUBLIC)
|
||||
app.get('/api/products/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
app.get('/api/customers/search', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
if (!q) return res.json([]);
|
||||
const result = await pool.query(
|
||||
`SELECT 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`,
|
||||
`SELECT * FROM customers WHERE name ILIKE $1 OR phone ILIKE $1 ORDER BY name LIMIT 10`,
|
||||
[`%${q}%`]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error searching customers:', error);
|
||||
res.status(500).json({ error: 'Failed to search customers' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/customers/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const customerResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||
if (customerResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Customer not found' });
|
||||
}
|
||||
const salesResult = await pool.query(
|
||||
`SELECT s.*,
|
||||
(SELECT COUNT(*) FROM sale_items si WHERE si.sale_id = s.id) as item_count
|
||||
FROM sales s WHERE s.customer_id = $1 ORDER BY s.created_at DESC`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
res.json({ ...customerResult.rows[0], sales: salesResult.rows });
|
||||
} catch (error) {
|
||||
console.error('Error fetching product:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch product' });
|
||||
console.error('Error fetching customer:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch customer' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
app.post('/api/customers', authenticateToken, async (req, res) => {
|
||||
const { name, phone, city, notes } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
try {
|
||||
const slug = generateSlug(name);
|
||||
const result = await pool.query(
|
||||
'INSERT INTO customers (name, phone, city, notes) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[name, phone || null, city || null, notes || null]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
if (error.code === '23505') {
|
||||
return res.status(409).json({ error: 'Customer with this phone already exists' });
|
||||
}
|
||||
console.error('Error creating customer:', error);
|
||||
res.status(500).json({ error: 'Failed to create customer' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/customers/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, phone, city, notes } = req.body;
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE customers SET name = $1, phone = $2, city = $3, notes = $4, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5 RETURNING *`,
|
||||
[name, phone || null, city || null, notes || null, id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating customer:', error);
|
||||
res.status(500).json({ error: 'Failed to update customer' });
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Sale endpoints (admin-only)
|
||||
// ==========================================
|
||||
|
||||
app.post('/api/sales', authenticateToken, async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { customer, items, notes } = req.body;
|
||||
|
||||
if (!customer || !customer.name) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: 'Customer name is required' });
|
||||
}
|
||||
if (!items || items.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: 'At least one item is required' });
|
||||
}
|
||||
|
||||
// Find-or-create customer by phone
|
||||
let customerId;
|
||||
if (customer.phone) {
|
||||
const existing = await client.query(
|
||||
'SELECT id FROM customers WHERE phone = $1', [customer.phone]
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
customerId = existing.rows[0].id;
|
||||
// Update name/city if provided
|
||||
await client.query(
|
||||
`UPDATE customers SET name = $1, city = COALESCE($2, city), notes = COALESCE($3, notes), updated_at = CURRENT_TIMESTAMP WHERE id = $4`,
|
||||
[customer.name, customer.city, customer.notes, customerId]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!customerId) {
|
||||
const newCust = await client.query(
|
||||
'INSERT INTO customers (name, phone, city, notes) VALUES ($1, $2, $3, $4) RETURNING id',
|
||||
[customer.name, customer.phone || null, customer.city || null, customer.notes || null]
|
||||
);
|
||||
customerId = newCust.rows[0].id;
|
||||
}
|
||||
|
||||
// Process items
|
||||
let totalAmount = 0;
|
||||
const saleItems = [];
|
||||
|
||||
for (const item of items) {
|
||||
const { filament_id, item_type, quantity } = item;
|
||||
if (!['refill', 'spulna'].includes(item_type)) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: `Invalid item_type: ${item_type}` });
|
||||
}
|
||||
|
||||
// Get filament with stock check
|
||||
const filamentResult = await client.query(
|
||||
'SELECT f.*, c.cena_refill, c.cena_spulna FROM filaments f JOIN colors c ON f.boja = c.name WHERE f.id = $1 FOR UPDATE',
|
||||
[filament_id]
|
||||
);
|
||||
if (filamentResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: `Filament ${filament_id} not found` });
|
||||
}
|
||||
|
||||
const filament = filamentResult.rows[0];
|
||||
if (filament[item_type] < quantity) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({
|
||||
error: `Insufficient stock for ${filament.boja} ${item_type}: have ${filament[item_type]}, need ${quantity}`
|
||||
});
|
||||
}
|
||||
|
||||
// Determine price (apply sale discount if active)
|
||||
let unitPrice = item_type === 'refill' ? (filament.cena_refill || 3499) : (filament.cena_spulna || 3999);
|
||||
if (filament.sale_active && filament.sale_percentage > 0) {
|
||||
unitPrice = Math.round(unitPrice * (1 - filament.sale_percentage / 100));
|
||||
}
|
||||
|
||||
// Decrement inventory
|
||||
const updateField = item_type === 'refill' ? 'refill' : 'spulna';
|
||||
await client.query(
|
||||
`UPDATE filaments SET ${updateField} = ${updateField} - $1, kolicina = kolicina - $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
|
||||
[quantity, filament_id]
|
||||
);
|
||||
|
||||
totalAmount += unitPrice * quantity;
|
||||
saleItems.push({ filament_id, item_type, quantity, unit_price: unitPrice });
|
||||
}
|
||||
|
||||
// Insert sale
|
||||
const saleResult = await client.query(
|
||||
'INSERT INTO sales (customer_id, total_amount, notes) VALUES ($1, $2, $3) RETURNING *',
|
||||
[customerId, totalAmount, notes || null]
|
||||
);
|
||||
const sale = saleResult.rows[0];
|
||||
|
||||
// Insert sale items
|
||||
for (const si of saleItems) {
|
||||
await client.query(
|
||||
'INSERT INTO sale_items (sale_id, filament_id, item_type, quantity, unit_price) VALUES ($1, $2, $3, $4, $5)',
|
||||
[sale.id, si.filament_id, si.item_type, si.quantity, si.unit_price]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Fetch full sale with items
|
||||
const fullSale = await pool.query(
|
||||
`SELECT s.*, c.name as customer_name, c.phone as customer_phone
|
||||
FROM sales s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.id = $1`,
|
||||
[sale.id]
|
||||
);
|
||||
const fullItems = await pool.query(
|
||||
`SELECT si.*, f.tip as filament_tip, f.finish as filament_finish, f.boja as filament_boja
|
||||
FROM sale_items si JOIN filaments f ON si.filament_id = f.id WHERE si.sale_id = $1`,
|
||||
[sale.id]
|
||||
);
|
||||
res.json({ ...fullSale.rows[0], items: fullItems.rows });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error creating sale:', error);
|
||||
res.status(500).json({ error: 'Failed to create sale' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/sales', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const countResult = await pool.query('SELECT COUNT(*) FROM sales');
|
||||
const total = parseInt(countResult.rows[0].count);
|
||||
|
||||
const result = await pool.query(
|
||||
`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]
|
||||
`SELECT s.*, c.name as customer_name, c.phone as customer_phone,
|
||||
(SELECT COUNT(*) FROM sale_items si WHERE si.sale_id = s.id) as item_count
|
||||
FROM sales s LEFT JOIN customers c ON s.customer_id = c.id
|
||||
ORDER BY s.created_at DESC LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
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);
|
||||
res.json({ sales: result.rows, total });
|
||||
} catch (error) {
|
||||
console.error('Error creating product:', error);
|
||||
res.status(500).json({ error: 'Failed to create product' });
|
||||
console.error('Error fetching sales:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch sales' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
app.get('/api/sales/:id', authenticateToken, async (req, res) => {
|
||||
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]
|
||||
const { id } = req.params;
|
||||
const saleResult = await pool.query(
|
||||
`SELECT s.*, c.name as customer_name, c.phone as customer_phone, c.city as customer_city
|
||||
FROM sales s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.id = $1`,
|
||||
[id]
|
||||
);
|
||||
} 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 (saleResult.rows.length === 0) return res.status(404).json({ error: 'Sale not found' });
|
||||
|
||||
const itemsResult = await pool.query(
|
||||
`SELECT si.*, f.tip as filament_tip, f.finish as filament_finish, f.boja as filament_boja
|
||||
FROM sale_items si JOIN filaments f ON si.filament_id = f.id WHERE si.sale_id = $1 ORDER BY si.created_at`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
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]);
|
||||
res.json({ ...saleResult.rows[0], items: itemsResult.rows });
|
||||
} catch (error) {
|
||||
console.error('Error updating product:', error);
|
||||
res.status(500).json({ error: 'Failed to update product' });
|
||||
console.error('Error fetching sale:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch sale' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete product (auth required)
|
||||
app.delete('/api/products/:id', authenticateToken, async (req, res) => {
|
||||
app.delete('/api/sales/:id', authenticateToken, async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
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' });
|
||||
// Get sale items to restore inventory
|
||||
const itemsResult = await client.query('SELECT * FROM sale_items WHERE sale_id = $1', [id]);
|
||||
if (itemsResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'Sale not found' });
|
||||
}
|
||||
|
||||
// Restore inventory for each item
|
||||
for (const item of itemsResult.rows) {
|
||||
const updateField = item.item_type === 'refill' ? 'refill' : 'spulna';
|
||||
await client.query(
|
||||
`UPDATE filaments SET ${updateField} = ${updateField} + $1, kolicina = kolicina + $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
|
||||
[item.quantity, item.filament_id]
|
||||
);
|
||||
}
|
||||
|
||||
// Delete sale (cascade deletes sale_items)
|
||||
await client.query('DELETE FROM sales WHERE id = $1', [id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting product:', error);
|
||||
res.status(500).json({ error: 'Failed to delete product' });
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error deleting sale:', error);
|
||||
res.status(500).json({ error: 'Failed to delete sale' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
// ==========================================
|
||||
// Analytics endpoints (admin-only)
|
||||
// ==========================================
|
||||
|
||||
function getPeriodInterval(period) {
|
||||
const map = {
|
||||
'7d': '7 days', '30d': '30 days', '90d': '90 days',
|
||||
'6m': '6 months', '1y': '1 year', 'all': '100 years'
|
||||
};
|
||||
return map[period] || '30 days';
|
||||
}
|
||||
|
||||
app.get('/api/analytics/overview', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
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
|
||||
});
|
||||
const interval = getPeriodInterval(req.query.period);
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
COALESCE(SUM(total_amount), 0) as revenue,
|
||||
COUNT(*) as sales_count,
|
||||
COALESCE(ROUND(AVG(total_amount)), 0) as avg_order_value,
|
||||
COUNT(DISTINCT customer_id) as unique_customers
|
||||
FROM sales WHERE created_at >= NOW() - $1::interval`,
|
||||
[interval]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating product sale:', error);
|
||||
res.status(500).json({ error: 'Failed to update product sale' });
|
||||
console.error('Error fetching analytics overview:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Printer models endpoints
|
||||
|
||||
// List all printer models (PUBLIC)
|
||||
app.get('/api/printer-models', async (req, res) => {
|
||||
app.get('/api/analytics/top-sellers', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM printer_models ORDER BY series, name');
|
||||
const interval = getPeriodInterval(req.query.period);
|
||||
const result = await pool.query(
|
||||
`SELECT f.boja, f.tip, f.finish,
|
||||
SUM(si.quantity) as total_qty,
|
||||
SUM(si.quantity * si.unit_price) as total_revenue
|
||||
FROM sale_items si
|
||||
JOIN filaments f ON si.filament_id = f.id
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
WHERE s.created_at >= NOW() - $1::interval
|
||||
GROUP BY f.boja, f.tip, f.finish
|
||||
ORDER BY total_qty DESC LIMIT 10`,
|
||||
[interval]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching printer models:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch printer models' });
|
||||
console.error('Error fetching top sellers:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch top sellers' });
|
||||
}
|
||||
});
|
||||
|
||||
// Analytics endpoints
|
||||
|
||||
// Aggregated inventory stats (auth required)
|
||||
app.get('/api/analytics/inventory', authenticateToken, async (req, res) => {
|
||||
app.get('/api/analytics/inventory-alerts', 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)
|
||||
}
|
||||
});
|
||||
const result = await pool.query(
|
||||
`SELECT f.id, f.boja, f.tip, f.finish, f.refill, f.spulna, f.kolicina,
|
||||
COALESCE(
|
||||
(SELECT SUM(si.quantity)::float / GREATEST(EXTRACT(EPOCH FROM (NOW() - MIN(s.created_at))) / 86400, 1)
|
||||
FROM sale_items si JOIN sales s ON si.sale_id = s.id
|
||||
WHERE si.filament_id = f.id AND s.created_at >= NOW() - INTERVAL '90 days'),
|
||||
0
|
||||
) as avg_daily_sales
|
||||
FROM filaments f
|
||||
WHERE f.kolicina <= 5
|
||||
ORDER BY f.kolicina ASC, f.boja`
|
||||
);
|
||||
const rows = result.rows.map(r => ({
|
||||
...r,
|
||||
avg_daily_sales: parseFloat(r.avg_daily_sales) || 0,
|
||||
days_until_stockout: r.avg_daily_sales > 0
|
||||
? Math.round(r.kolicina / r.avg_daily_sales)
|
||||
: null
|
||||
}));
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching inventory analytics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch inventory analytics' });
|
||||
console.error('Error fetching inventory alerts:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch inventory alerts' });
|
||||
}
|
||||
});
|
||||
|
||||
// Active sales overview (auth required)
|
||||
app.get('/api/analytics/sales', authenticateToken, async (req, res) => {
|
||||
app.get('/api/analytics/revenue-chart', 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 interval = getPeriodInterval(req.query.period || '6m');
|
||||
const group = req.query.group || 'month';
|
||||
const truncTo = group === 'day' ? 'day' : group === 'week' ? 'week' : 'month';
|
||||
|
||||
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
|
||||
});
|
||||
const result = await pool.query(
|
||||
`SELECT DATE_TRUNC($1, created_at) as period,
|
||||
SUM(total_amount) as revenue,
|
||||
COUNT(*) as count
|
||||
FROM sales
|
||||
WHERE created_at >= NOW() - $2::interval
|
||||
GROUP BY DATE_TRUNC($1, created_at)
|
||||
ORDER BY period`,
|
||||
[truncTo, interval]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching sales analytics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch sales analytics' });
|
||||
console.error('Error fetching revenue chart:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch revenue chart' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/analytics/type-breakdown', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const interval = getPeriodInterval(req.query.period);
|
||||
const result = await pool.query(
|
||||
`SELECT si.item_type,
|
||||
SUM(si.quantity) as total_qty,
|
||||
SUM(si.quantity * si.unit_price) as total_revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
WHERE s.created_at >= NOW() - $1::interval
|
||||
GROUP BY si.item_type`,
|
||||
[interval]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching type breakdown:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch type breakdown' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,70 +7,40 @@ import { Filament } from '@/src/types/filament';
|
||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||
import { SaleManager } from '@/src/components/SaleManager';
|
||||
import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor';
|
||||
// Removed unused imports for Bambu Lab color categorization
|
||||
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||
import {
|
||||
getFinishesForMaterial,
|
||||
getColorsForMaterialFinish,
|
||||
catalogIsSpoolOnly,
|
||||
catalogIsRefillOnly,
|
||||
getMaterialOptions,
|
||||
} from '@/src/data/bambuLabCatalog';
|
||||
import '@/src/styles/select.css';
|
||||
|
||||
// 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';
|
||||
// Catalog-driven helpers
|
||||
const isSpoolOnly = (finish?: string, type?: string, color?: string): boolean => {
|
||||
if (!type || !finish) return false;
|
||||
if (color) return catalogIsSpoolOnly(type, finish, color);
|
||||
// If no specific color, check if ALL colors in this finish are spool-only
|
||||
const colors = getColorsForMaterialFinish(type, finish);
|
||||
return colors.length > 0 && colors.every(c => c.spool && !c.refill);
|
||||
};
|
||||
|
||||
// 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);
|
||||
if (!type || !finish || !color) return false;
|
||||
return catalogIsRefillOnly(type, finish, 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;
|
||||
const getFilteredColors = (
|
||||
colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>,
|
||||
type?: string,
|
||||
finish?: string
|
||||
) => {
|
||||
if (!type || !finish) return colors;
|
||||
const catalogColors = getColorsForMaterialFinish(type, finish);
|
||||
if (catalogColors.length === 0) return colors;
|
||||
const catalogNames = new Set(catalogColors.map(c => c.name.toLowerCase()));
|
||||
return colors.filter(c => catalogNames.has(c.name.toLowerCase()));
|
||||
};
|
||||
|
||||
interface FilamentWithId extends Filament {
|
||||
@@ -80,22 +50,6 @@ interface FilamentWithId extends Filament {
|
||||
boja_hex?: string;
|
||||
}
|
||||
|
||||
// Finish options by filament type
|
||||
const FINISH_OPTIONS_BY_TYPE: Record<string, string[]> = {
|
||||
'ABS': ['GF', 'Bez Finisha'],
|
||||
'PLA': ['85A', '90A', '95A HF', 'Aero', 'Basic', 'Basic Gradient', 'CF', 'FR', 'Galaxy', 'GF', 'Glow', 'HF', 'Marble', 'Matte', 'Metal', 'Silk Multi-Color', 'Silk+', 'Sparkle', 'Tough+', 'Translucent', 'Wood'],
|
||||
'TPU': ['85A', '90A', '95A HF'],
|
||||
'PETG': ['Basic', 'CF', 'FR', 'HF', 'Translucent'],
|
||||
'PC': ['CF', 'FR', 'Bez Finisha'],
|
||||
'ASA': ['Bez Finisha'],
|
||||
'PA': ['CF', 'GF', 'Bez Finisha'],
|
||||
'PA6': ['CF', 'GF'],
|
||||
'PAHT': ['CF', 'Bez Finisha'],
|
||||
'PPA': ['CF'],
|
||||
'PVA': ['Bez Finisha'],
|
||||
'HIPS': ['Bez Finisha']
|
||||
};
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const router = useRouter();
|
||||
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
|
||||
@@ -360,8 +314,10 @@ export default function AdminDashboard() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
{/* Main Content - Full Screen */}
|
||||
<div className="w-full">
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
{/* Main Content */}
|
||||
<div className="flex-1">
|
||||
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 lg:py-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
@@ -401,24 +357,6 @@ export default function AdminDashboard() {
|
||||
filaments={filaments}
|
||||
onUpdate={fetchFilaments}
|
||||
/>
|
||||
<button
|
||||
onClick={() => router.push('/upadaj/colors')}
|
||||
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 text-sm sm:text-base"
|
||||
>
|
||||
Boje
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/upadaj/requests')}
|
||||
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-indigo-500 text-white rounded hover:bg-indigo-600 text-sm sm:text-base"
|
||||
>
|
||||
Zahtevi
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm sm:text-base"
|
||||
>
|
||||
Nazad na sajt
|
||||
</button>
|
||||
{mounted && (
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
@@ -688,6 +626,7 @@ export default function AdminDashboard() {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -710,7 +649,7 @@ function FilamentForm({
|
||||
finish: filament.finish || (filament.id ? '' : 'Basic'), // Default to Basic for new filaments
|
||||
boja: filament.boja || '',
|
||||
boja_hex: filament.boja_hex || '',
|
||||
refill: isSpoolOnly(filament.finish, filament.tip) ? 0 : (filament.refill || 0), // Store as number
|
||||
refill: isSpoolOnly(filament.finish, filament.tip, filament.boja) ? 0 : (filament.refill || 0), // Store as number
|
||||
spulna: isRefillOnly(filament.boja || '', filament.finish, filament.tip) ? 0 : (filament.spulna || 0), // Store as number
|
||||
kolicina: filament.kolicina || 0, // Default to 0, stored as number
|
||||
cena: '', // Price is now determined by color selection
|
||||
@@ -787,9 +726,9 @@ function FilamentForm({
|
||||
});
|
||||
} else if (name === 'tip') {
|
||||
// If changing filament type, reset finish if it's not compatible
|
||||
const newTypeFinishes = FINISH_OPTIONS_BY_TYPE[value] || [];
|
||||
const newTypeFinishes = getFinishesForMaterial(value);
|
||||
const resetFinish = !newTypeFinishes.includes(formData.finish);
|
||||
const spoolOnly = isSpoolOnly(formData.finish, value);
|
||||
const spoolOnly = isSpoolOnly(formData.finish, value, formData.boja);
|
||||
// If changing to PPA with CF finish and current color is not black, reset color
|
||||
const needsColorReset = value === 'PPA' && formData.finish === 'CF' && formData.boja.toLowerCase() !== 'black';
|
||||
setFormData({
|
||||
@@ -810,7 +749,7 @@ function FilamentForm({
|
||||
} else if (name === 'finish') {
|
||||
// If changing to Translucent finish, enable spool option and disable refill
|
||||
const refillOnly = isRefillOnly(formData.boja, value, formData.tip);
|
||||
const spoolOnly = isSpoolOnly(value, formData.tip);
|
||||
const spoolOnly = isSpoolOnly(value, formData.tip, formData.boja);
|
||||
// If changing to PPA CF and current color is not black, reset color
|
||||
const needsColorReset = formData.tip === 'PPA' && value === 'CF' && formData.boja.toLowerCase() !== 'black';
|
||||
setFormData({
|
||||
@@ -887,17 +826,9 @@ function FilamentForm({
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<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>
|
||||
{getMaterialOptions().map(mat => (
|
||||
<option key={mat} value={mat}>{mat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -911,7 +842,7 @@ function FilamentForm({
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Izaberi finiš</option>
|
||||
{(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
|
||||
{getFinishesForMaterial(formData.tip).map(finish => (
|
||||
<option key={finish} value={finish}>{finish}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -991,9 +922,9 @@ function FilamentForm({
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="3499"
|
||||
disabled={isSpoolOnly(formData.finish, formData.tip)}
|
||||
disabled={isSpoolOnly(formData.finish, formData.tip, formData.boja)}
|
||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
||||
isSpoolOnly(formData.finish, formData.tip)
|
||||
isSpoolOnly(formData.finish, formData.tip, formData.boja)
|
||||
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
||||
: 'bg-white dark:bg-gray-700'
|
||||
} text-green-600 dark:text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`}
|
||||
@@ -1025,7 +956,7 @@ function FilamentForm({
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Refil
|
||||
{isSpoolOnly(formData.finish, formData.tip) && (
|
||||
{isSpoolOnly(formData.finish, formData.tip, formData.boja) && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo špulna postoji)</span>
|
||||
)}
|
||||
</label>
|
||||
@@ -1037,9 +968,9 @@ function FilamentForm({
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
disabled={isSpoolOnly(formData.finish, formData.tip)}
|
||||
disabled={isSpoolOnly(formData.finish, formData.tip, formData.boja)}
|
||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
|
||||
isSpoolOnly(formData.finish, formData.tip)
|
||||
isSpoolOnly(formData.finish, formData.tip, formData.boja)
|
||||
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
|
||||
: 'bg-white dark:bg-gray-700'
|
||||
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -4,27 +4,8 @@ import { BackToTop } from '../src/components/BackToTop'
|
||||
import { MatomoAnalytics } from '../src/components/MatomoAnalytics'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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',
|
||||
},
|
||||
title: 'Filamenteka',
|
||||
description: 'Automatsko praćenje filamenata sa kodiranjem bojama',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -32,40 +13,27 @@ 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" 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')) {
|
||||
// Apply dark mode immediately for admin pages
|
||||
if (window.location.pathname.startsWith('/upadaj')) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
// For non-admin pages, check localStorage
|
||||
try {
|
||||
var darkMode = localStorage.getItem('darkMode');
|
||||
const darkMode = localStorage.getItem('darkMode');
|
||||
if (darkMode === 'true') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
// Remove no-transitions class after a short delay
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(function() {
|
||||
document.documentElement.classList.remove('no-transitions');
|
||||
@@ -75,10 +43,6 @@ export default function RootLayout({
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
{children}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
557
app/page.tsx
557
app/page.tsx
@@ -1,380 +1,307 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
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';
|
||||
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
|
||||
import { SaleCountdown } from '../src/components/SaleCountdown';
|
||||
import ColorRequestModal from '../src/components/ColorRequestModal';
|
||||
import { Filament } from '../src/types/filament';
|
||||
import { filamentService } from '../src/services/api';
|
||||
import { trackEvent } from '../src/components/MatomoAnalytics';
|
||||
|
||||
export default function Home() {
|
||||
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||
const [showColorRequestModal, setShowColorRequestModal] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [showColorRequestModal, setShowColorRequestModal] = useState(false);
|
||||
// Removed V1/V2 toggle - now only using V2
|
||||
|
||||
const categories = getEnabledCategories();
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
// Initialize dark mode from localStorage after mounting
|
||||
useEffect(() => {
|
||||
const fetchFilaments = async () => {
|
||||
try {
|
||||
const data = await filamentService.getAll();
|
||||
setFilaments(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch filaments for sale check:', err);
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved) {
|
||||
setDarkMode(JSON.parse(saved));
|
||||
}
|
||||
};
|
||||
fetchFilaments();
|
||||
}, []);
|
||||
|
||||
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;
|
||||
})();
|
||||
// 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);
|
||||
|
||||
const filamentsData = await filamentService.getAll();
|
||||
setFilaments(filamentsData);
|
||||
} catch (err: any) {
|
||||
console.error('API Error:', err);
|
||||
|
||||
// More descriptive error messages
|
||||
if (err.code === 'ERR_NETWORK') {
|
||||
setError('Network Error - Unable to connect to API');
|
||||
} else if (err.response) {
|
||||
setError(`Server error: ${err.response.status} - ${err.response.statusText}`);
|
||||
} else if (err.request) {
|
||||
setError('No response from server - check if API is running');
|
||||
} else {
|
||||
setError(err.message || 'Greška pri učitavanju filamenata');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilaments();
|
||||
|
||||
// Auto-refresh data every 30 seconds to stay in sync
|
||||
const interval = setInterval(() => {
|
||||
fetchFilaments();
|
||||
}, 30000);
|
||||
|
||||
// Also refresh when window regains focus
|
||||
const handleFocus = () => {
|
||||
fetchFilaments();
|
||||
};
|
||||
window.addEventListener('focus', handleFocus);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogoClick = () => {
|
||||
setResetKey(prev => prev + 1);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader />
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
{/* Full-screen notice overlay - remove after March 8 */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 px-6 overflow-hidden">
|
||||
{/* Subtle background glow */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-amber-500/5 rounded-full blur-3xl" />
|
||||
|
||||
{/* ═══ 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 */}
|
||||
<div className="relative max-w-xl w-full text-center space-y-10">
|
||||
<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"
|
||||
className="h-32 sm:h-44 w-auto mx-auto drop-shadow-2xl"
|
||||
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>
|
||||
<div className="space-y-6">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full">
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" />
|
||||
<span className="text-amber-400 text-sm font-medium tracking-wide uppercase">Obaveštenje</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
|
||||
Privremena pauza
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-gray-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 className="text-lg sm:text-xl text-gray-400 leading-relaxed max-w-md mx-auto">
|
||||
Trenutno primamo porudžbine samo od postojećih kupaca. Redovna prodaja se vraća nakon <span className="text-amber-400 font-semibold">8. marta</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="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 className="pt-6 border-t border-white/5">
|
||||
<p className="text-gray-600 text-sm">Hvala na razumevanju</p>
|
||||
</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
|
||||
<header className="bg-gradient-to-r from-blue-50 to-orange-50 dark:from-gray-800 dark:to-gray-900 shadow-lg transition-all duration-300">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-0">
|
||||
<div className="flex-1 flex flex-col sm:flex-row justify-center items-center gap-1 sm:gap-3 text-sm sm:text-lg">
|
||||
<span className="text-blue-700 dark:text-blue-300 font-medium animate-pulse text-center">
|
||||
Kupovina po gramu dostupna
|
||||
</span>
|
||||
<span className="hidden sm:inline text-gray-400 dark:text-gray-600">•</span>
|
||||
<span className="text-orange-700 dark:text-orange-300 font-medium animate-pulse text-center">
|
||||
Popust za 5+ komada
|
||||
</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)' }}
|
||||
<div className="flex-shrink-0">
|
||||
{mounted ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDarkMode(!darkMode);
|
||||
trackEvent('UI', 'Dark Mode Toggle', darkMode ? 'Light' : 'Dark');
|
||||
}}
|
||||
className="p-2 bg-white/50 dark:bg-gray-700/50 backdrop-blur text-gray-800 dark:text-gray-200 rounded-full hover:bg-white/80 dark:hover:bg-gray-600/80 transition-all duration-200 shadow-md"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
>
|
||||
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>
|
||||
{darkMode ? '☀️' : '🌙'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-10 h-10" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-3 sm:gap-4">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Logo centered above content */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<button
|
||||
onClick={handleLogoClick}
|
||||
className="group transition-transform duration-200"
|
||||
title="Klikni za reset"
|
||||
>
|
||||
{/* Using next/image would cause preload, so we use regular img with loading="lazy" */}
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Filamenteka"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-36 sm:h-44 md:h-52 w-auto drop-shadow-lg group-hover:drop-shadow-2xl transition-all duration-200"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 mb-8">
|
||||
<a
|
||||
href={KP_URL}
|
||||
href="https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246"
|
||||
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]"
|
||||
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Homepage')}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" 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 className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 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" />
|
||||
Kupi na Kupujem Prodajem
|
||||
<svg className="w-4 h-4" 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>
|
||||
</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]"
|
||||
onClick={() => trackEvent('Contact', 'Phone Call', 'Homepage')}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<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 className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
+381 63 103 1048
|
||||
Pozovi +381 63 103 1048
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Color Request */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowColorRequestModal(true);
|
||||
trackEvent('Navigation', 'Request Color', 'Contact CTA');
|
||||
trackEvent('Navigation', 'Request Color', 'Homepage');
|
||||
}}
|
||||
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"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<svg className="w-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 className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
Ne nalazite boju? Zatrazite novu
|
||||
Zatraži Novu Boju
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SaleCountdown
|
||||
hasActiveSale={filaments.some(f => f.sale_active === true)}
|
||||
maxSalePercentage={Math.max(...filaments.filter(f => f.sale_active === true).map(f => f.sale_percentage || 0), 0)}
|
||||
saleEndDate={(() => {
|
||||
const activeSales = filaments.filter(f => f.sale_active === true && f.sale_end_date);
|
||||
if (activeSales.length === 0) return null;
|
||||
const latestSale = activeSales.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;
|
||||
return latestSale;
|
||||
})()}
|
||||
/>
|
||||
|
||||
{/* Pricing Information */}
|
||||
<div className="mb-6 space-y-4">
|
||||
{/* Reusable Spool Price Notice */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-blue-800 dark:text-blue-200 font-medium">
|
||||
Cena višekratne špulne: 499 RSD
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Selling by Grams Notice */}
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
||||
</svg>
|
||||
<span className="text-green-800 dark:text-green-200 font-semibold">
|
||||
Prodaja filamenta na grame - idealno za testiranje materijala ili manje projekte
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-4 text-sm">
|
||||
<span className="text-green-700 dark:text-green-300 font-medium">50gr - 299 RSD</span>
|
||||
<span className="text-gray-400 dark:text-gray-600">•</span>
|
||||
<span className="text-green-700 dark:text-green-300 font-medium">100gr - 499 RSD</span>
|
||||
<span className="text-gray-400 dark:text-gray-600">•</span>
|
||||
<span className="text-green-700 dark:text-green-300 font-medium">200gr - 949 RSD</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilamentTableV2
|
||||
key={resetKey}
|
||||
filaments={filaments}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
<footer className="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col justify-center items-center gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Kontakt</h3>
|
||||
<a
|
||||
href="tel:+381631031048"
|
||||
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
+381 63 103 1048
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Color Request Modal */}
|
||||
<ColorRequestModal
|
||||
isOpen={showColorRequestModal}
|
||||
onClose={() => setShowColorRequestModal(false)}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
427
app/upadaj/analytics/page.tsx
Normal file
427
app/upadaj/analytics/page.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { analyticsService } from '@/src/services/api';
|
||||
import type { AnalyticsOverview, TopSeller, RevenueDataPoint, InventoryAlert, TypeBreakdown } from '@/src/types/sales';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend } from 'recharts';
|
||||
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||
|
||||
type Period = '7d' | '30d' | '90d' | '6m' | '1y' | 'all';
|
||||
|
||||
const PERIOD_LABELS: Record<Period, string> = {
|
||||
'7d': '7d',
|
||||
'30d': '30d',
|
||||
'90d': '90d',
|
||||
'6m': '6m',
|
||||
'1y': '1y',
|
||||
'all': 'Sve',
|
||||
};
|
||||
|
||||
const PERIOD_GROUP: Record<Period, string> = {
|
||||
'7d': 'day',
|
||||
'30d': 'day',
|
||||
'90d': 'week',
|
||||
'6m': 'month',
|
||||
'1y': 'month',
|
||||
'all': 'month',
|
||||
};
|
||||
|
||||
function formatRSD(value: number): string {
|
||||
return new Intl.NumberFormat('sr-RS', {
|
||||
style: 'currency',
|
||||
currency: 'RSD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default function AnalyticsDashboard() {
|
||||
const router = useRouter();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [period, setPeriod] = useState<Period>('30d');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||
const [topSellers, setTopSellers] = useState<TopSeller[]>([]);
|
||||
const [revenueData, setRevenueData] = useState<RevenueDataPoint[]>([]);
|
||||
const [inventoryAlerts, setInventoryAlerts] = useState<InventoryAlert[]>([]);
|
||||
const [typeBreakdown, setTypeBreakdown] = useState<TypeBreakdown[]>([]);
|
||||
|
||||
// Initialize dark mode
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
setDarkMode(saved !== null ? JSON.parse(saved) : true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
if (darkMode) document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
}, [darkMode, mounted]);
|
||||
|
||||
// Check authentication
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||
window.location.href = '/upadaj';
|
||||
}
|
||||
}, [mounted]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const [overviewData, topSellersData, revenueChartData, alertsData, breakdownData] = await Promise.all([
|
||||
analyticsService.getOverview(period),
|
||||
analyticsService.getTopSellers(period),
|
||||
analyticsService.getRevenueChart(period, PERIOD_GROUP[period]),
|
||||
analyticsService.getInventoryAlerts(),
|
||||
analyticsService.getTypeBreakdown(period),
|
||||
]);
|
||||
setOverview(overviewData);
|
||||
setTopSellers(topSellersData);
|
||||
setRevenueData(revenueChartData);
|
||||
setInventoryAlerts(alertsData);
|
||||
setTypeBreakdown(breakdownData);
|
||||
} catch (err) {
|
||||
setError('Greska pri ucitavanju analitike');
|
||||
console.error('Analytics fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [period]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
router.push('/upadaj');
|
||||
};
|
||||
|
||||
const PIE_COLORS = ['#22c55e', '#3b82f6'];
|
||||
|
||||
const getStockRowClass = (alert: InventoryAlert): string => {
|
||||
if (alert.days_until_stockout === null) return '';
|
||||
if (alert.days_until_stockout < 7) return 'bg-red-50 dark:bg-red-900/20';
|
||||
if (alert.days_until_stockout < 14) return 'bg-yellow-50 dark:bg-yellow-900/20';
|
||||
return 'bg-green-50 dark:bg-green-900/20';
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">Ucitavanje...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1">
|
||||
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Filamenteka"
|
||||
className="h-20 sm:h-32 w-auto drop-shadow-lg"
|
||||
/>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Analitika</h1>
|
||||
</div>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Nazad na sajt
|
||||
</button>
|
||||
{mounted && (
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
>
|
||||
{darkMode ? '\u2600\uFE0F' : '\uD83C\uDF19'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Odjava
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Period Selector */}
|
||||
<div className="mb-6 flex gap-2">
|
||||
{(Object.keys(PERIOD_LABELS) as Period[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-4 py-2 rounded font-medium transition-colors ${
|
||||
period === p
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 shadow'
|
||||
}`}
|
||||
>
|
||||
{PERIOD_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-gray-600 dark:text-gray-400">Ucitavanje analitike...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prihod</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{overview ? formatRSD(overview.revenue) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Broj prodaja</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{overview ? overview.sales_count : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prosecna vrednost</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{overview ? formatRSD(overview.avg_order_value) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Jedinstveni kupci</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{overview ? overview.unique_customers : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue Chart */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Prihod po periodu</h2>
|
||||
<div className="h-80">
|
||||
{mounted && revenueData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={revenueData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={darkMode ? '#374151' : '#e5e7eb'} />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
||||
tickFormatter={(value) => formatRSD(value)}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={((value: number) => [formatRSD(value), 'Prihod']) as any}
|
||||
contentStyle={{
|
||||
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
||||
borderRadius: '0.5rem',
|
||||
color: darkMode ? '#f3f4f6' : '#111827',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#3b82f6', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||
Nema podataka za prikaz
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Top Sellers */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Najprodavanije boje</h2>
|
||||
<div className="h-80">
|
||||
{mounted && topSellers.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topSellers} layout="vertical" margin={{ left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={darkMode ? '#374151' : '#e5e7eb'} />
|
||||
<XAxis type="number" stroke={darkMode ? '#9ca3af' : '#6b7280'} tick={{ fontSize: 12 }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="boja"
|
||||
stroke={darkMode ? '#9ca3af' : '#6b7280'}
|
||||
tick={{ fontSize: 11 }}
|
||||
width={120}
|
||||
tickFormatter={(value: string, index: number) => {
|
||||
const seller = topSellers[index];
|
||||
return seller ? `${value} (${seller.tip})` : value;
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={((value: number) => [value, 'Kolicina']) as any}
|
||||
contentStyle={{
|
||||
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
||||
borderRadius: '0.5rem',
|
||||
color: darkMode ? '#f3f4f6' : '#111827',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="total_qty" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||
Nema podataka za prikaz
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type Breakdown */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Refill vs Spulna</h2>
|
||||
<div className="h-64">
|
||||
{mounted && typeBreakdown.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={typeBreakdown}
|
||||
dataKey="total_qty"
|
||||
nameKey="item_type"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
label={({ name, percent }: any) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||
>
|
||||
{typeBreakdown.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={((value: number, name: string) => [
|
||||
`${value} kom | ${formatRSD(typeBreakdown.find(t => t.item_type === name)?.total_revenue ?? 0)}`,
|
||||
name,
|
||||
]) as any}
|
||||
contentStyle={{
|
||||
backgroundColor: darkMode ? '#1f2937' : '#ffffff',
|
||||
border: `1px solid ${darkMode ? '#374151' : '#e5e7eb'}`,
|
||||
borderRadius: '0.5rem',
|
||||
color: darkMode ? '#f3f4f6' : '#111827',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||
Nema podataka za prikaz
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inventory Alerts */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upozorenja za zalihe</h2>
|
||||
{inventoryAlerts.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Boja</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tip</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Finish</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Refill</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Spulna</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Kolicina</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pros. dnevna prodaja</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Dana do nestanka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{inventoryAlerts.map((alert) => (
|
||||
<tr key={alert.id} className={getStockRowClass(alert)}>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.boja}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.tip}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alert.finish}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">{alert.refill}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">{alert.spulna}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right font-medium">{alert.kolicina}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 text-right">
|
||||
{alert.avg_daily_sales > 0 ? alert.avg_daily_sales.toFixed(1) : 'N/A'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium">
|
||||
{alert.days_until_stockout !== null ? (
|
||||
<span className={
|
||||
alert.days_until_stockout < 7
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: alert.days_until_stockout < 14
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-green-600 dark:text-green-400'
|
||||
}>
|
||||
{Math.round(alert.days_until_stockout)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500 dark:text-gray-400">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400">Sve zalihe su na zadovoljavajucem nivou.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||
import '@/src/styles/select.css';
|
||||
|
||||
interface Color {
|
||||
@@ -180,38 +181,19 @@ export default function ColorsManagement() {
|
||||
};
|
||||
|
||||
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>
|
||||
return <div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">Učitavanje...</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-[#060a14] transition-colors">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 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>
|
||||
<AdminSidebar />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1">
|
||||
<header className="bg-white dark:bg-white/[0.04] shadow transition-colors">
|
||||
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -249,7 +231,7 @@ export default function ColorsManagement() {
|
||||
{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"
|
||||
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 ? '☀️' : '🌙'}
|
||||
@@ -292,7 +274,7 @@ export default function ColorsManagement() {
|
||||
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"
|
||||
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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" />
|
||||
@@ -301,9 +283,9 @@ export default function ColorsManagement() {
|
||||
</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]">
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<input
|
||||
@@ -316,36 +298,36 @@ export default function ColorsManagement() {
|
||||
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]"
|
||||
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-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</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>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Boja</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Naziv</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Hex kod</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Cena Refil</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Cena Spulna</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-white/[0.04] divide-y divide-gray-200 dark:divide-white/[0.06]">
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{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]">
|
||||
<tr key={color.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<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]"
|
||||
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-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</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]"
|
||||
className="w-10 h-10 rounded border-2 border-gray-300 dark:border-gray-600"
|
||||
style={{ backgroundColor: color.hex }}
|
||||
/>
|
||||
</td>
|
||||
@@ -388,10 +370,24 @@ export default function ColorsManagement() {
|
||||
|
||||
{/* 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="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setEditingColor(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 max-w-md w-full max-h-[90vh] overflow-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center p-6 pb-0">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Izmeni boju</h3>
|
||||
<button
|
||||
onClick={() => setEditingColor(null)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Izmeni boju</h3>
|
||||
<ColorForm
|
||||
color={editingColor}
|
||||
onSave={(color) => {
|
||||
@@ -454,7 +450,7 @@ function ColorForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isModal ? "" : "mb-8 p-6 bg-white dark:bg-white/[0.04] rounded-lg shadow transition-colors"}>
|
||||
<div className={isModal ? "" : "mb-8 p-6 bg-white dark:bg-gray-800 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'}
|
||||
@@ -462,7 +458,7 @@ function ColorForm({
|
||||
)}
|
||||
<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>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Naziv boje</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
@@ -470,12 +466,12 @@ function ColorForm({
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Hex kod boje</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Hex kod boje</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
@@ -483,7 +479,7 @@ function ColorForm({
|
||||
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'}`}
|
||||
className={`w-12 h-12 p-1 border-2 border-gray-300 dark:border-gray-600 rounded-md ${isBambuLabColor ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
||||
style={{ backgroundColor: isBambuLabColor && bambuHex ? bambuHex : formData.hex }}
|
||||
/>
|
||||
<input
|
||||
@@ -495,18 +491,18 @@ function ColorForm({
|
||||
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' : ''}`}
|
||||
className={`flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}`}
|
||||
/>
|
||||
</div>
|
||||
{isBambuLabColor && (
|
||||
<p className="text-xs text-gray-500 dark:text-white/40 mt-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 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>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Cena Refil</label>
|
||||
<input
|
||||
type="number"
|
||||
name="cena_refill"
|
||||
@@ -516,12 +512,12 @@ function ColorForm({
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Cena Spulna</label>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Cena Spulna</label>
|
||||
<input
|
||||
type="number"
|
||||
name="cena_spulna"
|
||||
@@ -531,7 +527,7 @@ function ColorForm({
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -539,7 +535,7 @@ function ColorForm({
|
||||
<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"
|
||||
className="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
|
||||
634
app/upadaj/customers/page.tsx
Normal file
634
app/upadaj/customers/page.tsx
Normal file
@@ -0,0 +1,634 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { customerService } from '@/src/services/api';
|
||||
import { Customer, Sale } from '@/src/types/sales';
|
||||
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||
|
||||
interface CustomerWithSales extends Customer {
|
||||
sales?: Sale[];
|
||||
total_purchases?: number;
|
||||
}
|
||||
|
||||
export default function CustomersManagement() {
|
||||
const router = useRouter();
|
||||
const [customers, setCustomers] = useState<CustomerWithSales[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedCustomerId, setExpandedCustomerId] = useState<string | null>(null);
|
||||
const [expandedSales, setExpandedSales] = useState<Sale[]>([]);
|
||||
const [loadingSales, setLoadingSales] = useState(false);
|
||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||
const [editForm, setEditForm] = useState<Partial<Customer>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ name: '', phone: '', city: '', notes: '' });
|
||||
const [addError, setAddError] = useState('');
|
||||
const [addSaving, setAddSaving] = useState(false);
|
||||
|
||||
// Initialize dark mode
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved !== null) {
|
||||
setDarkMode(JSON.parse(saved));
|
||||
} else {
|
||||
setDarkMode(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [darkMode, mounted]);
|
||||
|
||||
// Check authentication
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||
window.location.href = '/upadaj';
|
||||
}
|
||||
}, [mounted]);
|
||||
|
||||
// Fetch customers
|
||||
const fetchCustomers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await customerService.getAll();
|
||||
setCustomers(data);
|
||||
} catch (err) {
|
||||
setError('Greska pri ucitavanju kupaca');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers();
|
||||
}, [fetchCustomers]);
|
||||
|
||||
// Search customers
|
||||
const filteredCustomers = customers.filter((customer) => {
|
||||
if (!searchTerm) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
customer.name.toLowerCase().includes(term) ||
|
||||
(customer.phone && customer.phone.toLowerCase().includes(term)) ||
|
||||
(customer.city && customer.city.toLowerCase().includes(term))
|
||||
);
|
||||
});
|
||||
|
||||
// Toggle expanded row to show purchase history
|
||||
const handleToggleExpand = async (customerId: string) => {
|
||||
if (expandedCustomerId === customerId) {
|
||||
setExpandedCustomerId(null);
|
||||
setExpandedSales([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedCustomerId(customerId);
|
||||
setLoadingSales(true);
|
||||
try {
|
||||
const data = await customerService.getById(customerId);
|
||||
setExpandedSales(data.sales || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching customer sales:', err);
|
||||
setExpandedSales([]);
|
||||
} finally {
|
||||
setLoadingSales(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Edit customer
|
||||
const handleStartEdit = (customer: Customer) => {
|
||||
setEditingCustomer(customer);
|
||||
setEditForm({
|
||||
name: customer.name,
|
||||
phone: customer.phone || '',
|
||||
city: customer.city || '',
|
||||
notes: customer.notes || '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingCustomer(null);
|
||||
setEditForm({});
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingCustomer) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await customerService.update(editingCustomer.id, editForm);
|
||||
setEditingCustomer(null);
|
||||
setEditForm({});
|
||||
await fetchCustomers();
|
||||
} catch (err) {
|
||||
setError('Greska pri cuvanju izmena');
|
||||
console.error('Save error:', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCustomer = async () => {
|
||||
if (!addForm.name.trim()) {
|
||||
setAddError('Ime je obavezno');
|
||||
return;
|
||||
}
|
||||
setAddSaving(true);
|
||||
setAddError('');
|
||||
try {
|
||||
await customerService.create({
|
||||
name: addForm.name.trim(),
|
||||
phone: addForm.phone.trim() || undefined,
|
||||
city: addForm.city.trim() || undefined,
|
||||
notes: addForm.notes.trim() || undefined,
|
||||
});
|
||||
setShowAddModal(false);
|
||||
setAddForm({ name: '', phone: '', city: '', notes: '' });
|
||||
await fetchCustomers();
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { code?: string } } };
|
||||
if (error.response?.data?.code === '23505') {
|
||||
setAddError('Kupac sa ovim brojem telefona vec postoji');
|
||||
} else {
|
||||
setAddError('Greska pri dodavanju kupca');
|
||||
}
|
||||
} finally {
|
||||
setAddSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
router.push('/upadaj');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('sr-Latn-RS', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('sr-Latn-RS', {
|
||||
style: 'decimal',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount) + ' RSD';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">Ucitavanje...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1">
|
||||
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Filamenteka"
|
||||
className="h-20 sm:h-32 w-auto drop-shadow-lg"
|
||||
/>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Upravljanje kupcima
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Nazad na sajt
|
||||
</button>
|
||||
{mounted && (
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
>
|
||||
{darkMode ? '\u2600' : '\u263D'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Odjava
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
||||
{error}
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
className="ml-4 text-red-600 dark:text-red-300 underline text-sm"
|
||||
>
|
||||
Zatvori
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar + Add button */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pretrazi kupce..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full max-w-md px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 whitespace-nowrap font-medium"
|
||||
>
|
||||
+ Dodaj kupca
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Customer count */}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Ukupno kupaca: {filteredCustomers.length}
|
||||
</p>
|
||||
|
||||
{/* Customer table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Ime
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Telefon
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Grad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Beleske
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Datum registracije
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Akcije
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredCustomers.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-6 py-8 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{searchTerm ? 'Nema rezultata pretrage' : 'Nema registrovanih kupaca'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredCustomers.map((customer) => (
|
||||
<>
|
||||
<tr
|
||||
key={customer.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
{editingCustomer?.id === customer.id ? (
|
||||
<>
|
||||
<td className="px-6 py-4">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.name || ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, name: e.target.value })
|
||||
}
|
||||
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.phone || ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, phone: e.target.value })
|
||||
}
|
||||
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.city || ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, city: e.target.value })
|
||||
}
|
||||
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<textarea
|
||||
value={editForm.notes || ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, notes: e.target.value })
|
||||
}
|
||||
rows={2}
|
||||
placeholder="npr. stampa figurice, obicno crna..."
|
||||
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(customer.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '...' : 'Sacuvaj'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="px-3 py-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 text-sm rounded hover:bg-gray-400 dark:hover:bg-gray-500"
|
||||
>
|
||||
Otkazi
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{customer.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{customer.phone || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{customer.city || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs whitespace-pre-wrap">
|
||||
{customer.notes || <span className="text-gray-400 dark:text-gray-600 italic">Nema beleski</span>}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(customer.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleStartEdit(customer)}
|
||||
className="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600"
|
||||
>
|
||||
Izmeni
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleExpand(customer.id)}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
expandedCustomerId === customer.id
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||
: 'bg-indigo-100 dark:bg-indigo-900 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-800'
|
||||
}`}
|
||||
>
|
||||
Istorija kupovina
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
{/* Expanded purchase history */}
|
||||
{expandedCustomerId === customer.id && (
|
||||
<tr key={`${customer.id}-sales`}>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50"
|
||||
>
|
||||
<div className="ml-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Istorija kupovina - {customer.name}
|
||||
</h4>
|
||||
{loadingSales ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Ucitavanje...
|
||||
</p>
|
||||
) : expandedSales.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Nema evidentiranih kupovina
|
||||
</p>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded overflow-hidden">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
Datum
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
Stavke
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
Iznos
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
Napomena
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{expandedSales.map((sale) => (
|
||||
<tr key={sale.id}>
|
||||
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||
{sale.id.substring(0, 8)}...
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatDate(sale.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{sale.item_count ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white font-medium">
|
||||
{formatCurrency(sale.total_amount)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{sale.notes || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-right"
|
||||
>
|
||||
Ukupno ({expandedSales.length} kupovina):
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
{formatCurrency(
|
||||
expandedSales.reduce(
|
||||
(sum, sale) => sum + sale.total_amount,
|
||||
0
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Customer Modal */}
|
||||
{showAddModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={() => setShowAddModal(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Dodaj kupca
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addError && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded text-sm">
|
||||
{addError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Ime *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addForm.name}
|
||||
onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
placeholder="Ime i prezime"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Telefon
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addForm.phone}
|
||||
onChange={(e) => setAddForm({ ...addForm, phone: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
placeholder="Broj telefona"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Grad
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addForm.city}
|
||||
onChange={(e) => setAddForm({ ...addForm, city: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
placeholder="Grad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Napomena
|
||||
</label>
|
||||
<textarea
|
||||
value={addForm.notes}
|
||||
onChange={(e) => setAddForm({ ...addForm, notes: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
placeholder="npr. stampa figurice, obicno crna..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Otkazi
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddCustomer}
|
||||
disabled={addSaving}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{addSaving ? 'Cuvanje...' : 'Dodaj'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export function generateStaticParams() {
|
||||
return [
|
||||
{ category: 'stampaci' },
|
||||
{ category: 'ploce' },
|
||||
{ category: 'mlaznice' },
|
||||
{ category: 'delovi' },
|
||||
{ category: 'oprema' },
|
||||
];
|
||||
}
|
||||
|
||||
export default function CategoryLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,990 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'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>;
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,15 @@ export default function AdminLogin() {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Redirect to dashboard if already authenticated
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
if (token && expiry && Date.now() < parseInt(expiry)) {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set dark mode by default
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
@@ -33,7 +42,7 @@ export default function AdminLogin() {
|
||||
trackEvent('Admin', 'Login', 'Success');
|
||||
|
||||
// Redirect to admin dashboard using window.location for static export
|
||||
window.location.href = '/upadaj/dashboard';
|
||||
window.location.href = '/dashboard';
|
||||
} catch (err: any) {
|
||||
setError('Neispravno korisničko ime ili lozinka');
|
||||
console.error('Login error:', err);
|
||||
@@ -44,7 +53,7 @@ export default function AdminLogin() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#060a14] py-12 px-4 sm:px-6 lg:px-8">
|
||||
<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="max-w-md w-full space-y-8">
|
||||
<div className="flex flex-col items-center">
|
||||
<img
|
||||
@@ -52,23 +61,20 @@ export default function AdminLogin() {
|
||||
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)' }}
|
||||
>
|
||||
<h2 className="text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Admin Prijava
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-white/40">
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Prijavite se za upravljanje filamentima
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<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 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>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Korisničko ime
|
||||
@@ -79,7 +85,7 @@ export default function AdminLogin() {
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
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]"
|
||||
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"
|
||||
placeholder="Korisničko ime"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -95,7 +101,7 @@ export default function AdminLogin() {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
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]"
|
||||
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"
|
||||
placeholder="Lozinka"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -107,8 +113,7 @@ export default function AdminLogin() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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)' }}
|
||||
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"
|
||||
>
|
||||
{loading ? 'Prijavljivanje...' : 'Prijavite se'}
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { colorRequestService } from '@/src/services/api';
|
||||
import Link from 'next/link';
|
||||
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||
|
||||
interface ColorRequest {
|
||||
id: string;
|
||||
@@ -89,7 +90,7 @@ export default function ColorRequestsAdmin() {
|
||||
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';
|
||||
return colors[status as keyof typeof colors] || 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
@@ -112,31 +113,19 @@ export default function ColorRequestsAdmin() {
|
||||
|
||||
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 className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-gray-500 dark:text-gray-400">Učitavanje zahteva za boje...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-[#060a14]">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
<div className="flex-1 p-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
|
||||
<div 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 && (
|
||||
@@ -145,42 +134,42 @@ export default function ColorRequestsAdmin() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-white/[0.04] rounded-lg shadow overflow-hidden">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-100 dark:bg-white/[0.06]">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Boja
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Materijal/Finiš
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Broj Zahteva
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Korisnik
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Datum
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Akcije
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-white/[0.04] divide-y divide-gray-200 dark:divide-white/[0.06]">
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{requests.map((request) => (
|
||||
<tr key={request.id} className="hover:bg-gray-50 dark:hover:bg-white/[0.06]">
|
||||
<tr key={request.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{request.color_name}</div>
|
||||
{request.description && (
|
||||
<div className="text-sm text-gray-500 dark:text-white/40 mt-1">{request.description}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">{request.description}</div>
|
||||
)}
|
||||
{request.reference_url && (
|
||||
<a
|
||||
@@ -198,7 +187,7 @@ export default function ColorRequestsAdmin() {
|
||||
<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 className="text-gray-500 dark:text-gray-400">{request.finish_type}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -264,7 +253,7 @@ export default function ColorRequestsAdmin() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm text-gray-600 dark:text-white/40">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatDate(request.created_at)}
|
||||
</div>
|
||||
{request.processed_at && (
|
||||
@@ -287,7 +276,7 @@ export default function ColorRequestsAdmin() {
|
||||
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"
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
@@ -322,33 +311,33 @@ export default function ColorRequestsAdmin() {
|
||||
</div>
|
||||
|
||||
{requests.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-white/40">
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Nema zahteva za boje
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-white/40">Ukupno Zahteva</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">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="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Na Čekanju</div>
|
||||
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{requests.filter(r => r.status === 'pending').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-white/40">Odobreno</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Odobreno</div>
|
||||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{requests.filter(r => r.status === 'approved').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-white/40">Završeno</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Završeno</div>
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{requests.filter(r => r.status === 'completed').length}
|
||||
</div>
|
||||
@@ -356,5 +345,6 @@ export default function ColorRequestsAdmin() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
827
app/upadaj/sales/page.tsx
Normal file
827
app/upadaj/sales/page.tsx
Normal file
@@ -0,0 +1,827 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { saleService, customerService, filamentService, colorService } from '@/src/services/api';
|
||||
import { Customer, Sale, SaleItem, CreateSaleRequest } from '@/src/types/sales';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { AdminSidebar } from '@/src/components/AdminSidebar';
|
||||
|
||||
interface LineItem {
|
||||
filament_id: string;
|
||||
item_type: 'refill' | 'spulna';
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export default function SalesPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
// Sales list state
|
||||
const [sales, setSales] = useState<Sale[]>([]);
|
||||
const [totalSales, setTotalSales] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Modal state
|
||||
const [showNewSaleModal, setShowNewSaleModal] = useState(false);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [selectedSale, setSelectedSale] = useState<Sale | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
// Dark mode init
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
setDarkMode(saved !== null ? JSON.parse(saved) : true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
if (darkMode) document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
}, [darkMode, mounted]);
|
||||
|
||||
// Auth check
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||
window.location.href = '/upadaj';
|
||||
}
|
||||
}, [mounted]);
|
||||
|
||||
// Fetch sales
|
||||
const fetchSales = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const data = await saleService.getAll(page, LIMIT);
|
||||
setSales(data.sales);
|
||||
setTotalSales(data.total);
|
||||
} catch {
|
||||
setError('Greska pri ucitavanju prodaja');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
fetchSales();
|
||||
}, [mounted, fetchSales]);
|
||||
|
||||
const totalPages = Math.ceil(totalSales / LIMIT);
|
||||
|
||||
const handleViewDetail = async (sale: Sale) => {
|
||||
try {
|
||||
setDetailLoading(true);
|
||||
setShowDetailModal(true);
|
||||
const detail = await saleService.getById(sale.id);
|
||||
setSelectedSale(detail);
|
||||
} catch {
|
||||
setError('Greska pri ucitavanju detalja prodaje');
|
||||
setShowDetailModal(false);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSale = async (id: string) => {
|
||||
if (!window.confirm('Da li ste sigurni da zelite da obrisete ovu prodaju?')) return;
|
||||
try {
|
||||
await saleService.delete(id);
|
||||
setShowDetailModal(false);
|
||||
setSelectedSale(null);
|
||||
fetchSales();
|
||||
} catch {
|
||||
setError('Greska pri brisanju prodaje');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('sr-RS', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatPrice = (amount: number) => {
|
||||
return amount.toLocaleString('sr-RS') + ' RSD';
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex">
|
||||
<AdminSidebar />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 p-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Prodaja</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{darkMode ? 'Svetli mod' : 'Tamni mod'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewSaleModal(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors font-medium"
|
||||
>
|
||||
Nova prodaja
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sales Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Ucitavanje...</div>
|
||||
) : sales.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Nema prodaja</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Datum
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Ime kupca
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Stavki
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Cena
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Akcije
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sales.map((sale) => (
|
||||
<tr key={sale.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{formatDate(sale.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{sale.customer_name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
{sale.item_count ?? 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formatPrice(sale.total_amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm space-x-2">
|
||||
<button
|
||||
onClick={() => handleViewDetail(sale)}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Detalji
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSale(sale.id)}
|
||||
className="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Obrisi
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex justify-center items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Prethodna
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Strana {page} od {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Sledeca
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Sale Modal */}
|
||||
{showNewSaleModal && (
|
||||
<NewSaleModal
|
||||
onClose={() => setShowNewSaleModal(false)}
|
||||
onCreated={() => {
|
||||
setShowNewSaleModal(false);
|
||||
fetchSales();
|
||||
}}
|
||||
formatPrice={formatPrice}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sale Detail Modal */}
|
||||
{showDetailModal && (
|
||||
<SaleDetailModal
|
||||
sale={selectedSale}
|
||||
loading={detailLoading}
|
||||
onClose={() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedSale(null);
|
||||
}}
|
||||
onDelete={(id) => handleDeleteSale(id)}
|
||||
formatPrice={formatPrice}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- NewSaleModal ---
|
||||
|
||||
function NewSaleModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
formatPrice,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
formatPrice: (n: number) => string;
|
||||
}) {
|
||||
const [customerName, setCustomerName] = useState('');
|
||||
const [customerPhone, setCustomerPhone] = useState('');
|
||||
const [customerCity, setCustomerCity] = useState('');
|
||||
const [customerNotes, setCustomerNotes] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [items, setItems] = useState<LineItem[]>([{ filament_id: '', item_type: 'refill', quantity: 1 }]);
|
||||
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||
const [colorPrices, setColorPrices] = useState<Record<string, { cena_refill: number; cena_spulna: number }>>({});
|
||||
const [customerSuggestions, setCustomerSuggestions] = useState<Customer[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [filamentData, colorData] = await Promise.all([
|
||||
filamentService.getAll(),
|
||||
colorService.getAll(),
|
||||
]);
|
||||
setFilaments(filamentData);
|
||||
const priceMap: Record<string, { cena_refill: number; cena_spulna: number }> = {};
|
||||
for (const c of colorData) {
|
||||
priceMap[c.name] = { cena_refill: c.cena_refill || 3499, cena_spulna: c.cena_spulna || 3999 };
|
||||
}
|
||||
setColorPrices(priceMap);
|
||||
} catch {
|
||||
// Data failed to load
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Close suggestions on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (suggestionsRef.current && !suggestionsRef.current.contains(e.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const handleCustomerSearch = (value: string) => {
|
||||
setCustomerName(value);
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
if (value.length < 2) {
|
||||
setCustomerSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
return;
|
||||
}
|
||||
searchTimeout.current = setTimeout(async () => {
|
||||
try {
|
||||
const results = await customerService.search(value);
|
||||
setCustomerSuggestions(results);
|
||||
setShowSuggestions(results.length > 0);
|
||||
} catch {
|
||||
setCustomerSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const selectCustomer = (customer: Customer) => {
|
||||
setCustomerName(customer.name);
|
||||
setCustomerPhone(customer.phone || '');
|
||||
setCustomerCity(customer.city || '');
|
||||
setCustomerNotes(customer.notes || '');
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
const getFilamentById = (id: string): Filament | undefined => {
|
||||
return filaments.find((f) => String(f.id) === String(id));
|
||||
};
|
||||
|
||||
const getFilamentPrice = (filament: Filament, type: 'refill' | 'spulna'): number => {
|
||||
const prices = colorPrices[filament.boja];
|
||||
const base = type === 'refill' ? (prices?.cena_refill || 3499) : (prices?.cena_spulna || 3999);
|
||||
if (filament.sale_active && filament.sale_percentage) {
|
||||
return Math.round(base * (1 - filament.sale_percentage / 100));
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
const getItemPrice = (item: LineItem): number => {
|
||||
const filament = getFilamentById(item.filament_id);
|
||||
if (!filament) return 0;
|
||||
return getFilamentPrice(filament, item.item_type) * item.quantity;
|
||||
};
|
||||
|
||||
const totalAmount = items.reduce((sum, item) => sum + getItemPrice(item), 0);
|
||||
|
||||
const getAvailableStock = (filament: Filament, type: 'refill' | 'spulna'): number => {
|
||||
return type === 'refill' ? filament.refill : filament.spulna;
|
||||
};
|
||||
|
||||
const updateItem = (index: number, updates: Partial<LineItem>) => {
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], ...updates };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
if (items.length <= 1) return;
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
setItems((prev) => [...prev, { filament_id: '', item_type: 'refill', quantity: 1 }]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!customerName.trim()) {
|
||||
setSubmitError('Ime kupca je obavezno');
|
||||
return;
|
||||
}
|
||||
const validItems = items.filter((item) => item.filament_id !== '' && item.quantity > 0);
|
||||
if (validItems.length === 0) {
|
||||
setSubmitError('Dodajte bar jednu stavku');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate stock
|
||||
for (const item of validItems) {
|
||||
const filament = getFilamentById(item.filament_id);
|
||||
if (!filament) {
|
||||
setSubmitError('Nevalidan filament');
|
||||
return;
|
||||
}
|
||||
const available = getAvailableStock(filament, item.item_type);
|
||||
if (item.quantity > available) {
|
||||
setSubmitError(
|
||||
`Nedovoljno zaliha za ${filament.boja} ${filament.tip} (${item.item_type}): dostupno ${available}, trazeno ${item.quantity}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: CreateSaleRequest = {
|
||||
customer: {
|
||||
name: customerName.trim(),
|
||||
phone: customerPhone.trim() || undefined,
|
||||
city: customerCity.trim() || undefined,
|
||||
notes: customerNotes.trim() || undefined,
|
||||
},
|
||||
items: validItems.map((item) => ({
|
||||
filament_id: item.filament_id,
|
||||
item_type: item.item_type,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
await saleService.create(payload);
|
||||
onCreated();
|
||||
} catch {
|
||||
setSubmitError('Greska pri kreiranju prodaje');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Nova prodaja</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 rounded text-sm">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customer Section */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Kupac</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative" ref={suggestionsRef}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Ime kupca *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customerName}
|
||||
onChange={(e) => handleCustomerSearch(e.target.value)}
|
||||
onFocus={() => customerSuggestions.length > 0 && setShowSuggestions(true)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Ime i prezime"
|
||||
/>
|
||||
{showSuggestions && customerSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-lg max-h-40 overflow-y-auto">
|
||||
{customerSuggestions.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => selectCustomer(c)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-900 dark:text-white"
|
||||
>
|
||||
<span className="font-medium">{c.name}</span>
|
||||
{c.city && (
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-2">({c.city})</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Telefon
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customerPhone}
|
||||
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Broj telefona"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Grad
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customerCity}
|
||||
onChange={(e) => setCustomerCity(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Grad"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beleske o kupcu</div>
|
||||
<textarea
|
||||
value={customerNotes}
|
||||
onChange={(e) => setCustomerNotes(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="npr. stampa figurice, obicno koristi crnu..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Stavke</h3>
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Dodaj stavku
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => {
|
||||
const filament = getFilamentById(item.filament_id);
|
||||
const available = filament ? getAvailableStock(filament, item.item_type) : 0;
|
||||
const itemPrice = getItemPrice(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-wrap gap-3 items-end p-3 bg-gray-50 dark:bg-gray-700/50 rounded border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Filament
|
||||
</label>
|
||||
<select
|
||||
value={item.filament_id}
|
||||
onChange={(e) => updateItem(index, { filament_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value={0}>-- Izaberite filament --</option>
|
||||
{filaments
|
||||
.filter((f) => f.kolicina > 0)
|
||||
.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.boja} - {f.tip} {f.finish} (refill: {f.refill}, spulna: {f.spulna})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Tip
|
||||
</label>
|
||||
<select
|
||||
value={item.item_type}
|
||||
onChange={(e) =>
|
||||
updateItem(index, {
|
||||
item_type: e.target.value as 'refill' | 'spulna',
|
||||
quantity: 1,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="refill">Refill</option>
|
||||
<option value="spulna">Spulna</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Kolicina {filament ? `(max ${available})` : ''}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={available || undefined}
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(index, { quantity: Math.max(1, Number(e.target.value)) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32 text-right">
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Cena
|
||||
</label>
|
||||
<div className="px-3 py-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{itemPrice > 0 ? formatPrice(itemPrice) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => removeItem(index)}
|
||||
disabled={items.length <= 1}
|
||||
className="px-3 py-2 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Ukloni
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-700 rounded flex justify-between items-center">
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">Ukupno:</span>
|
||||
<span className="text-xl font-bold text-gray-900 dark:text-white">{formatPrice(totalAmount)}</span>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Beleske
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Napomena za prodaju..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
|
||||
>
|
||||
Otkazi
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{submitting ? 'Cuvanje...' : 'Sacuvaj'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- SaleDetailModal ---
|
||||
|
||||
function SaleDetailModal({
|
||||
sale,
|
||||
loading,
|
||||
onClose,
|
||||
onDelete,
|
||||
formatPrice,
|
||||
formatDate,
|
||||
}: {
|
||||
sale: Sale | null;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
formatPrice: (n: number) => string;
|
||||
formatDate: (d?: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Detalji prodaje</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Ucitavanje...</div>
|
||||
) : !sale ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">Prodaja nije pronadjena</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Customer Info */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Kupac</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Ime: </span>
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
{sale.customer_name || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Telefon: </span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{sale.customer_phone || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Datum: </span>
|
||||
<span className="text-gray-900 dark:text-white">{formatDate(sale.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Stavke</h3>
|
||||
{sale.items && sale.items.length > 0 ? (
|
||||
<div className="border border-gray-200 dark:border-gray-600 rounded overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Filament
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Tip
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Kolicina
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Cena
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-600">
|
||||
{sale.items.map((item: SaleItem) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white">
|
||||
{item.filament_boja} - {item.filament_tip} {item.filament_finish}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 capitalize">
|
||||
{item.item_type}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white text-right">
|
||||
{item.quantity}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white text-right font-medium">
|
||||
{formatPrice(item.unit_price * item.quantity)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Nema stavki</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div className="mb-6 p-4 bg-gray-100 dark:bg-gray-700 rounded flex justify-between items-center">
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">Ukupno:</span>
|
||||
<span className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{formatPrice(sale.total_amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{sale.notes && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Beleske</h3>
|
||||
<p className="text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-gray-700/50 p-3 rounded">
|
||||
{sale.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Da li ste sigurni da zelite da obrisete ovu prodaju?')) {
|
||||
onDelete(sale.id);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Obrisi prodaju
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
|
||||
>
|
||||
Zatvori
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
16
database/migrations/021_add_customers_table.sql
Normal file
16
database/migrations/021_add_customers_table.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Add customers table for tracking buyers
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE customers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(50),
|
||||
city VARCHAR(255),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Phone is the natural dedup key
|
||||
CREATE UNIQUE INDEX idx_customers_phone ON customers (phone) WHERE phone IS NOT NULL;
|
||||
CREATE INDEX idx_customers_name ON customers (name);
|
||||
@@ -1,32 +0,0 @@
|
||||
-- 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);
|
||||
24
database/migrations/022_add_sales_tables.sql
Normal file
24
database/migrations/022_add_sales_tables.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Add sales and sale_items tables for tracking transactions
|
||||
CREATE TABLE sales (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
|
||||
total_amount INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE sale_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
sale_id UUID NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
|
||||
filament_id UUID NOT NULL REFERENCES filaments(id) ON DELETE RESTRICT,
|
||||
item_type VARCHAR(10) NOT NULL CHECK (item_type IN ('refill', 'spulna')),
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
unit_price INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sales_customer_id ON sales (customer_id);
|
||||
CREATE INDEX idx_sales_created_at ON sales (created_at);
|
||||
CREATE INDEX idx_sale_items_sale_id ON sale_items (sale_id);
|
||||
CREATE INDEX idx_sale_items_filament_id ON sale_items (filament_id);
|
||||
@@ -1,25 +0,0 @@
|
||||
-- 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');
|
||||
100
database/migrations/023_cleanup_fake_colors_add_missing.sql
Normal file
100
database/migrations/023_cleanup_fake_colors_add_missing.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
-- Migration 023: Clean up fake colors and add missing real Bambu Lab colors
|
||||
-- Source of truth: bambu_lab_filaments.numbers spreadsheet
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Rename typo: IronGray Metallic → Iron Gray Metallic (FK cascades to filaments)
|
||||
UPDATE colors SET name = 'Iron Gray Metallic' WHERE name = 'IronGray Metallic';
|
||||
|
||||
-- 2. Delete 67 fake colors not used by any filament
|
||||
DELETE FROM colors WHERE name IN (
|
||||
'Coton Candy Cloud',
|
||||
'Glow in the Dark Blue',
|
||||
'Glow in the Dark Green',
|
||||
'Grey',
|
||||
'Indingo Purple',
|
||||
'Ivory',
|
||||
'Ivory White',
|
||||
'Light Blue',
|
||||
'Light Green',
|
||||
'Matte Black',
|
||||
'Matte Blue',
|
||||
'Matte Brown',
|
||||
'Matte Coral',
|
||||
'Matte Green',
|
||||
'Matte Grey',
|
||||
'Matte Lime',
|
||||
'Matte Mint',
|
||||
'Matte Navy',
|
||||
'Matte Orange',
|
||||
'Matte Pink',
|
||||
'Matte Purple',
|
||||
'Matte White',
|
||||
'Matte Yellow',
|
||||
'Metal Bronze',
|
||||
'Metal Copper',
|
||||
'Metal Gold',
|
||||
'Metal Grey',
|
||||
'Metal Silver',
|
||||
'Mint Green',
|
||||
'Natural',
|
||||
'Navy Blue',
|
||||
'Nebulane',
|
||||
'Silk Black',
|
||||
'Silk Blue',
|
||||
'Silk Bronze',
|
||||
'Silk Copper',
|
||||
'Silk Emerald',
|
||||
'Silk Gold',
|
||||
'Silk Green',
|
||||
'Silk Jade',
|
||||
'Silk Orange',
|
||||
'Silk Pearl',
|
||||
'Silk Pink',
|
||||
'Silk Purple',
|
||||
'Silk Red',
|
||||
'Silk Rose Gold',
|
||||
'Silk Ruby',
|
||||
'Silk Sapphire',
|
||||
'Silk Silver',
|
||||
'Silk White',
|
||||
'Sky Blue',
|
||||
'Sparkle Blue',
|
||||
'Sparkle Gold',
|
||||
'Sparkle Green',
|
||||
'Sparkle Purple',
|
||||
'Sparkle Red',
|
||||
'Sparkle Silver',
|
||||
'Support G',
|
||||
'Support White',
|
||||
'Translucent Tea',
|
||||
'Transparent Blue',
|
||||
'Transparent Green',
|
||||
'Transparent Orange',
|
||||
'Transparent Purple',
|
||||
'Transparent Red',
|
||||
'Transparent Yellow',
|
||||
'Violet'
|
||||
);
|
||||
|
||||
-- 3. Add 17 missing real Bambu Lab colors from spreadsheet
|
||||
INSERT INTO colors (name, hex) VALUES
|
||||
('Aurora Purple', '#285BB7'),
|
||||
('Candy Green', '#408619'),
|
||||
('Candy Red', '#BB3A2E'),
|
||||
('Crystal Blue', '#5BC0EB'),
|
||||
('Flesh', '#E8C4A2'),
|
||||
('Forest Green', '#415520'),
|
||||
('Grape Jelly', '#6B2D75'),
|
||||
('Lake Blue', '#4672E4'),
|
||||
('Lime', '#C5ED48'),
|
||||
('Mystic Magenta', '#720062'),
|
||||
('Nebulae', '#424379'),
|
||||
('Neon Green', '#ABFF1E'),
|
||||
('Phantom Blue', '#00629B'),
|
||||
('Quicksilver', '#A6A9AA'),
|
||||
('Rose Gold', '#B29593'),
|
||||
('Translucent Teal', '#008080')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,12 +0,0 @@
|
||||
-- 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);
|
||||
@@ -4,7 +4,7 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Colors table
|
||||
CREATE TABLE colors (
|
||||
CREATE TABLE IF NOT EXISTS colors (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
hex VARCHAR(7) NOT NULL,
|
||||
@@ -15,7 +15,7 @@ CREATE TABLE colors (
|
||||
);
|
||||
|
||||
-- Filaments table
|
||||
CREATE TABLE filaments (
|
||||
CREATE TABLE IF NOT EXISTS filaments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tip VARCHAR(50) NOT NULL,
|
||||
finish VARCHAR(50) NOT NULL,
|
||||
@@ -32,9 +32,9 @@ CREATE TABLE filaments (
|
||||
);
|
||||
|
||||
-- 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 INDEX IF NOT EXISTS idx_filaments_tip ON filaments(tip);
|
||||
CREATE INDEX IF NOT EXISTS idx_filaments_boja ON filaments(boja);
|
||||
CREATE INDEX IF NOT EXISTS idx_filaments_created_at ON filaments(created_at);
|
||||
|
||||
-- Create updated_at trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
@@ -46,10 +46,12 @@ END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply trigger to filaments table
|
||||
DROP TRIGGER IF EXISTS update_filaments_updated_at ON filaments;
|
||||
CREATE TRIGGER update_filaments_updated_at BEFORE UPDATE
|
||||
ON filaments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Apply trigger to colors table
|
||||
DROP TRIGGER IF EXISTS update_colors_updated_at ON colors;
|
||||
CREATE TRIGGER update_colors_updated_at BEFORE UPDATE
|
||||
ON colors FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
|
||||
407
package-lock.json
generated
407
package-lock.json
generated
@@ -15,7 +15,8 @@
|
||||
"next": "^16.1.6",
|
||||
"pg": "^8.16.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -2672,6 +2673,42 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -3056,6 +3093,18 @@
|
||||
"@sinonjs/commons": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -3214,6 +3263,69 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3326,7 +3438,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3363,6 +3475,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
@@ -4560,6 +4678,15 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
@@ -4715,9 +4842,130 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
@@ -4757,6 +5005,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
|
||||
@@ -5059,6 +5313,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -5336,6 +5600,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
@@ -6012,6 +6282,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -6088,6 +6368,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
@@ -8569,10 +8858,32 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -8606,6 +8917,36 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -8620,6 +8961,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -8630,6 +8986,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -9423,6 +9785,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -9724,6 +10092,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -9746,6 +10123,28 @@
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"next": "^16.1.6",
|
||||
"pg": "^8.16.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /upadaj
|
||||
Disallow: /upadaj/
|
||||
Disallow: /dashboard
|
||||
Disallow: /dashboard/
|
||||
|
||||
Sitemap: https://filamenteka.rs/sitemap.xml
|
||||
@@ -1,48 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://filamenteka.rs</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filamenteka.rs/filamenti</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filamenteka.rs/stampaci</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filamenteka.rs/ploce</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filamenteka.rs/mlaznice</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filamenteka.rs/delovi</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filamenteka.rs/oprema</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filamenteka.rs/politika-privatnosti</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://filamenteka.rs/uslovi-koriscenja</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
42
src/components/AdminSidebar.tsx
Normal file
42
src/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Filamenti' },
|
||||
{ href: '/upadaj/colors', label: 'Boje' },
|
||||
{ href: '/upadaj/requests', label: 'Zahtevi za boje' },
|
||||
{ href: '/upadaj/sales', label: 'Prodaja' },
|
||||
{ href: '/upadaj/customers', label: 'Kupci' },
|
||||
{ href: '/upadaj/analytics', label: 'Analitika' },
|
||||
]
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-white dark:bg-gray-800 shadow-lg h-screen sticky top-0 flex-shrink-0">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
|
||||
<nav className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block px-4 py-2 rounded ${
|
||||
isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import '@/src/styles/select.css';
|
||||
import { trackEvent } from './MatomoAnalytics';
|
||||
import { getFinishesForMaterial, getAllFinishes, getMaterialOptions } from '@/src/data/bambuLabCatalog';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
|
||||
interface EnhancedFiltersProps {
|
||||
filters: {
|
||||
@@ -8,22 +10,74 @@ interface EnhancedFiltersProps {
|
||||
finish: string;
|
||||
color: string;
|
||||
};
|
||||
onFilterChange: (filters: any) => void;
|
||||
onFilterChange: (filters: { material: string; finish: string; color: string }) => void;
|
||||
uniqueValues: {
|
||||
materials: string[];
|
||||
finishes: string[];
|
||||
colors: string[];
|
||||
};
|
||||
inventoryFilaments?: Filament[];
|
||||
}
|
||||
|
||||
export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||
filters,
|
||||
onFilterChange,
|
||||
uniqueValues
|
||||
uniqueValues,
|
||||
inventoryFilaments = []
|
||||
}) => {
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = filters.material || filters.finish || filters.color;
|
||||
|
||||
// Catalog-aware material list (static from catalog)
|
||||
const materialOptions = useMemo(() => getMaterialOptions(), []);
|
||||
|
||||
// Finish options: conditional on selected material
|
||||
const finishOptions = useMemo(() => {
|
||||
const inStock = inventoryFilaments.filter(f => f.kolicina > 0);
|
||||
if (filters.material) {
|
||||
// Show finishes from catalog for this material, but only if they exist in inventory
|
||||
const catalogFinishes = getFinishesForMaterial(filters.material);
|
||||
const inventoryFinishes = new Set(
|
||||
inStock.filter(f => f.tip === filters.material).map(f => f.finish)
|
||||
);
|
||||
return catalogFinishes.filter(f => inventoryFinishes.has(f));
|
||||
}
|
||||
// No material selected: show all finishes from inventory
|
||||
if (inStock.length > 0) {
|
||||
return [...new Set(inStock.map(f => f.finish))].sort();
|
||||
}
|
||||
return getAllFinishes();
|
||||
}, [filters.material, inventoryFilaments]);
|
||||
|
||||
// Color options: conditional on selected material + finish
|
||||
const colorOptions = useMemo(() => {
|
||||
const inStock = inventoryFilaments.filter(f => f.kolicina > 0);
|
||||
let filtered = inStock;
|
||||
if (filters.material) {
|
||||
filtered = filtered.filter(f => f.tip === filters.material);
|
||||
}
|
||||
if (filters.finish) {
|
||||
filtered = filtered.filter(f => f.finish === filters.finish);
|
||||
}
|
||||
return [...new Set(filtered.map(f => f.boja))].sort();
|
||||
}, [filters.material, filters.finish, inventoryFilaments]);
|
||||
|
||||
const handleMaterialChange = (value: string) => {
|
||||
// Reset finish and color when material changes
|
||||
onFilterChange({ material: value, finish: '', color: '' });
|
||||
trackEvent('Filter', 'Material', value || 'All');
|
||||
};
|
||||
|
||||
const handleFinishChange = (value: string) => {
|
||||
// Reset color when finish changes
|
||||
onFilterChange({ ...filters, finish: value, color: '' });
|
||||
trackEvent('Filter', 'Finish', value || 'All');
|
||||
};
|
||||
|
||||
const handleColorChange = (value: string) => {
|
||||
onFilterChange({ ...filters, color: value });
|
||||
trackEvent('Filter', 'Color', value || 'All');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
{/* Filters Grid */}
|
||||
@@ -35,26 +89,15 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||
</label>
|
||||
<select
|
||||
value={filters.material}
|
||||
onChange={(e) => {
|
||||
onFilterChange({ ...filters, material: e.target.value });
|
||||
trackEvent('Filter', 'Material', e.target.value || 'All');
|
||||
}}
|
||||
onChange={(e) => handleMaterialChange(e.target.value)}
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Svi materijali</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>
|
||||
{materialOptions.map(mat => (
|
||||
<option key={mat} value={mat}>{mat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -65,35 +108,15 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||
</label>
|
||||
<select
|
||||
value={filters.finish}
|
||||
onChange={(e) => {
|
||||
onFilterChange({ ...filters, finish: e.target.value });
|
||||
trackEvent('Filter', 'Finish', e.target.value || 'All');
|
||||
}}
|
||||
onChange={(e) => handleFinishChange(e.target.value)}
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Svi finish tipovi</option>
|
||||
<option value="85A">85A</option>
|
||||
<option value="90A">90A</option>
|
||||
<option value="95A HF">95A HF</option>
|
||||
<option value="Aero">Aero</option>
|
||||
<option value="Basic">Basic</option>
|
||||
<option value="Basic Gradient">Basic Gradient</option>
|
||||
<option value="CF">CF</option>
|
||||
<option value="FR">FR</option>
|
||||
<option value="Galaxy">Galaxy</option>
|
||||
<option value="GF">GF</option>
|
||||
<option value="Glow">Glow</option>
|
||||
<option value="HF">HF</option>
|
||||
<option value="Marble">Marble</option>
|
||||
<option value="Matte">Matte</option>
|
||||
<option value="Metal">Metal</option>
|
||||
<option value="Silk Multi-Color">Silk Multi-Color</option>
|
||||
<option value="Silk+">Silk+</option>
|
||||
<option value="Sparkle">Sparkle</option>
|
||||
<option value="Translucent">Translucent</option>
|
||||
<option value="Wood">Wood</option>
|
||||
{finishOptions.map(finish => (
|
||||
<option key={finish} value={finish}>{finish}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -104,16 +127,13 @@ export const EnhancedFilters: React.FC<EnhancedFiltersProps> = ({
|
||||
</label>
|
||||
<select
|
||||
value={filters.color}
|
||||
onChange={(e) => {
|
||||
onFilterChange({ ...filters, color: e.target.value });
|
||||
trackEvent('Filter', 'Color', e.target.value || 'All');
|
||||
}}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Sve boje</option>
|
||||
{uniqueValues.colors.map(color => (
|
||||
{colorOptions.map(color => (
|
||||
<option key={color} value={color}>{color}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -145,6 +145,7 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
uniqueValues={uniqueValues}
|
||||
inventoryFilaments={filaments}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import '@/src/styles/select.css';
|
||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||
|
||||
interface FilterConfigItem {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'select' | 'range' | 'checkbox';
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
interface CatalogFiltersProps {
|
||||
filters: Record<string, string>;
|
||||
onFilterChange: (filters: Record<string, string>) => void;
|
||||
filterConfig: FilterConfigItem[];
|
||||
dynamicOptions?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export function CatalogFilters({
|
||||
filters,
|
||||
onFilterChange,
|
||||
filterConfig,
|
||||
dynamicOptions,
|
||||
}: CatalogFiltersProps) {
|
||||
const hasActiveFilters = Object.values(filters).some((v) => v !== '');
|
||||
|
||||
const getOptions = (config: FilterConfigItem): string[] => {
|
||||
if (dynamicOptions && dynamicOptions[config.key]) {
|
||||
return dynamicOptions[config.key];
|
||||
}
|
||||
return config.options || [];
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
onFilterChange({ ...filters, [key]: value });
|
||||
trackEvent('Filter', key, value || 'All');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const cleared: Record<string, string> = {};
|
||||
filterConfig.forEach((f) => {
|
||||
cleared[f.key] = '';
|
||||
});
|
||||
onFilterChange(cleared);
|
||||
trackEvent('Filter', 'Reset', 'All Filters');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-4 sm:p-5"
|
||||
style={{
|
||||
borderColor: 'var(--border-subtle)',
|
||||
background: 'var(--surface-secondary)',
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4 max-w-4xl mx-auto">
|
||||
{filterConfig.map((config) => {
|
||||
if (config.type === 'select') {
|
||||
const options = getOptions(config);
|
||||
return (
|
||||
<div key={config.key}>
|
||||
<label
|
||||
className="block text-xs font-semibold uppercase tracking-wider mb-1.5"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{config.label}
|
||||
</label>
|
||||
<select
|
||||
value={filters[config.key] || ''}
|
||||
onChange={(e) => handleFilterChange(config.key, e.target.value)}
|
||||
className="custom-select w-full px-3 py-2.5 text-sm font-medium
|
||||
border rounded-xl
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500
|
||||
transition-all duration-200"
|
||||
style={{
|
||||
borderColor: 'var(--border-color)',
|
||||
background: 'var(--surface-elevated)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<option value="">Sve</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.type === 'checkbox') {
|
||||
return (
|
||||
<div key={config.key} className="flex items-end">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters[config.key] === 'true'}
|
||||
onChange={(e) =>
|
||||
handleFilterChange(config.key, e.target.checked ? 'true' : '')
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600
|
||||
text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
|
||||
/>
|
||||
<span className="text-sm font-medium group-hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--text-primary)' }}>
|
||||
{config.label}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.type === 'range') {
|
||||
return (
|
||||
<div key={config.key}>
|
||||
<label
|
||||
className="block text-xs font-semibold uppercase tracking-wider mb-1.5"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{config.label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="npr. 1000-5000"
|
||||
value={filters[config.key] || ''}
|
||||
onChange={(e) => handleFilterChange(config.key, e.target.value)}
|
||||
className="w-full px-3 py-2.5 text-sm border rounded-xl
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500
|
||||
transition-all duration-200"
|
||||
style={{
|
||||
borderColor: 'var(--border-color)',
|
||||
background: 'var(--surface-elevated)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold
|
||||
text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10
|
||||
hover:bg-red-100 dark:hover:bg-red-500/20
|
||||
rounded-xl transition-colors duration-200"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Resetuj filtere
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,676 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { Product } from '@/src/types/product';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { CategoryConfig } from '@/src/config/categories';
|
||||
import { CONDITION_LABELS } from '@/src/config/categories';
|
||||
import { filamentService, productService, colorService } from '@/src/services/api';
|
||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||
import { PriceDisplay } from '@/src/components/ui/PriceDisplay';
|
||||
import { Badge } from '@/src/components/ui/Badge';
|
||||
import { EmptyState } from '@/src/components/ui/EmptyState';
|
||||
import { CatalogFilters } from './CatalogFilters';
|
||||
import { ProductTable, Column } from './ProductTable';
|
||||
import { ProductGrid } from './ProductGrid';
|
||||
import { FilamentRow } from './FilamentRow';
|
||||
import { FilamentCard } from './FilamentCard';
|
||||
import '@/src/styles/select.css';
|
||||
|
||||
type ViewMode = 'table' | 'grid';
|
||||
|
||||
interface CatalogPageProps {
|
||||
category: CategoryConfig;
|
||||
seoContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
function CatalogSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5 animate-pulse">
|
||||
{/* Search skeleton */}
|
||||
<div className="h-12 rounded-2xl" style={{ background: 'var(--surface-secondary)' }} />
|
||||
{/* Filter skeleton */}
|
||||
<div className="p-5 rounded-2xl" style={{ background: 'var(--surface-secondary)' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-4xl mx-auto">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-3 w-16 rounded-full" style={{ background: 'var(--border-color)' }} />
|
||||
<div className="h-10 rounded-xl" style={{ background: 'var(--border-color)' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Table skeleton */}
|
||||
<div className="rounded-2xl border overflow-hidden" style={{ borderColor: 'var(--border-subtle)' }}>
|
||||
<div className="h-12" style={{ background: 'var(--surface-secondary)' }} />
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-14 flex items-center px-5 gap-4" style={{ borderTop: '1px solid var(--border-subtle)' }}>
|
||||
<div className="h-3 w-16 rounded-full" style={{ background: 'var(--border-color)' }} />
|
||||
<div className="h-3 w-20 rounded-full" style={{ background: 'var(--border-color)' }} />
|
||||
<div className="h-3 w-24 rounded-full" style={{ background: 'var(--border-color)' }} />
|
||||
<div className="h-3 w-12 rounded-full" style={{ background: 'var(--border-color)' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewToggle({
|
||||
viewMode,
|
||||
onToggle,
|
||||
}: {
|
||||
viewMode: ViewMode;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 text-sm font-semibold rounded-xl
|
||||
border transition-all duration-200 hover:shadow-sm active:scale-[0.97]"
|
||||
style={{
|
||||
borderColor: 'var(--border-color)',
|
||||
background: 'var(--surface-elevated)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
title={viewMode === 'table' ? 'Prikazi kao kartice' : 'Prikazi kao tabelu'}
|
||||
>
|
||||
{viewMode === 'table' ? (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
Kartice
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
Tabela
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const FILAMENT_COLUMNS: Column<Filament>[] = [
|
||||
{
|
||||
key: 'tip',
|
||||
label: 'Tip',
|
||||
sortable: true,
|
||||
render: (f) => <span className="font-semibold">{f.tip}</span>,
|
||||
},
|
||||
{
|
||||
key: 'finish',
|
||||
label: 'Finis',
|
||||
sortable: true,
|
||||
render: (f) => <span>{f.finish}</span>,
|
||||
},
|
||||
{
|
||||
key: 'boja',
|
||||
label: 'Boja',
|
||||
sortable: true,
|
||||
render: (f) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{f.boja_hex && (
|
||||
<div
|
||||
className="w-6 h-6 rounded-lg flex-shrink-0 shadow-sm"
|
||||
style={{
|
||||
backgroundColor: f.boja_hex,
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
}}
|
||||
title={f.boja_hex}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs sm:text-sm font-medium">{f.boja}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'refill',
|
||||
label: 'Refil',
|
||||
sortable: true,
|
||||
render: (f) =>
|
||||
f.refill > 0 ? (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-bold">{f.refill}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)' }}>0</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'spulna',
|
||||
label: 'Spulna',
|
||||
sortable: true,
|
||||
render: (f) =>
|
||||
f.spulna > 0 ? (
|
||||
<span className="text-blue-500 dark:text-blue-400 font-bold">{f.spulna}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)' }}>0</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kolicina',
|
||||
label: 'Kolicina',
|
||||
sortable: true,
|
||||
render: (f) => <span className="font-semibold">{f.kolicina}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
function getProductColumns(): Column<Product>[] {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Naziv',
|
||||
sortable: true,
|
||||
render: (p) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{p.image_url && (
|
||||
<img
|
||||
src={p.image_url}
|
||||
alt={p.name}
|
||||
className="w-10 h-10 rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold" style={{ fontFamily: 'var(--font-display)' }}>{p.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'condition',
|
||||
label: 'Stanje',
|
||||
sortable: true,
|
||||
render: (p) => (
|
||||
<Badge
|
||||
variant={p.condition === 'new' ? 'success' : 'info'}
|
||||
size="sm"
|
||||
>
|
||||
{CONDITION_LABELS[p.condition] || p.condition}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'stock',
|
||||
label: 'Na stanju',
|
||||
sortable: true,
|
||||
render: (p) =>
|
||||
p.stock > 0 ? (
|
||||
<Badge variant="success" size="sm">
|
||||
{p.stock}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="danger" size="sm">
|
||||
Nema
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
label: 'Cena',
|
||||
sortable: true,
|
||||
render: (p) => (
|
||||
<PriceDisplay
|
||||
price={p.price}
|
||||
saleActive={p.sale_active}
|
||||
salePercentage={p.sale_percentage}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function CatalogPage({ category, seoContent }: CatalogPageProps) {
|
||||
const isFilament = category.isFilament === true;
|
||||
|
||||
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [availableColors, setAvailableColors] = useState<
|
||||
Array<{ id: string; name: string; hex: string; cena_refill?: number; cena_spulna?: number }>
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
category.filters.forEach((f) => {
|
||||
initial[f.key] = '';
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
const [sortField, setSortField] = useState<string>(isFilament ? 'boja' : 'name');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||
const [isDesktop, setIsDesktop] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDesktop = () => {
|
||||
setIsDesktop(window.innerWidth >= 1024);
|
||||
};
|
||||
checkDesktop();
|
||||
window.addEventListener('resize', checkDesktop);
|
||||
return () => window.removeEventListener('resize', checkDesktop);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (isFilament) {
|
||||
const [filamentData, colorData] = await Promise.all([
|
||||
filamentService.getAll(),
|
||||
colorService.getAll(),
|
||||
]);
|
||||
setFilaments(filamentData);
|
||||
setAvailableColors(colorData);
|
||||
} else if (category.apiCategory) {
|
||||
const data = await productService.getAll({
|
||||
category: category.apiCategory,
|
||||
});
|
||||
setProducts(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching catalog data:', err);
|
||||
setError('Greska pri ucitavanju podataka. Pokusajte ponovo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [isFilament, category.apiCategory]);
|
||||
|
||||
const dynamicOptions = useMemo(() => {
|
||||
if (!isFilament) return undefined;
|
||||
const filamentsWithInventory = filaments.filter((f) => f.kolicina > 0);
|
||||
return {
|
||||
material: [...new Set(filamentsWithInventory.map((f) => f.tip))].sort(),
|
||||
finish: [...new Set(filamentsWithInventory.map((f) => f.finish))].sort(),
|
||||
color: [...new Set(filamentsWithInventory.map((f) => f.boja))].sort(),
|
||||
};
|
||||
}, [isFilament, filaments]);
|
||||
|
||||
const handleSort = useCallback(
|
||||
(field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
trackEvent('Table', 'Sort', field);
|
||||
},
|
||||
[sortField]
|
||||
);
|
||||
|
||||
const handleViewToggle = useCallback(() => {
|
||||
const next = viewMode === 'table' ? 'grid' : 'table';
|
||||
setViewMode(next);
|
||||
trackEvent('View', 'Toggle', next);
|
||||
}, [viewMode]);
|
||||
|
||||
const effectiveView = isDesktop ? viewMode : 'grid';
|
||||
|
||||
const filteredFilaments = useMemo(() => {
|
||||
if (!isFilament) return [];
|
||||
let filtered = filaments.filter((f) => {
|
||||
if (f.kolicina === 0) return false;
|
||||
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
const matches =
|
||||
f.tip.toLowerCase().includes(term) ||
|
||||
f.finish.toLowerCase().includes(term) ||
|
||||
f.boja.toLowerCase().includes(term) ||
|
||||
f.cena.toLowerCase().includes(term);
|
||||
if (!matches) return false;
|
||||
}
|
||||
|
||||
if (filters.material && f.tip !== filters.material) return false;
|
||||
if (filters.finish && f.finish !== filters.finish) return false;
|
||||
if (filters.color && f.boja !== filters.color) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (sortField) {
|
||||
filtered.sort((a, b) => {
|
||||
let aVal: string | number = (a as unknown as Record<string, unknown>)[sortField] as string | number;
|
||||
let bVal: string | number = (b as unknown as Record<string, unknown>)[sortField] as string | number;
|
||||
|
||||
if (sortField === 'kolicina' || sortField === 'cena' || sortField === 'refill' || sortField === 'spulna') {
|
||||
aVal = parseFloat(String(aVal)) || 0;
|
||||
bVal = parseFloat(String(bVal)) || 0;
|
||||
} else {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [isFilament, filaments, searchTerm, filters, sortField, sortOrder]);
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (isFilament) return [];
|
||||
let filtered = products.filter((p) => {
|
||||
if (p.is_active === false) return false;
|
||||
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
if (!p.name.toLowerCase().includes(term)) return false;
|
||||
}
|
||||
|
||||
if (filters.condition && p.condition !== filters.condition) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (sortField) {
|
||||
filtered.sort((a, b) => {
|
||||
let aVal: string | number = (a as unknown as Record<string, unknown>)[sortField] as string | number;
|
||||
let bVal: string | number = (b as unknown as Record<string, unknown>)[sortField] as string | number;
|
||||
|
||||
if (sortField === 'price' || sortField === 'stock') {
|
||||
aVal = Number(aVal) || 0;
|
||||
bVal = Number(bVal) || 0;
|
||||
} else {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [isFilament, products, searchTerm, filters, sortField, sortOrder]);
|
||||
|
||||
const totalCount = isFilament ? filteredFilaments.length : filteredProducts.length;
|
||||
|
||||
const filamentColumnsWithPrice = useMemo((): Column<Filament>[] => {
|
||||
return [
|
||||
...FILAMENT_COLUMNS,
|
||||
{
|
||||
key: 'cena',
|
||||
label: 'Cena',
|
||||
sortable: true,
|
||||
render: (filament: Filament) => {
|
||||
const hasRefill = filament.refill > 0;
|
||||
const hasSpool = filament.spulna > 0;
|
||||
if (!hasRefill && !hasSpool) return <span>-</span>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const saleActive = filament.sale_active && filament.sale_percentage;
|
||||
const saleRefillPrice = saleActive
|
||||
? Math.round(refillPrice * (1 - filament.sale_percentage! / 100))
|
||||
: refillPrice;
|
||||
const saleSpoolPrice = saleActive
|
||||
? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100))
|
||||
: spoolPrice;
|
||||
|
||||
return (
|
||||
<span className="font-bold">
|
||||
{hasRefill && (
|
||||
<span className={saleActive ? 'line-through text-gray-400' : 'text-emerald-600 dark:text-emerald-400'}>
|
||||
{refillPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
{hasRefill && saleActive && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-bold ml-1">
|
||||
{saleRefillPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
{hasRefill && hasSpool && <span className="mx-1" style={{ color: 'var(--text-muted)' }}>/</span>}
|
||||
{hasSpool && (
|
||||
<span className={saleActive ? 'line-through text-gray-400' : 'text-blue-500 dark:text-blue-400'}>
|
||||
{spoolPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
{hasSpool && saleActive && (
|
||||
<span className="text-blue-500 dark:text-blue-400 font-bold ml-1">
|
||||
{saleSpoolPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 font-normal text-xs" style={{ color: 'var(--text-muted)' }}>RSD</span>
|
||||
{saleActive && (
|
||||
<span className="ml-2">
|
||||
<Badge variant="sale" size="sm">
|
||||
-{filament.sale_percentage}%
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [availableColors]);
|
||||
|
||||
const productColumns = useMemo(() => getProductColumns(), []);
|
||||
|
||||
const filterConfigWithLabels = useMemo(() => {
|
||||
return category.filters.map((f) => {
|
||||
if (f.key === 'condition' && f.options) {
|
||||
return {
|
||||
...f,
|
||||
options: f.options.map((opt) => opt),
|
||||
};
|
||||
}
|
||||
return f;
|
||||
});
|
||||
}, [category.filters]);
|
||||
|
||||
const conditionDynamicOptions = useMemo(() => {
|
||||
if (isFilament) return undefined;
|
||||
const opts: Record<string, string[]> = {};
|
||||
category.filters.forEach((f) => {
|
||||
if (f.key === 'condition' && f.options) {
|
||||
opts[f.key] = f.options;
|
||||
}
|
||||
});
|
||||
return Object.keys(opts).length > 0 ? opts : undefined;
|
||||
}, [isFilament, category.filters]);
|
||||
|
||||
const displayFilterConfig = useMemo(() => {
|
||||
return category.filters.map((f) => {
|
||||
if (f.key === 'condition' && f.options) {
|
||||
return {
|
||||
...f,
|
||||
options: f.options.map((opt) => CONDITION_LABELS[opt] || opt),
|
||||
};
|
||||
}
|
||||
return f;
|
||||
});
|
||||
}, [category.filters]);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(newFilters: Record<string, string>) => {
|
||||
const mapped = { ...newFilters };
|
||||
const conditionFilter = category.filters.find((f) => f.key === 'condition');
|
||||
if (conditionFilter && mapped.condition) {
|
||||
const conditionEntries = Object.entries(CONDITION_LABELS);
|
||||
const entry = conditionEntries.find(([, label]) => label === mapped.condition);
|
||||
if (entry) {
|
||||
mapped.condition = entry[0];
|
||||
}
|
||||
}
|
||||
setFilters(mapped);
|
||||
},
|
||||
[category.filters]
|
||||
);
|
||||
|
||||
const displayFilters = useMemo(() => {
|
||||
const mapped = { ...filters };
|
||||
if (mapped.condition) {
|
||||
mapped.condition = CONDITION_LABELS[mapped.condition] || mapped.condition;
|
||||
}
|
||||
return mapped;
|
||||
}, [filters]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<CatalogSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-8 text-center"
|
||||
style={{
|
||||
borderColor: 'rgba(239,68,68,0.2)',
|
||||
background: 'rgba(239,68,68,0.04)',
|
||||
}}
|
||||
>
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-red-50 dark:bg-red-500/10 flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold text-red-700 dark:text-red-300 mb-4" style={{ fontFamily: 'var(--font-display)' }}>
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-5 py-2.5 text-sm font-bold text-white bg-red-500 hover:bg-red-600
|
||||
rounded-xl transition-colors duration-200 active:scale-[0.97]"
|
||||
>
|
||||
Pokusaj ponovo
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={isFilament ? 'Pretrazi po materijalu, boji...' : 'Pretrazi proizvode...'}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (e.target.value) {
|
||||
trackEvent('Search', category.slug, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="w-full px-4 py-3 pl-11 text-sm font-medium border rounded-2xl
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500
|
||||
transition-all duration-200 placeholder:font-normal"
|
||||
style={{
|
||||
borderColor: 'var(--border-color)',
|
||||
background: 'var(--surface-elevated)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3.5 top-3.5 h-4.5 w-4.5"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
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>
|
||||
|
||||
{/* Filters */}
|
||||
<CatalogFilters
|
||||
filters={isFilament ? filters : displayFilters}
|
||||
onFilterChange={isFilament ? setFilters : handleFilterChange}
|
||||
filterConfig={isFilament ? category.filters : displayFilterConfig}
|
||||
dynamicOptions={isFilament ? dynamicOptions : conditionDynamicOptions}
|
||||
/>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className="font-bold" style={{ color: 'var(--text-primary)' }}>{totalCount}</span>{' '}
|
||||
{isFilament ? 'filamenata' : 'proizvoda'}
|
||||
</p>
|
||||
<ViewToggle viewMode={effectiveView} onToggle={handleViewToggle} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{totalCount === 0 ? (
|
||||
<EmptyState
|
||||
title="Nema rezultata"
|
||||
description="Pokusajte da promenite filtere ili pretragu."
|
||||
/>
|
||||
) : isFilament ? (
|
||||
effectiveView === 'table' ? (
|
||||
<ProductTable<Filament>
|
||||
items={filteredFilaments}
|
||||
columns={filamentColumnsWithPrice}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
|
||||
{filteredFilaments.map((filament) => (
|
||||
<FilamentCard key={filament.id} filament={filament} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-center mt-5" style={{ color: 'var(--text-muted)' }}>
|
||||
Prikazano {filteredFilaments.length} filamenata
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : effectiveView === 'table' ? (
|
||||
<ProductTable<Product>
|
||||
items={filteredProducts}
|
||||
columns={productColumns}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
) : (
|
||||
<ProductGrid
|
||||
products={filteredProducts}
|
||||
categoryColor={category.colorHex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SEO content slot */}
|
||||
{seoContent && <div className="mt-8">{seoContent}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { Badge } from '@/src/components/ui/Badge';
|
||||
|
||||
interface FilamentCardProps {
|
||||
filament: Filament;
|
||||
}
|
||||
|
||||
function getContrastColor(hex: string | undefined): 'light' | 'dark' {
|
||||
if (!hex) return 'dark';
|
||||
const cleanHex = hex.replace('#', '');
|
||||
const r = parseInt(cleanHex.substring(0, 2), 16);
|
||||
const g = parseInt(cleanHex.substring(2, 4), 16);
|
||||
const b = parseInt(cleanHex.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function FilamentCard({ filament }: FilamentCardProps) {
|
||||
const hasRefill = filament.refill > 0;
|
||||
const hasSpool = filament.spulna > 0;
|
||||
const contrast = getContrastColor(filament.boja_hex);
|
||||
const isLight = contrast === 'light';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const saleActive = filament.sale_active && filament.sale_percentage;
|
||||
const saleRefillPrice = saleActive
|
||||
? Math.round(refillPrice * (1 - filament.sale_percentage! / 100))
|
||||
: refillPrice;
|
||||
const saleSpoolPrice = saleActive
|
||||
? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100))
|
||||
: spoolPrice;
|
||||
|
||||
const textClass = isLight ? 'text-gray-900' : 'text-white';
|
||||
const textShadow = isLight ? 'none' : '0 1px 3px rgba(0,0,0,0.5)';
|
||||
const subtextClass = isLight ? 'text-gray-700' : 'text-gray-100';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden shadow-md
|
||||
transition-all duration-300 hover:shadow-xl hover:scale-[1.02] hover:-translate-y-1"
|
||||
style={{
|
||||
backgroundColor: filament.boja_hex || '#f3f4f6',
|
||||
border: isLight ? '1px solid rgba(0,0,0,0.08)' : '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
{/* Top badges */}
|
||||
<div className="p-4 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={filament.tip === 'PLA' ? 'success' : filament.tip === 'PETG' ? 'info' : 'default'}
|
||||
size="md"
|
||||
>
|
||||
{filament.tip}
|
||||
</Badge>
|
||||
{saleActive && (
|
||||
<Badge variant="sale" size="sm">
|
||||
-{filament.sale_percentage}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`mt-2 ${subtextClass}`} style={{ textShadow }}>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.12em]">
|
||||
{filament.finish}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color name */}
|
||||
<div className="px-4 py-3">
|
||||
<h3
|
||||
className={`text-xl font-black ${textClass}`}
|
||||
style={{ textShadow, fontFamily: 'var(--font-display)', letterSpacing: '-0.02em' }}
|
||||
>
|
||||
{filament.boja}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Bottom info section */}
|
||||
<div
|
||||
className="px-4 py-3.5 space-y-2.5"
|
||||
style={{
|
||||
backgroundColor: isLight ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.4)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
{/* Stock counts */}
|
||||
<div className="flex items-center gap-3">
|
||||
{hasRefill && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[11px] font-semibold uppercase tracking-wide ${isLight ? 'text-green-700' : 'text-green-300'}`}>
|
||||
Refil
|
||||
</span>
|
||||
<span className={`text-sm font-black ${isLight ? 'text-green-800' : 'text-green-200'}`}>
|
||||
{filament.refill}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasSpool && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[11px] font-semibold uppercase tracking-wide ${isLight ? 'text-blue-700' : 'text-blue-300'}`}>
|
||||
Spulna
|
||||
</span>
|
||||
<span className={`text-sm font-black ${isLight ? 'text-blue-800' : 'text-blue-200'}`}>
|
||||
{filament.spulna}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<span className={`text-[11px] font-semibold uppercase tracking-wide ${subtextClass}`} style={{ textShadow }}>
|
||||
Ukupno
|
||||
</span>
|
||||
<span className={`text-sm font-black ${textClass}`} style={{ textShadow }}>
|
||||
{filament.kolicina}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className={`flex items-center gap-2 flex-wrap pt-2 ${isLight ? 'border-gray-300/60' : 'border-white/15'}`}
|
||||
style={{ borderTop: `1px solid ${isLight ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.15)'}` }}>
|
||||
{!hasRefill && !hasSpool ? (
|
||||
<span className={`text-sm ${subtextClass}`} style={{ textShadow }}>-</span>
|
||||
) : (
|
||||
<>
|
||||
{hasRefill && (
|
||||
<>
|
||||
{saleActive ? (
|
||||
<>
|
||||
<span className={`text-xs line-through ${isLight ? 'text-gray-500' : 'text-gray-300'}`}>
|
||||
{refillPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
<span className={`text-sm font-black ${isLight ? 'text-green-700' : 'text-green-300'}`}>
|
||||
{saleRefillPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={`text-sm font-black ${isLight ? 'text-green-700' : 'text-green-300'}`}>
|
||||
{refillPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasRefill && hasSpool && (
|
||||
<span className={`text-sm ${subtextClass}`} style={{ textShadow }}>/</span>
|
||||
)}
|
||||
{hasSpool && (
|
||||
<>
|
||||
{saleActive ? (
|
||||
<>
|
||||
<span className={`text-xs line-through ${isLight ? 'text-gray-500' : 'text-gray-300'}`}>
|
||||
{spoolPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
<span className={`text-sm font-black ${isLight ? 'text-blue-700' : 'text-blue-300'}`}>
|
||||
{saleSpoolPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={`text-sm font-black ${isLight ? 'text-blue-700' : 'text-blue-300'}`}>
|
||||
{spoolPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className={`text-[11px] font-semibold ${subtextClass} ml-0.5`} style={{ textShadow }}>RSD</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
|
||||
interface FilamentRowProps {
|
||||
filament: Filament;
|
||||
}
|
||||
|
||||
export function FilamentRow({ filament }: FilamentRowProps) {
|
||||
const hasRefill = filament.refill > 0;
|
||||
const hasSpool = filament.spulna > 0;
|
||||
|
||||
// Parse prices from the cena field (format: "3499" or "3499/3999")
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const saleActive = filament.sale_active && filament.sale_percentage;
|
||||
const saleRefillPrice = saleActive
|
||||
? Math.round(refillPrice * (1 - filament.sale_percentage! / 100))
|
||||
: refillPrice;
|
||||
const saleSpoolPrice = saleActive
|
||||
? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100))
|
||||
: spoolPrice;
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{filament.tip}
|
||||
</td>
|
||||
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{filament.finish}
|
||||
</td>
|
||||
<td className="px-2 sm:px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
{filament.boja_hex && (
|
||||
<div
|
||||
className="w-5 h-5 sm:w-7 sm:h-7 rounded border border-gray-300 dark:border-gray-600 flex-shrink-0"
|
||||
style={{ backgroundColor: filament.boja_hex }}
|
||||
title={filament.boja_hex}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs sm:text-sm text-gray-900 dark:text-gray-100">
|
||||
{filament.boja}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm">
|
||||
{hasRefill ? (
|
||||
<span className="text-green-600 dark:text-green-400 font-bold">{filament.refill}</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm">
|
||||
{hasSpool ? (
|
||||
<span className="text-blue-500 dark:text-blue-400 font-bold">{filament.spulna}</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{filament.kolicina}
|
||||
</td>
|
||||
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm font-bold text-gray-900 dark:text-white">
|
||||
{!hasRefill && !hasSpool ? (
|
||||
'-'
|
||||
) : (
|
||||
<>
|
||||
{hasRefill && (
|
||||
<span
|
||||
className={
|
||||
saleActive
|
||||
? 'line-through text-gray-500 dark:text-gray-400'
|
||||
: 'text-green-600 dark:text-green-400'
|
||||
}
|
||||
>
|
||||
{refillPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
{hasRefill && saleActive && (
|
||||
<span className="text-green-600 dark:text-green-400 font-bold ml-1">
|
||||
{saleRefillPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
{hasRefill && hasSpool && <span className="mx-1">/</span>}
|
||||
{hasSpool && (
|
||||
<span
|
||||
className={
|
||||
saleActive
|
||||
? 'line-through text-gray-500 dark:text-gray-400'
|
||||
: 'text-blue-500 dark:text-blue-400'
|
||||
}
|
||||
>
|
||||
{spoolPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
{hasSpool && saleActive && (
|
||||
<span className="text-blue-500 dark:text-blue-400 font-bold ml-1">
|
||||
{saleSpoolPrice.toLocaleString('sr-RS')}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 text-gray-600 dark:text-gray-400">RSD</span>
|
||||
{saleActive && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
-{filament.sale_percentage}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Product } from '@/src/types/product';
|
||||
import { PriceDisplay } from '@/src/components/ui/PriceDisplay';
|
||||
import { Badge } from '@/src/components/ui/Badge';
|
||||
import { CONDITION_LABELS } from '@/src/config/categories';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
categoryColor?: string;
|
||||
}
|
||||
|
||||
function getCategoryIcon(category: string): React.ReactNode {
|
||||
switch (category) {
|
||||
case 'printer':
|
||||
return (
|
||||
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
);
|
||||
case 'build_plate':
|
||||
return (
|
||||
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
);
|
||||
case 'nozzle':
|
||||
return (
|
||||
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function ProductCard({ product, categoryColor = '#3b82f6' }: ProductCardProps) {
|
||||
const isOutOfStock = product.stock === 0;
|
||||
const conditionLabel = CONDITION_LABELS[product.condition] || product.condition;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="category-card rounded-2xl border overflow-hidden"
|
||||
style={{
|
||||
'--card-color': categoryColor,
|
||||
borderColor: 'var(--border-subtle)',
|
||||
background: 'var(--surface-elevated)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{/* Category accent bar */}
|
||||
<div className="h-1" style={{ background: `linear-gradient(90deg, ${categoryColor}, ${categoryColor}80)` }} />
|
||||
|
||||
{/* Image or placeholder */}
|
||||
<div className="relative aspect-[4/3] flex items-center justify-center" style={{ background: 'var(--surface-secondary)' }}>
|
||||
{product.image_url ? (
|
||||
<img
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
getCategoryIcon(product.category)
|
||||
)}
|
||||
|
||||
{isOutOfStock && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<Badge variant="danger" size="sm">Nema na stanju</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 sm:p-5 space-y-3">
|
||||
<h3
|
||||
className="font-bold line-clamp-2 min-h-[2.5rem]"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant={product.condition === 'new' ? 'success' : 'info'}
|
||||
size="sm"
|
||||
>
|
||||
{conditionLabel}
|
||||
</Badge>
|
||||
{!isOutOfStock && (
|
||||
<Badge variant="success" size="sm">
|
||||
Na stanju ({product.stock})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2" style={{ borderTop: '1px solid var(--border-subtle)' }}>
|
||||
<PriceDisplay
|
||||
price={product.price}
|
||||
saleActive={product.sale_active}
|
||||
salePercentage={product.sale_percentage}
|
||||
className="font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Product } from '@/src/types/product';
|
||||
import { ProductCard } from './ProductCard';
|
||||
|
||||
interface ProductGridProps {
|
||||
products: Product[];
|
||||
categoryColor?: string;
|
||||
renderCard?: (product: Product) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProductGrid({
|
||||
products,
|
||||
categoryColor,
|
||||
renderCard,
|
||||
}: ProductGridProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
|
||||
{products.map((product) =>
|
||||
renderCard ? (
|
||||
<React.Fragment key={product.id}>{renderCard(product)}</React.Fragment>
|
||||
) : (
|
||||
<ProductCard key={product.id} product={product} categoryColor={categoryColor} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-center mt-5" style={{ color: 'var(--text-muted)' }}>
|
||||
Prikazano {products.length} proizvoda
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
render?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ProductTableProps<T> {
|
||||
items: T[];
|
||||
columns: Column<T>[];
|
||||
sortField: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onSort: (field: string) => void;
|
||||
}
|
||||
|
||||
function SortIndicator({ field, sortField, sortOrder }: { field: string; sortField: string; sortOrder: 'asc' | 'desc' }) {
|
||||
if (field !== sortField) {
|
||||
return (
|
||||
<span className="ml-1.5 opacity-0 group-hover:opacity-60 transition-opacity text-[10px]">
|
||||
↕
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="ml-1.5 text-blue-500 dark:text-blue-400 text-[10px]">
|
||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductTable<T extends { id?: string }>({
|
||||
items,
|
||||
columns,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSort,
|
||||
}: ProductTableProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className="overflow-x-auto rounded-2xl border shadow-sm"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
>
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-secondary)' }}>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
onClick={col.sortable !== false ? () => onSort(col.key) : undefined}
|
||||
className={`px-3 sm:px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.1em] group
|
||||
${col.sortable !== false ? 'cursor-pointer select-none' : ''}
|
||||
${col.className || ''}`}
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
{col.label}
|
||||
{col.sortable !== false && (
|
||||
<SortIndicator field={col.key} sortField={sortField} sortOrder={sortOrder} />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody style={{ background: 'var(--surface-elevated)' }}>
|
||||
{items.map((item, index) => (
|
||||
<tr
|
||||
key={item.id || index}
|
||||
className="transition-colors duration-150 hover:bg-blue-50/50 dark:hover:bg-white/[0.03]"
|
||||
style={{ borderTop: '1px solid var(--border-subtle)' }}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3 sm:px-5 py-3.5 whitespace-nowrap text-sm ${col.className || ''}`}
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
{col.render
|
||||
? col.render(item)
|
||||
: String((item as Record<string, unknown>)[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from '@/src/hooks/useAuth';
|
||||
import { AdminSidebar } from './AdminSidebar';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
export function AdminLayout({ children, currentPath }: AdminLayoutProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Force dark mode for admin
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#060a14] flex items-center justify-center">
|
||||
<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" style={{ fontFamily: 'var(--font-body)' }}>
|
||||
Ucitavanje...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#060a14] flex">
|
||||
<AdminSidebar currentPath={currentPath} />
|
||||
|
||||
<main className="flex-1 min-w-0 lg:ml-0">
|
||||
{/* Top bar for mobile spacing (hamburger menu space) */}
|
||||
<div className="lg:hidden h-14" />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||
|
||||
interface AdminSidebarProps {
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
interface SidebarLink {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
colorHex: string;
|
||||
}
|
||||
|
||||
const sidebarLinks: SidebarLink[] = [
|
||||
{
|
||||
href: '/upadaj/dashboard',
|
||||
label: 'Pregled',
|
||||
colorHex: '#3b82f6',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/upadaj/dashboard/filamenti',
|
||||
label: 'Filamenti',
|
||||
colorHex: '#3b82f6',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/upadaj/dashboard/stampaci',
|
||||
label: 'Stampaci',
|
||||
colorHex: '#ef4444',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/upadaj/dashboard/ploce',
|
||||
label: 'Ploce',
|
||||
colorHex: '#22c55e',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/upadaj/dashboard/mlaznice',
|
||||
label: 'Mlaznice',
|
||||
colorHex: '#f59e0b',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/upadaj/dashboard/delovi',
|
||||
label: 'Delovi',
|
||||
colorHex: '#a855f7',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/upadaj/dashboard/oprema',
|
||||
label: 'Oprema',
|
||||
colorHex: '#06b6d4',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/upadaj/dashboard/boje',
|
||||
label: 'Boje',
|
||||
colorHex: '#ec4899',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/upadaj/dashboard/zahtevi',
|
||||
label: 'Zahtevi',
|
||||
colorHex: '#8b5cf6',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/upadaj/dashboard/prodaja',
|
||||
label: 'Prodaja',
|
||||
colorHex: '#f97316',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/upadaj/dashboard/analitika',
|
||||
label: 'Analitika',
|
||||
colorHex: '#14b8a6',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminSidebar({ currentPath }: AdminSidebarProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/upadaj/dashboard' && currentPath === '/upadaj/dashboard') return true;
|
||||
if (href !== '/upadaj/dashboard' && currentPath.startsWith(href)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
trackEvent('Admin', 'Logout', 'Sidebar');
|
||||
window.location.href = '/upadaj';
|
||||
};
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Logo */}
|
||||
<div className="p-4 border-b border-white/[0.08]">
|
||||
<Link href="/upadaj/dashboard" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Filamenteka Admin"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-10 w-auto"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className="text-lg font-black tracking-tight text-white"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
Filament<span className="gradient-text">eka</span>
|
||||
</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-white/40">Admin</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation links */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1" aria-label="Admin navigacija">
|
||||
{sidebarLinks.map((link) => {
|
||||
const active = isActive(link.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => {
|
||||
trackEvent('Admin', 'Sidebar Navigation', link.label);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold
|
||||
transition-all duration-200
|
||||
${
|
||||
active
|
||||
? 'text-white shadow-lg'
|
||||
: 'text-white/50 hover:text-white hover:bg-white/[0.06]'
|
||||
}
|
||||
`}
|
||||
style={
|
||||
active
|
||||
? {
|
||||
backgroundColor: link.colorHex,
|
||||
boxShadow: `0 4px 12px ${link.colorHex}40`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{link.icon}
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="p-3 border-t border-white/[0.08] space-y-1">
|
||||
{/* Back to site */}
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold text-white/50 hover:text-white hover:bg-white/[0.06] transition-all duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Nazad na sajt
|
||||
</Link>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold text-red-400/80 hover:text-red-400 hover:bg-red-500/10 transition-all duration-200 w-full text-left"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Odjava
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile hamburger button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="lg:hidden fixed top-4 left-4 z-50 p-2.5 bg-slate-900 text-white rounded-xl shadow-lg hover:bg-slate-800 transition-colors border border-white/[0.08]"
|
||||
aria-label={isOpen ? 'Zatvori meni' : 'Otvori meni'}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{isOpen ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
fixed top-0 left-0 z-40 h-full w-64 bg-[#0a0e1a] border-r border-white/[0.06]
|
||||
transition-transform duration-300 ease-in-out
|
||||
lg:translate-x-0 lg:static lg:z-auto
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
aria-label="Admin bocna navigacija"
|
||||
>
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export function Breadcrumb({ items }: BreadcrumbProps) {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.label,
|
||||
...(item.href
|
||||
? {
|
||||
item: `https://filamenteka.rs${item.href}`,
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<nav aria-label="Breadcrumb" className="py-3">
|
||||
<ol className="flex items-center flex-wrap gap-1 text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
|
||||
return (
|
||||
<li key={index} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 mx-1.5 flex-shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isLast || !item.href ? (
|
||||
<span
|
||||
className={isLast ? 'font-semibold' : ''}
|
||||
style={{ color: isLast ? 'var(--text-primary)' : 'var(--text-muted)' }}
|
||||
aria-current={isLast ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="hover:underline underline-offset-4 transition-colors duration-200"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { getEnabledCategories, CategoryConfig } from '@/src/config/categories';
|
||||
import { CategoryIcon } from '@/src/components/ui/CategoryIcon';
|
||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||
|
||||
// Darker color variants for readable text on light backgrounds
|
||||
const darkTextMap: Record<string, string> = {
|
||||
'#3b82f6': '#1d4ed8',
|
||||
'#ef4444': '#b91c1c',
|
||||
'#22c55e': '#15803d',
|
||||
'#f59e0b': '#b45309',
|
||||
'#a855f7': '#7e22ce',
|
||||
'#06b6d4': '#0e7490',
|
||||
};
|
||||
|
||||
interface CategoryNavProps {
|
||||
currentSlug?: string;
|
||||
}
|
||||
|
||||
export function CategoryNav({ currentSlug }: CategoryNavProps) {
|
||||
const categories = getEnabledCategories();
|
||||
const [isDark, setIsDark] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsDark(document.documentElement.classList.contains('dark'));
|
||||
check();
|
||||
const observer = new MutationObserver(check);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex gap-2 overflow-x-auto scrollbar-hide py-1 px-0.5"
|
||||
aria-label="Kategorije proizvoda"
|
||||
>
|
||||
{categories.map((category: CategoryConfig) => {
|
||||
const isActive = currentSlug === category.slug;
|
||||
const textColor = isDark ? category.colorHex : (darkTextMap[category.colorHex] || category.colorHex);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={category.slug}
|
||||
href={`/${category.slug}`}
|
||||
onClick={() => trackEvent('Navigation', 'Category Click', category.label)}
|
||||
className={`
|
||||
flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-bold
|
||||
transition-all duration-250 whitespace-nowrap
|
||||
${isActive
|
||||
? 'text-white shadow-lg scale-[1.02]'
|
||||
: 'hover:scale-[1.01]'
|
||||
}
|
||||
`}
|
||||
style={isActive ? {
|
||||
backgroundColor: category.colorHex,
|
||||
boxShadow: `0 4px 20px -2px ${category.colorHex}60`,
|
||||
fontFamily: 'var(--font-display)',
|
||||
} : {
|
||||
backgroundColor: `${category.colorHex}${isDark ? '12' : '10'}`,
|
||||
color: textColor,
|
||||
fontFamily: 'var(--font-display)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = `${category.colorHex}${isDark ? '20' : '18'}`;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = `${category.colorHex}${isDark ? '12' : '10'}`;
|
||||
}
|
||||
}}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<CategoryIcon slug={category.slug} className="w-4 h-4" />
|
||||
<span>{category.labelShort}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
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 function SiteFooter() {
|
||||
return (
|
||||
<footer className="relative mt-20">
|
||||
{/* Bold category color accent strip */}
|
||||
<div
|
||||
className="h-1.5"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #3b82f6 0%, #6366f1 10%, #ef4444 20%, #f97316 30%, #22c55e 42%, #10b981 52%, #f59e0b 62%, #a855f7 74%, #ec4899 84%, #06b6d4 92%, #3b82f6 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="bg-gradient-to-b from-slate-900 to-slate-950 dark:from-[#0f172a] dark:to-[#0a0f1e]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-12 pb-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-10 lg:gap-14">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<Link href="/" className="inline-flex items-center gap-2.5 group mb-5">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Filamenteka"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-9 w-auto opacity-85 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
<span className="text-lg font-bold tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>
|
||||
<span className="text-white">Filament</span>
|
||||
<span className="gradient-text">eka</span>
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-sm text-gray-300/80 leading-relaxed max-w-xs">
|
||||
Privatna prodaja originalne Bambu Lab opreme u Srbiji. Filamenti, stampaci, podloge, mlaznice i rezervni delovi.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div>
|
||||
<h3
|
||||
className="text-xs font-bold text-gray-300 uppercase tracking-[0.15em] mb-5"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
Navigacija
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link
|
||||
href="/filamenti"
|
||||
className="text-sm text-gray-400 hover:text-blue-400 transition-colors duration-300 font-medium"
|
||||
>
|
||||
Filamenti
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/stampaci"
|
||||
className="text-sm text-gray-400 hover:text-red-400 transition-colors duration-300 font-medium"
|
||||
>
|
||||
3D Stampaci
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={KP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Footer')}
|
||||
className="text-sm text-gray-400 hover:text-emerald-400 transition-colors duration-300 inline-flex items-center gap-1.5 font-medium"
|
||||
>
|
||||
KupujemProdajem
|
||||
<svg className="w-3 h-3 opacity-50" 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>
|
||||
</a>
|
||||
</li>
|
||||
<li className="pt-2 border-t border-white/[0.08]">
|
||||
<Link
|
||||
href="/politika-privatnosti"
|
||||
className="text-sm text-gray-500 hover:text-gray-300 transition-colors duration-300"
|
||||
>
|
||||
Politika privatnosti
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/uslovi-koriscenja"
|
||||
className="text-sm text-gray-500 hover:text-gray-300 transition-colors duration-300"
|
||||
>
|
||||
Uslovi koriscenja
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h3
|
||||
className="text-xs font-bold text-gray-300 uppercase tracking-[0.15em] mb-5"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
Kontakt
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<a
|
||||
href="tel:+381631031048"
|
||||
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
|
||||
className="flex items-center gap-3 text-gray-400 hover:text-blue-300 transition-all duration-300 group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-white/[0.06] flex items-center justify-center group-hover:bg-blue-500/20 group-hover:shadow-[0_0_12px_rgba(59,130,246,0.15)] transition-all duration-300">
|
||||
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">+381 63 103 1048</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={KP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Footer Contact')}
|
||||
className="flex items-center gap-3 text-gray-400 hover:text-emerald-300 transition-all duration-300 group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-white/[0.06] flex items-center justify-center group-hover:bg-emerald-500/20 group-hover:shadow-[0_0_12px_rgba(16,185,129,0.15)] transition-all duration-300">
|
||||
<svg className="w-[18px] h-[18px]" 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>
|
||||
</div>
|
||||
<span className="text-sm font-medium">KupujemProdajem profil</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-10 pt-6 border-t border-white/[0.08] flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
© {new Date().getFullYear()} <span className="font-semibold text-gray-300">Filamenteka</span>. Sva prava zadrzana.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 text-center sm:text-right max-w-sm">
|
||||
Privatna prodaja fizickog lica, ne registrovana prodavnica.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getEnabledCategories } from '@/src/config/categories';
|
||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||
import { CategoryNav } from './CategoryNav';
|
||||
import { CategoryIcon } from '@/src/components/ui/CategoryIcon';
|
||||
|
||||
const KP_URL = 'https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246';
|
||||
|
||||
interface SiteHeaderProps {
|
||||
currentCategory?: string;
|
||||
}
|
||||
|
||||
export function SiteHeader({ currentCategory }: SiteHeaderProps) {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const categories = getEnabledCategories();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved) setDarkMode(JSON.parse(saved));
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
// Compact header on scroll
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20);
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
// Close mobile menu on outside click
|
||||
useEffect(() => {
|
||||
if (!mobileMenuOpen) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
const handleMobileLinkClick = () => setMobileMenuOpen(false);
|
||||
|
||||
const handleDarkModeToggle = () => {
|
||||
setDarkMode(!darkMode);
|
||||
trackEvent('UI', 'Dark Mode Toggle', darkMode ? 'Light' : 'Dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-50 transition-all duration-300 ${
|
||||
scrolled
|
||||
? 'bg-white/95 dark:bg-[#0f172a]/90 backdrop-blur-xl shadow-lg shadow-black/[0.06] dark:shadow-black/30'
|
||||
: 'bg-white dark:bg-[#0f172a]'
|
||||
} border-b border-gray-200 dark:border-white/[0.08]`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className={`flex items-center justify-between transition-all duration-300 ${scrolled ? 'h-12 sm:h-14' : 'h-14 sm:h-16'}`}>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex-shrink-0 flex items-center gap-2.5 group"
|
||||
aria-label="Filamenteka - Pocetna"
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Filamenteka"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={`transition-all duration-300 ${scrolled ? 'h-7 sm:h-8' : 'h-8 sm:h-10'} w-auto group-hover:brightness-110 group-hover:scale-105`}
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
<span className="hidden sm:inline text-xl font-extrabold tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>
|
||||
<span className="text-gray-900 dark:text-white">Filament</span>
|
||||
<span className="gradient-text">eka</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop: Category Nav */}
|
||||
<div className="hidden md:block flex-1 mx-6 overflow-x-auto scrollbar-hide">
|
||||
<CategoryNav currentSlug={currentCategory} />
|
||||
</div>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 sm:gap-2.5">
|
||||
{/* KP link — desktop */}
|
||||
<a
|
||||
href={KP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Header')}
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-4 py-1.5
|
||||
bg-gradient-to-r from-emerald-500 to-teal-500
|
||||
hover:from-emerald-400 hover:to-teal-400
|
||||
text-white text-sm font-bold rounded-full
|
||||
shadow-md shadow-emerald-500/30 hover:shadow-lg hover:shadow-teal-500/40
|
||||
transition-all duration-200 active:scale-95 hover:scale-[1.03]"
|
||||
title="KupujemProdajem"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
KP
|
||||
</a>
|
||||
|
||||
{/* Dark mode toggle */}
|
||||
{mounted ? (
|
||||
<button
|
||||
onClick={handleDarkModeToggle}
|
||||
className="relative p-2.5 rounded-full text-gray-500 dark:text-gray-300
|
||||
hover:text-amber-500 dark:hover:text-amber-400
|
||||
hover:bg-amber-50 dark:hover:bg-amber-500/10
|
||||
transition-all duration-200 hover:scale-110"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
aria-label={darkMode ? 'Prebaci na svetlu temu' : 'Prebaci na tamnu temu'}
|
||||
>
|
||||
{darkMode ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-10 h-10" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{/* Hamburger — mobile */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden p-2 rounded-xl text-gray-700 dark:text-gray-200
|
||||
hover:bg-gray-100 dark:hover:bg-white/10
|
||||
transition-all duration-200"
|
||||
aria-label={mobileMenuOpen ? 'Zatvori meni' : 'Otvori meni'}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
<div className="w-6 h-5 flex flex-col justify-between relative">
|
||||
<span className={`block h-[3px] w-6 bg-current rounded-full transition-all duration-300 origin-center ${mobileMenuOpen ? 'rotate-45 translate-y-[9px]' : ''}`} />
|
||||
<span className={`block h-[3px] w-6 bg-current rounded-full transition-all duration-200 ${mobileMenuOpen ? 'opacity-0 scale-0' : ''}`} />
|
||||
<span className={`block h-[3px] w-6 bg-current rounded-full transition-all duration-300 origin-center ${mobileMenuOpen ? '-rotate-45 -translate-y-[9px]' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`md:hidden overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
mobileMenuOpen ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="border-t border-gray-200/50 dark:border-white/[0.08]">
|
||||
<nav className="max-w-7xl mx-auto px-4 py-3 space-y-1" aria-label="Kategorije proizvoda">
|
||||
{categories.map((cat) => {
|
||||
const isActive = currentCategory === cat.slug;
|
||||
return (
|
||||
<Link
|
||||
key={cat.slug}
|
||||
href={`/${cat.slug}`}
|
||||
onClick={handleMobileLinkClick}
|
||||
className={`flex items-center gap-3 px-4 py-3.5 rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||
isActive
|
||||
? 'text-white shadow-lg scale-[1.01]'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/[0.06] hover:scale-[1.01]'
|
||||
}`}
|
||||
style={isActive ? {
|
||||
backgroundColor: cat.colorHex,
|
||||
boxShadow: `0 6px 20px ${cat.colorHex}50`,
|
||||
} : undefined}
|
||||
>
|
||||
{/* Colored dot indicator */}
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-white/60' : ''}`}
|
||||
style={!isActive ? { backgroundColor: cat.colorHex } : undefined}
|
||||
/>
|
||||
<CategoryIcon slug={cat.slug} className="w-5 h-5" />
|
||||
<span className="font-bold">{cat.label}</span>
|
||||
{isActive && (
|
||||
<svg className="w-4 h-4 ml-auto" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* KP link in mobile menu */}
|
||||
<a
|
||||
href={KP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
trackEvent('External Link', 'Kupujem Prodajem', 'Mobile Menu');
|
||||
handleMobileLinkClick();
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-3.5 rounded-xl text-sm font-bold
|
||||
text-emerald-600 dark:text-emerald-400
|
||||
hover:bg-emerald-50 dark:hover:bg-emerald-900/20
|
||||
transition-all duration-200 hover:scale-[1.01]"
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-emerald-500" />
|
||||
<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>
|
||||
<span>KupujemProdajem</span>
|
||||
<svg className="w-3.5 h-3.5 ml-auto opacity-50" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'sale';
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const variantStyles: Record<NonNullable<BadgeProps['variant']>, string> = {
|
||||
default:
|
||||
'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-300',
|
||||
success:
|
||||
'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',
|
||||
warning:
|
||||
'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',
|
||||
danger:
|
||||
'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-300',
|
||||
info:
|
||||
'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300',
|
||||
sale:
|
||||
'bg-gradient-to-r from-red-500 to-rose-600 text-white shadow-sm shadow-red-500/20',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<NonNullable<BadgeProps['size']>, string> = {
|
||||
sm: 'px-2 py-0.5 text-[11px]',
|
||||
md: 'px-2.5 py-1 text-xs',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center rounded-full font-semibold whitespace-nowrap tracking-wide
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
`}
|
||||
>
|
||||
{variant === 'sale' && (
|
||||
<svg
|
||||
className="w-3 h-3 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const variantStyles: Record<NonNullable<ButtonProps['variant']>, string> = {
|
||||
primary:
|
||||
'bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 focus:ring-blue-500 dark:from-blue-600 dark:to-blue-700 dark:hover:from-blue-700 dark:hover:to-blue-800',
|
||||
secondary:
|
||||
'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
|
||||
danger:
|
||||
'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 dark:bg-red-700 dark:hover:bg-red-800',
|
||||
ghost:
|
||||
'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-400 dark:text-gray-300 dark:hover:bg-gray-800',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<NonNullable<ButtonProps['size']>, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
disabled,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled || isLoading}
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2 rounded-md font-medium
|
||||
transition-all duration-200 ease-in-out
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
hover?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
hover = false,
|
||||
onClick,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`
|
||||
bg-white dark:bg-gray-800 rounded-lg shadow-md
|
||||
border border-gray-200 dark:border-gray-700
|
||||
${hover ? 'transition-all duration-200 hover:shadow-lg hover:scale-[1.02] cursor-pointer' : ''}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
'use client';
|
||||
|
||||
interface CategoryIconProps {
|
||||
slug: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CategoryIcon({ slug, className = 'w-6 h-6' }: CategoryIconProps) {
|
||||
const svgProps = {
|
||||
className,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.5,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
};
|
||||
|
||||
switch (slug) {
|
||||
case 'filamenti':
|
||||
// Spool / reel
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<line x1="12" y1="3" x2="12" y2="9" />
|
||||
<line x1="12" y1="15" x2="12" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
case 'stampaci':
|
||||
// 3D cube
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<path d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
|
||||
</svg>
|
||||
);
|
||||
case 'ploce':
|
||||
// Build plate with alignment marks
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<rect x="3" y="8" width="18" height="8" rx="1.5" />
|
||||
<path d="M7 8V5.5M12 8V4.5M17 8V5.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'mlaznice':
|
||||
// Nozzle / hotend
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<path d="M8 3h8v4l-2 7h-4L8 7V3Z" />
|
||||
<path d="M10 14v3M14 14v3" />
|
||||
<circle cx="12" cy="20" r="1.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'delovi':
|
||||
// Gear / settings
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v3m0 14v3M2 12h3m14 0h3M4.93 4.93l2.12 2.12m9.9 9.9 2.12 2.12M4.93 19.07l2.12-2.12m9.9-9.9 2.12-2.12" />
|
||||
</svg>
|
||||
);
|
||||
case 'oprema':
|
||||
// Wrench
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<path d="M21.75 6.75a4.5 4.5 0 0 1-4.884 4.484c-1.076-.091-2.264.071-2.95.904l-7.152 8.684a2.548 2.548 0 1 1-3.586-3.586l8.684-7.152c.833-.686.995-1.874.904-2.95a4.5 4.5 0 0 1 6.336-4.486l-3.276 3.276a3.004 3.004 0 0 0 2.25 2.25l3.276-3.276c.256.565.398 1.192.398 1.852Z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const defaultIcon = (
|
||||
<svg
|
||||
className="w-14 h-14 text-gray-300 dark:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
|
||||
<div className="mb-5 p-4 rounded-2xl bg-gray-100/80 dark:bg-white/[0.04]">
|
||||
{icon || defaultIcon}
|
||||
</div>
|
||||
<h3
|
||||
className="text-lg font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-sm max-w-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeStyles: Record<NonNullable<ModalProps['size']>, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl',
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
}: ModalProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
className={`
|
||||
relative bg-white dark:bg-gray-800 rounded-lg shadow-xl
|
||||
w-full ${sizeStyles[size]} p-6
|
||||
border border-gray-200 dark:border-gray-700
|
||||
`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{title && (
|
||||
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors rounded-md p-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-label="Zatvori"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
interface PriceDisplayProps {
|
||||
price: number;
|
||||
saleActive?: boolean;
|
||||
salePercentage?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
return price.toLocaleString('sr-RS');
|
||||
}
|
||||
|
||||
function calculateSalePrice(price: number, percentage: number): number {
|
||||
return Math.round(price * (1 - percentage / 100));
|
||||
}
|
||||
|
||||
export function PriceDisplay({
|
||||
price,
|
||||
saleActive = false,
|
||||
salePercentage = 0,
|
||||
className = '',
|
||||
}: PriceDisplayProps) {
|
||||
const salePrice = saleActive && salePercentage > 0
|
||||
? calculateSalePrice(price, salePercentage)
|
||||
: price;
|
||||
|
||||
if (saleActive && salePercentage > 0) {
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${className}`}>
|
||||
<span className="line-through text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
{formatPrice(price)} RSD
|
||||
</span>
|
||||
<span className="font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{formatPrice(salePrice)} RSD
|
||||
</span>
|
||||
<Badge variant="sale" size="sm">
|
||||
-{salePercentage}%
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`font-semibold ${className}`} style={{ color: 'var(--text-primary)' }}>
|
||||
{formatPrice(price)} <span className="text-sm font-normal" style={{ color: 'var(--text-muted)' }}>RSD</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { ProductCategory } from '@/src/types/product';
|
||||
|
||||
export interface CategoryFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'select' | 'range' | 'checkbox';
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface CategoryConfig {
|
||||
slug: string;
|
||||
label: string;
|
||||
labelShort: string;
|
||||
labelEn: string;
|
||||
apiCategory?: ProductCategory;
|
||||
color: string;
|
||||
colorHex: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
filters: CategoryFilter[];
|
||||
enabled: boolean;
|
||||
isFilament?: boolean;
|
||||
}
|
||||
|
||||
export const CATEGORIES: CategoryConfig[] = [
|
||||
{
|
||||
slug: 'filamenti',
|
||||
label: 'Filamenti',
|
||||
labelShort: 'Filamenti',
|
||||
labelEn: 'Filaments',
|
||||
color: 'blue',
|
||||
colorHex: '#3b82f6',
|
||||
icon: '🧵',
|
||||
description: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU i mnogi drugi materijali.',
|
||||
metaTitle: 'Bambu Lab Filamenti | PLA, PETG, ABS - Filamenteka',
|
||||
metaDescription: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU. Privatna prodaja u Srbiji.',
|
||||
filters: [
|
||||
{ key: 'material', label: 'Materijal', type: 'select', options: ['ABS', 'ASA', 'PA6', 'PAHT', 'PC', 'PET', 'PETG', 'PLA', 'PPA', 'PPS', 'TPU'] },
|
||||
{ key: 'finish', label: 'Finish', type: 'select' },
|
||||
{ key: 'color', label: 'Boja', type: 'select' },
|
||||
],
|
||||
enabled: true,
|
||||
isFilament: true,
|
||||
},
|
||||
{
|
||||
slug: 'stampaci',
|
||||
label: '3D Stampaci',
|
||||
labelShort: 'Stampaci',
|
||||
labelEn: '3D Printers',
|
||||
apiCategory: 'printer',
|
||||
color: 'red',
|
||||
colorHex: '#ef4444',
|
||||
icon: '🖨️',
|
||||
description: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
|
||||
metaTitle: 'Bambu Lab 3D Stampaci | A1, P1S, X1C - Filamenteka',
|
||||
metaDescription: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
|
||||
filters: [
|
||||
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
slug: 'ploce',
|
||||
label: 'Build Plate (Podloge)',
|
||||
labelShort: 'Podloge',
|
||||
labelEn: 'Build Plates',
|
||||
apiCategory: 'build_plate',
|
||||
color: 'green',
|
||||
colorHex: '#22c55e',
|
||||
icon: '📐',
|
||||
description: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
|
||||
metaTitle: 'Bambu Lab Build Plate (Podloga) - Filamenteka',
|
||||
metaDescription: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
|
||||
filters: [
|
||||
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
|
||||
{ key: 'printer_model', label: 'Kompatibilnost', type: 'select' },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
slug: 'mlaznice',
|
||||
label: 'Mlaznice i Hotend (Nozzles)',
|
||||
labelShort: 'Mlaznice',
|
||||
labelEn: 'Nozzles & Hotend',
|
||||
apiCategory: 'nozzle',
|
||||
color: 'amber',
|
||||
colorHex: '#f59e0b',
|
||||
icon: '🔩',
|
||||
description: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
|
||||
metaTitle: 'Bambu Lab Nozzle i Hotend (Mlaznice/Dizne) - Filamenteka',
|
||||
metaDescription: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
|
||||
filters: [
|
||||
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
|
||||
{ key: 'printer_model', label: 'Kompatibilnost', type: 'select' },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
slug: 'delovi',
|
||||
label: 'Rezervni Delovi (Spare Parts)',
|
||||
labelShort: 'Delovi',
|
||||
labelEn: 'Spare Parts',
|
||||
apiCategory: 'spare_part',
|
||||
color: 'purple',
|
||||
colorHex: '#a855f7',
|
||||
icon: '🔧',
|
||||
description: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
|
||||
metaTitle: 'Bambu Lab Rezervni Delovi (Spare Parts) - Filamenteka',
|
||||
metaDescription: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
|
||||
filters: [
|
||||
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
|
||||
{ key: 'printer_model', label: 'Kompatibilnost', type: 'select' },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
slug: 'oprema',
|
||||
label: 'Oprema i Dodaci (Accessories)',
|
||||
labelShort: 'Oprema',
|
||||
labelEn: 'Accessories',
|
||||
apiCategory: 'accessory',
|
||||
color: 'cyan',
|
||||
colorHex: '#06b6d4',
|
||||
icon: '🛠️',
|
||||
description: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
|
||||
metaTitle: 'Bambu Lab Oprema i Dodaci (Accessories) - Filamenteka',
|
||||
metaDescription: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
|
||||
filters: [
|
||||
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const getCategoryBySlug = (slug: string): CategoryConfig | undefined =>
|
||||
CATEGORIES.find(c => c.slug === slug);
|
||||
|
||||
export const getEnabledCategories = (): CategoryConfig[] =>
|
||||
CATEGORIES.filter(c => c.enabled);
|
||||
|
||||
export const CONDITION_LABELS: Record<string, string> = {
|
||||
new: 'Novo',
|
||||
used_like_new: 'Kao novo',
|
||||
used_good: 'Dobro stanje',
|
||||
used_fair: 'Korisceno',
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
interface DarkModeContextType {
|
||||
darkMode: boolean;
|
||||
setDarkMode: (value: boolean) => void;
|
||||
toggleDarkMode: () => void;
|
||||
mounted: boolean;
|
||||
}
|
||||
|
||||
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
|
||||
|
||||
export function DarkModeProvider({
|
||||
children,
|
||||
defaultDark = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
defaultDark?: boolean;
|
||||
}) {
|
||||
const [darkMode, setDarkMode] = useState(defaultDark);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved !== null) {
|
||||
setDarkMode(JSON.parse(saved));
|
||||
} else if (defaultDark) {
|
||||
setDarkMode(true);
|
||||
}
|
||||
}, [defaultDark]);
|
||||
|
||||
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 toggleDarkMode = () => setDarkMode(prev => !prev);
|
||||
|
||||
return (
|
||||
<DarkModeContext.Provider value={{ darkMode, setDarkMode, toggleDarkMode, mounted }}>
|
||||
{children}
|
||||
</DarkModeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDarkMode(): DarkModeContextType {
|
||||
const context = useContext(DarkModeContext);
|
||||
if (!context) {
|
||||
throw new Error('useDarkMode must be used within a DarkModeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
407
src/data/bambuLabCatalog.ts
Normal file
407
src/data/bambuLabCatalog.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
// Master Bambu Lab product catalog — single source of truth
|
||||
// Material → Finish → Color[] with refill/spool availability
|
||||
|
||||
export interface CatalogColorEntry {
|
||||
name: string;
|
||||
refill: boolean;
|
||||
spool: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogFinish {
|
||||
colors: CatalogColorEntry[];
|
||||
}
|
||||
|
||||
export type BambuLabCatalog = Record<string, Record<string, CatalogFinish>>;
|
||||
|
||||
export const BAMBU_LAB_CATALOG: BambuLabCatalog = {
|
||||
PLA: {
|
||||
Basic: {
|
||||
colors: [
|
||||
{ name: 'Jade White', refill: true, spool: true },
|
||||
{ name: 'Black', refill: true, spool: true },
|
||||
{ name: 'Red', refill: true, spool: true },
|
||||
{ name: 'Bambu Green', refill: true, spool: true },
|
||||
{ name: 'Blue', refill: true, spool: true },
|
||||
{ name: 'Scarlet Red', refill: true, spool: true },
|
||||
{ name: 'Lemon Yellow', refill: true, spool: true },
|
||||
{ name: 'Cyan', refill: true, spool: true },
|
||||
{ name: 'Sakura Pink', refill: true, spool: true },
|
||||
{ name: 'Cobalt Blue', refill: true, spool: true },
|
||||
{ name: 'Mistletoe Green', refill: true, spool: true },
|
||||
{ name: 'Dark Red', refill: true, spool: true },
|
||||
{ name: 'Hot Pink', refill: true, spool: true },
|
||||
{ name: 'Lavender', refill: true, spool: true },
|
||||
{ name: 'Light Blue', refill: true, spool: true },
|
||||
{ name: 'Sky Blue', refill: true, spool: true },
|
||||
{ name: 'Sunflower Yellow', refill: true, spool: true },
|
||||
{ name: 'Pumpkin Orange', refill: true, spool: true },
|
||||
{ name: 'Lime', refill: true, spool: true },
|
||||
{ name: 'Blue Grey', refill: true, spool: false },
|
||||
{ name: 'Beige', refill: true, spool: false },
|
||||
{ name: 'Light Gray', refill: true, spool: false },
|
||||
{ name: 'Yellow', refill: true, spool: false },
|
||||
{ name: 'Orange', refill: true, spool: false },
|
||||
{ name: 'Gold', refill: true, spool: false },
|
||||
{ name: 'Bright Green', refill: true, spool: false },
|
||||
{ name: 'Pink', refill: true, spool: false },
|
||||
{ name: 'Magenta', refill: true, spool: false },
|
||||
{ name: 'Maroon Red', refill: true, spool: false },
|
||||
{ name: 'Purple', refill: true, spool: false },
|
||||
{ name: 'Turquoise', refill: true, spool: false },
|
||||
{ name: 'Brown', refill: true, spool: false },
|
||||
{ name: 'Bronze', refill: true, spool: false },
|
||||
{ name: 'Silver', refill: true, spool: false },
|
||||
{ name: 'Dark Gray', refill: true, spool: false },
|
||||
],
|
||||
},
|
||||
'Basic Gradient': {
|
||||
colors: [
|
||||
{ name: 'Neon City', refill: false, spool: true },
|
||||
{ name: 'Midnight Blaze', refill: false, spool: true },
|
||||
{ name: 'South Beach', refill: false, spool: true },
|
||||
{ name: 'Arctic Whisper', refill: false, spool: true },
|
||||
{ name: 'Cotton Candy Cloud', refill: false, spool: true },
|
||||
{ name: 'Ocean to Meadow', refill: false, spool: true },
|
||||
{ name: 'Solar Breeze', refill: false, spool: true },
|
||||
{ name: 'Velvet Eclipse', refill: false, spool: true },
|
||||
{ name: 'Dawn Radiance', refill: false, spool: true },
|
||||
{ name: 'Dusk Glare', refill: false, spool: true },
|
||||
{ name: 'Blueberry Bubblegum', refill: false, spool: true },
|
||||
{ name: 'Blue Hawaii', refill: false, spool: true },
|
||||
{ name: 'Gilded Rose', refill: false, spool: true },
|
||||
{ name: 'Pink Citrus', refill: false, spool: true },
|
||||
{ name: 'Mint Lime', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
Matte: {
|
||||
colors: [
|
||||
{ name: 'Matte Ivory White', refill: true, spool: false },
|
||||
{ name: 'Matte Charcoal', refill: true, spool: false },
|
||||
{ name: 'Matte Scarlet Red', refill: true, spool: false },
|
||||
{ name: 'Matte Marine Blue', refill: true, spool: false },
|
||||
{ name: 'Matte Mandarin Orange', refill: true, spool: false },
|
||||
{ name: 'Matte Ash Gray', refill: true, spool: false },
|
||||
{ name: 'Matte Desert Tan', refill: true, spool: false },
|
||||
{ name: 'Matte Nardo Gray', refill: true, spool: false },
|
||||
{ name: 'Matte Apple Green', refill: true, spool: false },
|
||||
{ name: 'Matte Bone White', refill: true, spool: false },
|
||||
{ name: 'Matte Caramel', refill: true, spool: false },
|
||||
{ name: 'Matte Dark Blue', refill: true, spool: false },
|
||||
{ name: 'Matte Dark Brown', refill: true, spool: false },
|
||||
{ name: 'Matte Dark Chocolate', refill: true, spool: false },
|
||||
{ name: 'Matte Dark Green', refill: true, spool: false },
|
||||
{ name: 'Matte Dark Red', refill: true, spool: false },
|
||||
{ name: 'Matte Grass Green', refill: true, spool: false },
|
||||
{ name: 'Matte Ice Blue', refill: true, spool: false },
|
||||
{ name: 'Matte Lemon Yellow', refill: true, spool: false },
|
||||
{ name: 'Matte Lilac Purple', refill: true, spool: false },
|
||||
{ name: 'Matte Plum', refill: true, spool: false },
|
||||
{ name: 'Matte Sakura Pink', refill: true, spool: false },
|
||||
{ name: 'Matte Sky Blue', refill: true, spool: false },
|
||||
{ name: 'Matte Latte Brown', refill: true, spool: false },
|
||||
{ name: 'Matte Terracotta', refill: true, spool: false },
|
||||
],
|
||||
},
|
||||
'Silk+': {
|
||||
colors: [
|
||||
{ name: 'Candy Green', refill: false, spool: true },
|
||||
{ name: 'Candy Red', refill: false, spool: true },
|
||||
{ name: 'Mint', refill: false, spool: true },
|
||||
{ name: 'Titan Gray', refill: false, spool: true },
|
||||
{ name: 'Rose Gold', refill: false, spool: true },
|
||||
{ name: 'Champagne', refill: false, spool: true },
|
||||
{ name: 'Baby Blue', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
'Silk Multi-Color': {
|
||||
colors: [
|
||||
{ name: 'Aurora Purple', refill: false, spool: true },
|
||||
{ name: 'Phantom Blue', refill: false, spool: true },
|
||||
{ name: 'Mystic Magenta', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
Metal: {
|
||||
colors: [
|
||||
{ name: 'Iron Gray Metallic', refill: false, spool: true },
|
||||
{ name: 'Iridium Gold Metallic', refill: false, spool: true },
|
||||
{ name: 'Cobalt Blue Metallic', refill: false, spool: true },
|
||||
{ name: 'Copper Brown Metallic', refill: false, spool: true },
|
||||
{ name: 'Oxide Green Metallic', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
Sparkle: {
|
||||
colors: [
|
||||
{ name: 'Onyx Black Sparkle', refill: false, spool: true },
|
||||
{ name: 'Classic Gold Sparkle', refill: false, spool: true },
|
||||
{ name: 'Crimson Red Sparkle', refill: false, spool: true },
|
||||
{ name: 'Royal Purple Sparkle', refill: false, spool: true },
|
||||
{ name: 'Slate Gray Sparkle', refill: false, spool: true },
|
||||
{ name: 'Alpine Green Sparkle', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
Galaxy: {
|
||||
colors: [
|
||||
{ name: 'Nebulae', refill: true, spool: true },
|
||||
],
|
||||
},
|
||||
Marble: {
|
||||
colors: [
|
||||
{ name: 'White Marble', refill: false, spool: true },
|
||||
{ name: 'Red Granite', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
Glow: {
|
||||
colors: [
|
||||
{ name: 'Glow Blue', refill: false, spool: true },
|
||||
{ name: 'Glow Green', refill: false, spool: true },
|
||||
{ name: 'Glow Orange', refill: false, spool: true },
|
||||
{ name: 'Glow Pink', refill: false, spool: true },
|
||||
{ name: 'Glow Yellow', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
Wood: {
|
||||
colors: [
|
||||
{ name: 'Ochre Yellow', refill: false, spool: true },
|
||||
{ name: 'White Oak', refill: false, spool: true },
|
||||
{ name: 'Clay Brown', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
'Tough+': {
|
||||
colors: [
|
||||
{ name: 'Black', refill: true, spool: true },
|
||||
],
|
||||
},
|
||||
CF: {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
{ name: 'Burgundy Red', refill: false, spool: true },
|
||||
{ name: 'Jeans Blue', refill: false, spool: true },
|
||||
{ name: 'Lava Gray', refill: false, spool: true },
|
||||
{ name: 'Matcha Green', refill: false, spool: true },
|
||||
{ name: 'Royal Blue', refill: false, spool: true },
|
||||
{ name: 'Iris Purple', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
PETG: {
|
||||
HF: {
|
||||
colors: [
|
||||
{ name: 'Jade White', refill: true, spool: true },
|
||||
{ name: 'Black', refill: true, spool: true },
|
||||
{ name: 'Red', refill: true, spool: true },
|
||||
{ name: 'Green', refill: true, spool: true },
|
||||
{ name: 'Blue', refill: true, spool: true },
|
||||
{ name: 'Gray', refill: true, spool: true },
|
||||
{ name: 'Orange', refill: true, spool: true },
|
||||
{ name: 'Cream', refill: true, spool: true },
|
||||
{ name: 'Forest Green', refill: true, spool: true },
|
||||
{ name: 'Lake Blue', refill: true, spool: true },
|
||||
{ name: 'Lime Green', refill: true, spool: true },
|
||||
{ name: 'Peanut Brown', refill: true, spool: true },
|
||||
],
|
||||
},
|
||||
Translucent: {
|
||||
colors: [
|
||||
{ name: 'Clear', refill: false, spool: true },
|
||||
{ name: 'Translucent Gray', refill: false, spool: true },
|
||||
{ name: 'Translucent Brown', refill: false, spool: true },
|
||||
{ name: 'Translucent Purple', refill: false, spool: true },
|
||||
{ name: 'Translucent Orange', refill: false, spool: true },
|
||||
{ name: 'Translucent Olive', refill: false, spool: true },
|
||||
{ name: 'Translucent Pink', refill: false, spool: true },
|
||||
{ name: 'Translucent Light Blue', refill: false, spool: true },
|
||||
{ name: 'Translucent Tea', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
CF: {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
{ name: 'Brick Red', refill: false, spool: true },
|
||||
{ name: 'Indigo Blue', refill: false, spool: true },
|
||||
{ name: 'Malachite Green', refill: false, spool: true },
|
||||
{ name: 'Titan Gray', refill: false, spool: true },
|
||||
{ name: 'Violet Purple', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
ABS: {
|
||||
Basic: {
|
||||
colors: [
|
||||
{ name: 'ABS Azure', refill: true, spool: true },
|
||||
{ name: 'ABS Black', refill: true, spool: true },
|
||||
{ name: 'ABS Blue', refill: true, spool: true },
|
||||
{ name: 'ABS Olive', refill: true, spool: true },
|
||||
{ name: 'ABS Tangerine Yellow', refill: true, spool: true },
|
||||
{ name: 'ABS Navy Blue', refill: true, spool: true },
|
||||
{ name: 'ABS Orange', refill: true, spool: true },
|
||||
{ name: 'ABS Bambu Green', refill: true, spool: true },
|
||||
{ name: 'ABS Red', refill: true, spool: true },
|
||||
{ name: 'ABS White', refill: true, spool: true },
|
||||
{ name: 'ABS Silver', refill: true, spool: true },
|
||||
],
|
||||
},
|
||||
GF: {
|
||||
colors: [
|
||||
{ name: 'ABS GF Yellow', refill: true, spool: false },
|
||||
{ name: 'ABS GF Orange', refill: true, spool: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
TPU: {
|
||||
'85A': {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
{ name: 'White', refill: false, spool: true },
|
||||
{ name: 'Flesh', refill: false, spool: true },
|
||||
{ name: 'Light Cyan', refill: false, spool: true },
|
||||
{ name: 'Neon Orange', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
'90A': {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
{ name: 'White', refill: false, spool: true },
|
||||
{ name: 'Red', refill: false, spool: true },
|
||||
{ name: 'Blaze', refill: false, spool: true },
|
||||
{ name: 'Frozen', refill: false, spool: true },
|
||||
{ name: 'Grape Jelly', refill: false, spool: true },
|
||||
{ name: 'Crystal Blue', refill: false, spool: true },
|
||||
{ name: 'Quicksilver', refill: false, spool: true },
|
||||
{ name: 'Cocoa Brown', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
'95A HF': {
|
||||
colors: [
|
||||
{ name: 'Black', refill: true, spool: false },
|
||||
{ name: 'White', refill: true, spool: false },
|
||||
{ name: 'TPU 95A HF Yellow', refill: true, spool: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
ASA: {
|
||||
Basic: {
|
||||
colors: [
|
||||
{ name: 'Black', refill: true, spool: true },
|
||||
{ name: 'Blue', refill: true, spool: true },
|
||||
{ name: 'Gray', refill: true, spool: true },
|
||||
{ name: 'Green', refill: true, spool: true },
|
||||
{ name: 'Red', refill: true, spool: true },
|
||||
{ name: 'White', refill: true, spool: true },
|
||||
],
|
||||
},
|
||||
CF: {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
Aero: {
|
||||
colors: [
|
||||
{ name: 'White', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
PA6: {
|
||||
GF: {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
PAHT: {
|
||||
CF: {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
PC: {
|
||||
Basic: {
|
||||
colors: [
|
||||
{ name: 'Clear Black', refill: false, spool: true },
|
||||
{ name: 'Transparent', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
FR: {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
PET: {
|
||||
CF: {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
PPA: {
|
||||
CF: {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
PPS: {
|
||||
CF: {
|
||||
colors: [
|
||||
{ name: 'Black', refill: false, spool: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
|
||||
export function getMaterialOptions(): string[] {
|
||||
return Object.keys(BAMBU_LAB_CATALOG).sort();
|
||||
}
|
||||
|
||||
export function getFinishesForMaterial(material: string): string[] {
|
||||
const materialData = BAMBU_LAB_CATALOG[material];
|
||||
if (!materialData) return [];
|
||||
return Object.keys(materialData).sort();
|
||||
}
|
||||
|
||||
export function getAllFinishes(): string[] {
|
||||
const finishes = new Set<string>();
|
||||
for (const material of Object.values(BAMBU_LAB_CATALOG)) {
|
||||
for (const finish of Object.keys(material)) {
|
||||
finishes.add(finish);
|
||||
}
|
||||
}
|
||||
return [...finishes].sort();
|
||||
}
|
||||
|
||||
export function getColorsForMaterialFinish(material: string, finish: string): CatalogColorEntry[] {
|
||||
return BAMBU_LAB_CATALOG[material]?.[finish]?.colors ?? [];
|
||||
}
|
||||
|
||||
export function getColorsForMaterial(material: string): CatalogColorEntry[] {
|
||||
const materialData = BAMBU_LAB_CATALOG[material];
|
||||
if (!materialData) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: CatalogColorEntry[] = [];
|
||||
for (const finish of Object.values(materialData)) {
|
||||
for (const color of finish.colors) {
|
||||
if (!seen.has(color.name)) {
|
||||
seen.add(color.name);
|
||||
result.push(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function catalogIsSpoolOnly(material: string, finish: string, color: string): boolean {
|
||||
const entry = BAMBU_LAB_CATALOG[material]?.[finish]?.colors.find(c => c.name === color);
|
||||
return entry ? (entry.spool && !entry.refill) : false;
|
||||
}
|
||||
|
||||
export function catalogIsRefillOnly(material: string, finish: string, color: string): boolean {
|
||||
const entry = BAMBU_LAB_CATALOG[material]?.[finish]?.colors.find(c => c.name === color);
|
||||
return entry ? (entry.refill && !entry.spool) : false;
|
||||
}
|
||||
|
||||
export function getFinishOptionsForType(type: string): string[] {
|
||||
return getFinishesForMaterial(type);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'Apple Green': { hex: '#C6E188' },
|
||||
'Arctic Whisper': { hex: '#ECF7F8' },
|
||||
'Ash Grey': { hex: '#9B9EA0' },
|
||||
'Aurora Purple': { hex: '#285BB7' },
|
||||
'Aurora Purple': { hex: ['#7F3696', '#006EC9'], isGradient: true },
|
||||
'Azure': { hex: '#489FDF' },
|
||||
'Baby Blue': { hex: '#AEC3ED' },
|
||||
'Bambu Green': { hex: '#00AE42' },
|
||||
@@ -73,8 +73,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'Iridium Gold Metallic': { hex: '#B39B84' },
|
||||
'Iris Purple': { hex: '#69398E' },
|
||||
'Iron Gray Metallic': { hex: '#6B6C6F' },
|
||||
'IronGray Metallic': { hex: '#6B6C6F' },
|
||||
'Ivory White': { hex: '#FFFFFF' },
|
||||
'Jade White': { hex: '#FFFFFF' },
|
||||
'Jeans Blue': { hex: '#6E88BC' },
|
||||
'Lake Blue': { hex: '#4672E4' },
|
||||
@@ -103,7 +101,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'Nardo Gray': { hex: '#747474' },
|
||||
'Navy Blue': { hex: '#0C2340' },
|
||||
'Nebulae': { hex: '#424379' },
|
||||
'Nebulane': { hex: '#424379' },
|
||||
'Neon City': { hex: '#0047BB' },
|
||||
'Neon Green': { hex: '#ABFF1E' },
|
||||
'Neon Orange': { hex: '#F68A1B' },
|
||||
@@ -146,11 +143,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'White Oak': { hex: '#D2CCA2' },
|
||||
'Yellow': { hex: '#F4EE2A' },
|
||||
|
||||
// ABS Colors
|
||||
// ABS GF Colors
|
||||
'ABS GF Yellow': { hex: '#FDD835' },
|
||||
'ABS GF Orange': { hex: '#F48438' },
|
||||
|
||||
// ABS Colors
|
||||
'ABS Azure': { hex: '#489FDF' },
|
||||
'ABS Olive': { hex: '#748C45' },
|
||||
@@ -164,7 +156,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'ABS Black': { hex: '#000000' },
|
||||
'ABS Silver': { hex: '#A6A9AA' },
|
||||
|
||||
// Translucent Colors
|
||||
// PETG Translucent Colors
|
||||
'Translucent Gray': { hex: '#B8B8B8' },
|
||||
'Translucent Brown': { hex: '#C89A74' },
|
||||
'Translucent Purple': { hex: '#C5A8D8' },
|
||||
@@ -172,9 +164,17 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'Translucent Olive': { hex: '#A4B885' },
|
||||
'Translucent Pink': { hex: '#F9B8D0' },
|
||||
'Translucent Light Blue': { hex: '#A8D8F0' },
|
||||
'Translucent Tea': { hex: '#D9C7A8' },
|
||||
'Translucent Teal': { hex: '#008080' },
|
||||
|
||||
// PLA Matte - New Colors (2025)
|
||||
// PLA Matte Colors
|
||||
'Matte Ivory White': { hex: '#FFFFF0' },
|
||||
'Matte Charcoal': { hex: '#333333' },
|
||||
'Matte Scarlet Red': { hex: '#DE4343' },
|
||||
'Matte Marine Blue': { hex: '#0078BF' },
|
||||
'Matte Mandarin Orange': { hex: '#F99963' },
|
||||
'Matte Ash Gray': { hex: '#9B9EA0' },
|
||||
'Matte Desert Tan': { hex: '#E8DBB7' },
|
||||
'Matte Nardo Gray': { hex: '#747474' },
|
||||
'Matte Apple Green': { hex: '#C6E188' },
|
||||
'Matte Bone White': { hex: '#C8C5B6' },
|
||||
'Matte Caramel': { hex: '#A4845C' },
|
||||
@@ -185,7 +185,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'Matte Dark Red': { hex: '#BB3D43' },
|
||||
'Matte Grass Green': { hex: '#7CB342' },
|
||||
'Matte Ice Blue': { hex: '#A3D8E1' },
|
||||
'Matte Ivory': { hex: '#FFFFF0' },
|
||||
'Matte Lemon Yellow': { hex: '#F7D959' },
|
||||
'Matte Lilac Purple': { hex: '#AE96D4' },
|
||||
'Matte Plum': { hex: '#851A52' },
|
||||
@@ -195,15 +194,14 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'Matte Terracotta': { hex: '#A25A37' },
|
||||
|
||||
// PLA Silk Multi-Color
|
||||
'Silk Aurora Purple': { hex: ['#7F3696', '#006EC9'], isGradient: true },
|
||||
'Silk Phantom Blue': { hex: ['#00629B', '#000000'], isGradient: true },
|
||||
'Silk Mystic Magenta': { hex: ['#720062', '#3A913F'], isGradient: true },
|
||||
'Phantom Blue': { hex: ['#00629B', '#000000'], isGradient: true },
|
||||
'Mystic Magenta': { hex: ['#720062', '#3A913F'], isGradient: true },
|
||||
|
||||
// TPU 95A HF Colors
|
||||
'TPU 95A HF Yellow': { hex: '#F3E600' },
|
||||
|
||||
// Default fallback
|
||||
'Unknown': { hex: '#CCCCCC' }
|
||||
// TPU Colors
|
||||
'Flesh': { hex: '#E8C4A2' },
|
||||
'Grape Jelly': { hex: '#6B2D75' },
|
||||
'Crystal Blue': { hex: '#5BC0EB' },
|
||||
'Quicksilver': { hex: '#A6A9AA' }
|
||||
};
|
||||
|
||||
export function getFilamentColor(colorName: string): ColorMapping {
|
||||
@@ -232,7 +230,7 @@ export function getFilamentColor(colorName: string): ColorMapping {
|
||||
}
|
||||
|
||||
// Return default unknown color
|
||||
return bambuLabColors['Unknown'];
|
||||
return { hex: '#CCCCCC' };
|
||||
}
|
||||
|
||||
export function getColorStyle(colorMapping: ColorMapping): React.CSSProperties {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Complete Bambu Lab color database with hex codes
|
||||
export const bambuLabColors = {
|
||||
// Basic Colors
|
||||
// Re-exports catalog-aligned color groupings
|
||||
|
||||
import { BAMBU_LAB_CATALOG } from './bambuLabCatalog';
|
||||
|
||||
// Flat hex lookup (for backwards compatibility)
|
||||
export const bambuLabColors: Record<string, string> = {
|
||||
// PLA Basic
|
||||
"Jade White": "#FFFFFF",
|
||||
"Black": "#000000",
|
||||
"White": "#FFFFFF",
|
||||
"Red": "#E53935",
|
||||
@@ -8,39 +14,64 @@ export const bambuLabColors = {
|
||||
"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",
|
||||
"Purple": "#5E43B7",
|
||||
"Pink": "#F55A74",
|
||||
"Gray": "#8E9089",
|
||||
"Brown": "#9D432C",
|
||||
"Light Blue": "#61B0FF",
|
||||
"Light Gray": "#D0D2D4",
|
||||
"Sky Blue": "#73B2E5",
|
||||
"Navy Blue": "#0C2340",
|
||||
"Magenta": "#EC008C",
|
||||
"Beige": "#F7E6DE",
|
||||
"Bambu Green": "#00AE42",
|
||||
"Scarlet Red": "#DE4343",
|
||||
"Lemon Yellow": "#F7D959",
|
||||
"Cyan": "#0086D6",
|
||||
"Sakura Pink": "#E8AFCF",
|
||||
"Cobalt Blue": "#0055B8",
|
||||
"Mistletoe Green": "#3F8E43",
|
||||
"Dark Red": "#BB3D43",
|
||||
"Hot Pink": "#F5547D",
|
||||
"Lavender": "#B5AAD5",
|
||||
"Sunflower Yellow": "#FEC601",
|
||||
"Pumpkin Orange": "#FF8E16",
|
||||
"Lime": "#C5ED48",
|
||||
"Blue Grey": "#5B6579",
|
||||
"Gold": "#E4BD68",
|
||||
"Bright Green": "#BDCF00",
|
||||
"Maroon Red": "#0A2989",
|
||||
"Turquoise": "#00B1B7",
|
||||
"Bronze": "#847D48",
|
||||
"Silver": "#A6A9AA",
|
||||
"Dark Gray": "#555555",
|
||||
|
||||
// Matte Colors
|
||||
"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",
|
||||
// PLA Basic Gradient
|
||||
"Neon City": "#0047BB",
|
||||
"Midnight Blaze": "#0047BB",
|
||||
"South Beach": "#468791",
|
||||
"Arctic Whisper": "#ECF7F8",
|
||||
"Cotton Candy Cloud": "#E9E2EC",
|
||||
"Ocean to Meadow": "#A1E4CA",
|
||||
"Solar Breeze": "#F3D9D5",
|
||||
"Velvet Eclipse": "#000000",
|
||||
"Dawn Radiance": "#C472A1",
|
||||
"Dusk Glare": "#F6B790",
|
||||
"Blueberry Bubblegum": "#BADCF4",
|
||||
"Blue Hawaii": "#739FE6",
|
||||
"Gilded Rose": "#ED982C",
|
||||
"Pink Citrus": "#F8C4BC",
|
||||
"Mint Lime": "#BAF382",
|
||||
|
||||
// Matte Colors - New 2025
|
||||
// PLA Matte
|
||||
"Matte Ivory White": "#FFFFF0",
|
||||
"Matte Charcoal": "#333333",
|
||||
"Matte Scarlet Red": "#DE4343",
|
||||
"Matte Marine Blue": "#0078BF",
|
||||
"Matte Mandarin Orange": "#F99963",
|
||||
"Matte Ash Gray": "#9B9EA0",
|
||||
"Matte Desert Tan": "#E8DBB7",
|
||||
"Matte Nardo Gray": "#747474",
|
||||
"Matte Apple Green": "#C6E188",
|
||||
"Matte Bone White": "#C8C5B6",
|
||||
"Matte Caramel": "#A4845C",
|
||||
@@ -49,7 +80,7 @@ export const bambuLabColors = {
|
||||
"Matte Dark Chocolate": "#4A3729",
|
||||
"Matte Dark Green": "#68724D",
|
||||
"Matte Dark Red": "#BB3D43",
|
||||
"Matte Grass Green": "#61C680",
|
||||
"Matte Grass Green": "#7CB342",
|
||||
"Matte Ice Blue": "#A3D8E1",
|
||||
"Matte Lemon Yellow": "#F7D959",
|
||||
"Matte Lilac Purple": "#AE96D4",
|
||||
@@ -59,131 +90,146 @@ export const bambuLabColors = {
|
||||
"Matte Latte Brown": "#D3B7A7",
|
||||
"Matte Terracotta": "#A25A37",
|
||||
|
||||
// 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",
|
||||
// PLA Silk+
|
||||
"Candy Green": "#408619",
|
||||
"Candy Red": "#BB3A2E",
|
||||
"Mint": "#A5DAB7",
|
||||
"Titan Gray": "#606367",
|
||||
"Rose Gold": "#B29593",
|
||||
"Champagne": "#EBD0B1",
|
||||
"Baby Blue": "#AEC3ED",
|
||||
|
||||
// Silk Multi-Color
|
||||
"Silk Aurora Purple": "#7F3696",
|
||||
"Silk Phantom Blue": "#00629B",
|
||||
"Silk Mystic Magenta": "#720062",
|
||||
// PLA Silk Multi-Color
|
||||
"Aurora Purple": "#7F3696",
|
||||
"Phantom Blue": "#00629B",
|
||||
"Mystic Magenta": "#720062",
|
||||
|
||||
// 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",
|
||||
|
||||
// Metal Colors (PLA)
|
||||
// PLA Metal
|
||||
"Iron Gray Metallic": "#6B6C6F",
|
||||
"Iridium Gold Metallic": "#B39B84",
|
||||
"Cobalt Blue Metallic": "#39699E",
|
||||
"Copper Brown Metallic": "#AA6443",
|
||||
"Oxide Green Metallic": "#1D7C6A",
|
||||
|
||||
// ABS GF Colors
|
||||
// PLA Sparkle
|
||||
"Onyx Black Sparkle": "#2D2B28",
|
||||
"Classic Gold Sparkle": "#E4BD68",
|
||||
"Crimson Red Sparkle": "#792B36",
|
||||
"Royal Purple Sparkle": "#483D8B",
|
||||
"Slate Gray Sparkle": "#8E9089",
|
||||
"Alpine Green Sparkle": "#3F5443",
|
||||
|
||||
// PLA Galaxy
|
||||
"Nebulae": "#424379",
|
||||
|
||||
// PLA Marble
|
||||
"White Marble": "#F7F3F0",
|
||||
"Red Granite": "#AD4E38",
|
||||
|
||||
// PLA Glow
|
||||
"Glow Blue": "#7AC0E9",
|
||||
"Glow Green": "#A1FFAC",
|
||||
"Glow Orange": "#FF9D5B",
|
||||
"Glow Pink": "#F17B8F",
|
||||
"Glow Yellow": "#F8FF80",
|
||||
|
||||
// PLA Wood
|
||||
"Ochre Yellow": "#BC8B39",
|
||||
"White Oak": "#D2CCA2",
|
||||
"Clay Brown": "#8E621A",
|
||||
|
||||
// PLA CF
|
||||
"Burgundy Red": "#951E23",
|
||||
"Jeans Blue": "#6E88BC",
|
||||
"Lava Gray": "#4D5054",
|
||||
"Matcha Green": "#5C9748",
|
||||
"Royal Blue": "#2842AD",
|
||||
"Iris Purple": "#69398E",
|
||||
|
||||
// PETG HF
|
||||
"Cream": "#F3E0B8",
|
||||
"Forest Green": "#415520",
|
||||
"Lake Blue": "#4672E4",
|
||||
"Lime Green": "#8EE43D",
|
||||
"Peanut Brown": "#7E5A1F",
|
||||
|
||||
// PETG Translucent
|
||||
"Clear": "#FAFAFA",
|
||||
"Translucent Gray": "#B8B8B8",
|
||||
"Translucent Brown": "#C89A74",
|
||||
"Translucent Purple": "#C5A8D8",
|
||||
"Translucent Orange": "#FFB380",
|
||||
"Translucent Olive": "#A4B885",
|
||||
"Translucent Pink": "#F9B8D0",
|
||||
"Translucent Light Blue": "#A8D8F0",
|
||||
"Translucent Teal": "#008080",
|
||||
|
||||
// PETG CF
|
||||
"Brick Red": "#9F332A",
|
||||
"Indigo Blue": "#324585",
|
||||
"Malachite Green": "#16B08E",
|
||||
"Violet Purple": "#583061",
|
||||
|
||||
// ABS
|
||||
"ABS Azure": "#489FDF",
|
||||
"ABS Black": "#000000",
|
||||
"ABS Blue": "#0A2989",
|
||||
"ABS Olive": "#748C45",
|
||||
"ABS Tangerine Yellow": "#FFC72C",
|
||||
"ABS Navy Blue": "#0C2340",
|
||||
"ABS Orange": "#FF6A13",
|
||||
"ABS Bambu Green": "#00AE42",
|
||||
"ABS Red": "#C12E1F",
|
||||
"ABS White": "#FFFFFF",
|
||||
"ABS Silver": "#A6A9AA",
|
||||
"ABS GF Yellow": "#FDD835",
|
||||
"ABS GF Orange": "#F48438",
|
||||
|
||||
// TPU 95A HF Colors
|
||||
// TPU
|
||||
"Flesh": "#E8C4A2",
|
||||
"Light Cyan": "#B9E3DF",
|
||||
"Neon Orange": "#F68A1B",
|
||||
"Blaze": "#E78390",
|
||||
"Frozen": "#A6DEF3",
|
||||
"Grape Jelly": "#6B2D75",
|
||||
"Crystal Blue": "#5BC0EB",
|
||||
"Quicksilver": "#A6A9AA",
|
||||
"Cocoa Brown": "#6F5034",
|
||||
"TPU 95A HF Yellow": "#F3E600",
|
||||
|
||||
// Wood Colors
|
||||
"Ochre Yellow": "#BC8B39",
|
||||
"White Oak": "#D2CCA2",
|
||||
"Clay Brown": "#8E621A"
|
||||
// PC
|
||||
"Clear Black": "#5A5161",
|
||||
"Transparent": "#FFFFFF",
|
||||
};
|
||||
|
||||
// Colors grouped by finish type for easier selection
|
||||
export const colorsByFinish = {
|
||||
"Basic": [
|
||||
"Black", "White", "Red", "Blue", "Green", "Yellow", "Orange",
|
||||
"Purple", "Pink", "Grey", "Brown", "Light Blue", "Light Green",
|
||||
"Mint Green", "Lime Green", "Sky Blue", "Navy Blue", "Magenta",
|
||||
"Violet", "Beige", "Ivory"
|
||||
],
|
||||
"Matte": [
|
||||
"Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green",
|
||||
"Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey",
|
||||
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral",
|
||||
"Matte Apple Green", "Matte Bone White", "Matte Caramel", "Matte Dark Blue",
|
||||
"Matte Dark Brown", "Matte Dark Chocolate", "Matte Dark Green", "Matte Dark Red",
|
||||
"Matte Grass Green", "Matte Ice Blue", "Matte Lemon Yellow", "Matte Lilac Purple",
|
||||
"Matte Latte Brown", "Matte Plum", "Matte Sakura Pink", "Matte Sky Blue", "Matte Terracotta"
|
||||
],
|
||||
"Silk": [
|
||||
"Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green",
|
||||
"Silk Gold", "Silk Silver", "Silk Purple", "Silk Pink", "Silk Orange",
|
||||
"Silk Bronze", "Silk Copper", "Silk Jade", "Silk Rose Gold", "Silk Pearl",
|
||||
"Silk Ruby", "Silk Sapphire", "Silk Emerald"
|
||||
],
|
||||
"Metal": [
|
||||
"Metal Grey", "Metal Silver", "Metal Gold", "Metal Copper", "Metal Bronze"
|
||||
],
|
||||
"Sparkle": [
|
||||
"Sparkle Red", "Sparkle Blue", "Sparkle Green", "Sparkle Purple",
|
||||
"Sparkle Gold", "Sparkle Silver"
|
||||
],
|
||||
"Glow": [
|
||||
"Glow in the Dark Green", "Glow in the Dark Blue"
|
||||
],
|
||||
"Transparent": [
|
||||
"Clear", "Transparent Red", "Transparent Blue", "Transparent Green",
|
||||
"Transparent Yellow", "Transparent Orange", "Transparent Purple"
|
||||
],
|
||||
"Support": [
|
||||
"Natural", "Support White", "Support G"
|
||||
],
|
||||
"Wood": [
|
||||
"Ochre Yellow", "White Oak", "Clay Brown"
|
||||
]
|
||||
};
|
||||
// Colors grouped by finish type — derived from catalog
|
||||
export const colorsByFinish: Record<string, string[]> = {};
|
||||
|
||||
// Build colorsByFinish from catalog
|
||||
for (const [, finishes] of Object.entries(BAMBU_LAB_CATALOG)) {
|
||||
for (const [finishName, finishData] of Object.entries(finishes)) {
|
||||
if (!colorsByFinish[finishName]) {
|
||||
colorsByFinish[finishName] = [];
|
||||
}
|
||||
for (const color of finishData.colors) {
|
||||
if (!colorsByFinish[finishName].includes(color.name)) {
|
||||
colorsByFinish[finishName].push(color.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each finish's colors
|
||||
for (const finish of Object.keys(colorsByFinish)) {
|
||||
colorsByFinish[finish].sort();
|
||||
}
|
||||
|
||||
// Function to get hex code for a color
|
||||
export function getColorHex(colorName: string): string {
|
||||
return bambuLabColors[colorName as keyof typeof bambuLabColors] || "#000000";
|
||||
return bambuLabColors[colorName] || "#000000";
|
||||
}
|
||||
|
||||
// Function to get colors for a specific finish
|
||||
export function getColorsForFinish(finish: string): string[] {
|
||||
return colorsByFinish[finish as keyof typeof colorsByFinish] || [];
|
||||
return colorsByFinish[finish] || [];
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface UseAuthResult {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export function useAuth(): UseAuthResult {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
|
||||
if (token && expiry && Date.now() <= parseInt(expiry)) {
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
window.location.href = '/upadaj';
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
window.location.href = '/upadaj';
|
||||
};
|
||||
|
||||
return { isAuthenticated, isLoading, logout };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Product, ProductCategory } from '@/src/types/product';
|
||||
import { productService } from '@/src/services/api';
|
||||
|
||||
interface UseProductsOptions {
|
||||
category?: ProductCategory;
|
||||
condition?: string;
|
||||
printer_model?: string;
|
||||
in_stock?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
interface UseProductsResult {
|
||||
products: Product[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useProducts(options: UseProductsOptions = {}): UseProductsResult {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchProducts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await productService.getAll(options);
|
||||
setProducts(data);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Greska pri ucitavanju proizvoda';
|
||||
setError(message);
|
||||
console.error('Error fetching products:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [options.category, options.condition, options.printer_model, options.in_stock, options.search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [fetchProducts]);
|
||||
|
||||
return { products, loading, error, refetch: fetchProducts };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { Product, ProductCategory, InventoryStats, SalesStats } from '@/src/types/product';
|
||||
import { Customer, Sale, CreateSaleRequest, AnalyticsOverview, TopSeller, RevenueDataPoint, InventoryAlert, TypeBreakdown } from '@/src/types/sales';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
@@ -133,72 +133,77 @@ export const colorRequestService = {
|
||||
},
|
||||
};
|
||||
|
||||
export const productService = {
|
||||
getAll: async (params: {
|
||||
category?: ProductCategory;
|
||||
condition?: string;
|
||||
printer_model?: string;
|
||||
in_stock?: boolean;
|
||||
search?: string;
|
||||
} = {}) => {
|
||||
const cacheBuster = Date.now();
|
||||
const queryParams = new URLSearchParams({ _t: String(cacheBuster) });
|
||||
if (params.category) queryParams.set('category', params.category);
|
||||
if (params.condition) queryParams.set('condition', params.condition);
|
||||
if (params.printer_model) queryParams.set('printer_model', params.printer_model);
|
||||
if (params.in_stock !== undefined) queryParams.set('in_stock', String(params.in_stock));
|
||||
if (params.search) queryParams.set('search', params.search);
|
||||
const response = await api.get(`/products?${queryParams.toString()}`);
|
||||
export const customerService = {
|
||||
getAll: async (): Promise<Customer[]> => {
|
||||
const response = await api.get('/customers');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
const response = await api.get(`/products/${id}`);
|
||||
search: async (q: string): Promise<Customer[]> => {
|
||||
const response = await api.get(`/customers/search?q=${encodeURIComponent(q)}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (product: Partial<Product> & { printer_model_ids?: string[] }) => {
|
||||
const response = await api.post('/products', product);
|
||||
getById: async (id: string): Promise<Customer & { sales: Sale[] }> => {
|
||||
const response = await api.get(`/customers/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, product: Partial<Product> & { printer_model_ids?: string[] }) => {
|
||||
const response = await api.put(`/products/${id}`, product);
|
||||
create: async (customer: Partial<Customer>): Promise<Customer> => {
|
||||
const response = await api.post('/customers', customer);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const response = await api.delete(`/products/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateBulkSale: async (data: {
|
||||
productIds?: string[];
|
||||
salePercentage: number;
|
||||
saleStartDate?: string;
|
||||
saleEndDate?: string;
|
||||
enableSale: boolean;
|
||||
}) => {
|
||||
const response = await api.post('/products/sale/bulk', data);
|
||||
update: async (id: string, customer: Partial<Customer>): Promise<Customer> => {
|
||||
const response = await api.put(`/customers/${id}`, customer);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const printerModelService = {
|
||||
getAll: async () => {
|
||||
const response = await api.get('/printer-models');
|
||||
export const saleService = {
|
||||
getAll: async (page = 1, limit = 50): Promise<{ sales: Sale[]; total: number }> => {
|
||||
const response = await api.get(`/sales?page=${page}&limit=${limit}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Sale> => {
|
||||
const response = await api.get(`/sales/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateSaleRequest): Promise<Sale> => {
|
||||
const response = await api.post('/sales', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/sales/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const analyticsService = {
|
||||
getInventory: async (): Promise<InventoryStats> => {
|
||||
const response = await api.get('/analytics/inventory');
|
||||
getOverview: async (period = '30d'): Promise<AnalyticsOverview> => {
|
||||
const response = await api.get(`/analytics/overview?period=${period}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSales: async (): Promise<SalesStats> => {
|
||||
const response = await api.get('/analytics/sales');
|
||||
getTopSellers: async (period = '30d'): Promise<TopSeller[]> => {
|
||||
const response = await api.get(`/analytics/top-sellers?period=${period}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getInventoryAlerts: async (): Promise<InventoryAlert[]> => {
|
||||
const response = await api.get('/analytics/inventory-alerts');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRevenueChart: async (period = '6m', group = 'month'): Promise<RevenueDataPoint[]> => {
|
||||
const response = await api.get(`/analytics/revenue-chart?period=${period}&group=${group}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTypeBreakdown: async (period = '30d'): Promise<TypeBreakdown[]> => {
|
||||
const response = await api.get(`/analytics/type-breakdown?period=${period}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=Nunito+Sans:ital,opsz,wght@0,6..12,300..1000;1,6..12,300..1000&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -12,244 +10,43 @@
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Prevent white flash on admin pages */
|
||||
@layer base {
|
||||
:root {
|
||||
--font-display: 'Sora', system-ui, sans-serif;
|
||||
--font-body: 'Nunito Sans', system-ui, sans-serif;
|
||||
|
||||
/* Category palette */
|
||||
--color-filamenti: #3b82f6;
|
||||
--color-stampaci: #ef4444;
|
||||
--color-ploce: #22c55e;
|
||||
--color-mlaznice: #f59e0b;
|
||||
--color-delovi: #a855f7;
|
||||
--color-oprema: #06b6d4;
|
||||
|
||||
/* Surface tokens — light (vibrant, clean) */
|
||||
--surface-primary: #f8fafc;
|
||||
--surface-secondary: #f1f5f9;
|
||||
--surface-elevated: #ffffff;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #334155;
|
||||
--text-muted: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
--border-subtle: #e2e8f0;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--surface-primary: #0f172a;
|
||||
--surface-secondary: #1e293b;
|
||||
--surface-elevated: #1e293b;
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--surface-primary);
|
||||
overflow-x: hidden;
|
||||
scroll-behavior: smooth;
|
||||
background-color: rgb(249 250 251);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: var(--surface-primary);
|
||||
background-color: rgb(17 24 39);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 400;
|
||||
background-color: var(--surface-primary);
|
||||
color: var(--text-primary);
|
||||
transition: none;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.03em;
|
||||
font-weight: 700;
|
||||
@apply bg-gray-50 dark:bg-gray-900 transition-none;
|
||||
}
|
||||
|
||||
/* Disable transitions on page load to prevent flash */
|
||||
.no-transitions * {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Noise texture overlay ─── */
|
||||
.noise-overlay::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dark .noise-overlay::after {
|
||||
opacity: 0.04;
|
||||
}
|
||||
|
||||
/* ─── Gradient text utility ─── */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed, #c026d3);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
html.dark .gradient-text {
|
||||
background: linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ─── Hero rainbow gradient ─── */
|
||||
.hero-gradient-text {
|
||||
background: linear-gradient(90deg, #3b82f6, #a855f7, #ef4444, #f59e0b, #22c55e, #06b6d4);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: gradient-shift 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0%, 100% { background-position: 0% center; }
|
||||
50% { background-position: 100% center; }
|
||||
}
|
||||
|
||||
/* ─── Glass card ─── */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.dark .glass-card {
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ─── Category card hover glow ─── */
|
||||
.category-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
transform: translateY(-6px) scale(1.02);
|
||||
}
|
||||
|
||||
/* Light mode: softer shadow for elevation on light backgrounds */
|
||||
:root .category-card {
|
||||
box-shadow: 0 2px 12px -2px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:root .category-card:hover {
|
||||
box-shadow: 0 12px 40px -8px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
html.dark .category-card {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html.dark .category-card:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ─── Shimmer animation ─── */
|
||||
/* Shimmer animation for sale banner */
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(200%); }
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(200%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ─── Float animation ─── */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-16px); }
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-slow {
|
||||
animation: float 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float 6s ease-in-out 1.5s infinite;
|
||||
}
|
||||
|
||||
/* ─── Pulse glow ─── */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(139, 92, 246, 0.3); }
|
||||
50% { box-shadow: 0 0 40px rgba(139, 92, 246, 0.6); }
|
||||
}
|
||||
|
||||
/* ─── Gradient orbit background ─── */
|
||||
@keyframes orbit {
|
||||
0% { transform: rotate(0deg) translateX(100px) rotate(0deg); }
|
||||
100% { transform: rotate(360deg) translateX(100px) rotate(-360deg); }
|
||||
}
|
||||
|
||||
.animate-orbit {
|
||||
animation: orbit 20s linear infinite;
|
||||
}
|
||||
|
||||
.animate-orbit-reverse {
|
||||
animation: orbit 25s linear infinite reverse;
|
||||
}
|
||||
|
||||
/* ─── Staggered reveal ─── */
|
||||
@keyframes reveal-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.reveal-up {
|
||||
animation: reveal-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.reveal-delay-1 { animation-delay: 0.08s; }
|
||||
.reveal-delay-2 { animation-delay: 0.16s; }
|
||||
.reveal-delay-3 { animation-delay: 0.24s; }
|
||||
.reveal-delay-4 { animation-delay: 0.32s; }
|
||||
.reveal-delay-5 { animation-delay: 0.40s; }
|
||||
.reveal-delay-6 { animation-delay: 0.48s; }
|
||||
|
||||
/* ─── Scrollbar hide ─── */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Safari form styling fixes ─── */
|
||||
/* Safari form styling fixes */
|
||||
@layer base {
|
||||
/* Remove Safari's default styling for form inputs */
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="url"],
|
||||
@@ -264,6 +61,7 @@ html.dark .category-card:hover {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Fix Safari select arrow */
|
||||
select {
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
@@ -272,14 +70,19 @@ html.dark .category-card:hover {
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
/* Dark mode select arrow */
|
||||
.dark select {
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23d1d5db' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
border-radius: 0.5rem;
|
||||
/* Ensure consistent border radius on iOS */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Remove iOS zoom on focus */
|
||||
@media screen and (max-width: 768px) {
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
export type ProductCategory = 'printer' | 'build_plate' | 'nozzle' | 'spare_part' | 'accessory';
|
||||
export type ProductCondition = 'new' | 'used_like_new' | 'used_good' | 'used_fair';
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
category: ProductCategory;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
condition: ProductCondition;
|
||||
stock: number;
|
||||
attributes: Record<string, unknown>;
|
||||
sale_active?: boolean;
|
||||
sale_percentage?: number;
|
||||
sale_start_date?: string;
|
||||
sale_end_date?: string;
|
||||
image_url?: string;
|
||||
is_active?: boolean;
|
||||
compatible_printers?: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface PrinterModel {
|
||||
id: string;
|
||||
name: string;
|
||||
series: string;
|
||||
}
|
||||
|
||||
export interface InventoryStats {
|
||||
filaments: {
|
||||
total_skus: number;
|
||||
total_units: number;
|
||||
total_refills: number;
|
||||
total_spools: number;
|
||||
out_of_stock: number;
|
||||
inventory_value: number;
|
||||
};
|
||||
products: {
|
||||
total_skus: number;
|
||||
total_units: number;
|
||||
out_of_stock: number;
|
||||
inventory_value: number;
|
||||
by_category: Record<string, number>;
|
||||
};
|
||||
combined: {
|
||||
total_skus: number;
|
||||
total_units: number;
|
||||
out_of_stock: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SalesStats {
|
||||
filament_sales: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
sale_percentage: number;
|
||||
sale_end_date?: string;
|
||||
original_price: number;
|
||||
sale_price: number;
|
||||
}>;
|
||||
product_sales: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
category: ProductCategory;
|
||||
sale_percentage: number;
|
||||
sale_end_date?: string;
|
||||
original_price: number;
|
||||
sale_price: number;
|
||||
}>;
|
||||
total_active_sales: number;
|
||||
}
|
||||
91
src/types/sales.ts
Normal file
91
src/types/sales.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
notes?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface SaleItem {
|
||||
id: string;
|
||||
sale_id: string;
|
||||
filament_id: string;
|
||||
item_type: 'refill' | 'spulna';
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
created_at?: string;
|
||||
// Joined fields
|
||||
filament_tip?: string;
|
||||
filament_finish?: string;
|
||||
filament_boja?: string;
|
||||
}
|
||||
|
||||
export interface Sale {
|
||||
id: string;
|
||||
customer_id?: string;
|
||||
total_amount: number;
|
||||
notes?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
// Joined fields
|
||||
customer_name?: string;
|
||||
customer_phone?: string;
|
||||
items?: SaleItem[];
|
||||
item_count?: number;
|
||||
}
|
||||
|
||||
export interface CreateSaleRequest {
|
||||
customer: {
|
||||
name: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
notes?: string;
|
||||
};
|
||||
items: {
|
||||
filament_id: string;
|
||||
item_type: 'refill' | 'spulna';
|
||||
quantity: number;
|
||||
}[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsOverview {
|
||||
revenue: number;
|
||||
sales_count: number;
|
||||
avg_order_value: number;
|
||||
unique_customers: number;
|
||||
}
|
||||
|
||||
export interface TopSeller {
|
||||
boja: string;
|
||||
tip: string;
|
||||
finish: string;
|
||||
total_qty: number;
|
||||
total_revenue: number;
|
||||
}
|
||||
|
||||
export interface RevenueDataPoint {
|
||||
period: string;
|
||||
revenue: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface InventoryAlert {
|
||||
id: string;
|
||||
boja: string;
|
||||
tip: string;
|
||||
finish: string;
|
||||
refill: number;
|
||||
spulna: number;
|
||||
kolicina: number;
|
||||
avg_daily_sales: number;
|
||||
days_until_stockout: number | null;
|
||||
}
|
||||
|
||||
export interface TypeBreakdown {
|
||||
item_type: string;
|
||||
total_qty: number;
|
||||
total_revenue: number;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user