14 Commits

Author SHA1 Message Date
DaX
543e51cc3c Add 19 new Bambu Lab colors and fix sale banner display
Added 16 new PLA Matte refill-only colors and 3 PLA Wood spool-only colors.
Updated admin panel to automatically handle Matte prefix and Wood finish.
Fixed sale banner not displaying due to expired sale dates.
Updated all active sales to expire in 7 days (November 7, 2025).
2025-10-31 00:55:43 +01:00
DaX
56a21b27fe Add new Bambu Lab colors and update documentation
Add three new filament colors to support Wood and Matte finishes:
- Classic Birch (PLA Wood) - #E8D5B7
- Rosewood (PLA Wood) - #472A22
- Desert Tan (PLA Matte) - #E8DBB7

Update CLAUDE.md with improved documentation:
- Enhanced architecture details
- Added color requests schema
- Expanded deployment and database sections
- Added testing and security details
2025-10-07 17:57:34 +02:00
DaX
f99a132e7b Center phone number in footer 2025-09-30 15:01:13 +02:00
DaX
990221792a Translate table headers to Serbian (Finish -> Finiš, Spulna -> Špulna, Refill -> Refil) 2025-09-30 14:13:27 +02:00
DaX
6f75abf6ee Add selling by grams pricing information 2025-09-30 14:08:30 +02:00
DaX
01a24e781b Fix sale discount reset when updating filament quantities
Preserve sale fields when updating filament basic data
2025-09-30 11:58:45 +02:00
DaX
92b186b6bc Add reusable spool price notice above table 2025-09-30 10:57:53 +02:00
DaX
06b0a20bef Update phone number to 0631031048 2025-09-24 18:17:43 +02:00
DaX
747d15f1c3 Make email and phone fields required in color requests 2025-08-29 12:44:00 +02:00
DaX
6d534352b2 Add phone field to color request form and database 2025-08-29 12:41:34 +02:00
DaX
9f2dade0e3 Fix styling issues in color requests admin panel
- Add Serbian status labels instead of English status names
- Fix dark mode colors for better contrast
- Update date formatting to use Serbian locale
- Fix request count display to show proper total
- Improve text colors consistency across light/dark themes
- Tested all API endpoints (create, read, update, delete) - working correctly
- Build succeeds without errors
2025-08-06 00:20:26 +02:00
DaX
fd3ba36ae2 Add color request feature with modal and Safari styling fixes
- Implement color request modal popup instead of separate page
- Add Serbian translations throughout
- Fix Safari form styling issues with custom CSS
- Add 'Other' option to material and finish dropdowns
- Create admin panel for managing color requests
- Add database migration for color_requests table
- Implement API endpoints for color request management
2025-08-05 23:34:35 +02:00
DaX
52f93df34a Merge branch 'improvement' 2025-08-05 23:09:14 +02:00
DaX
470cf63b83 Merge pull request #1 from daxdax89/improvement
Improvement
2025-07-21 12:16:31 +02:00
19 changed files with 3089 additions and 1942 deletions

View File

@@ -1,6 +1,6 @@
# CLAUDE.md
This file provides guidance for AI-assisted development in this repository.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
@@ -22,20 +22,19 @@ Filamenteka is a 3D printing filament inventory management system for tracking B
```bash
# Development
npm run dev # Start Next.js development server (port 3000)
npm run build # Build static export to /out directory
npm run lint # Run ESLint
npm test # Run Jest tests
npm run build # Build static export to /out directory
npm run lint # Run ESLint
npm test # Run Jest tests
npm run test:watch # Run Jest in watch mode
# Security & Quality
npm run security:check # Check for credential leaks
npm run test:build # Test if build succeeds
npm run security:check # Check for credential leaks
npm run test:build # Test if build succeeds
./scripts/pre-commit.sh # Runs security, build, and test checks (use before commits)
# Database Migrations
npm run migrate # Run pending migrations
npm run migrate:clear # Clear migration history
# Pre-commit Hook
./scripts/pre-commit.sh # Runs security, build, and test checks
npm run migrate # Run pending migrations
npm run migrate:clear # Clear migration history
```
## Architecture
@@ -47,18 +46,26 @@ npm run migrate:clear # Clear migration history
- `/page.tsx` - Admin login
- `/dashboard/page.tsx` - Filament CRUD operations
- `/colors/page.tsx` - Color management
- `/requests/page.tsx` - Customer color requests
- `/src` - Source files
- `/components` - React components
- `/services/api.ts` - Axios instance with auth interceptors
- `/data` - Color definitions (bambuLabColors.ts, bambuLabColorsComplete.ts)
- `/types` - TypeScript type definitions
### API Structure
- `/api` - Node.js Express server (runs on EC2)
- `/server.js` - Main server file
- `/routes` - API endpoints for filaments, colors, auth
- `/api` - Node.js Express server (runs on EC2, port 80)
- `/server.js` - Main Express server with all routes inline
- Database: PostgreSQL on AWS RDS
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests`
### Key Components
- `FilamentTableV2` - Main inventory display with sorting/filtering
- `SaleManager` - Bulk sale management interface
- `ColorCell` - Smart color rendering with gradient support
- `EnhancedFilters` - Advanced filtering system
- `ColorRequestForm` - Customer color request form
- `ColorRequestModal` - Modal for color requests
### Data Models
@@ -90,22 +97,41 @@ colors: {
}
```
#### Color Requests Schema
```sql
color_requests: {
id: UUID,
color_name: VARCHAR(255), # Requested color name
message: TEXT, # Customer message
contact_name: VARCHAR(255), # Customer name (required)
contact_phone: VARCHAR(50), # Customer phone (required)
status: VARCHAR(20), # Status: pending, reviewed, fulfilled
created_at: TIMESTAMP
}
```
## Deployment
### Frontend (AWS Amplify)
- Automatic deployment on push to main branch
- Build output: Static files in `/out` directory
- Config: `amplify.yml`, `next.config.js` (output: 'export')
- Security check runs during build (amplify.yml preBuild phase)
### API Server (EC2)
- Manual deployment via `scripts/deploy-api.sh`
- Manual deployment via `scripts/deploy-api.sh` or `scripts/deploy-api-update.sh`
- Server: `3.71.161.51`
- Domain: `api.filamenteka.rs`
- Service: `node-api` (systemd)
- IMPORTANT: When deploying API, remember to build for AMD64 Linux (not ARM macOS)
### Database (RDS PostgreSQL)
- Host: `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com`
- User: `filamenteka_admin`
- Database: `filamenteka`
- Migrations in `/database/migrations/`
- Schema in `/database/schema.sql`
- Use `scripts/update-db-via-aws.sh` for running migrations on production
## Important Patterns
@@ -127,7 +153,10 @@ colors: {
### Testing
- Jest + React Testing Library
- Tests in `__tests__/` directory
- Config: `jest.config.js`, `jest.setup.js`
- Coverage goal: >80%
- Run with `npm test` or `npm run test:watch`
- Tests include: component tests, API integration tests, data consistency checks
## Environment Variables
@@ -135,27 +164,38 @@ colors: {
# Frontend (.env.local)
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
# API Server
DATABASE_URL=postgresql://...
# API Server (.env in /api directory)
DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka
JWT_SECRET=...
ADMIN_PASSWORD=...
NODE_ENV=production
PORT=80
```
## Security Considerations
- Admin routes protected by JWT authentication
- Password hashing with bcrypt
- Admin routes protected by JWT authentication (24h expiry)
- Password hashing with bcrypt (for future multi-user support)
- SQL injection prevention via parameterized queries
- Credential leak detection in pre-commit hooks
- CORS configured for production domains only
- Credential leak detection in pre-commit hooks (`scripts/security/security-check.js`)
- CORS configured to allow all origins (update for production hardening)
- Auth token interceptors handle 401/403 automatically
- Pre-commit hook runs security checks, build tests, and unit tests
## Database Operations
When modifying the database:
1. Create migration file in `/database/migrations/`
1. Create migration file in `/database/migrations/` with sequential numbering
2. Test locally first
3. Run migration on production via scripts
4. Update corresponding TypeScript types
3. Run migration on production:
- Use `scripts/update-db-via-aws.sh` for remote execution
- Or use `npm run migrate` for local/scripted execution
4. Update corresponding TypeScript types in `/src/types/`
Important database constraints:
- `filaments.boja` has foreign key to `colors.name` (ON UPDATE CASCADE)
- `filaments.kolicina` has check constraint: `kolicina = refill + spulna`
- Always update `colors` table first before adding filaments with new colors
## Terraform Infrastructure

View File

@@ -28,8 +28,8 @@ describe('UI Features Tests', () => {
// Check for number inputs for quantities
expect(adminContent).toMatch(/type="number"[\s\S]*?name="refill"/);
expect(adminContent).toMatch(/type="number"[\s\S]*?name="spulna"/);
expect(adminContent).toContain('Refill');
expect(adminContent).toContain('Spulna');
expect(adminContent).toContain('Refil');
expect(adminContent).toContain('Špulna');
expect(adminContent).toContain('Ukupna količina');
});

View File

@@ -154,24 +154,43 @@ app.post('/api/filaments', authenticateToken, async (req, res) => {
app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
const { tip, finish, boja, boja_hex, refill, spulna, cena, sale_percentage, sale_active, sale_start_date, sale_end_date } = req.body;
try {
// Ensure refill and spulna are numbers
const refillNum = parseInt(refill) || 0;
const spulnaNum = parseInt(spulna) || 0;
const kolicina = refillNum + spulnaNum;
const result = await pool.query(
`UPDATE filaments
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
refill = $5, spulna = $6, kolicina = $7, cena = $8,
sale_percentage = $9, sale_active = $10,
sale_start_date = $11, sale_end_date = $12,
updated_at = CURRENT_TIMESTAMP
WHERE id = $13 RETURNING *`,
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
);
// Check if sale fields are provided in the request
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
'sale_start_date' in req.body || 'sale_end_date' in req.body;
let result;
if (hasSaleFields) {
// Update with sale fields if they are provided
result = await pool.query(
`UPDATE filaments
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
refill = $5, spulna = $6, kolicina = $7, cena = $8,
sale_percentage = $9, sale_active = $10,
sale_start_date = $11, sale_end_date = $12,
updated_at = CURRENT_TIMESTAMP
WHERE id = $13 RETURNING *`,
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
);
} else {
// Update without touching sale fields if they are not provided
result = await pool.query(
`UPDATE filaments
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
refill = $5, spulna = $6, kolicina = $7, cena = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $9 RETURNING *`,
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena, id]
);
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating filament:', error);
@@ -236,6 +255,136 @@ app.post('/api/filaments/sale/bulk', authenticateToken, async (req, res) => {
}
});
// Color request endpoints
// Get all color requests (admin only)
app.get('/api/color-requests', authenticateToken, async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM color_requests ORDER BY created_at DESC'
);
res.json(result.rows);
} catch (error) {
console.error('Error fetching color requests:', error);
res.status(500).json({ error: 'Failed to fetch color requests' });
}
});
// Submit a new color request (public)
app.post('/api/color-requests', async (req, res) => {
try {
const {
color_name,
material_type,
finish_type,
user_email,
user_phone,
user_name,
description,
reference_url
} = req.body;
// Validate required fields
if (!color_name || !material_type || !user_email || !user_phone) {
return res.status(400).json({
error: 'Color name, material type, email, and phone are required'
});
}
// Check if similar request already exists
const existingRequest = await pool.query(
`SELECT id, request_count FROM color_requests
WHERE LOWER(color_name) = LOWER($1)
AND material_type = $2
AND (finish_type = $3 OR (finish_type IS NULL AND $3 IS NULL))
AND status = 'pending'`,
[color_name, material_type, finish_type]
);
if (existingRequest.rows.length > 0) {
// Increment request count for existing request
const result = await pool.query(
`UPDATE color_requests
SET request_count = request_count + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *`,
[existingRequest.rows[0].id]
);
res.json({
message: 'Your request has been added to an existing request for this color',
request: result.rows[0]
});
} else {
// Create new request
const result = await pool.query(
`INSERT INTO color_requests
(color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url]
);
res.json({
message: 'Color request submitted successfully',
request: result.rows[0]
});
}
} catch (error) {
console.error('Error creating color request:', error);
res.status(500).json({ error: 'Failed to submit color request' });
}
});
// Update color request status (admin only)
app.put('/api/color-requests/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const { status, admin_notes } = req.body;
const result = await pool.query(
`UPDATE color_requests
SET status = $1,
admin_notes = $2,
processed_at = CURRENT_TIMESTAMP,
processed_by = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $4
RETURNING *`,
[status, admin_notes, req.user.username, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Color request not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating color request:', error);
res.status(500).json({ error: 'Failed to update color request' });
}
});
// Delete color request (admin only)
app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
'DELETE FROM color_requests WHERE id = $1 RETURNING *',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Color request not found' });
}
res.json({ message: 'Color request deleted successfully' });
} catch (error) {
console.error('Error deleting color request:', error);
res.status(500).json({ error: 'Failed to delete color request' });
}
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
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';
@@ -14,6 +15,7 @@ export default function Home() {
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
// Initialize dark mode from localStorage after mounting
@@ -164,18 +166,31 @@ export default function Home() {
</a>
<a
href="tel:+381677102845"
href="tel:+381631031048"
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" 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>
Pozovi +381 67 710 2845
Pozovi +381 63 103 1048
</a>
<button
onClick={() => {
setShowColorRequestModal(true);
trackEvent('Navigation', 'Request Color', 'Homepage');
}}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Zatraži Novu Boju
</button>
</div>
<SaleCountdown
<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={(() => {
@@ -189,7 +204,43 @@ export default function Home() {
return latestSale;
})()}
/>
{/* Pricing Information */}
<div className="mb-6 space-y-4">
{/* Reusable Spool Price Notice */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center justify-center gap-2">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-blue-800 dark:text-blue-200 font-medium">
Cena višekratne špulne: 499 RSD
</span>
</div>
</div>
{/* Selling by Grams Notice */}
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex items-center justify-center gap-2">
<svg className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
</svg>
<span className="text-green-800 dark:text-green-200 font-semibold">
Prodaja filamenta na grame - idealno za testiranje materijala ili manje projekte
</span>
</div>
<div className="flex flex-wrap justify-center gap-4 text-sm">
<span className="text-green-700 dark:text-green-300 font-medium">50gr - 299 RSD</span>
<span className="text-gray-400 dark:text-gray-600"></span>
<span className="text-green-700 dark:text-green-300 font-medium">100gr - 499 RSD</span>
<span className="text-gray-400 dark:text-gray-600"></span>
<span className="text-green-700 dark:text-green-300 font-medium">200gr - 949 RSD</span>
</div>
</div>
</div>
</div>
<FilamentTableV2
key={resetKey}
filaments={filaments}
@@ -198,24 +249,29 @@ export default function Home() {
<footer className="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col sm:flex-row justify-center items-center gap-6">
<div className="text-center sm:text-left">
<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:+381677102845"
<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 67 710 2845
+381 63 103 1048
</a>
</div>
</div>
</div>
</footer>
{/* Color Request Modal */}
<ColorRequestModal
isOpen={showColorRequestModal}
onClose={() => setShowColorRequestModal(false)}
/>
</div>
);
}

View File

@@ -32,7 +32,7 @@ const REFILL_ONLY_COLORS = [
// Helper function to check if a filament is spool-only
const isSpoolOnly = (finish?: string, type?: string): boolean => {
return finish === 'Translucent' || finish === 'Metal' || finish === 'Silk+' || (type === 'PPA' && finish === 'CF') || type === 'PA6' || type === 'PC';
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
@@ -45,6 +45,10 @@ const isRefillOnly = (color: string, finish?: string, type?: string): boolean =>
if (finish === 'Translucent') {
return false;
}
// All colors starting with "Matte " prefix are refill-only
if (color.startsWith('Matte ')) {
return true;
}
return REFILL_ONLY_COLORS.includes(color);
};
@@ -377,6 +381,18 @@ export default function AdminDashboard() {
selectedFilaments={selectedFilaments}
onSaleUpdate={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"
@@ -448,8 +464,8 @@ export default function AdminDashboard() {
<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: Finish (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finish (Z-A)</option>
<option value="finish-asc">Sortiraj po: Finiš (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finiš (Z-A)</option>
<option value="created_at-desc">Sortiraj po: Poslednje dodano</option>
<option value="created_at-asc">Sortiraj po: Prvo dodano</option>
<option value="updated_at-desc">Sortiraj po: Poslednje ažurirano</option>
@@ -495,16 +511,16 @@ export default function AdminDashboard() {
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('finish')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
Finiš {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('boja')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('refill')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('spulna')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
Špulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('kolicina')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}
@@ -854,7 +870,7 @@ function FilamentForm({
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Finish</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Finiš</label>
<select
name="finish"
value={formData.finish}
@@ -862,7 +878,7 @@ function FilamentForm({
required
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberi finish</option>
<option value="">Izaberi finiš</option>
{(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
<option key={finish} value={finish}>{finish}</option>
))}
@@ -933,7 +949,7 @@ function FilamentForm({
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
<span className="text-green-600 dark:text-green-400">Cena Refill</span>
<span className="text-green-600 dark:text-green-400">Cena Refila</span>
</label>
<input
type="number"
@@ -945,8 +961,8 @@ function FilamentForm({
placeholder="3499"
disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isSpoolOnly(formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
isSpoolOnly(formData.finish, formData.tip)
? '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`}
/>
@@ -954,7 +970,7 @@ function FilamentForm({
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
<span className="text-blue-500 dark:text-blue-400">Cena Spulna</span>
<span className="text-blue-500 dark:text-blue-400">Cena Špulne</span>
</label>
<input
type="number"
@@ -966,8 +982,8 @@ function FilamentForm({
placeholder="3999"
disabled={isRefillOnly(formData.boja, formData.finish)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isRefillOnly(formData.boja, formData.finish)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed text-gray-400'
isRefillOnly(formData.boja, formData.finish)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed text-gray-400'
: 'bg-white dark:bg-gray-700 text-blue-500 dark:text-blue-400 font-bold focus:outline-none focus:ring-2 focus:ring-blue-500'
}`}
/>
@@ -976,9 +992,9 @@ function FilamentForm({
{/* Quantity inputs for refill, vakuum, and otvoreno */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Refill
Refil
{isSpoolOnly(formData.finish, formData.tip) && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo spulna postoji)</span>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo špulna postoji)</span>
)}
</label>
<input
@@ -991,8 +1007,8 @@ function FilamentForm({
placeholder="0"
disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isSpoolOnly(formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
isSpoolOnly(formData.finish, formData.tip)
? '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`}
/>
@@ -1000,7 +1016,7 @@ function FilamentForm({
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Spulna
Špulna
{isRefillOnly(formData.boja, formData.finish, formData.tip) && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo refil postoji)</span>
)}
@@ -1015,8 +1031,8 @@ function FilamentForm({
placeholder="0"
disabled={isRefillOnly(formData.boja, formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isRefillOnly(formData.boja, formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
isRefillOnly(formData.boja, formData.finish, formData.tip)
? '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`}
/>

View File

@@ -0,0 +1,360 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { colorRequestService } from '@/src/services/api';
import Link from 'next/link';
interface ColorRequest {
id: string;
color_name: string;
material_type: string;
finish_type: string;
user_email: string;
user_phone: string;
user_name: string;
description: string;
reference_url: string;
status: 'pending' | 'approved' | 'rejected' | 'completed';
admin_notes: string;
request_count: number;
created_at: string;
updated_at: string;
processed_at: string;
processed_by: string;
}
export default function ColorRequestsAdmin() {
const [requests, setRequests] = useState<ColorRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ status: '', admin_notes: '' });
const router = useRouter();
useEffect(() => {
checkAuth();
fetchRequests();
}, []);
const checkAuth = () => {
const token = localStorage.getItem('authToken');
const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || new Date().getTime() > parseInt(expiry)) {
router.push('/upadaj');
}
};
const fetchRequests = async () => {
try {
const data = await colorRequestService.getAll();
setRequests(data);
} catch (error) {
setError('Failed to fetch color requests');
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const handleStatusUpdate = async (id: string) => {
try {
await colorRequestService.updateStatus(id, editForm.status, editForm.admin_notes);
await fetchRequests();
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
} catch (error) {
setError('Failed to update request');
console.error('Error:', error);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this request?')) return;
try {
await colorRequestService.delete(id);
await fetchRequests();
} catch (error) {
setError('Failed to delete request');
console.error('Error:', error);
}
};
const getStatusBadge = (status: string) => {
const colors = {
pending: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
approved: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
rejected: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
completed: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
};
return colors[status as keyof typeof colors] || 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
};
const getStatusLabel = (status: string) => {
const labels = {
pending: 'Na čekanju',
approved: 'Odobreno',
rejected: 'Odbijeno',
completed: 'Završeno'
};
return labels[status as keyof typeof labels] || status;
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
const date = new Date(dateString);
const month = date.toLocaleDateString('sr-RS', { month: 'short' });
const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
return `${capitalizedMonth} ${date.getDate()}, ${date.getFullYear()}`;
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-gray-500 dark:text-gray-400">Učitavanje zahteva za boje...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
<div className="space-x-4">
<Link
href="/upadaj/dashboard"
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Inventar
</Link>
<Link
href="/upadaj/colors"
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Boje
</Link>
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Boja
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Materijal/Finiš
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Broj Zahteva
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Korisnik
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Datum
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Akcije
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{requests.map((request) => (
<tr key={request.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3">
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">{request.color_name}</div>
{request.description && (
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">{request.description}</div>
)}
{request.reference_url && (
<a
href={request.reference_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Pogledaj referencu
</a>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm">
<div className="text-gray-900 dark:text-gray-100">{request.material_type}</div>
{request.finish_type && (
<div className="text-gray-500 dark:text-gray-400">{request.finish_type}</div>
)}
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
{request.request_count || 1} {(request.request_count || 1) === 1 ? 'zahtev' : 'zahteva'}
</span>
</td>
<td className="px-4 py-3">
<div className="text-sm">
{request.user_email ? (
<a href={`mailto:${request.user_email}`} className="text-blue-600 dark:text-blue-400 hover:underline">
{request.user_email}
</a>
) : (
<span className="text-gray-400 dark:text-gray-500">Anonimno</span>
)}
{request.user_phone && (
<div className="mt-1">
<a href={`tel:${request.user_phone}`} className="text-blue-600 dark:text-blue-400 hover:underline">
{request.user_phone}
</a>
</div>
)}
</div>
</td>
<td className="px-4 py-3">
{editingId === request.id ? (
<div className="space-y-2">
<select
value={editForm.status}
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
className="text-sm border rounded px-2 py-1"
>
<option value="">Izaberi status</option>
<option value="pending">Na čekanju</option>
<option value="approved">Odobreno</option>
<option value="rejected">Odbijeno</option>
<option value="completed">Završeno</option>
</select>
<textarea
placeholder="Napomene..."
value={editForm.admin_notes}
onChange={(e) => setEditForm({ ...editForm, admin_notes: e.target.value })}
className="text-sm border rounded px-2 py-1 w-full"
rows={2}
/>
</div>
) : (
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadge(request.status)}`}>
{getStatusLabel(request.status)}
</span>
{request.admin_notes && (
<div className="text-xs text-gray-500 mt-1">{request.admin_notes}</div>
)}
{request.processed_by && (
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
od {request.processed_by}
</div>
)}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="text-sm text-gray-600 dark:text-gray-400">
{formatDate(request.created_at)}
</div>
{request.processed_at && (
<div className="text-xs text-gray-500 dark:text-gray-500">
Obrađeno: {formatDate(request.processed_at)}
</div>
)}
</td>
<td className="px-4 py-3">
{editingId === request.id ? (
<div className="space-x-2">
<button
onClick={() => handleStatusUpdate(request.id)}
className="text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 text-sm"
>
Sačuvaj
</button>
<button
onClick={() => {
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
}}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
>
Otkaži
</button>
</div>
) : (
<div className="space-x-2">
<button
onClick={() => {
setEditingId(request.id);
setEditForm({
status: request.status,
admin_notes: request.admin_notes || ''
});
}}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm"
>
Izmeni
</button>
<button
onClick={() => handleDelete(request.id)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 text-sm"
>
Obriši
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{requests.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
Nema zahteva za boje
</div>
)}
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Ukupno Zahteva</div>
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
{requests.length}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Na Čekanju</div>
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{requests.filter(r => r.status === 'pending').length}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Odobreno</div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{requests.filter(r => r.status === 'approved').length}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Završeno</div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{requests.filter(r => r.status === 'completed').length}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
-- Migration: Add color requests feature
-- Allows users to request new colors and admins to view/manage requests
-- Create color_requests table
CREATE TABLE IF NOT EXISTS color_requests (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
color_name VARCHAR(100) NOT NULL,
material_type VARCHAR(50) NOT NULL,
finish_type VARCHAR(50),
user_email VARCHAR(255),
user_name VARCHAR(100),
description TEXT,
reference_url VARCHAR(500),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'completed')),
admin_notes TEXT,
request_count INTEGER DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP WITH TIME ZONE,
processed_by VARCHAR(100)
);
-- Create indexes for better performance
CREATE INDEX idx_color_requests_status ON color_requests(status);
CREATE INDEX idx_color_requests_created_at ON color_requests(created_at DESC);
CREATE INDEX idx_color_requests_color_name ON color_requests(LOWER(color_name));
-- Apply updated_at trigger to color_requests table
CREATE TRIGGER update_color_requests_updated_at BEFORE UPDATE
ON color_requests FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Add comment to describe the table
COMMENT ON TABLE color_requests IS 'User requests for new filament colors to be added to inventory';
COMMENT ON COLUMN color_requests.status IS 'Request status: pending (new), approved (will be ordered), rejected (won''t be added), completed (added to inventory)';
COMMENT ON COLUMN color_requests.request_count IS 'Number of users who have requested this same color';

View File

@@ -0,0 +1,8 @@
-- Migration: Add phone field to color_requests table
-- Allows users to provide phone number for contact
ALTER TABLE color_requests
ADD COLUMN IF NOT EXISTS user_phone VARCHAR(50);
-- Add comment to describe the new column
COMMENT ON COLUMN color_requests.user_phone IS 'User phone number for contact (optional)';

View File

@@ -0,0 +1,22 @@
-- Migration: Make email and phone fields required in color_requests table
-- These fields are now mandatory for all color requests
-- First, update any existing NULL values to prevent constraint violation
UPDATE color_requests
SET user_email = 'unknown@example.com'
WHERE user_email IS NULL;
UPDATE color_requests
SET user_phone = 'unknown'
WHERE user_phone IS NULL;
-- Now add NOT NULL constraints
ALTER TABLE color_requests
ALTER COLUMN user_email SET NOT NULL;
ALTER TABLE color_requests
ALTER COLUMN user_phone SET NOT NULL;
-- Update comments to reflect the requirement
COMMENT ON COLUMN color_requests.user_email IS 'User email address for contact (required)';
COMMENT ON COLUMN color_requests.user_phone IS 'User phone number for contact (required)';

View File

@@ -0,0 +1,77 @@
-- Migration: Add new Bambu Lab PLA Matte and PLA Wood colors (2025)
-- This migration adds the new color offerings from Bambu Lab
-- Add new PLA Matte colors to the colors table
INSERT INTO colors (name, hex) VALUES
('Matte Apple Green', '#C6E188'),
('Matte Bone White', '#C8C5B6'),
('Matte Caramel', '#A4845C'),
('Matte Dark Blue', '#042F56'),
('Matte Dark Brown', '#7D6556'),
('Matte Dark Chocolate', '#4A3729'),
('Matte Dark Green', '#68724D'),
('Matte Dark Red', '#BB3D43'),
('Matte Grass Green', '#7CB342'),
('Matte Ice Blue', '#A3D8E1'),
('Matte Lemon Yellow', '#F7D959'),
('Matte Lilac Purple', '#AE96D4'),
('Matte Plum', '#851A52'),
('Matte Sakura Pink', '#E8AFCF'),
('Matte Sky Blue', '#73B2E5'),
('Matte Terracotta', '#A25A37')
ON CONFLICT (name)
DO UPDATE SET hex = EXCLUDED.hex;
-- Add new PLA Wood colors to the colors table
INSERT INTO colors (name, hex) VALUES
('Ochre Yellow', '#BC8B39'),
('White Oak', '#D2CCA2'),
('Clay Brown', '#8E621A')
ON CONFLICT (name)
DO UPDATE SET hex = EXCLUDED.hex;
-- Add PLA Matte filaments (all Refill only - 1 refill each, 0 spool)
INSERT INTO filaments (tip, finish, boja, refill, spulna, kolicina, cena)
SELECT
'PLA' as tip,
'Matte' as finish,
c.name as boja,
1 as refill,
0 as spulna,
1 as kolicina,
'3499' as cena
FROM colors c
WHERE c.name IN (
'Matte Apple Green', 'Matte Bone White', 'Matte Caramel',
'Matte Dark Blue', 'Matte Dark Brown', 'Matte Dark Chocolate',
'Matte Dark Green', 'Matte Dark Red', 'Matte Grass Green',
'Matte Ice Blue', 'Matte Lemon Yellow', 'Matte Lilac Purple',
'Matte Plum', 'Matte Sakura Pink', 'Matte Sky Blue', 'Matte Terracotta'
)
AND NOT EXISTS (
SELECT 1 FROM filaments f
WHERE f.tip = 'PLA'
AND f.finish = 'Matte'
AND f.boja = c.name
);
-- Add PLA Wood filaments (all Spool only - 0 refill, 1 spool each)
INSERT INTO filaments (tip, finish, boja, refill, spulna, kolicina, cena)
SELECT
'PLA' as tip,
'Wood' as finish,
c.name as boja,
0 as refill,
1 as spulna,
1 as kolicina,
'3999' as cena
FROM colors c
WHERE c.name IN (
'Ochre Yellow', 'White Oak', 'Clay Brown'
)
AND NOT EXISTS (
SELECT 1 FROM filaments f
WHERE f.tip = 'PLA'
AND f.finish = 'Wood'
AND f.boja = c.name
);

3516
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
// Verification script to check consistency between frontend colors and migration
const fs = require('fs');
const path = require('path');
console.log('Verifying color consistency between frontend and backend...\n');
// Read frontend color definitions
const frontendFile = fs.readFileSync(
path.join(__dirname, '../src/data/bambuLabColors.ts'),
'utf8'
);
// Read migration file
const migrationFile = fs.readFileSync(
path.join(__dirname, '../database/migrations/019_add_new_bambu_colors_2025.sql'),
'utf8'
);
// Extract colors from frontend (looking for pattern: 'ColorName': { hex: '#HEXCODE' })
const frontendColorRegex = /'([^']+)':\s*\{\s*hex:\s*'(#[A-F0-9]{6})'/gi;
const frontendColors = {};
let match;
while ((match = frontendColorRegex.exec(frontendFile)) !== null) {
frontendColors[match[1]] = match[2];
}
// Extract new Matte colors from migration
const migrationColorRegex = /\('([^']+)',\s*'(#[A-F0-9]{6})'\)/gi;
const migrationColors = {};
while ((match = migrationColorRegex.exec(migrationFile)) !== null) {
migrationColors[match[1]] = match[2];
}
console.log('New colors added in migration 019:');
console.log('='.repeat(60));
let hasErrors = false;
// Check each migration color exists in frontend with same hex code
Object.keys(migrationColors).forEach(colorName => {
const migrationHex = migrationColors[colorName];
const frontendHex = frontendColors[colorName];
if (!frontendHex) {
console.log(`❌ ERROR: '${colorName}' missing in frontend`);
hasErrors = true;
} else if (frontendHex !== migrationHex) {
console.log(`❌ ERROR: '${colorName}' hex mismatch:`);
console.log(` Frontend: ${frontendHex}`);
console.log(` Migration: ${migrationHex}`);
hasErrors = true;
} else {
console.log(`${colorName}: ${frontendHex}`);
}
});
// Check if PLA Wood colors exist in frontend
console.log('\nPLA Wood colors (should already exist in frontend):');
console.log('='.repeat(60));
const woodColors = ['Ochre Yellow', 'White Oak', 'Clay Brown'];
woodColors.forEach(colorName => {
if (frontendColors[colorName]) {
console.log(`${colorName}: ${frontendColors[colorName]}`);
} else {
console.log(`❌ ERROR: '${colorName}' missing in frontend`);
hasErrors = true;
}
});
console.log('\n' + '='.repeat(60));
if (hasErrors) {
console.log('❌ VERIFICATION FAILED: Inconsistencies detected');
process.exit(1);
} else {
console.log('✓ VERIFICATION PASSED: All colors are consistent');
process.exit(0);
}

View File

@@ -0,0 +1,170 @@
'use client';
import React, { useState } from 'react';
import { colorRequestService } from '@/src/services/api';
interface ColorRequestFormProps {
onSuccess?: () => void;
}
export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
const [formData, setFormData] = useState({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_name: '',
user_email: '',
description: '',
reference_url: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setMessage(null);
try {
const response = await colorRequestService.submit(formData);
setMessage({
type: 'success',
text: 'Vaš zahtev je uspešno poslat!'
});
setFormData({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_name: '',
user_email: '',
description: '',
reference_url: ''
});
if (onSuccess) onSuccess();
} catch (error) {
setMessage({
type: 'error',
text: 'Greška pri slanju zahteva. Pokušajte ponovo.'
});
} finally {
setIsSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold mb-6 text-gray-800 dark:text-gray-100">Zatraži Novu Boju</h2>
{message && (
<div className={`mb-4 p-4 rounded ${
message.type === 'success'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
}`}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label htmlFor="color_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Naziv Boje *
</label>
<input
type="text"
id="color_name"
name="color_name"
required
value={formData.color_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
placeholder="npr. Sunset Orange"
/>
</div>
<div>
<label htmlFor="material_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tip Materijala *
</label>
<select
id="material_type"
name="material_type"
required
value={formData.material_type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
>
<option value="PLA">PLA</option>
<option value="PETG">PETG</option>
<option value="ABS">ABS</option>
<option value="TPU">TPU</option>
<option value="PLA-CF">PLA-CF</option>
<option value="PETG-CF">PETG-CF</option>
</select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label htmlFor="finish_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tip Finiša
</label>
<select
id="finish_type"
name="finish_type"
value={formData.finish_type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
>
<option value="Basic">Basic</option>
<option value="Matte">Matte</option>
<option value="Silk">Silk</option>
<option value="Metal">Metal</option>
<option value="Sparkle">Sparkle</option>
<option value="Glow">Glow</option>
<option value="Transparent">Transparent</option>
</select>
</div>
<div>
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email (opciono)
</label>
<input
type="email"
id="user_email"
name="user_email"
value={formData.user_email}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
placeholder="Za obaveštenja o statusu"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className={`px-6 py-2 rounded-md text-white font-medium ${
isSubmitting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-purple-600 hover:bg-purple-700'
} transition-colors`}
>
{isSubmitting ? 'Slanje...' : 'Pošalji Zahtev'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,232 @@
'use client';
import React, { useState, useEffect } from 'react';
import { colorRequestService } from '@/src/services/api';
interface ColorRequestModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function ColorRequestModal({ isOpen, onClose }: ColorRequestModalProps) {
const [formData, setFormData] = useState({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_email: '',
user_phone: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
useEffect(() => {
if (!isOpen) {
// Reset form when modal closes
setFormData({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_email: '',
user_phone: ''
});
setMessage(null);
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setMessage(null);
try {
await colorRequestService.submit(formData);
setMessage({
type: 'success',
text: 'Vaš zahtev je uspešno poslat!'
});
// Close modal after 2 seconds on success
setTimeout(() => {
onClose();
}, 2000);
} catch (error) {
setMessage({
type: 'error',
text: 'Greška pri slanju zahteva. Pokušajte ponovo.'
});
} finally {
setIsSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">
Zatraži Novu Boju
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Ne možete pronaći boju koju tražite? Javite nam!
</p>
{message && (
<div className={`mb-4 p-3 rounded text-sm ${
message.type === 'success'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
}`}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="color_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Naziv Boje *
</label>
<input
type="text"
id="color_name"
name="color_name"
required
value={formData.color_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
placeholder="npr. Sunset Orange"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="material_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Materijal *
</label>
<select
id="material_type"
name="material_type"
required
value={formData.material_type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
>
<option value="PLA">PLA</option>
<option value="PETG">PETG</option>
<option value="ABS">ABS</option>
<option value="TPU">TPU</option>
<option value="PLA-CF">PLA-CF</option>
<option value="PETG-CF">PETG-CF</option>
<option value="Other">Ostalo</option>
</select>
</div>
<div>
<label htmlFor="finish_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Finiš
</label>
<select
id="finish_type"
name="finish_type"
value={formData.finish_type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
>
<option value="Basic">Basic</option>
<option value="Matte">Matte</option>
<option value="Silk">Silk</option>
<option value="Metal">Metal</option>
<option value="Sparkle">Sparkle</option>
<option value="Glow">Glow</option>
<option value="Transparent">Transparent</option>
<option value="Other">Ostalo</option>
</select>
</div>
</div>
<div>
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email *
</label>
<input
type="email"
id="user_email"
name="user_email"
required
value={formData.user_email}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
placeholder="Za obaveštenja o statusu"
/>
</div>
<div>
<label htmlFor="user_phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Telefon *
</label>
<input
type="tel"
id="user_phone"
name="user_phone"
required
value={formData.user_phone}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
placeholder="Za kontakt"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Otkaži
</button>
<button
type="submit"
disabled={isSubmitting}
className={`px-6 py-2 rounded-md text-white font-medium ${
isSubmitting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-purple-600 hover:bg-purple-700'
} transition-colors`}
>
{isSubmitting ? 'Slanje...' : 'Pošalji'}
</button>
</div>
</form>
</div>
</div>
</div>
</>
);
}

View File

@@ -157,16 +157,16 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('finish')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
Finiš {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('boja')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('refill')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('spulna')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
Špulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('kolicina')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}

View File

@@ -33,6 +33,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Charcoal': { hex: '#000000' },
'Cherry Pink': { hex: '#E9B6CC' },
'Chocolate': { hex: '#4A3729' },
'Classic Birch': { hex: '#E8D5B7' },
'Clay Brown': { hex: '#8E621A' },
'Clear': { hex: '#FAFAFA' },
'Clear Black': { hex: '#5A5161' },
@@ -140,7 +141,25 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'White Marble': { hex: '#F7F3F0' },
'White Oak': { hex: '#D2CCA2' },
'Yellow': { hex: '#F4EE2A' },
// PLA Matte - New Colors (2025)
'Matte Apple Green': { hex: '#C6E188' },
'Matte Bone White': { hex: '#C8C5B6' },
'Matte Caramel': { hex: '#A4845C' },
'Matte Dark Blue': { hex: '#042F56' },
'Matte Dark Brown': { hex: '#7D6556' },
'Matte Dark Chocolate': { hex: '#4A3729' },
'Matte Dark Green': { hex: '#68724D' },
'Matte Dark Red': { hex: '#BB3D43' },
'Matte Grass Green': { hex: '#7CB342' },
'Matte Ice Blue': { hex: '#A3D8E1' },
'Matte Lemon Yellow': { hex: '#F7D959' },
'Matte Lilac Purple': { hex: '#AE96D4' },
'Matte Plum': { hex: '#851A52' },
'Matte Sakura Pink': { hex: '#E8AFCF' },
'Matte Sky Blue': { hex: '#73B2E5' },
'Matte Terracotta': { hex: '#A25A37' },
// Default fallback
'Unknown': { hex: '#CCCCCC' }
};

View File

@@ -39,6 +39,24 @@ export const bambuLabColors = {
"Matte Lime": "#9E9D24",
"Matte Navy": "#1A237E",
"Matte Coral": "#FF5252",
// Matte Colors - New 2025
"Matte Apple Green": "#C6E188",
"Matte Bone White": "#C8C5B6",
"Matte Caramel": "#A4845C",
"Matte Dark Blue": "#042F56",
"Matte Dark Brown": "#7D6556",
"Matte Dark Chocolate": "#4A3729",
"Matte Dark Green": "#68724D",
"Matte Dark Red": "#BB3D43",
"Matte Grass Green": "#7CB342",
"Matte Ice Blue": "#A3D8E1",
"Matte Lemon Yellow": "#F7D959",
"Matte Lilac Purple": "#AE96D4",
"Matte Plum": "#851A52",
"Matte Sakura Pink": "#E8AFCF",
"Matte Sky Blue": "#73B2E5",
"Matte Terracotta": "#A25A37",
// Silk Colors
"Silk White": "#FEFEFE",
@@ -105,7 +123,11 @@ export const colorsByFinish = {
"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 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 Plum", "Matte Sakura Pink", "Matte Sky Blue", "Matte Terracotta"
],
"Silk": [
"Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green",

View File

@@ -101,4 +101,34 @@ export const filamentService = {
},
};
export const colorRequestService = {
getAll: async () => {
const response = await api.get('/color-requests');
return response.data;
},
submit: async (request: {
color_name: string;
material_type: string;
finish_type?: string;
user_email?: string;
user_name?: string;
description?: string;
reference_url?: string;
}) => {
const response = await api.post('/color-requests', request);
return response.data;
},
updateStatus: async (id: string, status: string, admin_notes?: string) => {
const response = await api.put(`/color-requests/${id}`, { status, admin_notes });
return response.data;
},
delete: async (id: string) => {
const response = await api.delete(`/color-requests/${id}`);
return response.data;
},
};
export default api;

View File

@@ -42,4 +42,58 @@
.animate-shimmer {
animation: shimmer 3s ease-in-out infinite;
}
/* Safari form styling fixes */
@layer base {
/* Remove Safari's default styling for form inputs */
input[type="text"],
input[type="email"],
input[type="url"],
input[type="tel"],
input[type="number"],
input[type="password"],
input[type="search"],
select,
textarea {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
/* Fix Safari select arrow */
select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.7rem center;
background-size: 1em;
padding-right: 2.5rem;
}
/* Dark mode select arrow */
.dark select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23d1d5db' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
}
/* Ensure consistent border radius on iOS */
input,
select,
textarea {
border-radius: 0.375rem;
}
/* Remove iOS zoom on focus */
@media screen and (max-width: 768px) {
input[type="text"],
input[type="email"],
input[type="url"],
input[type="tel"],
input[type="number"],
input[type="password"],
input[type="search"],
select,
textarea {
font-size: 16px;
}
}
}